From 497868e4f9d5b16bd3b24f0aa1abaad2eb7f38b2 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Tue, 13 Oct 2026 14:04:35 +0000 Subject: [PATCH 01/13] fix: Use Strands Responses provider for OpenAI tool calls (#770) * fix: Use Strands Responses provider for OpenAI tool calls * fix: End Strands native loop after a successful decline * fix: Preserve Bedrock follow-up history after a decline * fix: Limit Strands decline stops to native platform tools * fix: Keep Strands examples runnable against the published SDK Standalone uv run installs the PEP 723 PyPI floor and ignores the local checkout. These examples imported band.core.defaultmodels, which band-sdk 4.0.0 does not ship, so the documented command failed at import. Model ids stay literals, matching the rest of the examples. Co-authored-by: Cursor * fix: Require the Strands-fixed SDK release in Strands examples band-sdk 5.0.0 on PyPI ships band.core.defaultmodels but not this branch's Strands adapter fixes, so standalone examples resolved a release without Responses history normalization or decline stops. Raise their PEP 723 floor to the next release, drop the now-false defaultmodels surface guard, and restore the ALL_TOOL_NAMES import lost in the main merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DPGqG2jLxWPYmPXyivYXxW --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 5.5 --- examples/strands/01_basic_agent.py | 6 +- examples/strands/02_custom_tools.py | 6 +- examples/strands/03_custom_instructions.py | 6 +- examples/strands/04_native_tools.py | 6 +- examples/strands/05_bedrock_model.py | 4 +- examples/strands/06_tom_agent.py | 6 +- examples/strands/07_jerry_agent.py | 6 +- examples/strands/README.md | 9 +- pyproject.toml | 5 +- src/band/adapters/strands.py | 34 ++- src/band/converters/strands.py | 14 +- tests/adapters/test_strands_adapter.py | 277 +++++++++++++++++- tests/e2e/baseline/expected-cells.json | 9 + .../baseline/guards/test_strands_provider.py | 71 +++++ tests/e2e/baseline/toolkit/builders.py | 6 +- tests/responsespeer.py | 122 ++++++++ uv.lock | 6 +- 17 files changed, 550 insertions(+), 43 deletions(-) create mode 100644 tests/e2e/baseline/guards/test_strands_provider.py create mode 100644 tests/responsespeer.py diff --git a/examples/strands/01_basic_agent.py b/examples/strands/01_basic_agent.py index 10c53f94b..d9cdb0ad9 100644 --- a/examples/strands/01_basic_agent.py +++ b/examples/strands/01_basic_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Basic Strands Agents example. @@ -22,7 +22,7 @@ import logging from dotenv import load_dotenv -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from band import Agent, configure_logging from band.adapters import StrandsAdapter, StrandsAdapterConfig @@ -39,7 +39,7 @@ async def main() -> None: StrandsAdapterConfig( custom_section="You are a helpful assistant. Be concise and friendly." ), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), ) logger.info("Starting Strands agent...") diff --git a/examples/strands/02_custom_tools.py b/examples/strands/02_custom_tools.py index 605e4ea7c..e4503c47c 100644 --- a/examples/strands/02_custom_tools.py +++ b/examples/strands/02_custom_tools.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Strands agent with custom tools and capabilities. @@ -20,7 +20,7 @@ from dotenv import load_dotenv from pydantic import BaseModel -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from band import Agent, configure_logging from band.adapters import StrandsAdapter, StrandsAdapterConfig @@ -47,7 +47,7 @@ async def main() -> None: StrandsAdapterConfig( custom_section="You can check the weather with the weather tool." ), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), additional_tools=[(WeatherInput, get_weather)], # CustomToolDef tuple capabilities=Capability.MEMORY | Capability.CONTACTS, ) diff --git a/examples/strands/03_custom_instructions.py b/examples/strands/03_custom_instructions.py index b2981fbaa..052c2f753 100644 --- a/examples/strands/03_custom_instructions.py +++ b/examples/strands/03_custom_instructions.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Strands agent with a fully custom system prompt. @@ -24,7 +24,7 @@ import logging from dotenv import load_dotenv -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from band import Agent, configure_logging from band.adapters import StrandsAdapter, StrandsAdapterConfig @@ -55,7 +55,7 @@ async def main() -> None: adapter = StrandsAdapter( # Full override: custom_section would be ignored alongside this. StrandsAdapterConfig(system_prompt=SUPPORT_PROMPT), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), # Post each tool call and result into the room for visibility. emit=Emit.TOOL_CALLS, ) diff --git a/examples/strands/04_native_tools.py b/examples/strands/04_native_tools.py index ba280e315..fc6cf7b6a 100644 --- a/examples/strands/04_native_tools.py +++ b/examples/strands/04_native_tools.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Strands agent with framework-native tools. @@ -27,7 +27,7 @@ from dotenv import load_dotenv from strands import tool -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from band import Agent, configure_logging from band.adapters import StrandsAdapter, StrandsAdapterConfig @@ -67,7 +67,7 @@ async def main() -> None: custom_section="You convert currencies with the convert_from_usd tool. Escalate to a " "human only when the request is outside currency conversion." ), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), additional_tools=[convert_from_usd, escalate_to_human], emit=Emit.TOOL_CALLS, ) diff --git a/examples/strands/05_bedrock_model.py b/examples/strands/05_bedrock_model.py index b2b52c99a..0db80acd4 100644 --- a/examples/strands/05_bedrock_model.py +++ b/examples/strands/05_bedrock_model.py @@ -1,13 +1,13 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Strands agent on Amazon Bedrock. Strands is model-agnostic, but it has no provider-prefix string shorthand: a bare ``StrandsAdapterConfig(model=...)`` string is a **Bedrock** model id, not -a provider route. The other examples therefore pass an ``OpenAIModel`` as +a provider route. The other examples therefore pass an ``OpenAIResponsesModel`` as ``llm=``, while this one uses the string form and its explicit ``BedrockModel`` equivalent — reach for the latter when you need a region, profile, or client config. diff --git a/examples/strands/06_tom_agent.py b/examples/strands/06_tom_agent.py index fedf21f9e..8ce5ca7d7 100644 --- a/examples/strands/06_tom_agent.py +++ b/examples/strands/06_tom_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Tom the cat agent - tries to catch Jerry! @@ -26,7 +26,7 @@ import sys from dotenv import load_dotenv -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -44,7 +44,7 @@ async def main() -> None: adapter = StrandsAdapter( StrandsAdapterConfig(custom_section=generate_tom_prompt("Tom")), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), ) logger.info("Tom is on the prowl, looking for Jerry...") diff --git a/examples/strands/07_jerry_agent.py b/examples/strands/07_jerry_agent.py index 0deadbe8a..331f345de 100644 --- a/examples/strands/07_jerry_agent.py +++ b/examples/strands/07_jerry_agent.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["band-sdk[strands]>=4.0.0"] +# dependencies = ["band-sdk[strands]>=5.0.1"] # /// """ Jerry the mouse agent - outsmarts Tom! @@ -26,7 +26,7 @@ import sys from dotenv import load_dotenv -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -44,7 +44,7 @@ async def main() -> None: adapter = StrandsAdapter( StrandsAdapterConfig(custom_section=generate_jerry_prompt("Jerry")), - llm=OpenAIModel(model_id="gpt-6-luna"), + llm=OpenAIResponsesModel(model_id="gpt-6-luna"), ) logger.info("Jerry is watching for Tom from his mouse hole...") diff --git a/examples/strands/README.md b/examples/strands/README.md index cfe16d431..35c06b93e 100644 --- a/examples/strands/README.md +++ b/examples/strands/README.md @@ -19,17 +19,22 @@ model id. Any other provider is constructed explicitly and passed as `llm=`. ```python notest from strands.models import BedrockModel -from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from band.adapters import StrandsAdapter, StrandsAdapterConfig -StrandsAdapter(llm=OpenAIModel(model_id="gpt-6-luna")) # OpenAI +StrandsAdapter(llm=OpenAIResponsesModel(model_id="gpt-6-luna")) # OpenAI StrandsAdapter( StrandsAdapterConfig(model="us.anthropic.claude-sonnet-4-5-20250929-v1:0") ) # Bedrock id StrandsAdapter(llm=BedrockModel(model_id="...", region_name="us-east-1")) ``` +These OpenAI examples use the Responses provider because the shared default model +supports native function tools through that API. Leave `stateful` disabled: Band +owns room history. Callers can still pass `OpenAIModel` for Chat Completions with +a compatible model and configuration. + Providers beyond `openai` need their own Strands extra (e.g. `strands-agents[anthropic]`). diff --git a/pyproject.toml b/pyproject.toml index 07cdaba34..976b4ea26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,7 +170,8 @@ agno = [ "agno>=2.6.0", ] strands = [ - "strands-agents[openai]>=1.40,<2", + "strands-agents[openai]>=1.50.1,<2", + "openai>=2.0.0", ] # NOOA supports Python 3.12 and 3.13 on POSIX only (it imports fcntl); elsewhere # the extra installs nothing. @@ -240,7 +241,7 @@ dev = [ # Include Agno for testing "agno>=2.6.0", # Include Strands Agents for testing - "strands-agents[openai]>=1.40,<2", + "strands-agents[openai]>=1.50.1,<2", # Include NOOA for testing (Python 3.12 and 3.13, POSIX only) "nooa>=0.0.10,<0.1; python_version >= '3.12' and python_version < '3.14' and sys_platform != 'win32'", # Include letta-client for testing diff --git a/src/band/adapters/strands.py b/src/band/adapters/strands.py index bdc99a37b..809b6eba8 100644 --- a/src/band/adapters/strands.py +++ b/src/band/adapters/strands.py @@ -16,7 +16,9 @@ from strands.hooks import HookProvider, HookRegistry from strands.hooks.events import AfterToolCallEvent, BeforeToolCallEvent from strands.models import Model + from strands.models.bedrock import BedrockModel from strands.models.openai import OpenAIModel + from strands.models.openai_responses import OpenAIResponsesModel from strands.types.media import ImageFormat from strands.types.tools import ( AgentTool, @@ -34,7 +36,11 @@ from typing_extensions import Unpack -from band.converters.strands import StrandsHistoryConverter, StrandsMessages +from band.converters.strands import ( + StrandsHistoryConverter, + StrandsMessages, + merge_consecutive_roles, +) from band.core.adapterconfig import BaseAdapterConfig from band.core.protocols import ( GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -61,6 +67,7 @@ ) from band.runtime.prompts import render_system_prompt, with_system_updates from band.runtime.tools import ( + ALL_TOOL_NAMES, ToolCallOutcome, ToolDefinition, TurnEffect, @@ -71,6 +78,7 @@ redact_tool_call_args, selected_tool_definitions, serialize_tool_result, + turn_effect, validate_tool_arguments, ) from band.runtime.tools.schema import is_failed_tool_output @@ -409,6 +417,13 @@ async def _on_after_tool(self, event: AfterToolCallEvent) -> None: ToolEventKey.IS_ERROR: not succeeded, } call.update(result_payload) + if ( + succeeded + and name in ALL_TOOL_NAMES + and turn_effect(name) is TurnEffect.DECLINE + ): + # Strands stops after recording every result in the current tool batch. + event.invocation_state["request_state"]["stop_event_loop"] = True # Native Strands tools run outside execute_custom_tool, so their # declared effect is recorded here. if ( @@ -497,7 +512,7 @@ def __init__( history_converter: Optional custom history converter. additional_tools: Strands tools and/or portable ``CustomToolDef`` (InputModel, handler) tuples. - llm: A constructed Strands ``Model`` (e.g. ``OpenAIModel``, + llm: A constructed Strands ``Model`` (e.g. ``OpenAIResponsesModel``, ``BedrockModel``), used instead of ``config.model``. Exactly one of the two must be set. """ @@ -575,7 +590,7 @@ def _history_for_turn( ) -> StrandsMessages: """Get the room transcript, using platform history only at session start.""" if is_session_bootstrap: - if isinstance(self._model, OpenAIModel): + if isinstance(self._model, (OpenAIModel, OpenAIResponsesModel)): history = _openai_history(history) self._message_history[room_id] = list(history) if history: @@ -596,7 +611,18 @@ async def _run_turn( result: AgentResult | None = None try: agent = self._build_agent(history, tools, hooks) - result = await agent.invoke_async(message) + prompt: str | None = message + if ( + isinstance(agent.model, BedrockModel) + and agent.messages + and agent.messages[-1]["role"] == "user" + ): + # Converse requires alternating roles, including after a stopped tool batch. + agent.messages = merge_consecutive_roles( + [*agent.messages, {"role": "user", "content": [{"text": message}]}] + ) + prompt = None + result = await agent.invoke_async(prompt) except Exception: logger.exception("Room %s: Strands turn failed", room_id) await tools.send_failure( diff --git a/src/band/converters/strands.py b/src/band/converters/strands.py index 4e4458e8d..588e307c3 100644 --- a/src/band/converters/strands.py +++ b/src/band/converters/strands.py @@ -104,14 +104,10 @@ def _patch_orphaned_tool_uses(messages: StrandsMessages) -> None: ) -def _merge_consecutive_roles(messages: StrandsMessages) -> StrandsMessages: - """Combine neighbouring messages that share a role. - - Bedrock's Converse rejects a conversation that does not alternate between - user and assistant, and room history routinely produces same-role - neighbours: two peers speaking in a row, or a peer's turn landing behind - the tool results it waited for. +def merge_consecutive_roles(messages: StrandsMessages) -> StrandsMessages: + """Combine same-role neighbours without changing the input messages. + Bedrock Converse requires alternating user and assistant roles. """ merged: StrandsMessages = [] for message in messages: @@ -120,7 +116,7 @@ def _merge_consecutive_roles(messages: StrandsMessages) -> StrandsMessages: message["content"] ) continue - merged.append(message) + merged.append({**message}) return merged @@ -194,7 +190,7 @@ def build(self) -> StrandsMessages: """Return the finished transcript: every call answered, roles alternating.""" self._close_tool_exchange() _patch_orphaned_tool_uses(self._messages) - return _merge_consecutive_roles(self._messages) + return merge_consecutive_roles(self._messages) def _close_tool_exchange(self) -> None: """Emit the exchange's calls, then its results, then the turns it held.""" diff --git a/tests/adapters/test_strands_adapter.py b/tests/adapters/test_strands_adapter.py index 28cabd1ab..634dc613a 100644 --- a/tests/adapters/test_strands_adapter.py +++ b/tests/adapters/test_strands_adapter.py @@ -11,6 +11,7 @@ import json import logging from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import replace from datetime import UTC, datetime from functools import partial from typing import Any, ClassVar, cast @@ -25,8 +26,11 @@ import itertools +import boto3 from strands import tool as strands_tool +from strands.models.bedrock import BedrockModel from strands.models.openai import OpenAIModel +from strands.models.openai_responses import OpenAIResponsesModel from strands.types.content import Messages from strands.types.exceptions import EventLoopException from strands.types.streaming import StreamEvent @@ -56,12 +60,13 @@ Capability, Emit, HistoryProvider, + MessageType, PlatformMessage, TurnUsage, is_usage_event, ) from band.runtime.custom_tools import declares_turn_effect -from band.runtime.tools import TurnEffect, get_tool_description +from band.runtime.tools import BandTool, TurnEffect, get_tool_description from band.testing import ( MISSING_REPLY_FAILURE, ErrorTurn, @@ -70,10 +75,13 @@ ScriptedTurn, TextTurn, ToolTurn, + events_of_type, failure_reports, reported_failures, ) from tests.framework_conformance.turnprobes import CUSTOM_TOOL_DECLARATIONS +from tests.responsespeer import ResponsesPeer +from tests.strandskit import outline _INPUT_TOKENS_PER_CALL = 7 _OUTPUT_TOKENS_PER_CALL = 3 @@ -409,6 +417,43 @@ class TestOpenAIRehydration: tool_result("calc", "4", "call-1"), ] + @pytest.mark.asyncio + @pytest.mark.parametrize("history_converter", [None, StrandsHistoryConverter()]) + async def test_responses_rehydrates_tool_results_before_user_text( + self, + history_converter: StrandsHistoryConverter | None, + tools: FakeAgentTools, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + peer = ResponsesPeer([SEND_TURN, TextTurn("")]) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + model = OpenAIResponsesModel( + client_args={"api_key": "test"}, model_id="test-model" + ) + adapter = StrandsAdapter(llm=model, history_converter=history_converter) + await adapter.on_started("Bot", "A bot") + inp = replace( + turn_input(tools, msg=_make_msg(ROOM)), + history=HistoryProvider(raw=self._HISTORY), + ) + + await adapter.on_event(inp) + + assert peer.requests[0]["input"][:3] == [ + { + "type": "function_call", + "call_id": "call-1", + "name": "calc", + "arguments": '{"expr": "2+2"}', + }, + {"type": "function_call_output", "call_id": "call-1", "output": "4"}, + { + "role": "user", + "content": [{"type": "input_text", "text": "[Alice]: also, hello"}], + }, + ] + tools.assert_message_sent(content="hi", count=1) + class RecordingOpenAIModel(OpenAIModel): """Run the real OpenAI serializer, then answer from the offline model.""" @@ -914,6 +959,28 @@ def native_finish(note: str) -> Any: assert failure_reports(tools) == [MISSING_REPLY_FAILURE] + @pytest.mark.asyncio + async def test_native_decline_alias_does_not_stop_the_reply( + self, tools: FakeAgentTools, scripted + ) -> None: + @strands_tool(name="band-band_no_reply") + def native_peek(note: str) -> str: + """Observe a note.""" + return "seen" + + adapter = await scripted( + ToolTurn(native_peek.tool_name, {"note": "go"}), + SEND_TURN, + TextTurn(""), + additional_tools=[native_peek], + ) + + await _run_turn(adapter, tools) + + tools.assert_message_sent(content="hi", count=1) + assert tools.turn.complete + assert failure_reports(tools) == [] + @pytest.mark.asyncio async def test_undeclared_native_tool_only_observes(self, tools, scripted): @strands_tool @@ -987,6 +1054,214 @@ async def file_report(args: FileInput) -> str: class TestTurnFailure: + class RecordingBedrockModel(BedrockModel): + """Serialize real Bedrock requests, then supply scripted model events.""" + + def __init__(self) -> None: + super().__init__( + boto_session=boto3.Session( + aws_access_key_id="test", + aws_secret_access_key="test", + region_name="us-east-1", + ), + model_id="anthropic.claude-3-5-sonnet-20241022-v2:0", + ) + self.requests: list[dict[str, Any]] = [] + self.scripted = ScriptedStrandsModel( + [ + ToolTurn(BandTool.NO_REPLY, {"reason": "FYI only"}), + SEND_TURN, + TextTurn(""), + ] + ) + + async def stream( + self, + messages: Messages, + tool_specs: list[ToolSpec] | None = None, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[StreamEvent, None]: + self.requests.append(self.format_request(messages, tool_specs)) + async for event in self.scripted.stream( + messages, tool_specs, system_prompt, **kwargs + ): + yield event + + @pytest.mark.asyncio + @pytest.mark.parametrize("emit_execution", [False, True]) + async def test_bedrock_decline_preserves_followup_turn( + self, emit_execution: bool + ) -> None: + model = self.RecordingBedrockModel() + adapter = StrandsAdapter( + llm=model, emit=Emit.TOOL_CALLS if emit_execution else [] + ) + await adapter.on_started("Bot", "A bot") + first = FakeAgentTools(room_id=ROOM) + await _run_turn(adapter, first) + second = FakeAgentTools(room_id=ROOM) + await adapter.on_event( + replace( + turn_input(second, msg=_make_msg(ROOM, "Please reply now.")), + is_session_bootstrap=False, + participants_msg="Bob joined", + contacts_msg="New contact", + ) + ) + + assert outline(model.requests[1]["messages"]) == [ + "user: text([Tester]: Hello)", + "assistant: toolUse(call-band_no_reply)", + ( + "user: toolResult(call-band_no_reply, success) " + "text([System]: Bob joined\n\n[System]: New contact\n\n" + "[Tester]: Please reply now.)" + ), + ] + assert first.turn.complete and second.turn.complete + assert first.messages_sent == [] + second.assert_message_sent(content="hi", count=1) + assert failure_reports(first) == failure_reports(second) == [] + + @pytest.mark.asyncio + async def test_responses_decline_preserves_followup_tool_output( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + peer = ResponsesPeer( + [ + ToolTurn(BandTool.NO_REPLY, {"reason": "FYI only"}), + SEND_TURN, + TextTurn(""), + ] + ) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + adapter = StrandsAdapter( + llm=OpenAIResponsesModel( + client_args={"api_key": "test"}, model_id="test-model" + ) + ) + await adapter.on_started("Bot", "A bot") + first = FakeAgentTools(room_id=ROOM) + await _run_turn(adapter, first) + second = FakeAgentTools(room_id=ROOM) + await adapter.on_event( + replace( + turn_input(second, msg=_make_msg(ROOM, "Please reply now.")), + is_session_bootstrap=False, + ) + ) + + assert peer.requests[1]["input"][2:] == [ + { + "type": "function_call_output", + "call_id": peer.CALL_ID, + "output": '{"status": "no_reply"}', + }, + { + "role": "user", + "content": [ + {"type": "input_text", "text": "[Tester]: Please reply now."} + ], + }, + ] + assert first.turn.complete and second.turn.complete + second.assert_message_sent(content="hi", count=1) + assert failure_reports(first) == failure_reports(second) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("fails", [False, True], ids=["declined", "failed-decline"]) + @pytest.mark.parametrize("emit_execution", [False, True]) + async def test_responses_decline_ends_only_a_successful_turn( + self, monkeypatch: pytest.MonkeyPatch, fails: bool, emit_execution: bool + ) -> None: + tools = FakeAgentTools(room_id=ROOM) + peer = ResponsesPeer( + [ + ToolTurn(BandTool.NO_REPLY, {"reason": [] if fails else "FYI only"}), + ToolTurn(BandTool.NO_REPLY, {"reason": "FYI only"}), + ErrorTurn(RuntimeError("decline requested another model call")), + ] + ) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + adapter = StrandsAdapter( + llm=OpenAIResponsesModel( + client_args={"api_key": "test"}, model_id="test-model" + ), + emit=Emit.USAGE | Emit.TOOL_CALLS if emit_execution else Emit.USAGE, + ) + await adapter.on_started("Bot", "A bot") + + await _run_turn(adapter, tools) + + assert len(peer.requests) == (2 if fails else 1) + assert tools.turn.complete + assert tools.messages_sent == [] + assert failure_reports(tools) == [] + if emit_execution: + results = events_of_type(tools, MessageType.TOOL_RESULT) + assert [ + json.loads(result["content"])["is_error"] for result in results + ] == ([True, False] if fails else [False]) + (usage,) = [ + event for event in tools.events_sent if is_usage_event(event["metadata"]) + ] + requests = 2 if fails else 1 + assert usage["metadata"][USAGE_METADATA_KEY]["input_tokens"] == ( + requests * peer.INPUT_TOKENS + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("fails", [False, True], ids=["completed", "failed-stream"]) + async def test_responses_tool_events_usage_and_failure( + self, tools: FakeAgentTools, monkeypatch: pytest.MonkeyPatch, fails: bool + ) -> None: + final = ErrorTurn(RuntimeError("provider down")) if fails else TextTurn("") + peer = ResponsesPeer([SEND_TURN, final]) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + adapter = StrandsAdapter( + llm=OpenAIResponsesModel( + client_args={"api_key": "test"}, model_id="test-model" + ), + emit=Emit.TOOL_CALLS | Emit.USAGE, + ) + await adapter.on_started("Bot", "A bot") + + if fails: + with pytest.raises(EventLoopException, match="provider down"): + await _run_turn(adapter, tools) + else: + await _run_turn(adapter, tools) + + tools.assert_message_sent(content="hi", count=1) + (call,) = events_of_type(tools, MessageType.TOOL_CALL) + (result,) = events_of_type(tools, MessageType.TOOL_RESULT) + assert json.loads(call["content"])["tool_call_id"] == peer.CALL_ID + result_data = json.loads(result["content"]) + assert json.loads(result_data.pop("output")) == { + "id": "msg-0", + "recipients": [{"handle": "tester", "id": "tester", "name": None}], + "success": True, + } + assert result_data == { + "name": SEND_TURN.name, + "tool_call_id": peer.CALL_ID, + "is_error": False, + } + (usage,) = [ + event for event in tools.events_sent if is_usage_event(event["metadata"]) + ] + completed_calls = 1 if fails else 2 + assert usage["metadata"][USAGE_METADATA_KEY] == { + "input_tokens": completed_calls * peer.INPUT_TOKENS, + "output_tokens": completed_calls * peer.OUTPUT_TOKENS, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + } + assert failure_reports(tools) == ( + [("strands", GENERIC_PROVIDER_FAILURE_MESSAGE)] if fails else [] + ) + @pytest.mark.asyncio async def test_provider_failure_keeps_the_transcript_and_reports_usage( self, tools, scripted diff --git a/tests/e2e/baseline/expected-cells.json b/tests/e2e/baseline/expected-cells.json index a027f4386..ddff3c48d 100644 --- a/tests/e2e/baseline/expected-cells.json +++ b/tests/e2e/baseline/expected-cells.json @@ -523,6 +523,15 @@ "status": "pass", "reason": null }, + { + "test": "tests/e2e/baseline/guards/test_strands_provider.py::test_strands_builder_executes_a_responses_tool_once", + "adapter": "suite", + "lanes": [ + "core" + ], + "status": "pass", + "reason": null + }, { "test": "tests/e2e/baseline/guards/test_tool_spec.py::test_ctx_field_collision_raises_clear_error", "adapter": "suite", diff --git a/tests/e2e/baseline/guards/test_strands_provider.py b/tests/e2e/baseline/guards/test_strands_provider.py new file mode 100644 index 000000000..25e383346 --- /dev/null +++ b/tests/e2e/baseline/guards/test_strands_provider.py @@ -0,0 +1,71 @@ +"""Exercise the baseline builder through the released provider's HTTP tool loop.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import BaseModel + +from band.runtime.custom_tools import declares_turn_effect +from band.runtime.tools import TurnEffect +from band.testing import FakeAgentTools +from tests.baseline.adapter import Adapter +from tests.e2e.baseline.agents import lane +from tests.e2e.baseline.settings import BaselineSettings, LLMCredentials, LLMModels +from tests.e2e.baseline.toolkit.adapters import build_adapter +from tests.e2e.baseline.toolkit.deps import Lane +from tests.e2e.baseline.toolkit.tools import ToolSpec +from tests.framework_conformance.turnprobes import turn_input + + +class RecordInput(BaseModel): + """Record a note.""" + + note: str + + +@pytest.mark.asyncio +@lane(Lane.CORE) +async def test_strands_builder_executes_a_responses_tool_once( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from band.testing import ( # noqa: PLC0415 -- Strands is absent from the crewai/parlant lanes + TextTurn, + ToolTurn, + ) + from tests.responsespeer import ( # noqa: PLC0415 -- this HTTP peer uses Strands turn fixtures + ResponsesPeer, + ) + + ledger = tmp_path / "notes.txt" + + @declares_turn_effect(TurnEffect.ACT) + def record(args: BaseModel) -> str: + assert isinstance(args, RecordInput) + with ledger.open("a", encoding="utf-8") as notes: + notes.write(args.note + "\n") + return "recorded" + + tool = ToolSpec(RecordInput, record) + peer = ResponsesPeer([ToolTurn(tool.name, {"note": "hello"}), TextTurn("")]) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + settings = BaselineSettings( + llm_credentials=LLMCredentials(openai_api_key="test"), + llm_models=LLMModels(E2E_LLM_MODEL="configured-model"), + ) + adapter = build_adapter(Adapter.STRANDS, settings, tools=[tool]) + await adapter.on_started("Bot", "A bot") + tools = FakeAgentTools() + + await adapter.on_event(turn_input(tools)) + + assert peer.paths == ["/v1/responses", "/v1/responses"] + assert all(request["model"] == "configured-model" for request in peer.requests) + assert all(request["store"] is False for request in peer.requests) + assert all("previous_response_id" not in request for request in peer.requests) + assert peer.tool_outputs(1) == [ + {"type": "function_call_output", "call_id": peer.CALL_ID, "output": "recorded"} + ] + assert ledger.read_text(encoding="utf-8") == "hello\n" + assert tools.turn.complete diff --git a/tests/e2e/baseline/toolkit/builders.py b/tests/e2e/baseline/toolkit/builders.py index 80140c089..647ed3c92 100644 --- a/tests/e2e/baseline/toolkit/builders.py +++ b/tests/e2e/baseline/toolkit/builders.py @@ -233,8 +233,8 @@ def _build_strands( features: AdapterFeatures | None, tools: list[ToolSpec] | None = None, ) -> SimpleAdapter[Any]: - from strands.models.openai import ( # noqa: PLC0415 -- isolates the strands extra from the other frameworks this file builds - OpenAIModel, + from strands.models.openai_responses import ( # noqa: PLC0415 -- isolates the strands extra from the other frameworks this file builds + OpenAIResponsesModel, ) from band.adapters.strands import ( # noqa: PLC0415 -- isolates the strands extra from the other frameworks this file builds @@ -247,7 +247,7 @@ def _build_strands( api_key = s.llm_credentials.openai_api_key return StrandsAdapter( StrandsAdapterConfig(custom_section=prompt), - llm=OpenAIModel( + llm=OpenAIResponsesModel( client_args={"api_key": api_key} if api_key else None, model_id=s.llm_models.openai_model, ), diff --git a/tests/responsespeer.py b/tests/responsespeer.py new file mode 100644 index 000000000..b6c5b5b7b --- /dev/null +++ b/tests/responsespeer.py @@ -0,0 +1,122 @@ +"""HTTP/SSE peer for exercising the real OpenAI Responses client and tool loop.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +import httpx +from openai import AsyncOpenAI +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Route + +from band.testing import ErrorTurn, ScriptedTurn, TextTurn, ToolTurn + + +class ResponsesPeer: + """Serve scripted provider responses while recording the serialized requests.""" + + CALL_ID = "peer-call-1" + INPUT_TOKENS = 7 + OUTPUT_TOKENS = 3 + + def __init__(self, turns: Sequence[ScriptedTurn]) -> None: + self.turns = iter(turns) + self.requests: list[dict[str, Any]] = [] + self.paths: list[str] = [] + self.app = Starlette( + routes=[Route("/v1/responses", self.respond, methods=["POST"])] + ) + + def client(self, **kwargs: Any) -> AsyncOpenAI: + """Keep request construction and SSE decoding in the released OpenAI SDK.""" + return AsyncOpenAI( + **kwargs, + max_retries=0, + http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=self.app)), + ) + + async def respond(self, request: Request) -> Response: + self.paths.append(request.url.path) + self.requests.append(await request.json()) + events: list[dict[str, Any]] = [] + match next(self.turns): + case ToolTurn(name=name, args=args): + item_id = "fc-1" + call_id = ( + self.CALL_ID + if len(self.requests) == 1 + else f"{self.CALL_ID}-{len(self.requests)}" + ) + events.extend( + [ + { + "type": "response.output_item.added", + "output_index": 0, + "item": { + "type": "function_call", + "id": item_id, + "call_id": call_id, + "name": name, + "arguments": "", + "status": "in_progress", + }, + }, + { + "type": "response.function_call_arguments.done", + "item_id": item_id, + "output_index": 0, + "arguments": json.dumps(args), + }, + ] + ) + case TextTurn(text=text): + events.append( + { + "type": "response.output_text.delta", + "item_id": "msg-1", + "output_index": 0, + "content_index": 0, + "delta": text, + } + ) + case ErrorTurn(error=error): + events.append( + { + "type": "response.failed", + "response": { + "error": {"code": "server_error", "message": str(error)} + }, + } + ) + if not any(event["type"] == "response.failed" for event in events): + events.append( + { + "type": "response.completed", + "response": { + "usage": { + "input_tokens": self.INPUT_TOKENS, + "output_tokens": self.OUTPUT_TOKENS, + "total_tokens": self.INPUT_TOKENS + self.OUTPUT_TOKENS, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + } + }, + } + ) + frames = "".join( + f"event: {event['type']}\ndata: {json.dumps({**event, 'sequence_number': index})}\n\n" + for index, event in enumerate(events) + ) + return Response(frames, media_type="text/event-stream") + + def tool_outputs(self, request_index: int) -> list[dict[str, Any]]: + """Function results sent back to the provider on a subsequent request.""" + return [ + item + for item in self.requests[request_index]["input"] + if item.get("type") == "function_call_output" + ] diff --git a/uv.lock b/uv.lock index 531e0d520..4ca9f558d 100644 --- a/uv.lock +++ b/uv.lock @@ -797,6 +797,7 @@ slack = [ { name = "uvicorn" }, ] strands = [ + { name = "openai" }, { name = "strands-agents", extra = ["openai"] }, ] @@ -890,6 +891,7 @@ requires-dist = [ { name = "openai", marker = "extra == 'langgraph'", specifier = ">=2.0.0" }, { name = "openai", marker = "extra == 'parlant'", specifier = ">=2.0.0" }, { name = "openai", marker = "extra == 'pydantic-ai'", specifier = ">=2.0.0" }, + { name = "openai", marker = "extra == 'strands'", specifier = ">=2.0.0" }, { name = "opentelemetry-instrumentation-logging", marker = "extra == 'dev'", specifier = ">=0.65b0" }, { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'dev'", specifier = ">=1.12.0a0,!=1.13.0" }, { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'google-adk'", specifier = ">=1.12.0a0,!=1.13.0" }, @@ -954,8 +956,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 = "strands-agents", extras = ["openai"], marker = "extra == 'dev'", specifier = ">=1.50.1,<2" }, + { name = "strands-agents", extras = ["openai"], marker = "extra == 'strands'", specifier = ">=1.50.1,<2" }, { name = "tenacity", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "tenacity", marker = "extra == 'dev-crewai'", specifier = ">=8.0.0" }, { name = "tenacity", marker = "extra == 'dev-parlant'", specifier = ">=8.0.0" }, From 97f83301f6506f5a8cddac8d1b57e73b7d992f87 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Tue, 13 Oct 2026 17:42:10 +0000 Subject: [PATCH 02/13] fix: bound native Google ADK generation retries (#773) * fix: bound native Google ADK generation retries * fix: enforce Google ADK HTTP attempt bounds * test: centralize Google HTTP peer modes * fix: cap Google ADK generation retry budget in config Reject max_retries above GOOGLE_ADK_MAX_GENERATION_RETRIES and document the upgrade default (one HTTP attempt to three) in the Google ADK examples README. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- examples/google_adk/README.md | 23 + pyproject.toml | 7 +- src/band/adapters/google_adk.py | 85 +++- tests/adapters/test_google_adk_adapter.py | 456 ++++++++++++++++-- .../turnprobes/toolloop.py | 3 + uv.lock | 340 ++++++------- 6 files changed, 681 insertions(+), 233 deletions(-) create mode 100644 examples/google_adk/README.md diff --git a/examples/google_adk/README.md b/examples/google_adk/README.md new file mode 100644 index 000000000..72ef44110 --- /dev/null +++ b/examples/google_adk/README.md @@ -0,0 +1,23 @@ +# Google ADK examples + +`GoogleADKAdapterConfig.max_retries` defaults to `2`: each Gemini generation +request makes at most three HTTP attempts. Upgrading from SDK versions before +this default: ADK left native retries unset, so genai made a single HTTP attempt +per generation unless you configured retries yourself. Google genai owns +transient error classification, exponential backoff and jitter. Completed tools +and the ADK runner are never replayed. Exhausted errors retain normal turn +failure reporting. Generation uses HTTPX to avoid aiohttp reconnect resends +outside that budget. + +Set `GoogleADKAdapterConfig(max_retries=0)` for one HTTP attempt per request. +The frozen config accepts budgets from `0` through `10` inclusive +(`GOOGLE_ADK_MAX_GENERATION_RETRIES`). + +Native HTTP retries run inside one test attempt. They do not rescue a failed +test or guarantee provider availability. First-attempt E2E accounting keeps +exhausted errors, missing cells and required skips red. + +For focused retry diagnostics, set the `google_genai._api_client` logger to +`INFO` to observe the native retry count, exception class and delay. Failed +requests contribute no fabricated token usage; successful model responses +contribute to one Band usage record for the turn. diff --git a/pyproject.toml b/pyproject.toml index 976b4ea26..d493a866e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,7 +163,8 @@ agentcore_runtime = [ "uvicorn>=0.29", ] google_adk = [ - "google-adk>=1.0.0,<2", + "google-adk>=1.10.0,<2", + "google-genai>=1.75.0", "opentelemetry-resourcedetector-gcp>=1.12.0a0,!=1.13.0", ] agno = [ @@ -233,9 +234,9 @@ dev = [ # Include Slack deps for testing "slack-sdk>=3.27.0", # Include Gemini SDK for testing - "google-genai>=1.43.0", + "google-genai>=1.75.0", # Include google-adk for testing - "google-adk>=1.0.0,<2", + "google-adk>=1.10.0,<2", # Google ADK currently requires this pre-release transitively. "opentelemetry-resourcedetector-gcp>=1.12.0a0,!=1.13.0", # Include Agno for testing diff --git a/src/band/adapters/google_adk.py b/src/band/adapters/google_adk.py index 64ed664b1..374c84f65 100644 --- a/src/band/adapters/google_adk.py +++ b/src/band/adapters/google_adk.py @@ -13,9 +13,10 @@ import logging import re import uuid -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, cast -from pydantic import PositiveInt, ValidationError +import httpx +from pydantic import Field, PositiveInt, ValidationError from typing_extensions import Unpack from band.converters.google_adk import GoogleADKHistoryConverter, GoogleADKMessages @@ -50,11 +51,15 @@ if TYPE_CHECKING: from google.adk.runners import InMemoryRunner from google.adk.tools import ToolContext + from google.genai import Client logger = logging.getLogger(__name__) _APP_NAME = "band" +# Extra HTTP attempts per generation request (total attempts = max_retries + 1). +GOOGLE_ADK_MAX_GENERATION_RETRIES: Final[int] = 10 + # Candidate method names that google-adk BaseTool may use to expose tool # declarations. The bridge overrides every match found on the installed # version so it keeps working if ADK renames the internal API. @@ -105,7 +110,7 @@ def _sanitize_adk_agent_name(agent_name: str) -> str: @functools.lru_cache(maxsize=1) -def _require_adk() -> tuple[type, type, type, Any]: +def _require_adk() -> tuple[type, type, type, type, Any]: """Import Google ADK dependencies lazily. Cached after the first successful call so repeated access is free. @@ -113,30 +118,64 @@ def _require_adk() -> tuple[type, type, type, Any]: module import time. Returns: - (ADKAgent, InMemoryRunner, BaseTool, types) tuple. + (ADKAgent, InMemoryRunner, BaseTool, Gemini, types) tuple. Raises: ImportError: If google-adk is not installed. """ try: + from google import ( # noqa: PLC0415 -- optional google_adk extra + genai, + ) from google.adk import ( # noqa: PLC0415 -- genuinely deferred; google_adk extra kept out of this module's unconditional import surface Agent as ADKAgent, ) + from google.adk.models.google_llm import ( # noqa: PLC0415 -- optional google_adk extra + Gemini, + ) from google.adk.runners import ( # noqa: PLC0415 -- genuinely deferred; google_adk extra kept out of this module's unconditional import surface InMemoryRunner, ) from google.adk.tools import ( # noqa: PLC0415 -- genuinely deferred; google_adk extra kept out of this module's unconditional import surface BaseTool, ) - from google.genai import ( # noqa: PLC0415 -- genuinely deferred; google_adk extra kept out of this module's unconditional import surface - types, - ) except ImportError as exc: raise ImportError( "google-adk is required for GoogleADKAdapter. " "Install with: uv add band-sdk[google_adk]" ) from exc - return ADKAgent, InMemoryRunner, BaseTool, types + + class BoundedGemini(Gemini): + @functools.cached_property + def httpx_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient() + + @functools.cached_property + def api_client(self) -> Client: + # aiohttp reconnects outside genai's configured attempt budget. + return genai.Client( + http_options=genai.types.HttpOptions( + headers=self._tracking_headers, + retry_options=self.retry_options, + httpx_async_client=self.httpx_client, + ) + ) + + async def aclose(self) -> None: + client = self.__dict__.get("api_client") + httpx_client = self.__dict__.get("httpx_client") + try: + if client is not None: + await client.aio.aclose() + finally: + try: + if httpx_client is not None: + await httpx_client.aclose() + finally: + if client is not None: + client.close() + + return ADKAgent, InMemoryRunner, BaseTool, BoundedGemini, genai.types @functools.lru_cache(maxsize=1) @@ -156,7 +195,7 @@ class is created only once. the declaration mechanism works end-to-end (not just that the method exists), catching signature changes that ``hasattr`` alone would miss. """ - _, _, BaseTool, types = _require_adk() + _, _, BaseTool, _, types = _require_adk() # Detect which declaration methods the installed BaseTool exposes. active_methods = [ @@ -170,7 +209,7 @@ class is created only once. "google-adk BaseTool has no known declaration method " f"(tried: {', '.join(_DECLARATION_CANDIDATES)}). " "This adapter relies on overriding the declaration method " - "(pinned to google-adk >=1.0,<2). Your installed version " + "(pinned to google-adk >=1.10,<2). Your installed version " "may be incompatible." ) @@ -303,7 +342,7 @@ async def run_async( "google-adk BaseTool declaration smoke-test failed. " f"Method '{active_methods[0]}' exists but did not return a valid " "FunctionDeclaration. The adapter is pinned to google-adk " - ">=1.0,<2 — your installed version may be incompatible." + ">=1.10,<2 — your installed version may be incompatible." ) from exc return _BandToolBridge @@ -316,6 +355,9 @@ class GoogleADKAdapterConfig(BaseAdapterConfig): model: Gemini model name passed to the ADK agent. system_prompt: Replaces the rendered Band system prompt entirely. custom_section: Extra instructions appended to the rendered prompt. + max_retries: Extra HTTP attempts per provider generation request; + never retries a tool or turn. Zero disables recovery. Capped at + :data:`GOOGLE_ADK_MAX_GENERATION_RETRIES`. max_history_messages: Most recent room messages replayed as a transcript at the start of each turn. max_transcript_chars: Character cap on that transcript; older lines @@ -325,6 +367,11 @@ class GoogleADKAdapterConfig(BaseAdapterConfig): model: str = GEMINI_MODEL system_prompt: str | None = None custom_section: str | None = None + max_retries: int = Field( + default=2, + ge=0, + le=GOOGLE_ADK_MAX_GENERATION_RETRIES, + ) max_history_messages: PositiveInt = 50 max_transcript_chars: PositiveInt = 100_000 @@ -457,12 +504,17 @@ def _build_adk_tools(self, tools: AgentToolsProtocol) -> list[Any]: def _create_runner(self, tools: AgentToolsProtocol) -> InMemoryRunner: """Create a fresh ADK InMemoryRunner with current tools.""" - ADKAgent, InMemoryRunnerCls, _, _ = _require_adk() + ADKAgent, InMemoryRunnerCls, _, Gemini, types = _require_adk() adk_tools = self._build_adk_tools(tools) adk_agent = ADKAgent( name=_sanitize_adk_agent_name(self.agent_name), - model=self.config.model, + model=Gemini( + model=self.config.model, + retry_options=types.HttpRetryOptions( + attempts=self.config.max_retries + 1 + ), + ), instruction=self._system_prompt, tools=adk_tools, ) @@ -489,7 +541,7 @@ async def on_message( Uses ADK's Runner for the full tool loop. The runner handles LLM calls, tool execution, and conversation management. """ - _, _, _, types = _require_adk() + _, _, _, _, types = _require_adk() logger.debug("Handling message %s in room %s", msg.id, room_id) @@ -636,7 +688,10 @@ async def on_message( await self.emit_usage(tools, turn_usage) finally: if runner is not None: - await runner.close() + try: + await runner.close() + finally: + await cast(Any, runner.agent).model.aclose() # Accumulate message history for future transcript injection self._room_history[room_id].append( diff --git a/tests/adapters/test_google_adk_adapter.py b/tests/adapters/test_google_adk_adapter.py index 79c4745d6..0bed0dc8e 100644 --- a/tests/adapters/test_google_adk_adapter.py +++ b/tests/adapters/test_google_adk_adapter.py @@ -12,14 +12,19 @@ import asyncio import importlib import json -from collections.abc import AsyncGenerator, Callable, Iterator +import logging +from collections.abc import AsyncIterator, Callable from datetime import UTC, datetime -from typing import Any, ClassVar +from enum import StrEnum +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from uuid import uuid4 +import httpx import pytest -from google.genai import types +import pytest_asyncio +from google import genai +from google.genai import Client, types +from google.genai.errors import APIError from pydantic import BaseModel, Field from band.core.protocols import ( @@ -29,13 +34,22 @@ from band.runtime.tools import AgentTools, BandTool from band.testing import ( FakeAgentTools, + failure_reports, ) from tests.adapters.genaikit import ( + SCRIPTED_USAGE, ModelFailureCases, + PlatformSchemaFakeTools, + ScriptedAdapter, no_candidates, + text_reply, + tool_call, ) +from tests.adapters.usage_events import recorded_usage_payloads from tests.framework_conformance.turnprobes import ( CUSTOM_TOOL_DECLARATIONS, + DispatchingFakeTools, + turn_input, ) pytest.importorskip("google.adk", reason="google-adk not installed") @@ -47,44 +61,176 @@ _get_tool_bridge_class = _google_adk_mod._get_tool_bridge_class _BandToolBridge = _get_tool_bridge_class() _sanitize_adk_agent_name = _google_adk_mod._sanitize_adk_agent_name -_adk_models = importlib.import_module("google.adk.models") +_google_llm = importlib.import_module("google.adk.models.google_llm") -class ScriptedGenaiModel(_adk_models.BaseLlm): - """A local ADK model that answers each request with the next scripted genai - response, read through ADK's own ``LlmResponse.create``.""" +LOCAL_API_KEY = "local-test-key" +EFFECT_ARGS = {"content": "Completed effect", "mentions": ["@alice"]} - scripts: ClassVar[dict[str, list[types.GenerateContentResponse]]] = {} - @classmethod - def supported_models(cls) -> list[str]: - return [r"fake-gemini-.*"] +class HTTPMode(StrEnum): + MOCK = "mock" + TCP = "tcp" + PROXY = "proxy" - async def generate_content_async( - self, llm_request: Any, stream: bool = False - ) -> AsyncGenerator[Any, None]: - yield _adk_models.LlmResponse.create(self.scripts[self.model].pop(0)) +class RecoveryTools(DispatchingFakeTools, PlatformSchemaFakeTools): + """Advertise platform schemas and execute the observable fake tool effects.""" -_adk_models.LLMRegistry.register(ScriptedGenaiModel) +class GoogleHTTPPeer: + """Script provider HTTP responses while retaining the real ADK tool loop.""" -@pytest.fixture -def scripted_adapter() -> Iterator[Callable[..., Any]]: - """Build a started adapter whose model plays ``responses`` in order.""" - models: list[str] = [] + def __init__(self) -> None: + self.responses: list[int | types.GenerateContentResponse | None] = [] + self.requests: list[httpx.Request] = [] + self.clients: list[Client] = [] + self.initial_delay = 0.001 + self.max_delay = 0.001 + self.retry_started = asyncio.Event() + + def respond(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + response = self.responses.pop(0) + assert response is not None, "Disconnects require the TCP peer" + if isinstance(response, int): + return httpx.Response( + response, + json={"error": {"code": response, "message": "Provider unavailable"}}, + ) + return httpx.Response( + 200, json=response.model_dump(mode="json", by_alias=True, exclude_none=True) + ) + + @property + def generation_requests(self) -> list[dict[str, Any]]: + return [json.loads(request.content) for request in self.requests] + + @property + def clients_closed(self) -> bool: + return bool(self.clients) and all( + client._api_client._httpx_client.is_closed + and client._api_client._async_httpx_client.is_closed + for client in self.clients + ) + + async def serve( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + headers = await reader.readuntil(b"\r\n\r\n") + request_headers = httpx.Headers( + line.split(b":", 1) + for line in headers.split(b"\r\n")[1:] + if b":" in line + ) + content = await reader.readexactly(int(request_headers["content-length"])) + request = httpx.Request( + "POST", + "http://localhost/generate", + headers=request_headers, + content=content, + ) + if self.responses[0] is None: + self.responses.pop(0) + self.requests.append(request) + return + response = self.respond(request) + writer.write( + f"HTTP/1.1 {response.status_code} {response.reason_phrase}\r\n" + "Content-Type: application/json\r\nConnection: close\r\n" + f"Content-Length: {len(response.content)}\r\n\r\n".encode() + + response.content + ) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + +@pytest_asyncio.fixture(loop_scope="function") +async def google_http( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[GoogleHTTPPeer]: + peer = GoogleHTTPPeer() + mode = HTTPMode(getattr(request, "param", HTTPMode.MOCK)) + server = ( + await asyncio.start_server(peer.serve, "127.0.0.1", 0) + if mode != HTTPMode.MOCK + else None + ) + base_url = ( + f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}" if server else None + ) + if mode == HTTPMode.PROXY: + assert base_url is not None + monkeypatch.setenv("HTTP_PROXY", base_url) + monkeypatch.setenv("NO_PROXY", "") + base_url = "http://provider.invalid" + elif server is not None: + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") + + def create_client(*, http_options: types.HttpOptions) -> Client: + retry = http_options.retry_options + if retry is not None: + retry = retry.model_copy( + update={ + "initial_delay": peer.initial_delay, + "max_delay": peer.max_delay, + "jitter": 0.001, + } + ) + options: dict[str, Any] = {"retry_options": retry} + if base_url is not None: + options["base_url"] = base_url + else: + options["httpx_async_client"] = None + options["async_client_args"] = { + "transport": httpx.MockTransport(peer.respond) + } + client = Client( + api_key=LOCAL_API_KEY, + http_options=http_options.model_copy(update=options), + ) + # Signal from native backoff, after the retry decision and before sleeping. + native_before_sleep = client._api_client._async_retry.before_sleep + + def before_sleep(state: Any) -> None: + peer.retry_started.set() + if native_before_sleep is not None: + native_before_sleep(state) + client._api_client._async_retry.before_sleep = before_sleep + peer.clients.append(client) + return client + + with ( + patch.object(genai, "Client", create_client), + patch.object(_google_llm, "Client", create_client), + ): + try: + yield peer + finally: + for client in peer.clients: + try: + await client.aio.aclose() + finally: + await client._api_client._async_httpx_client.aclose() + client.close() + if server is not None: + server.close() + await server.wait_closed() + + +@pytest.fixture +def scripted_adapter(google_http: GoogleHTTPPeer) -> ScriptedAdapter: async def build(*responses: types.GenerateContentResponse, **features: Any) -> Any: - model = f"fake-gemini-{uuid4().hex}" - ScriptedGenaiModel.scripts[model] = list(responses) - models.append(model) - adapter = GoogleADKAdapter(GoogleADKAdapterConfig(model=model), **features) + google_http.responses = list(responses) + adapter = GoogleADKAdapter(**features) await adapter.on_started("TestBot", "Test bot") return adapter - yield build - for model in models: - ScriptedGenaiModel.scripts.pop(model) + return build @pytest.fixture @@ -147,7 +293,8 @@ async def test_runner_agent_uses_configured_model(self, mock_tools): runner = adapter._create_runner(mock_tools) - assert runner.agent.model == "gemini-2.5-pro" + assert runner.agent.model.model == "gemini-2.5-pro" + await runner.close() @pytest.mark.asyncio async def test_custom_section_is_rendered_into_the_prompt(self): @@ -163,6 +310,17 @@ def test_history_bounds_must_be_positive(self, field): ): GoogleADKAdapterConfig(**{field: 0}) + @pytest.mark.parametrize( + ("value", "match"), + [ + (-1, "max_retries\n Input should be greater than or equal to 0"), + (11, "max_retries\n Input should be less than or equal to 10"), + ], + ) + def test_max_retries_rejects_out_of_range_budgets(self, value: int, match: str): + with pytest.raises(ValueError, match=match): + GoogleADKAdapterConfig(max_retries=value) + def test_execution_reporting_default(self): """Should default emit to everything the adapter supports.""" adapter = GoogleADKAdapter() @@ -1602,3 +1760,243 @@ async def test_concurrent_messages_different_rooms(self, mock_tools): async def _empty_async_iter(**kwargs): return yield # Make it an async generator + + +@pytest.mark.parametrize("google_http", [HTTPMode.MOCK, HTTPMode.TCP], indirect=True) +class TestNativeHTTPRecovery: + @pytest.mark.parametrize( + ("responses", "max_retries", "expected_calls", "error_code"), + [ + pytest.param( + [ + 503, + tool_call( + BandTool.SEND_MESSAGE, + {"content": "Recovered", "mentions": ["@alice"]}, + ), + text_reply("Done"), + ], + 2, + 3, + None, + id="recover", + ), + pytest.param([503, 503, 503], 2, 3, 503, id="exhaust"), + pytest.param([503], 0, 1, 503, id="disabled"), + pytest.param([400], 2, 1, 400, id="nonretryable"), + ], + ) + @pytest.mark.asyncio + async def test_generation_budget_and_failure_reporting( + self, + google_http: GoogleHTTPPeer, + responses: list[int | types.GenerateContentResponse], + max_retries: int, + expected_calls: int, + error_code: int | None, + caplog: pytest.LogCaptureFixture, + ) -> None: + google_http.responses = responses.copy() + adapter = GoogleADKAdapter( + GoogleADKAdapterConfig(max_retries=max_retries), emit={Emit.USAGE} + ) + await adapter.on_started("TestBot", "Test bot") + tools = RecoveryTools() + with caplog.at_level(logging.INFO, logger="google_genai._api_client"): + if error_code is None: + await adapter.on_event(turn_input(tools)) + assert failure_reports(tools) == [] + assert tools.chat == ["Recovered"] + [usage] = recorded_usage_payloads(tools) + assert (usage["input_tokens"], usage["output_tokens"]) == ( + SCRIPTED_USAGE.prompt_token_count * 2, + SCRIPTED_USAGE.candidates_token_count * 2, + ) + else: + with pytest.raises(APIError) as raised: + await adapter.on_event(turn_input(tools)) + assert raised.value.code == error_code + assert failure_reports(tools) == [ + ("google_adk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ] + assert tools.chat == [] + assert recorded_usage_payloads(tools) == [] + assert len(google_http.requests) == expected_calls + assert google_http.clients_closed + repeated = ( + google_http.generation_requests[:2] + if error_code is None + else google_http.generation_requests + ) + assert repeated == [repeated[0]] * len(repeated) + retry_logs = [ + r.getMessage() + for r in caplog.records + if r.name == "google_genai._api_client" + and r.getMessage().startswith("Retrying ") + ] + assert len(retry_logs) == (1 if error_code is None else expected_calls - 1) + for entry in retry_logs: + assert "ServerError" in entry and "503" in entry + assert LOCAL_API_KEY not in entry + assert "vault code" not in entry and "Completed effect" not in entry + + @pytest.mark.parametrize("exhausted", [False, True], ids=["recover", "exhaust"]) + @pytest.mark.asyncio + async def test_post_tool_recovery_never_replays_the_effect( + self, google_http: GoogleHTTPPeer, exhausted: bool + ) -> None: + google_http.responses = [ + tool_call( + BandTool.SEND_MESSAGE, + EFFECT_ARGS, + ), + 503, + *([503, 503] if exhausted else [text_reply("Done")]), + ] + adapter = GoogleADKAdapter(emit={Emit.USAGE}) + await adapter.on_started("TestBot", "Test bot") + tools = RecoveryTools() + if exhausted: + with pytest.raises(APIError): + await adapter.on_event(turn_input(tools)) + assert failure_reports(tools) == [ + ("google_adk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ] + else: + await adapter.on_event(turn_input(tools)) + assert failure_reports(tools) == [] + assert tools.chat == ["Completed effect"] + assert google_http.clients_closed + assert tools.tool_calls == [ + { + "tool_name": BandTool.SEND_MESSAGE, + "arguments": EFFECT_ARGS, + } + ] + [usage] = recorded_usage_payloads(tools) + successful_responses = 1 if exhausted else 2 + assert (usage["input_tokens"], usage["output_tokens"]) == ( + SCRIPTED_USAGE.prompt_token_count * successful_responses, + SCRIPTED_USAGE.candidates_token_count * successful_responses, + ) + requests = google_http.generation_requests + assert len(requests) == (4 if exhausted else 3) + assert requests[1:] == [requests[1]] * (len(requests) - 1) + assert any( + "functionResponse" in part + for content in requests[1]["contents"] + for part in content["parts"] + ) + + @pytest.mark.asyncio + async def test_cancellation_during_backoff_stops_generation( + self, google_http: GoogleHTTPPeer + ) -> None: + google_http.responses = [ + tool_call( + BandTool.SEND_MESSAGE, + EFFECT_ARGS, + ), + 503, + ] + google_http.initial_delay = 60 + google_http.max_delay = 60 + adapter = GoogleADKAdapter(emit={Emit.USAGE}) + await adapter.on_started("TestBot", "Test bot") + tools = RecoveryTools() + task = asyncio.create_task(adapter.on_event(turn_input(tools))) + try: + await asyncio.wait_for(google_http.retry_started.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + assert len(google_http.requests) == 2 + assert google_http.clients_closed + assert tools.tool_calls == [ + { + "tool_name": BandTool.SEND_MESSAGE, + "arguments": EFFECT_ARGS, + } + ] + assert failure_reports(tools) == [] + assert tools.chat == ["Completed effect"] + # Cancellation suppresses best-effort usage emission by contract. + assert recorded_usage_payloads(tools) == [] + + +class TestWireBudget: + @pytest.mark.parametrize("google_http", [HTTPMode.PROXY], indirect=True) + @pytest.mark.asyncio + async def test_generation_preserves_environment_proxy_routing( + self, google_http: GoogleHTTPPeer + ) -> None: + google_http.responses = [ + tool_call(BandTool.SEND_MESSAGE, EFFECT_ARGS), + text_reply("Done"), + ] + adapter = GoogleADKAdapter() + await adapter.on_started("TestBot", "Test bot") + tools = RecoveryTools() + await adapter.on_event(turn_input(tools)) + assert tools.chat == ["Completed effect"] + assert len(google_http.requests) == 2 + assert google_http.clients_closed + + @pytest.mark.parametrize("google_http", [HTTPMode.TCP], indirect=True) + @pytest.mark.asyncio + async def test_provider_clients_close_when_runner_cleanup_fails( + self, google_http: GoogleHTTPPeer + ) -> None: + google_http.responses = [ + tool_call(BandTool.SEND_MESSAGE, EFFECT_ARGS), + text_reply("Done"), + ] + adapter = GoogleADKAdapter() + await adapter.on_started("TestBot", "Test bot") + _, runner_class, _, _, _ = _google_adk_mod._require_adk() + with ( + patch.object( + runner_class, + "close", + new=AsyncMock(side_effect=RuntimeError("Tool cleanup failed")), + ), + pytest.raises(RuntimeError, match="Tool cleanup failed"), + ): + await adapter.on_event(turn_input(RecoveryTools())) + assert google_http.clients_closed + + @pytest.mark.parametrize("google_http", [HTTPMode.TCP], indirect=True) + @pytest.mark.parametrize("post_tool", [False, True], ids=["initial", "post-tool"]) + @pytest.mark.asyncio + async def test_zero_budget_never_resends_a_disconnected_request( + self, google_http: GoogleHTTPPeer, post_tool: bool + ) -> None: + google_http.responses = [ + *([tool_call(BandTool.SEND_MESSAGE, EFFECT_ARGS)] if post_tool else []), + None, + *([] if post_tool else [tool_call(BandTool.SEND_MESSAGE, EFFECT_ARGS)]), + text_reply("Done"), + ] + adapter = GoogleADKAdapter( + GoogleADKAdapterConfig(max_retries=0), emit={Emit.USAGE} + ) + await adapter.on_started("TestBot", "Test bot") + tools = RecoveryTools() + with pytest.raises(httpx.RemoteProtocolError): + await adapter.on_event(turn_input(tools)) + assert len(google_http.requests) == (2 if post_tool else 1) + assert tools.chat == (["Completed effect"] if post_tool else []) + assert failure_reports(tools) == [ + ("google_adk", GENERIC_PROVIDER_FAILURE_MESSAGE) + ] + assert len(recorded_usage_payloads(tools)) == (1 if post_tool else 0) + assert google_http.clients_closed + + def test_negative_retry_budget_is_rejected(self) -> None: + with pytest.raises(ValueError, match="max_retries"): + GoogleADKAdapterConfig(max_retries=-1) diff --git a/tests/framework_conformance/turnprobes/toolloop.py b/tests/framework_conformance/turnprobes/toolloop.py index 9a8d090c8..00e93e518 100644 --- a/tests/framework_conformance/turnprobes/toolloop.py +++ b/tests/framework_conformance/turnprobes/toolloop.py @@ -234,9 +234,11 @@ async def run_google_adk(script: TurnScript, tools: FakeAgentTools) -> None: from band.adapters.google_adk import ( # noqa: PLC0415 -- google_adk extra, absent from some lane venvs GoogleADKAdapter, _get_tool_bridge_class, + _require_adk, ) tool_bridge = _get_tool_bridge_class() + _, _, _, model_class, _ = _require_adk() def scripted_runner(turn_tools: Any) -> MagicMock: """An ADK runner whose tool loop calls the script through the adapter's @@ -259,6 +261,7 @@ async def run_async(**kwargs: Any) -> AsyncIterator[Any]: runner.session_service.create_session = AsyncMock() runner.run_async = run_async runner.close = AsyncMock() + runner.agent.model = model_class(model=adapter.config.model) return runner adapter = GoogleADKAdapter() diff --git a/uv.lock b/uv.lock index 4ca9f558d..3f62a5c05 100644 --- a/uv.lock +++ b/uv.lock @@ -659,9 +659,9 @@ dev = [ { name = "click" }, { name = "commitizen" }, { name = "github-copilot-sdk" }, - { name = "google-adk", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-adk", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai" }, + { name = "google-adk" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "httpx" }, { name = "langchain" }, { name = "langchain-anthropic" }, @@ -745,11 +745,13 @@ dev-parlant = [ { name = "werkzeug" }, ] gemini = [ - { name = "google-genai" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] google-adk = [ - { name = "google-adk", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-adk", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-adk" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "opentelemetry-resourcedetector-gcp" }, ] langgraph = [ @@ -843,10 +845,11 @@ requires-dist = [ { name = "filelock", specifier = ">=3.12" }, { name = "github-copilot-sdk", marker = "extra == 'copilot-sdk'", specifier = ">=1.0.5" }, { name = "github-copilot-sdk", marker = "extra == 'dev'", specifier = ">=1.0.5" }, - { name = "google-adk", marker = "extra == 'dev'", specifier = ">=1.0.0,<2" }, - { name = "google-adk", marker = "extra == 'google-adk'", specifier = ">=1.0.0,<2" }, - { name = "google-genai", marker = "extra == 'dev'", specifier = ">=1.43.0" }, + { name = "google-adk", marker = "extra == 'dev'", specifier = ">=1.10.0,<2" }, + { name = "google-adk", marker = "extra == 'google-adk'", specifier = ">=1.10.0,<2" }, + { name = "google-genai", marker = "extra == 'dev'", specifier = ">=1.75.0" }, { name = "google-genai", marker = "extra == 'gemini'", specifier = ">=1.43.0" }, + { name = "google-genai", marker = "extra == 'google-adk'", specifier = ">=1.75.0" }, { name = "httpx", marker = "extra == 'bridge'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'bridge-agentcore'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" }, @@ -2379,85 +2382,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/e2/4d8c622545f55f06773548cf86ffe60f59e6a4cedcc412ff84fcac244b2b/github_copilot_sdk-1.0.14-py3-none-any.whl", hash = "sha256:9f4538cec7295f24507c650d794307396458a51f280a0fbbcd840da177109050", size = 614607, upload-time = "2026-09-16T05:11:14.572Z" }, ] -[[package]] -name = "google-adk" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", -] -dependencies = [ - { name = "authlib", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "click", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "fastapi", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-api-python-client", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-aiplatform", version = "1.91.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-secret-manager", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-speech", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-storage", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "graphviz", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "opentelemetry-api", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-gcp-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "python-dotenv", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyyaml", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "sqlalchemy", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tzlocal", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "uvicorn", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/38/a67f59a3ecf9630d93007848ec3f41beec4415014e3fd1fa8f159c7e85fc/google_adk-1.1.1.tar.gz", hash = "sha256:9418675574d2e9fd8a3c8e423186b86fe4ad3f211dd5da400cf24f1ef39fe2d7", size = 1094600, upload-time = "2025-05-29T00:13:31.572Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/1d/cefb793fed19159e8a1975cb1767288b848b9c14020f70c1f0c8048a706c/google_adk-1.1.1-py3-none-any.whl", hash = "sha256:c6cdca1b9cab7126efa4984abaff2d7b26da7945da9e6b0e2e6ea1727fb3f966", size = 1235870, upload-time = "2025-05-29T00:13:29.563Z" }, -] - [[package]] name = "google-adk" version = "1.10.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] dependencies = [ - { name = "absolufy-imports", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "anyio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "authlib", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "click", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "fastapi", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-api-python-client", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "absolufy-imports" }, + { name = "anyio" }, + { name = "authlib" }, + { name = "click" }, + { name = "fastapi" }, + { name = "google-api-python-client" }, + { name = "google-cloud-aiplatform", version = "1.148.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "google-cloud-aiplatform", version = "1.165.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-secret-manager", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-speech", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-storage", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "graphviz", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.13' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.13' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-gcp-trace", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "python-dateutil", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "python-dotenv", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyyaml", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "requests", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "sqlalchemy", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "starlette", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tenacity", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tzlocal", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "uvicorn", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "watchdog", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "websockets", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-secret-manager" }, + { name = "google-cloud-speech" }, + { name = "google-cloud-storage" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "graphviz" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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" }, + { name = "opentelemetry-exporter-gcp-trace" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "tzlocal" }, + { name = "uvicorn" }, + { name = "watchdog" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/1f61a4e6f076d91e9354687d1ea71f61a950512c65eef4382434f7bd948d/google_adk-1.10.0.tar.gz", hash = "sha256:9a35fa6099c1a91c8b970ca98efb80640faad51d93520aa5d7c63ac08ebde8ba", size = 1620716, upload-time = "2025-08-07T16:56:12.856Z" } wheels = [ @@ -2535,7 +2496,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.91.0" +version = "1.148.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -2550,16 +2511,32 @@ dependencies = [ { name = "google-cloud-bigquery", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "google-cloud-resource-manager", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "google-cloud-storage", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "shapely", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/08/5854569782efbbc8efd0aeda3a4486153605104cbab6ac836b2328bae48e/google_cloud_aiplatform-1.91.0.tar.gz", hash = "sha256:b14e5e52b52b6012c7dc253beab34c511fdc53c69b13f436ddb06882c1a92cd7", size = 9102586, upload-time = "2025-04-30T17:15:23.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/88/cea8583fadd142e8ef26f8ec14a6ee4d7c69c4e5ab82bea01a077fddddbe/google_cloud_aiplatform-1.91.0-py2.py3-none-any.whl", hash = "sha256:ff8df100c2af692d114a2219d3abbb96110b3e5655f342fdbb6aefad43901b52", size = 7591910, upload-time = "2025-04-30T17:15:19.6Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" }, +] + +[package.optional-dependencies] +agent-engines = [ + { name = "aiohttp", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "cloudpickle", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-iam", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "opentelemetry-exporter-gcp-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "opentelemetry-exporter-gcp-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] [[package]] @@ -2580,7 +2557,7 @@ dependencies = [ { name = "google-cloud-bigquery", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "google-cloud-resource-manager", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "google-cloud-storage", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "packaging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "proto-plus", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, @@ -2610,11 +2587,11 @@ name = "google-cloud-appengine-logging" version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core", extra = ["grpc"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "grpcio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "proto-plus", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/02/800897064ca6f1a26835cdf23939c4b93e38a30f3fb5c7cec7c01ae2edc2/google_cloud_appengine_logging-1.9.0.tar.gz", hash = "sha256:ff397f0bbc1485f979ab45767c38e0f676c9598c97c384f7412216e6ea22f805", size = 17963, upload-time = "2026-03-30T22:51:33.556Z" } wheels = [ @@ -2626,8 +2603,8 @@ name = "google-cloud-audit-log" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "googleapis-common-protos" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/9f/3aedb3ce1d58c58ec7dd06b3964836eabfd17a16a95b60c8f609c0afff7f/google_cloud_audit_log-0.5.0.tar.gz", hash = "sha256:3b32d5e77db634c46fbd6c5e01f5bda836f420dfbb21d730501c75e9fab4e4a4", size = 44670, upload-time = "2026-03-30T22:50:42.295Z" } wheels = [ @@ -2665,21 +2642,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, ] +[[package]] +name = "google-cloud-iam" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-auth", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "grpc-google-iam-v1", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "grpcio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/08/4f88586e9e1cc65754200fc60adf25c2ca7b7da0407a6dcf13362e69e63a/google_cloud_iam-2.26.0.tar.gz", hash = "sha256:d44a86e5cb0bbc904c459b1367bd0124eef802fe0af72ac378f87f7f8e5b2d42", size = 613465, upload-time = "2026-10-01T18:16:49.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/62/bee32ed798f52c1188c57c5ef9ec584db3829ee02b8a3b2469c6d0dcf314/google_cloud_iam-2.26.0-py3-none-any.whl", hash = "sha256:77bb8d9d8a9c94b1632196fd4d88c52c0f2ceb35075514eb91af25ed6857b300", size = 564869, upload-time = "2026-10-01T18:10:35.8Z" }, +] + [[package]] name = "google-cloud-logging" version = "3.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core", extra = ["grpc"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-appengine-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-audit-log", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-core", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "grpc-google-iam-v1", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "grpcio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "proto-plus", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-appengine-logging" }, + { name = "google-cloud-audit-log" }, + { name = "google-cloud-core" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/99/06/253e9795a5877f35183a7175977ca47a17255fe0c8487155f48b86c83f3e/google_cloud_logging-3.15.0.tar.gz", hash = "sha256:72168a1e98bbfc27c75f0b8f630a7f5d786065f3f1f7e9e53d2d787a03693a4a", size = 294881, upload-time = "2026-03-26T22:18:36.947Z" } wheels = [ @@ -2799,21 +2793,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, ] +[[package]] +name = "google-genai" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "distro", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "httpx", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "requests", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "sniffio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tenacity", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "websockets", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, +] + [[package]] name = "google-genai" version = "2.25.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, + { name = "anyio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "distro", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-auth", extra = ["requests"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "httpx", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "requests", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "sniffio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tenacity", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "websockets", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/0a/a3b7856ca840031d4393dbdd97b67fe811b20061315ed68b67b5c85ca80d/google_genai-2.25.0.tar.gz", hash = "sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6", size = 699564, upload-time = "2026-09-22T17:23:01.238Z" } wheels = [ @@ -5347,10 +5374,10 @@ name = "opentelemetry-exporter-gcp-logging" version = "1.12.0a0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-cloud-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-resourcedetector-gcp", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-logging" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-resourcedetector-gcp" }, + { name = "opentelemetry-sdk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/e4/95ecebaa1c5134adaa0d0374028b25e3b3c5c08535d29a66d39d372a3d11/opentelemetry_exporter_gcp_logging-1.12.0a0.tar.gz", hash = "sha256:586529dbbcae5e22b880f7c121fde3f0fe8ae997aba1bad53f13c20eeb27cb3a", size = 22521, upload-time = "2026-04-28T20:59:40.237Z" } wheels = [ @@ -5390,7 +5417,7 @@ name = "opentelemetry-exporter-otlp-proto-common" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-proto", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "opentelemetry-proto" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } wheels = [ @@ -5420,13 +5447,13 @@ name = "opentelemetry-exporter-otlp-proto-http" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-otlp-proto-common", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-proto", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "requests", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } wheels = [ @@ -5481,7 +5508,7 @@ name = "opentelemetry-proto" version = "1.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "protobuf", marker = "python_full_version < '3.13' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } wheels = [ @@ -7615,65 +7642,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] -[[package]] -name = "shapely" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, - { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, - { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, - { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, - { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, - { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, - { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, - { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, - { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, - { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, - { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, - { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, - { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, - { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, - { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, - { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, - { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, - { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, - { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, - { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, - { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, -] - [[package]] name = "shellingham" version = "1.5.4" From 63de3e2aef25c3ff1892add3a80e8c8d76853da6 Mon Sep 17 00:00:00 2001 From: "band-release-please-public[bot]" <287690955+band-release-please-public[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 07:57:14 +0000 Subject: [PATCH 03/13] fix: bump band-mcp's band-sdk floor to 5.1.0 (#793) Co-authored-by: band-release-bot Co-authored-by: AlexanderZ-Band --- packages/band-mcp/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/band-mcp/pyproject.toml b/packages/band-mcp/pyproject.toml index c9f1491e4..8bd26e1b7 100644 --- a/packages/band-mcp/pyproject.toml +++ b/packages/band-mcp/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ # bump-mcp-sdk-floor job, so this always tracks the most recently # published band-sdk version -- band-mcp is developed in lockstep in # this monorepo and can depend on any already-released SDK feature. - "band-sdk>=5.0.0", + "band-sdk>=5.1.0", "uvicorn>=0.30.0", # Required for SSE transport mode ] From 69e15a20fcd69da8a1241a004062aa28637bcd41 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Wed, 14 Oct 2026 08:52:06 +0000 Subject: [PATCH 04/13] fix: keep a stopped room's synthetic queue from freezing the event loop (#789) * fix: keep a stopped room's synthetic queue from freezing the event loop A STOP during a contact-hub turn's setup was lost, and the synthetic drain read a message it had put back as progress, spinning without yielding. - TurnOutcome (with PAUSED) replaces the bool and BacklogProcessResult; _aborted_outcome is the one mapping for aborted turns. - Synthetic messages stay at the head of their queue until ADVANCED; _drain_synthetic matches on every outcome and nothing is put back. - _begin_turn is the single admission point for WebSocket, backlog and control-probe turns, so a control is never lost after a turn begins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MKLXPCh7iYDz9c8x3TxDpa * test: rename turn-admission platform fixture off the reserved `peer` name The E2E baseline's collection guard rejects any test requesting `peer` without @per_adapter(peer=...), which failed CI's whole-tree collection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MKLXPCh7iYDz9c8x3TxDpa * test: wait on the durable processed mark after PLAY replay The handler records completion before the processed mark's HTTP call lands, so slower Windows runners asserted the mark too early. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MKLXPCh7iYDz9c8x3TxDpa * refactor: tighten turn-outcome dispatch and turn-admission tests - _drain_synthetic blocks on any non-advancing, non-paused outcome instead of re-typing the deferred set, so a new outcome can't spin it. - _aborted_outcome and the resync result dispatch use match/case; the resync match is exhaustive. - _begin_turn logs when a control defers a turn before it begins. - Tests: shared synthetic_message helper, started/stop_room kit helpers, observed-stop INTERRUPT test in its own class, an assertion that could not fail removed, watchdog why-comments moved above their asserts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MKLXPCh7iYDz9c8x3TxDpa * test: finish synthetic_message reuse and clarify review-cycle wording Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MKLXPCh7iYDz9c8x3TxDpa --------- Co-authored-by: Claude Opus 5.5 --- src/band/runtime/execution.py | 239 ++++++++++-------- .../test_backlog_interrupt.py | 6 +- .../test_claim_window_control.py | 10 +- .../executioninterrupt/test_cycle_watchdog.py | 23 +- .../test_interrupt_cycle.py | 12 +- .../test_pending_ack_cancellation.py | 6 +- .../test_shutdown_vs_interrupt.py | 4 +- .../executioninterrupt/test_stop_cycle.py | 4 +- .../test_stop_resume_room.py | 10 +- .../executioninterrupt/test_stopped_acks.py | 11 +- .../executioninterrupt/test_stopped_claims.py | 21 +- .../test_stopped_failure_reporting.py | 12 +- .../executioninterrupt/test_stopped_posts.py | 21 +- tests/runtime/helpers.py | 17 +- tests/runtime/test_activity_stop.py | 7 +- tests/runtime/test_deferral.py | 23 +- tests/runtime/test_execution.py | 42 +-- tests/runtime/turnadmission/__init__.py | 0 tests/runtime/turnadmission/conftest.py | 78 ++++++ tests/runtime/turnadmission/kit.py | 78 ++++++ .../turnadmission/test_grace_controls.py | 94 +++++++ .../test_observed_stop_interrupt.py | 41 +++ .../turnadmission/test_setup_controls.py | 70 +++++ .../test_stopped_synthetic_queue.py | 67 +++++ 24 files changed, 696 insertions(+), 200 deletions(-) create mode 100644 tests/runtime/turnadmission/__init__.py create mode 100644 tests/runtime/turnadmission/conftest.py create mode 100644 tests/runtime/turnadmission/kit.py create mode 100644 tests/runtime/turnadmission/test_grace_controls.py create mode 100644 tests/runtime/turnadmission/test_observed_stop_interrupt.py create mode 100644 tests/runtime/turnadmission/test_setup_controls.py create mode 100644 tests/runtime/turnadmission/test_stopped_synthetic_queue.py diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index 819f477ac..cf0bb8dd0 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -108,10 +108,20 @@ class ResyncRequest: type: str = "_resync" # Matches the .type attribute pattern of PlatformEvent -class BacklogProcessResult(Enum): +class TurnOutcome(Enum): + """Where one delivery attempt left its message.""" + ADVANCED = "advanced" + """Settled: handled, skipped, failed and marked, or interrupted.""" + PAUSED = "paused" + """A STOP left it for replay on PLAY.""" RETRY_LATER = "retry_later" + """Not settled; reconciliation retries it.""" NOT_ACCEPTED = "not_accepted" + """The adapter refused the input (``InputNotAccepted``).""" + + +DEFERRED_OUTCOMES = frozenset({TurnOutcome.RETRY_LATER, TurnOutcome.NOT_ACCEPTED}) class ResyncOutcome(Enum): @@ -508,6 +518,11 @@ async def _retry_processed_ack(self, message_id: str) -> bool: return False + async def _retry_ack_outcome(self, message_id: str) -> TurnOutcome: + if await self._retry_processed_ack(message_id): + return TurnOutcome.ADVANCED + return TurnOutcome.RETRY_LATER + @contextlib.contextmanager def _claim_message(self, message_id: str) -> Iterator[bool]: """Yield whether an in-flight claim was acquired; release iff acquired. @@ -765,6 +780,33 @@ async def _begin_scope(self) -> TurnScope | None: self.current_scope = TurnScope(self._control_revision) return self.current_scope + async def _begin_turn(self, revision: int | None = None) -> TurnScope | None: + """Begin a message turn that controls terminate from this point on.""" + if revision is None: + revision = self._control_revision + scope = await self._begin_scope() + if scope is None: + return None + if revision != self._control_revision: + logger.debug( + "ExecutionContext %s: control changed while admitting the turn; deferring", + self.room_id, + ) + return None + scope.admitted = True + return scope + + @staticmethod + def _aborted_outcome(scope: TurnScope) -> TurnOutcome: + match scope: + case TurnScope(kind=ControlMode.INTERRUPT): + return TurnOutcome.ADVANCED + case TurnScope(stop_observed=True): + # No STOP push arrived; reconciliation learns the room's state. + return TurnOutcome.RETRY_LATER + case _: + return TurnOutcome.PAUSED + async def _stop_retained_tasks(self, deadline: float) -> None: owners = tuple(self._detached_cycle_tasks.values()) for owner in owners: @@ -1164,8 +1206,11 @@ async def _process_loop(self) -> None: ): await self._retain_or_handle_control(event) continue + if self._enqueue_synthetic(event): + await self._drain_synthetic() + continue if ( - await self._process_event(event) is False + await self._process_event(event) in DEFERRED_OUTCOMES and not self._has_pending_work ): await self._reconcile() @@ -1196,9 +1241,7 @@ def _defer_unaccepted_input(self, message_id: str | None) -> None: message_id or "synthetic", ) - def _retain_synthetic( - self, event: PlatformEvent | None, *, first: bool = False - ) -> bool: + def _enqueue_synthetic(self, event: PlatformEvent) -> bool: if not isinstance(event, MessageEvent) or event.payload is None: return False payload = event.payload @@ -1210,14 +1253,27 @@ def _retain_synthetic( item.payload is not None and item.payload.id == payload.id for item in self._pending_synthetic ): - if first: - self._pending_synthetic.appendleft(event) - else: - self._pending_synthetic.append(event) + self._pending_synthetic.append(event) return True + async def _drain_synthetic(self) -> ResyncOutcome | None: + """Run queued synthetic turns in receive order; None once the queue is empty. + + This is ``_process_event``'s only synthetic caller; the head stays + queued until its turn advances. + """ + while self._pending_synthetic: + match await self._process_event(self._pending_synthetic[0]): + case TurnOutcome.ADVANCED: + self._pending_synthetic.popleft() + case TurnOutcome.PAUSED: + return ResyncOutcome.STOPPED + case _: + return ResyncOutcome.BLOCKED + return None + async def _retain_or_handle_control(self, event: MessageEvent) -> None: - if self._retain_synthetic(event): + if self._enqueue_synthetic(event): return if not self._stopped and self._on_message_control is not None: await self._scan_controls() @@ -1266,9 +1322,9 @@ async def _try_control(self, event: MessageEvent) -> bool: self.claims.remember_completed(self.room_id, msg_id) return False with self._claim_message(msg_id) as acquired: - if not acquired or (scope := await self._begin_scope()) is None: + if not acquired or (scope := await self._begin_turn()) is None: return False - revision = self._control_revision + revision = scope.control_revision consumed = False async def probe() -> None: @@ -1276,7 +1332,6 @@ async def probe() -> None: consumed = await self._on_message_control(self, event) try: - scope.admitted = True if not await self.link.mark_processing(self.room_id, msg_id): return False await self._ensure_fresh_context() @@ -1415,10 +1470,7 @@ async def _synchronize_with_next(self) -> bool: result = await self._process_backlog_message( next_msg, revision=revision ) - if result in { - BacklogProcessResult.RETRY_LATER, - BacklogProcessResult.NOT_ACCEPTED, - }: + if result in DEFERRED_OUTCOMES: self._set_deferred_head(next_msg.id) return False if at_sync_point: @@ -1472,11 +1524,11 @@ async def _resync_pending_messages( ) -> ResyncOutcome: """Drain actionable work, distinguishing empty from blocked recovery.""" self._resync_handled_work = False - if self._deferred_message_id is None: - while self._pending_synthetic: - event = self._pending_synthetic.popleft() - if not await self._process_event(event): - return ResyncOutcome.BLOCKED + if ( + self._deferred_message_id is None + and (outcome := await self._drain_synthetic()) is not None + ): + return outcome previous_id: str | None = None try: while True: @@ -1509,11 +1561,14 @@ async def _resync_pending_messages( return ResyncOutcome.STOPPED if revision != self._control_revision: return ResyncOutcome.BLOCKED - if result is BacklogProcessResult.NOT_ACCEPTED: - return ResyncOutcome.BLOCKED - if result is BacklogProcessResult.RETRY_LATER: - self._set_deferred_head(next_msg.id) - return ResyncOutcome.RETRY + match result: + case TurnOutcome.NOT_ACCEPTED: + return ResyncOutcome.BLOCKED + case TurnOutcome.RETRY_LATER: + self._set_deferred_head(next_msg.id) + return ResyncOutcome.RETRY + case TurnOutcome.ADVANCED | TurnOutcome.PAUSED: + pass if next_msg.id == self._deferred_message_id: self._set_deferred_head(None) if self._pending_synthetic: @@ -1540,7 +1595,7 @@ def _backlog_is_nonrunnable(self, msg: PlatformMessage) -> bool: async def _process_backlog_message( self, msg: PlatformMessage, *, revision: int | None = None - ) -> BacklogProcessResult: + ) -> TurnOutcome: """ Process a backlog message from /next during sync. @@ -1564,40 +1619,37 @@ async def _process_backlog_message( agent_id=self._agent_id, ): logger.debug("Skipping self-message %s", msg_id) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED # Skip permanently failed messages if self._retry_tracker.is_permanently_failed(msg_id): logger.debug("Skipping permanently failed message %s", msg_id) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED # Skip if already processed (dedupe) if self.claims.is_completed(self.room_id, msg_id): logger.debug("Skipping duplicate backlog message: %s", msg_id) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED if self.claims.is_ack_pending(self.room_id, msg_id): logger.debug("Retrying processed ack for backlog message: %s", msg_id) - if await self._retry_processed_ack(msg_id): - return BacklogProcessResult.ADVANCED - return BacklogProcessResult.RETRY_LATER + return await self._retry_ack_outcome(msg_id) with self._claim_message(msg_id) as acquired: if not acquired: logger.debug("Deferring in-flight backlog message: %s", msg_id) - return BacklogProcessResult.RETRY_LATER + return TurnOutcome.RETRY_LATER return await self._process_claimed_backlog_message(msg, revision=revision) async def _process_claimed_backlog_message( self, msg: PlatformMessage, *, revision: int | None = None - ) -> BacklogProcessResult: + ) -> TurnOutcome: """Process a backlog message while its in-flight claim is held.""" msg_id = msg.id - if revision is None: - revision = self._control_revision - scope = await self._begin_scope() - if scope is None or revision != self._control_revision: - return BacklogProcessResult.RETRY_LATER + scope = await self._begin_turn(revision) + if scope is None: + return TurnOutcome.RETRY_LATER + revision = scope.control_revision self._set_state(ExecutionState.PROCESSING) logger.info("Processing backlog message %s in room %s", msg_id, self.room_id) @@ -1613,7 +1665,7 @@ async def _process_claimed_backlog_message( self.room_id, ) self.claims.remember_completed(self.room_id, msg_id) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED # Track attempts - check if exceeded BEFORE processing attempts, exceeded = self._retry_tracker.record_attempt(msg_id) @@ -1621,11 +1673,7 @@ async def _process_claimed_backlog_message( logger.warning( "Message %s exceeded max retries (%s attempts)", msg_id, attempts ) - return BacklogProcessResult.ADVANCED - - # From admission a control terminates this scope, even before its - # handler starts. - scope.admitted = True + return TurnOutcome.ADVANCED # Mark as processing on server BEFORE we start. If this fails, do not # invoke the adapter; otherwise the platform will keep returning the @@ -1636,7 +1684,7 @@ async def _process_claimed_backlog_message( self.room_id, msg_id, ) - return BacklogProcessResult.RETRY_LATER + return TurnOutcome.RETRY_LATER if revision != self._control_revision: return await self._defer_superseded_backlog(msg_id) @@ -1653,9 +1701,7 @@ async def _process_claimed_backlog_message( # handled inside _run_cycle and we advance without sending # anything. if not await self._run_cycle(scope, event, msg_id): - if scope.stop_observed: - return BacklogProcessResult.RETRY_LATER - return BacklogProcessResult.ADVANCED + return self._aborted_outcome(scope) # SUCCESS: record ack-pending BEFORE the awaited mark_processed # call, synchronously, so a cancellation landing inside that await @@ -1674,37 +1720,37 @@ async def _process_claimed_backlog_message( self.room_id, msg_id, ) - return BacklogProcessResult.RETRY_LATER + return TurnOutcome.RETRY_LATER logger.debug("Message %s processed successfully", msg_id) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED except InputNotAccepted: self._defer_unaccepted_input(msg_id) - return BacklogProcessResult.NOT_ACCEPTED + return TurnOutcome.NOT_ACCEPTED except RoomExecutionStoppedError: if not self.claims.is_ack_pending(self.room_id, msg_id): self._retry_tracker.discard_attempt(msg_id) - return BacklogProcessResult.RETRY_LATER + return TurnOutcome.RETRY_LATER except Exception as e: # noqa: BLE001 -- the turn boundary: logged by _log_turn_error, then marked failed _log_turn_error(e, "Error processing backlog message %s", msg_id) await self._handle_turn_failure(msg_id, attempts, e) - return BacklogProcessResult.ADVANCED + return TurnOutcome.ADVANCED finally: scope.settled = True self._turn_failure_reported = False self._set_state(ExecutionState.IDLE) - async def _defer_superseded_backlog(self, msg_id: str) -> BacklogProcessResult: + async def _defer_superseded_backlog(self, msg_id: str) -> TurnOutcome: scope = self.current_scope if scope is not None and scope.kind is not None: await self._abort_cycle(scope.kind, msg_id) - return BacklogProcessResult.ADVANCED + return self._aborted_outcome(scope) self._retry_tracker.discard_attempt(msg_id) - return BacklogProcessResult.RETRY_LATER + return TurnOutcome.RETRY_LATER def _drain_duplicate_from_queue(self, msg_id: str) -> None: """ @@ -1832,7 +1878,7 @@ async def _run_cycle( """Supervise one handler, then select its delivery operation once.""" if scope.kind is not None: scope.settled = True - return await self._abort_cycle(scope.kind, msg_id, event=event) + return await self._abort_cycle(scope.kind, msg_id) scope.admitted = True async def invoke() -> None: @@ -1877,7 +1923,7 @@ async def invoke() -> None: if scope.shutdown: raise asyncio.CancelledError if scope.kind is not None: - return await self._abort_cycle(scope.kind, msg_id, event=event) + return await self._abort_cycle(scope.kind, msg_id) if timed_out: raise TimeoutError( f"cycle exceeded max_cycle_seconds={self.config.max_cycle_seconds}" @@ -1927,13 +1973,7 @@ async def _claim(self, msg_id: str) -> bool: self._retry_tracker.discard_attempt(msg_id) return False - async def _abort_cycle( - self, - kind: ControlMode, - msg_id: str | None, - *, - event: PlatformEvent | None = None, - ) -> bool: + async def _abort_cycle(self, kind: ControlMode, msg_id: str | None) -> bool: """Unwind an aborted cycle (interrupt/stop): drop work, send nothing. Used by ``_run_cycle`` for a scope terminated by STOP/INTERRUPT, whether @@ -1948,8 +1988,6 @@ async def _abort_cycle( if msg_id: self._retry_tracker.discard_attempt(msg_id) await self._clear_activity() - if kind is ControlMode.STOP: - self._retain_synthetic(event, first=True) if kind is ControlMode.INTERRUPT and msg_id: # Consume the message so the idle /next resync does not re-return it # (excludes-only-processed) and re-fire the cycle the user just @@ -1990,7 +2028,7 @@ async def _abort_cycle( ) return False - async def _process_event(self, event: PlatformEvent) -> bool: + async def _process_event(self, event: PlatformEvent) -> TurnOutcome: """ Process single event through execution callback. @@ -2003,21 +2041,20 @@ async def _process_event(self, event: PlatformEvent) -> bool: """ if isinstance(event, ReconnectedEvent): await self._reconcile() - return True + return TurnOutcome.ADVANCED # While stopped, leave message triggers actionable for replay on play. # Suppression is platform-authoritative; skipping here is a local # efficiency short-circuit that avoids claiming/marking (mark->204) and - # never reaches the adapter (reply->403). The message is left untouched - # so /next replays it on play. + # never reaches the adapter (reply->403). A real message stays on the + # platform for /next; a synthetic one stays at the head of its queue. if self._stopped and isinstance(event, MessageEvent): - self._retain_synthetic(event) logger.debug( "ExecutionContext %s: stopped, skipping message %s (left for replay)", self.room_id, event.payload.id if event.payload else None, ) - return True + return TurnOutcome.PAUSED payload = event.payload if isinstance(event, MessageEvent) else None msg_id = payload.id if payload else None @@ -2035,7 +2072,7 @@ async def _process_event(self, event: PlatformEvent) -> bool: agent_id=self._agent_id, ): logger.debug("Skipping self-message %s", msg_id) - return True + return TurnOutcome.ADVANCED # Detect synthetic messages (e.g., contact events injected into hub room) # These don't exist in the database, so skip all tracking and marking @@ -2051,16 +2088,16 @@ async def _process_event(self, event: PlatformEvent) -> bool: # Skip permanently failed messages if self._retry_tracker.is_permanently_failed(msg_id): logger.debug("Skipping permanently failed message %s", msg_id) - return True + return TurnOutcome.ADVANCED # Skip duplicates if self.claims.is_completed(self.room_id, msg_id): logger.debug("Skipping duplicate message %s", msg_id) - return True + return TurnOutcome.ADVANCED if self.claims.is_ack_pending(self.room_id, msg_id): logger.debug("Retrying processed ack for message %s", msg_id) - return bool(await self._retry_processed_ack(msg_id)) + return await self._retry_ack_outcome(msg_id) with self._claim_message(msg_id) as acquired: if not acquired: @@ -2070,13 +2107,10 @@ async def _process_event(self, event: PlatformEvent) -> bool: "Message %s owned by another execution; deferring", msg_id, ) - return False + return TurnOutcome.RETRY_LATER return await self._process_event_body(event, msg_id, payload) - processed = await self._process_event_body(event, msg_id, payload) - if not processed: - self._retain_synthetic(event, first=True) - return processed + return await self._process_event_body(event, msg_id, payload) def note_turn_failure_reported(self) -> None: """Record that the adapter already told the room this turn failed.""" @@ -2140,7 +2174,7 @@ async def _handle_turn_failure( async def _process_event_body( self, event: PlatformEvent, msg_id: str | None, payload: Any - ) -> bool: + ) -> TurnOutcome: """Process an event after any required in-flight claim is acquired.""" if ( isinstance(event, MessageEvent) @@ -2154,11 +2188,16 @@ async def _process_event_body( self.room_id, ) self.claims.remember_completed(self.room_id, msg_id) - return True + return TurnOutcome.ADVANCED - scope = await self._begin_scope() + # Participant events are housekeeping, admitted only once their + # handler starts. + if isinstance(event, MessageEvent): + scope = await self._begin_turn() + else: + scope = await self._begin_scope() if scope is None: - return False + return TurnOutcome.RETRY_LATER self._set_state(ExecutionState.PROCESSING) logger.debug("Processing %s in room %s", event.type, self.room_id) @@ -2175,7 +2214,7 @@ async def _process_event_body( msg_id, attempts, ) - return True + return TurnOutcome.ADVANCED # Hydrate before claiming real WebSocket messages when payload # metadata did not prove they were already processed. Hydrated @@ -2191,11 +2230,7 @@ async def _process_event_body( ) self._retry_tracker.discard_attempt(msg_id) self.claims.remember_completed(self.room_id, msg_id) - return True - - # From admission a control terminates this scope, even before - # its handler starts. - scope.admitted = True + return TurnOutcome.ADVANCED # For messages: mark as processing on server if not await self._claim(msg_id): @@ -2204,7 +2239,7 @@ async def _process_event_body( self.room_id, msg_id, ) - return False + return TurnOutcome.RETRY_LATER # Hydrate context on first event (loads participants always, # history only if enable_context_hydration is True) @@ -2237,7 +2272,7 @@ async def _process_event_body( # cycles report the working signal (see _invoke_handler); # participant add/remove events are housekeeping and skip it. if not await self._run_cycle(scope, event, msg_id): - return not scope.stop_observed + return self._aborted_outcome(scope) # For messages: record ack-pending BEFORE the awaited mark_processed # call, synchronously, so a cancellation landing inside that await @@ -2256,27 +2291,27 @@ async def _process_event_body( self.room_id, msg_id, ) - return False + return TurnOutcome.RETRY_LATER if msg_id == self._deferred_message_id: self._set_deferred_head(None) logger.debug("Event %s processed successfully", event.type) - return True + return TurnOutcome.ADVANCED except InputNotAccepted: self._defer_unaccepted_input(msg_id) - return False + return TurnOutcome.NOT_ACCEPTED except RoomExecutionStoppedError: if msg_id and not self.claims.is_ack_pending(self.room_id, msg_id): self._retry_tracker.discard_attempt(msg_id) - return False + return TurnOutcome.RETRY_LATER except Exception as e: # noqa: BLE001 -- the turn boundary: logged by _log_turn_error, then marked failed _log_turn_error(e, "Error processing %s", event.type) if isinstance(event, MessageEvent) and msg_id: await self._handle_turn_failure(msg_id, attempts, e) - return True + return TurnOutcome.ADVANCED finally: scope.settled = True diff --git a/tests/runtime/executioninterrupt/test_backlog_interrupt.py b/tests/runtime/executioninterrupt/test_backlog_interrupt.py index fd4250bf0..1c810ee84 100644 --- a/tests/runtime/executioninterrupt/test_backlog_interrupt.py +++ b/tests/runtime/executioninterrupt/test_backlog_interrupt.py @@ -5,7 +5,7 @@ import asyncio from unittest.mock import AsyncMock -from band.runtime.execution import BacklogProcessResult, ExecutionContext, ResyncOutcome +from band.runtime.execution import ExecutionContext, ResyncOutcome, TurnOutcome from band.runtime.types import SessionConfig from tests.conftest import BlockingHandler from tests.runtime.executioninterrupt.kit import ( @@ -26,7 +26,7 @@ async def test_interrupt_during_backlog_consumes_and_advances(self, mock_link): ctx.interrupt() result = await proc - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED mock_link.mark_processed.assert_awaited_once_with("room-123", "bk1") assert "bk1" in ctx.claims.completed_ids(ctx.room_id) @@ -39,7 +39,7 @@ async def test_stop_during_backlog_leaves_actionable(self, mock_link): ctx.interrupt(kind="stop") result = await proc - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.PAUSED mock_link.mark_processed.assert_not_awaited() assert "bk2" not in ctx.claims.completed_ids(ctx.room_id) diff --git a/tests/runtime/executioninterrupt/test_claim_window_control.py b/tests/runtime/executioninterrupt/test_claim_window_control.py index db0e852eb..60f0365d2 100644 --- a/tests/runtime/executioninterrupt/test_claim_window_control.py +++ b/tests/runtime/executioninterrupt/test_claim_window_control.py @@ -5,7 +5,7 @@ import asyncio from unittest.mock import AsyncMock -from band.runtime.execution import BacklogProcessResult, ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, @@ -47,7 +47,7 @@ async def test_interrupt_in_window_aborts_before_handler(self, mock_link): release.set() result = await proc - assert result is True + assert result is TurnOutcome.ADVANCED assert handler.invoked == [] # handler never ran mock_link.mark_processed.assert_awaited_once_with("room-123", "w1") # consumed assert "w1" in ctx.claims.completed_ids(ctx.room_id) @@ -66,7 +66,7 @@ async def test_stop_in_window_leaves_message_actionable(self, mock_link): release.set() result = await proc - assert result is True + assert result is TurnOutcome.PAUSED assert handler.invoked == [] assert ctx._stopped is True mock_link.mark_processed.assert_not_awaited() # left for replay on play @@ -86,7 +86,7 @@ async def test_interrupt_in_backlog_window_aborts_and_advances(self, mock_link): release.set() result = await proc - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED assert handler.invoked == [] mock_link.mark_processed.assert_awaited_once_with("room-123", "bw1") @@ -100,5 +100,5 @@ async def test_interrupt_between_cycles_arms_nothing(self, mock_link): # The next message runs to completion, unaffected. result = await ctx._process_event(make_message_event(msg_id="n1")) - assert result is True + assert result is TurnOutcome.ADVANCED assert handler.completed == ["n1"] diff --git a/tests/runtime/executioninterrupt/test_cycle_watchdog.py b/tests/runtime/executioninterrupt/test_cycle_watchdog.py index 715a1f139..0cc73fde2 100644 --- a/tests/runtime/executioninterrupt/test_cycle_watchdog.py +++ b/tests/runtime/executioninterrupt/test_cycle_watchdog.py @@ -5,7 +5,7 @@ import asyncio from unittest.mock import patch -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from band.runtime.types import SessionConfig from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( @@ -38,7 +38,8 @@ async def test_cycle_exceeding_max_cycle_seconds_is_cancelled_and_marked_failed( result = await ctx._process_event(make_message_event(msg_id="watchdog-1")) - assert result is True # loop continues; the watchdog is a handled error + # Loop continues; the watchdog is a handled error. + assert result is TurnOutcome.ADVANCED assert handler.cancelled.is_set() # the stuck cycle was actually cancelled mock_link.mark_processed.assert_not_awaited() mock_link.mark_failed.assert_awaited_once() @@ -52,7 +53,7 @@ async def test_cycle_exceeding_max_cycle_seconds_is_cancelled_and_marked_failed( handler2 = BlockingHandler(block=False) ctx._on_execute = handler2 result2 = await ctx._process_event(make_message_event(msg_id="watchdog-2")) - assert result2 is True + assert result2 is TurnOutcome.ADVANCED assert handler2.completed == ["watchdog-2"] async def test_unset_max_cycle_seconds_never_cancels_a_slow_handler( @@ -64,7 +65,7 @@ async def test_unset_max_cycle_seconds_never_cancels_a_slow_handler( result = await ctx._process_event(make_message_event(msg_id="no-watchdog")) - assert result is True + assert result is TurnOutcome.ADVANCED assert not handler.cancelled.is_set() mock_link.mark_processed.assert_awaited_once_with("room-123", "no-watchdog") mock_link.mark_failed.assert_not_awaited() @@ -88,7 +89,7 @@ async def raises_own_timeout(ctx, event): result = await ctx._process_event(make_message_event(msg_id="own-timeout")) - assert result is True + assert result is TurnOutcome.ADVANCED mock_link.mark_failed.assert_awaited_once() room_id, msg_id, label = mock_link.mark_failed.await_args.args assert (room_id, msg_id) == ("room-123", "own-timeout") @@ -117,7 +118,8 @@ async def swallows_cancellation(ctx, event): result = await ctx._process_event(make_message_event(msg_id="swallowed")) - assert result is True # loop continues; the watchdog is a handled error + # Loop continues; the watchdog is a handled error. + assert result is TurnOutcome.ADVANCED mock_link.mark_processed.assert_not_awaited() mock_link.mark_failed.assert_awaited_once() room_id, msg_id, label = mock_link.mark_failed.await_args.args @@ -155,7 +157,7 @@ async def interrupted_cleanup(ctx, event): ctx._on_execute = interrupted_cleanup result = await ctx._process_event(make_message_event(msg_id="race-1")) - assert result is True + assert result is TurnOutcome.ADVANCED # The interrupt won, not the watchdog: consumed/acked, not failed. mock_link.mark_processed.assert_awaited_once_with("room-123", "race-1") mock_link.mark_failed.assert_not_awaited() @@ -177,7 +179,10 @@ async def finish_at_deadline(ctx, event): agent_id="agent-123", config=SessionConfig(max_cycle_seconds=1e-9), ) - assert await ctx._process_event(make_message_event(msg_id="boundary")) + assert ( + await ctx._process_event(make_message_event(msg_id="boundary")) + is TurnOutcome.ADVANCED + ) assert completed == ["reply"] mock_link.mark_processed.assert_awaited_once_with("room-123", "boundary") mock_link.mark_failed.assert_not_awaited() @@ -212,7 +217,7 @@ async def blocks_during_cleanup(ctx, event): timeout=0.2, ) - assert result is True + assert result is TurnOutcome.ADVANCED assert cleanup_started.is_set() mock_link.mark_processed.assert_not_awaited() mock_link.mark_failed.assert_awaited_once() diff --git a/tests/runtime/executioninterrupt/test_interrupt_cycle.py b/tests/runtime/executioninterrupt/test_interrupt_cycle.py index dceeb3774..6b88b61e0 100644 --- a/tests/runtime/executioninterrupt/test_interrupt_cycle.py +++ b/tests/runtime/executioninterrupt/test_interrupt_cycle.py @@ -9,7 +9,7 @@ import pytest -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event @@ -28,7 +28,7 @@ async def test_interrupt_cancels_cycle_marks_processed_loop_alive(self, mock_lin assert ctx.interrupt() is True result = await proc - assert result is True # loop continues, not a failure + assert result is TurnOutcome.ADVANCED # loop continues, not a failure assert handler.cancelled.is_set() # cycle was aborted assert "msg-1" not in handler.completed # nothing was sent # Consumed: durable mark + local dedupe. @@ -40,7 +40,7 @@ async def test_interrupt_cancels_cycle_marks_processed_loop_alive(self, mock_lin handler2 = BlockingHandler(block=False) ctx._on_execute = handler2 result2 = await ctx._process_event(make_message_event(msg_id="msg-2")) - assert result2 is True + assert result2 is TurnOutcome.ADVANCED assert handler2.completed == ["msg-2"] async def test_interrupt_during_tool_call_drops_result(self, mock_link): @@ -75,7 +75,7 @@ async def test_interrupt_between_cycles_is_noop(self, mock_link): # Next cycle runs normally. result = await ctx._process_event(make_message_event(msg_id="m1")) - assert result is True + assert result is TurnOutcome.ADVANCED mock_link.mark_processed.assert_awaited_once_with("room-123", "m1") async def test_interrupted_cleanup_failure_is_logged( @@ -95,7 +95,7 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: turn = asyncio.create_task(ctx._process_event(make_message_event())) await started.wait() ctx.interrupt() - assert await turn + assert await turn is TurnOutcome.ADVANCED [record] = caplog.records assert record.exc_info is not None @@ -120,7 +120,7 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: turn = asyncio.create_task(ctx._process_event(make_message_event())) await started.wait() ctx.interrupt() - assert await turn + assert await turn is TurnOutcome.ADVANCED [record] = caplog.records assert record.levelno == logging.WARNING diff --git a/tests/runtime/executioninterrupt/test_pending_ack_cancellation.py b/tests/runtime/executioninterrupt/test_pending_ack_cancellation.py index a67c9d6bb..13afde25b 100644 --- a/tests/runtime/executioninterrupt/test_pending_ack_cancellation.py +++ b/tests/runtime/executioninterrupt/test_pending_ack_cancellation.py @@ -10,7 +10,7 @@ import pytest -from band.runtime.execution import BacklogProcessResult, ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, @@ -89,7 +89,7 @@ async def test_websocket_path_cancellation_during_mark_processed(self, mock_link mock_link.mark_processed = AsyncMock(return_value=True) result = await ctx._process_event(make_message_event(msg_id="ws-cancel-ack")) - assert result is True + assert result is TurnOutcome.ADVANCED assert handler.invocations == 1 assert ctx.claims.is_completed("room-123", "ws-cancel-ack") assert not ctx.claims.is_ack_pending("room-123", "ws-cancel-ack") @@ -118,7 +118,7 @@ async def test_backlog_path_cancellation_during_mark_processed(self, mock_link): mock_link.mark_processed = AsyncMock(return_value=True) result = await ctx._process_backlog_message(msg) - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED assert handler.invocations == 1 assert ctx.claims.is_completed("room-123", "bk-cancel-ack") assert not ctx.claims.is_ack_pending("room-123", "bk-cancel-ack") diff --git a/tests/runtime/executioninterrupt/test_shutdown_vs_interrupt.py b/tests/runtime/executioninterrupt/test_shutdown_vs_interrupt.py index b163de1b7..e8ee8548a 100644 --- a/tests/runtime/executioninterrupt/test_shutdown_vs_interrupt.py +++ b/tests/runtime/executioninterrupt/test_shutdown_vs_interrupt.py @@ -4,7 +4,7 @@ import asyncio -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( assert_fresh_cycle_still_propagates_shutdown_cancel, @@ -32,7 +32,7 @@ async def swallow_cancel(ctx, event): await first_started.wait() assert ctx.interrupt() is True - assert await first is True + assert await first is TurnOutcome.ADVANCED await assert_fresh_cycle_still_propagates_shutdown_cancel(ctx, "shutdown") diff --git a/tests/runtime/executioninterrupt/test_stop_cycle.py b/tests/runtime/executioninterrupt/test_stop_cycle.py index 1706d2296..b51263680 100644 --- a/tests/runtime/executioninterrupt/test_stop_cycle.py +++ b/tests/runtime/executioninterrupt/test_stop_cycle.py @@ -4,7 +4,7 @@ import asyncio -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event @@ -20,7 +20,7 @@ async def test_stop_leaves_message_actionable(self, mock_link): ctx.interrupt(kind="stop") result = await proc - assert result is True + assert result is TurnOutcome.PAUSED mock_link.mark_processed.assert_not_awaited() assert "s1" not in ctx.claims.completed_ids(ctx.room_id) # Local in-flight claim released so it can be reprocessed on play. diff --git a/tests/runtime/executioninterrupt/test_stop_resume_room.py b/tests/runtime/executioninterrupt/test_stop_resume_room.py index db9876720..ebe11cadc 100644 --- a/tests/runtime/executioninterrupt/test_stop_resume_room.py +++ b/tests/runtime/executioninterrupt/test_stop_resume_room.py @@ -5,7 +5,7 @@ import asyncio from unittest.mock import AsyncMock -from band.runtime.execution import BacklogProcessResult, ExecutionContext, ResyncOutcome +from band.runtime.execution import ExecutionContext, ResyncOutcome, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, @@ -24,7 +24,7 @@ async def test_stop_room_sets_flag_and_interrupts(self, mock_link): result = await proc assert ctx._stopped is True - assert result is True + assert result is TurnOutcome.PAUSED mock_link.mark_processed.assert_not_awaited() async def test_stopped_room_skips_new_message(self, mock_link): @@ -36,7 +36,7 @@ async def test_stopped_room_skips_new_message(self, mock_link): result = await ctx._process_event(make_message_event(msg_id="while-stopped")) - assert result is True + assert result is TurnOutcome.PAUSED assert handler.invoked == [] # adapter never invoked mock_link.mark_processing.assert_not_awaited() mock_link.mark_processed.assert_not_awaited() @@ -104,7 +104,7 @@ async def test_stop_does_not_poison_retry_budget(self, mock_link): proc = asyncio.create_task(ctx._process_event(make_message_event(msg_id="p1"))) await handler.started.wait() ctx.stop_room() - assert await proc is True + assert await proc is TurnOutcome.PAUSED mock_link.mark_processed.assert_not_awaited() # Resume, then the platform replays the still-"processing" message via @@ -112,6 +112,6 @@ async def test_stop_does_not_poison_retry_budget(self, mock_link): handler.started.clear() result = await ctx._process_backlog_message(backlog_message("p1")) - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED assert handler.completed == ["p1"] # handler actually ran this time assert not ctx._retry_tracker.is_permanently_failed("p1") diff --git a/tests/runtime/executioninterrupt/test_stopped_acks.py b/tests/runtime/executioninterrupt/test_stopped_acks.py index 00d59e66b..44a6db19a 100644 --- a/tests/runtime/executioninterrupt/test_stopped_acks.py +++ b/tests/runtime/executioninterrupt/test_stopped_acks.py @@ -3,7 +3,7 @@ from __future__ import annotations from band.core.exceptions import RoomExecutionStoppedError -from band.runtime.execution import BacklogProcessResult, ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, @@ -19,11 +19,14 @@ async def test_stopped_ack_retries_only_ack_after_play_beyond_budget( handler = BlockingHandler(block=False) ctx = ExecutionContext("room-123", mock_link, handler, agent_id="agent-123") mock_link.mark_processed.side_effect = RoomExecutionStoppedError(ctx.room_id) - assert not await ctx._process_event(make_message_event(msg_id="ack")) + assert ( + await ctx._process_event(make_message_event(msg_id="ack")) + is TurnOutcome.RETRY_LATER + ) for _ in range(ctx.config.max_message_retries + 3): assert ( await ctx._process_backlog_message(backlog_message("ack")) - == BacklogProcessResult.RETRY_LATER + is TurnOutcome.RETRY_LATER ) assert ctx.claims.is_ack_pending(ctx.room_id, "ack") assert not ctx.claims.is_completed(ctx.room_id, "ack") @@ -31,7 +34,7 @@ async def test_stopped_ack_retries_only_ack_after_play_beyond_budget( mock_link.mark_processed.side_effect = None assert ( await ctx._process_backlog_message(backlog_message("ack")) - == BacklogProcessResult.ADVANCED + is TurnOutcome.ADVANCED ) assert handler.completed == ["ack"] assert ctx.claims.is_completed(ctx.room_id, "ack") diff --git a/tests/runtime/executioninterrupt/test_stopped_claims.py b/tests/runtime/executioninterrupt/test_stopped_claims.py index 69bd29c8d..32b94546e 100644 --- a/tests/runtime/executioninterrupt/test_stopped_claims.py +++ b/tests/runtime/executioninterrupt/test_stopped_claims.py @@ -9,7 +9,7 @@ from band.core.exceptions import RoomExecutionStoppedError from band.runtime.cycle import TurnScope -from band.runtime.execution import BacklogProcessResult, ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, @@ -27,11 +27,14 @@ async def test_stopped_claim_defers_without_invoking_or_poisoning_retry( ctx = ExecutionContext("room-123", mock_link, handler, agent_id="agent-123") mock_link.mark_processing.side_effect = RoomExecutionStoppedError(ctx.room_id) if path == "live": - assert not await ctx._process_event(make_message_event(msg_id="refused")) + assert ( + await ctx._process_event(make_message_event(msg_id="refused")) + is TurnOutcome.RETRY_LATER + ) else: assert ( await ctx._process_backlog_message(backlog_message("refused")) - == BacklogProcessResult.RETRY_LATER + is TurnOutcome.RETRY_LATER ) assert handler.invoked == [] mock_link.mark_processed.assert_not_awaited() @@ -39,7 +42,7 @@ async def test_stopped_claim_defers_without_invoking_or_poisoning_retry( mock_link.mark_processing.side_effect = None assert ( await ctx._process_backlog_message(backlog_message("refused")) - == BacklogProcessResult.ADVANCED + is TurnOutcome.ADVANCED ) assert handler.completed == ["refused"] @@ -59,7 +62,13 @@ async def observer(ctx: ExecutionContext, scope: TurnScope) -> None: ctx.observe_platform_stop(ctx.current_scope) await entered.wait() monkeypatch.setattr("band.core.turn_lifecycle.CYCLE_CANCEL_GRACE_SECONDS", 0.01) - assert not await ctx._process_event(make_message_event(msg_id="later")) + assert ( + await ctx._process_event(make_message_event(msg_id="later")) + is TurnOutcome.RETRY_LATER + ) mock_link.mark_processing.assert_not_awaited() release.set() - assert await ctx._process_event(make_message_event(msg_id="later")) + assert ( + await ctx._process_event(make_message_event(msg_id="later")) + is TurnOutcome.ADVANCED + ) diff --git a/tests/runtime/executioninterrupt/test_stopped_failure_reporting.py b/tests/runtime/executioninterrupt/test_stopped_failure_reporting.py index da2281208..693a62c7f 100644 --- a/tests/runtime/executioninterrupt/test_stopped_failure_reporting.py +++ b/tests/runtime/executioninterrupt/test_stopped_failure_reporting.py @@ -8,7 +8,7 @@ import pytest from band.core.exceptions import RoomExecutionStoppedError -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from tests.conftest import make_message_event @@ -34,6 +34,12 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: mock_link.rest.agent_api_events.create_agent_chat_event = AsyncMock( side_effect=RoomExecutionStoppedError(ctx.room_id) ) - assert await ctx._process_event(make_message_event(msg_id="failure")) - assert await ctx._process_event(make_message_event(msg_id="next")) + assert ( + await ctx._process_event(make_message_event(msg_id="failure")) + is TurnOutcome.ADVANCED + ) + assert ( + await ctx._process_event(make_message_event(msg_id="next")) + is TurnOutcome.ADVANCED + ) mock_link.mark_processed.assert_awaited_once_with("room-123", "next") diff --git a/tests/runtime/executioninterrupt/test_stopped_posts.py b/tests/runtime/executioninterrupt/test_stopped_posts.py index cc9396c3e..a8f30bc42 100644 --- a/tests/runtime/executioninterrupt/test_stopped_posts.py +++ b/tests/runtime/executioninterrupt/test_stopped_posts.py @@ -12,7 +12,7 @@ from band.core.exceptions import RoomExecutionStoppedError from band.core.turn_lifecycle import CYCLE_CANCEL_GRACE_SECONDS, bind_turn_task from band.runtime.cycle import TurnScope -from band.runtime.execution import BacklogProcessResult, ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from band.runtime.tools.agent import AgentTools from tests.conftest import make_message_event from tests.runtime.executioninterrupt.kit import ( @@ -56,7 +56,10 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: raise ctx = ExecutionContext("room-123", mock_link, handler, agent_id="agent-123") - assert not await ctx._process_event(make_message_event(msg_id="stopped")) + assert ( + await ctx._process_event(make_message_event(msg_id="stopped")) + is TurnOutcome.RETRY_LATER + ) assert not ctx.is_stopped assert not ctx.claims.is_completed(ctx.room_id, "stopped") mock_link.mark_processed.assert_not_awaited() @@ -64,7 +67,7 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: assert peer.posts == ["messages" if post == "message" else "events"] assert ( await ctx._process_backlog_message(backlog_message("stopped")) - == BacklogProcessResult.ADVANCED + is TurnOutcome.ADVANCED ) assert invoked == ["stopped", "stopped"] @@ -95,7 +98,7 @@ async def claim(*_: Any) -> bool: with pytest.raises(asyncio.CancelledError): await captured[0].send_event("late", "thought") release.set() - assert await task + assert await task is TurnOutcome.ADVANCED finally: task.cancel() with contextlib.suppress(asyncio.CancelledError): @@ -123,7 +126,10 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: await captured[0].send_event("delayed", "thought") observer.assert_not_awaited() assert not ctx.is_stopped - assert await ctx._process_event(make_message_event(msg_id="new")) + assert ( + await ctx._process_event(make_message_event(msg_id="new")) + is TurnOutcome.ADVANCED + ) async def test_detached_stop_hook_does_not_await_its_posting_task( self, mock_link @@ -154,7 +160,10 @@ async def observer(ctx: ExecutionContext, scope: TurnScope) -> None: with pytest.raises(asyncio.CancelledError): await provider await cleanup.wait() - assert await ctx._process_event(make_message_event(msg_id="after")) + assert ( + await ctx._process_event(make_message_event(msg_id="after")) + is TurnOutcome.ADVANCED + ) @pytest.mark.looptime async def test_stopped_post_cancels_a_released_worker_at_its_grace( diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 3bdd6cbb0..7f0840862 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -22,7 +22,12 @@ ) from band.core.protocols import TURN_FAILURE_PROVIDER from band.core.simple_adapter import SimpleAdapter -from band.core.types import PlatformMessage +from band.core.types import ( + SYNTHETIC_CONTACT_EVENTS_SENDER_ID, + SYNTHETIC_SENDER_TYPE, + PlatformMessage, +) +from band.platform.event import MessageEvent from band.runtime.execution import ExecutionContext from band.runtime.types import SessionConfig from tests.conftest import make_message_event @@ -87,6 +92,16 @@ def run_through( ) +def synthetic_message(message_id: str) -> MessageEvent: + """A contact-hub message: it exists only in the room's local queue.""" + return make_message_event( + room_id=ROOM_ID, + msg_id=message_id, + sender_type=SYNTHETIC_SENDER_TYPE, + sender_id=SYNTHETIC_CONTACT_EVENTS_SENDER_ID, + ) + + async def deliver(ctx: ExecutionContext, path: str) -> None: """Deliver one user message live over the socket, or from the backlog.""" match path: diff --git a/tests/runtime/test_activity_stop.py b/tests/runtime/test_activity_stop.py index ce0d7704f..352fac52b 100644 --- a/tests/runtime/test_activity_stop.py +++ b/tests/runtime/test_activity_stop.py @@ -14,7 +14,7 @@ from band.core.turn_lifecycle import CYCLE_CANCEL_GRACE_SECONDS from band.platform.event import PlatformEvent from band.platform.link import BandLink -from band.runtime.execution import ExecutionContext +from band.runtime.execution import ExecutionContext, TurnOutcome from band.runtime.runtime import AgentRuntime from band.runtime.tools.agent import AgentTools from band.runtime.types import SessionConfig @@ -103,7 +103,10 @@ async def handler(ctx: ExecutionContext, event: PlatformEvent) -> None: async with activity_context( peer, handler, observer, max_cycle_seconds=keep_alive - 0.5 ) as ctx: - assert not await ctx._process_event(make_message_event(msg_id="trigger")) + assert ( + await ctx._process_event(make_message_event(msg_id="trigger")) + is TurnOutcome.RETRY_LATER + ) assert peer.activity_reports == [KEEP_ALIVE, STOP_ECHO, IDLE] assert peer.marked("processing") == ["trigger"] diff --git a/tests/runtime/test_deferral.py b/tests/runtime/test_deferral.py index f0239c48e..665345126 100644 --- a/tests/runtime/test_deferral.py +++ b/tests/runtime/test_deferral.py @@ -10,10 +10,6 @@ from band.core.exceptions import InputNotAccepted from band.core.simple_adapter import SimpleAdapter -from band.core.types import ( - SYNTHETIC_CONTACT_EVENTS_SENDER_ID, - SYNTHETIC_SENDER_TYPE, -) from band.platform.event import ReconnectedEvent from band.platform.link import BandLink from band.runtime.oneshot import OneShotInvoker, OneShotStatus @@ -26,6 +22,7 @@ LifecyclePlatform, rest_client_over, run_through, + synthetic_message, ) @@ -211,14 +208,7 @@ async def test_synthetic_work_survives_stop_and_rejection_in_receive_order() -> ) ctx.stop_room() for message_id in ["local-B", "local-D"]: - await ctx._process_event( - make_message_event( - room_id=ROOM_ID, - msg_id=message_id, - sender_type=SYNTHETIC_SENDER_TYPE, - sender_id=SYNTHETIC_CONTACT_EVENTS_SENDER_ID, - ) - ) + ctx._enqueue_synthetic(synthetic_message(message_id)) assert adapter.completed == [] peer.stopped = False await ctx.resume_room() @@ -289,14 +279,7 @@ async def on_message( ) await ctx.start() try: - await ctx.on_event( - make_message_event( - room_id=ROOM_ID, - msg_id="local-B", - sender_type=SYNTHETIC_SENDER_TYPE, - sender_id=SYNTHETIC_CONTACT_EVENTS_SENDER_ID, - ) - ) + await ctx.on_event(synthetic_message("local-B")) await entered.wait() peer.stopped = True ctx.stop_room() diff --git a/tests/runtime/test_execution.py b/tests/runtime/test_execution.py index 8dce1ef8e..12e7a29b5 100644 --- a/tests/runtime/test_execution.py +++ b/tests/runtime/test_execution.py @@ -21,11 +21,11 @@ from band.platform.event import PlatformEvent from band.platform.link import BandLink from band.runtime.execution import ( - BacklogProcessResult, Execution, ExecutionContext, ExecutionState, ResyncOutcome, + TurnOutcome, _error_label, ) from band.runtime.tools import AgentTools @@ -987,7 +987,7 @@ async def test_backlog_skips_self_authored_message( result = await ctx._process_backlog_message(msg) - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED mock_handler.assert_not_awaited() mock_link_with_next.mark_processing.assert_not_awaited() mock_link_with_next.mark_processed.assert_not_awaited() @@ -1209,8 +1209,8 @@ def fresh_context() -> ExecutionContext: assert handler_calls == 1 assert mock_link_with_next.mark_processing.await_count == 1 - assert owner_handled is True - assert duplicate_handled is False + assert owner_handled is TurnOutcome.ADVANCED + assert duplicate_handled is TurnOutcome.RETRY_LATER async def test_mark_processing_failure_does_not_execute_message( self, mock_link_with_next, mock_handler @@ -1252,7 +1252,7 @@ async def failing_handler(ctx, event): ) event = make_message_event(room_id="room-123", msg_id="msg-handler-fails") - assert await ctx._process_event(event) is True + assert await ctx._process_event(event) is TurnOutcome.ADVANCED mock_link_with_next.mark_failed.assert_awaited_once_with( "room-123", "msg-handler-fails", "handler failed" @@ -1286,7 +1286,7 @@ async def test_backlog_processed_ack_failure_is_not_remembered( result = await ctx._process_backlog_message(msg) - assert result == BacklogProcessResult.RETRY_LATER + assert result is TurnOutcome.RETRY_LATER mock_handler.assert_awaited_once() mock_link_with_next.mark_processed.assert_awaited_once_with( "room-123", "msg-ack-fails" @@ -1343,10 +1343,8 @@ async def test_backlog_processed_ack_failure_retries_ack_without_handler_replay( config=SessionConfig(enable_context_hydration=False), ) - assert ( - await ctx._process_backlog_message(msg) == BacklogProcessResult.RETRY_LATER - ) - assert await ctx._process_backlog_message(msg) == BacklogProcessResult.ADVANCED + assert await ctx._process_backlog_message(msg) is TurnOutcome.RETRY_LATER + assert await ctx._process_backlog_message(msg) is TurnOutcome.ADVANCED mock_handler.assert_awaited_once() assert mock_link_with_next.mark_processed.await_count == 2 @@ -1381,10 +1379,8 @@ async def test_processed_ack_retry_budget_exhaustion_keeps_local_completion( ), ) - assert ( - await ctx._process_backlog_message(msg) == BacklogProcessResult.RETRY_LATER - ) - assert await ctx._process_backlog_message(msg) == BacklogProcessResult.ADVANCED + assert await ctx._process_backlog_message(msg) is TurnOutcome.RETRY_LATER + assert await ctx._process_backlog_message(msg) is TurnOutcome.ADVANCED mock_handler.assert_awaited_once() assert mock_link_with_next.mark_processed.await_count == 2 @@ -1883,7 +1879,7 @@ async def test_retry_saturation_skips_handler_on_next_delivery( # A further delivery of the same message must not invoke the handler. result = await ctx._process_backlog_message(msg) - assert result == BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED assert failing_handler.await_count == 1 @@ -1958,7 +1954,9 @@ async def test_websocket_final_failure_posts_an_error_event( room_id="room-123", msg_id="msg-ws", sender_id="user-1" ) - assert await self._context(mock_link)._process_event(event) is True + assert ( + await self._context(mock_link)._process_event(event) is TurnOutcome.ADVANCED + ) mock_link.mark_failed.assert_awaited_once() assert self._reporters(error_events) == [TURN_FAILURE_PROVIDER] @@ -1971,15 +1969,15 @@ async def test_report_waits_for_the_final_attempt(self, mock_link, error_events) room_id="room-123", msg_id="msg-retried", sender_id="user-1" ) - assert await ctx._process_event(event) is True + assert await ctx._process_event(event) is TurnOutcome.ADVANCED mock_link.mark_failed.assert_awaited_once() error_events.assert_not_awaited() - assert await ctx._process_event(event) is True + assert await ctx._process_event(event) is TurnOutcome.ADVANCED assert mock_link.mark_failed.await_count == 2 error_events.assert_awaited_once() - assert await ctx._process_event(event) is True + assert await ctx._process_event(event) is TurnOutcome.ADVANCED error_events.assert_awaited_once() @pytest.mark.parametrize("sender_type", ["Agent", "System"]) @@ -1995,7 +1993,9 @@ async def test_non_human_sender_failure_is_reported( sender_type=sender_type, ) - assert await self._context(mock_link)._process_event(event) is True + assert ( + await self._context(mock_link)._process_event(event) is TurnOutcome.ADVANCED + ) error_events.assert_awaited_once() @@ -2072,7 +2072,7 @@ async def test_report_post_failure_is_swallowed(self, mock_link, error_events): self._backlog_message("msg-post-fails") ) - assert result is BacklogProcessResult.ADVANCED + assert result is TurnOutcome.ADVANCED mock_link.mark_failed.assert_awaited_once() error_events.assert_awaited_once() diff --git a/tests/runtime/turnadmission/__init__.py b/tests/runtime/turnadmission/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/runtime/turnadmission/conftest.py b/tests/runtime/turnadmission/conftest.py new file mode 100644 index 000000000..86d6b47d6 --- /dev/null +++ b/tests/runtime/turnadmission/conftest.py @@ -0,0 +1,78 @@ +"""Fixtures for message-turn admission tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio + +from band.platform.link import BandLink +from band.runtime.cycle import TurnScope +from band.runtime.execution import ExecutionContext +from band.runtime.types import SessionConfig +from tests.runtime.helpers import ( + AGENT_ID, + LifecyclePlatform, + ResponseGate, + rest_client_over, + run_through, +) +from tests.runtime.turnadmission.kit import TurnRecorder + + +@pytest.fixture +def platform() -> LifecyclePlatform: + return LifecyclePlatform() + + +@pytest.fixture +def adapter() -> TurnRecorder: + return TurnRecorder() + + +@pytest.fixture +def setup_gate(platform: LifecyclePlatform) -> ResponseGate: + """Holds the participants load of a turn's setup once ``release`` clears.""" + gate = ResponseGate(platform, "/participants") + gate.release.set() + return gate + + +@pytest_asyncio.fixture(loop_scope="function") +async def ctx( + adapter: TurnRecorder, setup_gate: ResponseGate +) -> AsyncIterator[ExecutionContext]: + async with rest_client_over(setup_gate.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + try: + yield ctx + finally: + setup_gate.release.set() + adapter.release.set() + await ctx.stop() + + +@pytest_asyncio.fixture(loop_scope="function") +async def stop_observer(ctx: ExecutionContext) -> AsyncIterator[asyncio.Event]: + """A previous turn's platform-stop observer that runs until released. + + The next turn waits up to one cleanup grace for it before it begins. + """ + release = asyncio.Event() + observer = asyncio.create_task(release.wait()) + ctx.current_scope = TurnScope(ctx._control_revision, observer_task=observer) + try: + yield release + finally: + release.set() + await observer diff --git a/tests/runtime/turnadmission/kit.py b/tests/runtime/turnadmission/kit.py new file mode 100644 index 000000000..da9d62f2f --- /dev/null +++ b/tests/runtime/turnadmission/kit.py @@ -0,0 +1,78 @@ +"""Shared doubles for message-turn admission tests.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from band.core.simple_adapter import SimpleAdapter +from band.runtime.execution import ExecutionContext +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import LifecyclePlatform, synthetic_message + +APPROVAL = "approve" + + +class TurnRecorder(SimpleAdapter[Any]): + """Records every handled turn; clearing ``release`` holds the handler.""" + + def __init__(self) -> None: + super().__init__() + self.completed: list[str] = [] + self.controls: list[str] = [] + self.entered = asyncio.Event() + self.release = asyncio.Event() + self.release.set() + + async def try_handle_control(self, msg: Any, tools: Any) -> bool: + if msg.content != APPROVAL: + return False + self.controls.append(msg.id) + return True + + async def on_message(self, msg: Any, tools: Any, *args: Any, **kwargs: Any) -> None: + self.entered.set() + await self.release.wait() + self.completed.append(msg.id) + tools.turn.settle() + + +def queued_synthetic(ctx: ExecutionContext) -> list[str]: + return [event.payload.id for event in ctx._pending_synthetic if event.payload] + + +async def started(ctx: ExecutionContext) -> None: + """Start the room loop and wait out its startup /next synchronization.""" + await ctx.start() + await wait_for_condition(lambda: ctx._sync_complete) + + +async def queue_while_stopped( + ctx: ExecutionContext, platform: LifecyclePlatform, *message_ids: str +) -> None: + """Stop a started room and let its loop queue synthetic work for PLAY.""" + stop_room(ctx, platform) + for message_id in message_ids: + await ctx.on_event(synthetic_message(message_id)) + await wait_for_condition(lambda: queued_synthetic(ctx) == list(message_ids)) + + +def stop_room(ctx: ExecutionContext, platform: LifecyclePlatform) -> None: + platform.stopped = True + ctx.stop_room() + + +async def play(ctx: ExecutionContext, platform: LifecyclePlatform) -> None: + platform.stopped = False + await ctx.resume_room() + + +async def reconcile_once(ctx: ExecutionContext, platform: LifecyclePlatform) -> None: + """Run one scheduled pass; its /next read follows the synthetic drain.""" + reads = len(platform.next_reads) + await ctx.request_resync() + await wait_for_condition(lambda: len(platform.next_reads) > reads) + + +def turn_settled(ctx: ExecutionContext) -> bool: + return ctx.current_scope is not None and ctx.current_scope.settled diff --git a/tests/runtime/turnadmission/test_grace_controls.py b/tests/runtime/turnadmission/test_grace_controls.py new file mode 100644 index 000000000..42bc64fdd --- /dev/null +++ b/tests/runtime/turnadmission/test_grace_controls.py @@ -0,0 +1,94 @@ +"""A control while a turn waits to begin keeps that turn from starting.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.turn_lifecycle import CYCLE_CANCEL_GRACE_SECONDS +from band.runtime.execution import ExecutionContext +from tests.conftest import make_message_event +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import ROOM_ID, LifecyclePlatform, synthetic_message +from tests.runtime.turnadmission.kit import ( + APPROVAL, + TurnRecorder, + play, + queued_synthetic, + started, + stop_room, +) + +# Inside the begin grace, so the observer finishing still lets the turn begin. +WITHIN_GRACE = CYCLE_CANCEL_GRACE_SECONDS / 2 + + +@pytest.mark.timeout(10) +@pytest.mark.looptime +class TestGraceControls: + """STOP during the platform-stop observer's grace leaves the next turn unstarted.""" + + async def test_stop_during_turn_grace_leaves_real_message_unclaimed( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + stop_observer: asyncio.Event, + ) -> None: + platform.add_message("B") + turn = asyncio.create_task( + ctx._process_event(make_message_event(room_id=ROOM_ID, msg_id="B")) + ) + await asyncio.sleep(WITHIN_GRACE) + ctx.stop_room() + stop_observer.set() + await turn + + assert adapter.completed == [] + assert platform.requested("processing") == [] + + await ctx.resume_room() + await ctx._reconcile() + assert adapter.completed == ["B"] + + async def test_stop_during_turn_grace_keeps_synthetic_message_queued( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + stop_observer: asyncio.Event, + ) -> None: + await started(ctx) + await ctx.on_event(synthetic_message("local-A")) + await asyncio.sleep(WITHIN_GRACE) + stop_room(ctx, platform) + stop_observer.set() + await asyncio.sleep(CYCLE_CANCEL_GRACE_SECONDS) + + assert adapter.completed == [] + assert queued_synthetic(ctx) == ["local-A"] + + await play(ctx, platform) + await wait_for_condition(lambda: adapter.completed == ["local-A"]) + + async def test_stop_during_turn_grace_skips_control_probe( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + stop_observer: asyncio.Event, + ) -> None: + platform.add_message("B") + platform.add_message("C", content=APPROVAL) + ctx._deferred_message_id = "B" + scan = asyncio.create_task(ctx._scan_controls()) + # A STOP before the page returns ends the scan on its revision check. + await wait_for_condition(lambda: platform.actionable_cursors != []) + await asyncio.sleep(WITHIN_GRACE) + ctx.stop_room() + stop_observer.set() + await scan + + assert adapter.controls == [] + assert platform.requested("processing") == [] diff --git a/tests/runtime/turnadmission/test_observed_stop_interrupt.py b/tests/runtime/turnadmission/test_observed_stop_interrupt.py new file mode 100644 index 000000000..8a3fb1372 --- /dev/null +++ b/tests/runtime/turnadmission/test_observed_stop_interrupt.py @@ -0,0 +1,41 @@ +"""An INTERRUPT consumes its turn even after a platform stop was observed.""" + +from __future__ import annotations + +import pytest + +from band.runtime.execution import ExecutionContext +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import LifecyclePlatform, synthetic_message +from tests.runtime.turnadmission.kit import ( + TurnRecorder, + queued_synthetic, + reconcile_once, + started, +) + + +@pytest.mark.timeout(10) +class TestObservedStopInterrupt: + """INTERRUPT outranks an observed platform stop: the message is not replayed.""" + + async def test_interrupt_after_observed_stop_drops_synthetic_turn( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + ) -> None: + await started(ctx) + adapter.release.clear() + await ctx.on_event(synthetic_message("local-A")) + await adapter.entered.wait() + scope = ctx.current_scope + assert scope is not None + ctx.observe_platform_stop(scope) + ctx.interrupt() + + await wait_for_condition(lambda: scope.settled) + adapter.release.set() + await reconcile_once(ctx, platform) + assert adapter.completed == [] + assert queued_synthetic(ctx) == [] diff --git a/tests/runtime/turnadmission/test_setup_controls.py b/tests/runtime/turnadmission/test_setup_controls.py new file mode 100644 index 000000000..d6279a394 --- /dev/null +++ b/tests/runtime/turnadmission/test_setup_controls.py @@ -0,0 +1,70 @@ +"""A control during a begun turn's setup terminates the turn before its handler.""" + +from __future__ import annotations + +import pytest + +from band.runtime.execution import ExecutionContext +from tests.conftest import make_message_event +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import ( + ROOM_ID, + LifecyclePlatform, + ResponseGate, + synthetic_message, +) +from tests.runtime.turnadmission.kit import ( + TurnRecorder, + queued_synthetic, + reconcile_once, + started, + turn_settled, +) + + +@pytest.mark.timeout(10) +class TestSetupTimeControls: + """STOP or INTERRUPT is never lost once a message turn has begun.""" + + async def test_stop_during_websocket_setup_never_runs_real_message( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + setup_gate: ResponseGate, + ) -> None: + await started(ctx) + setup_gate.release.clear() + platform.add_message("B") + await ctx.on_event(make_message_event(room_id=ROOM_ID, msg_id="B")) + await setup_gate.entered.wait() + # The platform still accepts the claim: it raced the STOP. + ctx.stop_room() + setup_gate.release.set() + + await wait_for_condition(lambda: turn_settled(ctx)) + assert adapter.completed == [] + assert platform.marked("processed") == [] + + await ctx.resume_room() + await wait_for_condition(lambda: platform.marked("processed") == ["B"]) + assert adapter.completed == ["B"] + + async def test_interrupt_during_synthetic_setup_consumes_the_turn( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + setup_gate: ResponseGate, + ) -> None: + await started(ctx) + setup_gate.release.clear() + await ctx.on_event(synthetic_message("local-A")) + await setup_gate.entered.wait() + ctx.interrupt() + setup_gate.release.set() + + await wait_for_condition(lambda: turn_settled(ctx)) + await reconcile_once(ctx, platform) + assert adapter.completed == [] + assert queued_synthetic(ctx) == [] diff --git a/tests/runtime/turnadmission/test_stopped_synthetic_queue.py b/tests/runtime/turnadmission/test_stopped_synthetic_queue.py new file mode 100644 index 000000000..a9dddd014 --- /dev/null +++ b/tests/runtime/turnadmission/test_stopped_synthetic_queue.py @@ -0,0 +1,67 @@ +"""A STOP while queued synthetic work drains ends the pass and keeps the queue.""" + +from __future__ import annotations + +import pytest + +from band.runtime.execution import ExecutionContext +from tests.runtime.conftest import wait_for_condition +from tests.runtime.helpers import LifecyclePlatform, ResponseGate +from tests.runtime.turnadmission.kit import ( + TurnRecorder, + play, + queue_while_stopped, + queued_synthetic, + started, + stop_room, + turn_settled, +) + + +@pytest.mark.timeout(10) +class TestStoppedSyntheticQueue: + """A stopped room keeps its synthetic queue in order and stays responsive.""" + + async def test_stop_during_synthetic_setup_keeps_loop_responsive_and_replays_after_play( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + setup_gate: ResponseGate, + ) -> None: + await started(ctx) + await queue_while_stopped(ctx, platform, "local-A", "local-B") + setup_gate.release.clear() + await play(ctx, platform) + await setup_gate.entered.wait() + stop_room(ctx, platform) + setup_gate.release.set() + + await wait_for_condition(lambda: turn_settled(ctx)) + assert adapter.completed == [] + assert queued_synthetic(ctx) == ["local-A", "local-B"] + assert platform.posts == [] + + await play(ctx, platform) + await wait_for_condition(lambda: adapter.completed == ["local-A", "local-B"]) + + async def test_stop_during_queued_synthetic_handler_ends_pass_and_replays_in_order( + self, + ctx: ExecutionContext, + platform: LifecyclePlatform, + adapter: TurnRecorder, + ) -> None: + await started(ctx) + await queue_while_stopped(ctx, platform, "local-A", "local-B") + adapter.release.clear() + await play(ctx, platform) + await adapter.entered.wait() + stop_room(ctx, platform) + + # Settling proves STOP cancelled the held handler; an uncancelled one never settles. + await wait_for_condition(lambda: turn_settled(ctx)) + assert queued_synthetic(ctx) == ["local-A", "local-B"] + + adapter.release.set() + await play(ctx, platform) + await wait_for_condition(lambda: adapter.completed == ["local-A", "local-B"]) From bcf337bed80ca70b4ddfc6a3eb8db9fa01d376fb Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Wed, 14 Oct 2026 10:44:20 +0000 Subject: [PATCH 05/13] fix: preserve diagnostics for stalled Pydantic room turns (#775) * fix: preserve diagnostics for stalled Pydantic room turns * fix: retain durable evidence after Pydantic decline loops * refactor: simplify turn diagnostic correlation * fix: restore lint after Pydantic rebase * fix: keep stall diagnostics off the default log level Per-message boundary traces were INFO for every SDK user, and a swallowed output-retry warning dropped the only copy of pydantic-ai's retry sentence. Message lookup now stops after a fixed page ceiling so a missing id cannot walk a long-lived room. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/band/adapters/pydantic_ai.py | 107 +++++++++- src/band/runtime/execution.py | 90 +++++++-- tests/adapters/test_pydantic_ai_adapter.py | 188 +++++++++++++++++- .../matrix/test_rehydration_idempotency.py | 9 + tests/e2e/baseline/toolkit/capture.py | 54 ++++- tests/e2e/baseline/toolkit/user_ops.py | 44 +++- tests/runtime/test_execution.py | 87 ++++++++ .../websocket/test_reply_capture_delivery.py | 166 ++++++++++++++++ 8 files changed, 713 insertions(+), 32 deletions(-) diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 5cc77c73d..2079aace7 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -8,10 +8,13 @@ from __future__ import annotations +import asyncio import inspect import json import logging from collections.abc import Callable +from contextvars import ContextVar +from time import monotonic from typing import Any, ClassVar, get_origin, get_type_hints from band_sdk_core import AgentFailure @@ -77,6 +80,65 @@ logger = logging.getLogger(__name__) _PROVIDER = "pydantic_ai" +DIAGNOSTIC_TOOL_NAME_LIMIT = 80 + +_native_turn: ContextVar[tuple[str, str, float] | None] = ContextVar( + "pydantic_native_turn", default=None +) + + +def _log_native_progress( + phase: str, + *, + request: int | None = None, + events: int | None = None, + tool: str | None = None, + error: BaseException | None = None, +) -> None: + turn = _native_turn.get() + if turn is None: + return + room_id, message_id, started = turn + logger.debug( + "Pydantic native room=%s message=%s phase=%s elapsed=%.3f " + "request=%s events=%s tool=%r error=%s", + room_id, + message_id, + phase, + monotonic() - started, + request, + events, + tool[:DIAGNOSTIC_TOOL_NAME_LIMIT] if tool is not None else None, + type(error).__name__ if error is not None else None, + ) + + +def _before_model_request( + ctx: RunContext[AgentToolsProtocol], request_context: ModelRequestContext +) -> ModelRequestContext: + _log_native_progress("request_attempt", request=ctx.run_step) + return request_context + + +def _after_model_request( + ctx: RunContext[AgentToolsProtocol], + *, + request_context: ModelRequestContext, + response: ModelResponse, +) -> ModelResponse: + # With streaming, this hook sees the assembled response after consumption. + _log_native_progress("response_ready", request=ctx.run_step) + return _drop_blank_text(ctx, request_context=request_context, response=response) + + +def _model_request_error( + ctx: RunContext[AgentToolsProtocol], + *, + request_context: ModelRequestContext, + error: Exception, +) -> ModelResponse: + _log_native_progress("request_error", request=ctx.run_step, error=error) + raise error def _explicit_decline_is_terminal(request: ModelRequest) -> bool: @@ -398,7 +460,11 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]: # Strip content:null responses on every request, including mid-run # ones the storage filter can't reach (see the function docstring). ProcessHistory(_drop_non_replayable_messages), - Hooks(after_model_request=_drop_blank_text), + Hooks( + before_model_request=_before_model_request, + after_model_request=_after_model_request, + model_request_error=_model_request_error, + ), ], # The built-in Band surface, selected and built from the tool # registry. Custom tools keep their own registration paths below. @@ -477,10 +543,9 @@ async def on_message( user_message = msg.format_for_llm() logger.debug( - "Room %s: Running Pydantic AI agent (history: %s msgs, prompt: %s...)", + "Room %s: Running Pydantic AI agent (history: %s msgs)", room_id, len(self._message_history[room_id]), - user_message[:80], ) # Run agent with streaming to capture tool events. @@ -509,7 +574,10 @@ async def on_message( # failed run captured. capture_cm = capture_run_messages() captured = capture_cm.__enter__() + trace_token = _native_turn.set((room_id, msg.id, monotonic())) + event_count = 0 try: + _log_native_progress("run_started") # run_stream_events is an async context manager: it starts the run on the # first iteration and tears the background task down on exit. async with self._agent.run_stream_events( @@ -518,7 +586,16 @@ async def on_message( message_history=self._message_history[room_id], ) as events: async for event in events: + event_count += 1 + # Logarithmic sampling bounds output during long token streams. + if event_count & (event_count - 1) == 0: + _log_native_progress("stream_progress", events=event_count) if isinstance(event, FunctionToolCallEvent): + _log_native_progress( + "tool_started", + events=event_count, + tool=event.part.tool_name, + ) if Emit.TOOL_CALLS in self.features.emit: try: await tools.send_event( @@ -537,11 +614,19 @@ async def on_message( except Exception as e: # noqa: BLE001 -- tool calls may raise any exception type; must surface to the LLM as an error string, not crash the turn logger.warning("Failed to send tool_call event: %s", e) elif isinstance(event, FunctionToolResultEvent): + tool_succeeded = isinstance( + event.part, ToolReturnPart + ) and not is_failed_tool_output(event.part.content) + _log_native_progress( + "tool_succeeded" if tool_succeeded else "tool_failed", + events=event_count, + tool=event.part.tool_name, + ) # Native custom tools run outside execute_custom_tool, so # their declared effect is recorded here. if ( - isinstance(event.part, ToolReturnPart) - and not is_failed_tool_output(event.part.content) + tool_succeeded + and isinstance(event.part, ToolReturnPart) and ( effect := self._custom_effects.get(event.part.tool_name) ) @@ -577,6 +662,7 @@ async def on_message( "Failed to send tool_result event: %s", e ) elif isinstance(event, AgentRunResultEvent): + _log_native_progress("result_received", events=event_count) turn_usage = self._usage_from_result(event.result) # Keep native run history, but drop responses that replay as # content:null (e.g. thinking-only) — providers reject them @@ -595,7 +681,13 @@ async def on_message( room_id, dropped, ) + _log_native_progress("iterator_completed", events=event_count) + _log_native_progress("run_completed", events=event_count) + except asyncio.CancelledError as error: + _log_native_progress("run_cancelled", events=event_count, error=error) + raise except Exception as e: + _log_native_progress("run_error", events=event_count, error=e) # A turn that already did its work must not fail over the reply the model # owes pydantic-ai. Allowing `None` — and normalizing blank text into it # — ends the ordinary nothing-left-to-say response cleanly, but some @@ -633,12 +725,15 @@ async def on_message( ModelRequest(parts=[UserPromptPart(content=user_message)]), ] return - logger.exception("Room %s: Pydantic AI turn failed", room_id) + logger.error( + "Room %s: Pydantic AI turn failed (%s)", room_id, type(e).__name__ + ) await tools.send_failure( AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) raise finally: + _native_turn.reset(trace_token) capture_cm.__exit__(None, None, None) # Single emit point for every exit. A run that raised (benign # empty-final or a hard failure) never saw the result event, so diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index cf0bb8dd0..ab9072e59 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -22,6 +22,7 @@ from datetime import UTC, datetime from enum import Enum, StrEnum from functools import partial +from time import monotonic from typing import ( TYPE_CHECKING, Any, @@ -94,6 +95,39 @@ logger = logging.getLogger(__name__) +@contextlib.contextmanager +def _observe_phase( + phase: str, room_id: str, message_id: str | None = None +) -> Iterator[None]: + """Record boundary completion separately from entry, without payloads.""" + started = monotonic() + logger.debug( + "Turn phase=%s state=started room=%s message=%s", + phase, + room_id, + message_id, + ) + outcome = "returned" + try: + yield + except BaseException as error: + outcome = ( + "cancelled" + if isinstance(error, asyncio.CancelledError) + else type(error).__name__ + ) + raise + finally: + logger.debug( + "Turn phase=%s state=%s room=%s message=%s elapsed=%.6f", + phase, + outcome, + room_id, + message_id, + monotonic() - started, + ) + + def _log_turn_error(error: Exception, message: str, *args: object) -> None: """Log a failed turn; one whose failure already reached the room was logged where it was reported, so it stays out of ERROR alerting.""" @@ -495,7 +529,7 @@ async def _retry_processed_ack(self, message_id: str) -> bool: return False try: - durable_processed = await self.link.mark_processed(self.room_id, message_id) + durable_processed = await self._acknowledge(message_id) except RoomExecutionStoppedError: return False if durable_processed: @@ -1075,7 +1109,8 @@ async def _ensure_fresh_context(self, *, force_refresh: bool = False) -> None: self._expire_context_cache_if_needed() if not self._context_hydrated: - await self.hydrate() + with _observe_phase("hydrate", self.room_id): + await self.hydrate() def build_context(self) -> ConversationContext: """ @@ -1651,7 +1686,7 @@ async def _process_claimed_backlog_message( return TurnOutcome.RETRY_LATER revision = scope.control_revision self._set_state(ExecutionState.PROCESSING) - logger.info("Processing backlog message %s in room %s", msg_id, self.room_id) + logger.info("Dispatch source=backlog room=%s message=%s", self.room_id, msg_id) attempts: int | None = None try: @@ -1710,7 +1745,7 @@ async def _process_claimed_backlog_message( # retries only the ack via _retry_processed_ack, never re-running # the handler. remember_completed clears this on success below. self.claims.remember_ack_pending(self.room_id, msg_id) - durable_processed = await self.link.mark_processed(self.room_id, msg_id) + durable_processed = await self._acknowledge(msg_id) if durable_processed: self._retry_tracker.mark_success(msg_id) self.claims.remember_completed(self.room_id, msg_id) @@ -1787,10 +1822,16 @@ async def _invoke_handler(self, event: PlatformEvent) -> None: handler drains; participant add/remove events are housekeeping and skip it. """ - if isinstance(event, MessageEvent): - await self._execute_message_cycle(event) - else: - await self._on_execute(self, event) + message_id = ( + event.payload.id + if isinstance(event, MessageEvent) and event.payload + else None + ) + with _observe_phase("cycle", self.room_id, message_id): + if isinstance(event, MessageEvent): + await self._execute_message_cycle(event, message_id) + else: + await self._on_execute(self, event) async def _report_working_state(self, working: bool) -> bool: """Report the room's boolean working state (wired into the reporter).""" @@ -1817,14 +1858,17 @@ def _observe_activity_stop(self, scope: TurnScope | None, revision: int) -> None ) self.observe_platform_stop(scope, control_revision=revision) - async def _execute_message_cycle(self, event: PlatformEvent) -> None: + async def _execute_message_cycle( + self, event: PlatformEvent, message_id: str | None + ) -> None: await self._working_reporter.start() scope = self.current_scope if scope is not None and scope.terminating: raise asyncio.CancelledError self._resync_handled_work = True self._reset_resync_interval() - await self._on_execute(self, event) + with _observe_phase("handler", self.room_id, message_id): + await self._on_execute(self, event) def _retain_detached_cycle_task( self, task: asyncio.Task[None], *, owner: TurnTask | None = None @@ -1962,11 +2006,24 @@ async def claim_message(self, message_id: str) -> bool: raise asyncio.CancelledError return accepted + async def _acknowledge(self, message_id: str) -> bool: + with _observe_phase("ack", self.room_id, message_id): + accepted = await self.link.mark_processed(self.room_id, message_id) + logger.debug( + "Ack room=%s message=%s accepted=%s", self.room_id, message_id, accepted + ) + return accepted + async def _claim(self, msg_id: str) -> bool: """Mark ``msg_id`` processing on the platform; a refused claim never ran the handler, so it gives back the attempt already charged.""" try: - if await self.link.mark_processing(self.room_id, msg_id): + with _observe_phase("claim", self.room_id, msg_id): + accepted = await self.link.mark_processing(self.room_id, msg_id) + logger.debug( + "Claim room=%s message=%s accepted=%s", self.room_id, msg_id, accepted + ) + if accepted: return True except RoomExecutionStoppedError: pass @@ -1994,7 +2051,7 @@ async def _abort_cycle(self, kind: ControlMode, msg_id: str | None) -> bool: # interrupted. Mirror the success-path bookkeeping. self.claims.remember_ack_pending(self.room_id, msg_id) try: - accepted = await self.link.mark_processed(self.room_id, msg_id) + accepted = await self._acknowledge(msg_id) except RoomExecutionStoppedError: accepted = False if accepted: @@ -2200,7 +2257,12 @@ async def _process_event_body( return TurnOutcome.RETRY_LATER self._set_state(ExecutionState.PROCESSING) - logger.debug("Processing %s in room %s", event.type, self.room_id) + logger.debug( + "Dispatch source=websocket room=%s message=%s event=%s", + self.room_id, + msg_id, + event.type, + ) attempts: int | None = None try: @@ -2281,7 +2343,7 @@ async def _process_event_body( # remember_completed clears this on success below. if isinstance(event, MessageEvent) and msg_id: self.claims.remember_ack_pending(self.room_id, msg_id) - durable_processed = await self.link.mark_processed(self.room_id, msg_id) + durable_processed = await self._acknowledge(msg_id) if durable_processed: self._retry_tracker.mark_success(msg_id) self.claims.remember_completed(self.room_id, msg_id) diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index b3eb4a110..8256f360c 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -12,8 +12,10 @@ import asyncio import contextlib import json +import logging from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from contextlib import asynccontextmanager, contextmanager +from dataclasses import replace from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace @@ -62,6 +64,8 @@ _drop_non_replayable_messages, _is_output_retries_exhausted, _is_replayable_history_message, + _log_native_progress, + _native_turn, ) from band.core.exceptions import BandToolError from band.core.protocols import ( @@ -1217,6 +1221,183 @@ async def _run_on_message( ) +class TestNativeDiagnostics: + @pytest.mark.asyncio + async def test_initial_acknowledgement_then_decline_finishes_without_replay( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + requests = 0 + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[dict[int, DeltaToolCall]]: + nonlocal requests + requests += 1 + replying = requests == 1 + yield { + 0: DeltaToolCall( + name=BandTool.SEND_MESSAGE if replying else BandTool.NO_REPLY, + json_args=json.dumps( + _reply_arguments(sample_message, "Noted") + if replying + else {"reason": "Already acknowledged"} + ), + ) + } + + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter( + llm=FunctionModel(stream_function=stream), emit=Emit.USAGE + ) + await _run_on_message(adapter, sample_message, tools) + + assert requests == 2 + assert tools.chat == ["Noted"] + assert reported_failures(tools) == [] + assert "phase=run_completed" in caplog.text + _assert_billed_responses( + tools, adapter._message_history[tools.room_id], model_requests=2 + ) + + @pytest.mark.asyncio + async def test_streamed_tool_turn_keeps_effects_and_redacts_payloads( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + message = replace(sample_message, content="private prompt body") + tools = FakeAgentTools(room_id=message.room_id) + adapter = PydanticAIAdapter( + llm=_streamed_tool_calls( + ( + BandTool.SEND_MESSAGE, + {"content": "private tool body", "mentions": ["Alice"]}, + ) + ) + ) + + await _run_on_message(adapter, message, tools) + + tools.assert_message_sent(content="private tool body") + assert caplog.text.count("phase=request_attempt") == 2 + assert caplog.text.count("phase=response_ready") == 2 + assert "phase=tool_succeeded" in caplog.text + assert f"tool='{BandTool.SEND_MESSAGE}'" in caplog.text + assert f"room={message.room_id} message={message.id}" in caplog.text + assert ( + caplog.text.index("phase=result_received") + < caplog.text.index("phase=iterator_completed") + < caplog.text.index("phase=run_completed") + ) + assert "private prompt body" not in caplog.text + assert "private tool body" not in caplog.text + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "yield_first", [False, True], ids=["before-first", "midstream"] + ) + async def test_stream_error_is_observed_and_preserves_exception( + self, + sample_message: PlatformMessage, + caplog: pytest.LogCaptureFixture, + yield_first: bool, + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + failure = RuntimeError("private provider error body") + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + if yield_first: + yield "private response body" + raise failure + + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + + with pytest.raises(RuntimeError) as raised: + await _run_on_message(adapter, sample_message, tools) + + assert raised.value is failure + assert "phase=run_error" in caplog.text + assert "error=RuntimeError" in caplog.text + assert "phase=response_ready" not in caplog.text + assert "phase=run_completed" not in caplog.text + assert "private provider error body" not in caplog.text + assert "private response body" not in caplog.text + assert len(failure_reports(tools)) == 1 + + @pytest.mark.asyncio + async def test_cancellation_closes_native_stream_without_reporting_failure( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + blocked = asyncio.Event() + closed = asyncio.Event() + release = asyncio.Event() + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + try: + yield "private response body" + blocked.set() + await release.wait() + finally: + closed.set() + + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + async with asyncio.TaskGroup() as tasks: + task = tasks.create_task(_run_on_message(adapter, sample_message, tools)) + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + + assert task.cancelled() + assert closed.is_set() + assert "phase=stream_progress" in caplog.text + assert "phase=run_cancelled" in caplog.text + assert "error=CancelledError" in caplog.text + assert "phase=response_ready" not in caplog.text + assert "phase=run_completed" not in caplog.text + assert "private response body" not in caplog.text + assert not failure_reports(tools) + + @pytest.mark.asyncio + async def test_long_stream_reports_bounded_progress( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + for _ in range(128): + yield "private token" + + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + await _run_on_message( + adapter, sample_message, FakeAgentTools(room_id=sample_message.room_id) + ) + + assert 1 < caplog.text.count("phase=stream_progress") <= 9 + assert "phase=run_completed" in caplog.text + assert "private token" not in caplog.text + + def test_progress_lines_are_debug(self, caplog: pytest.LogCaptureFixture) -> None: + """Boundary traces stay off the default INFO log.""" + token = _native_turn.set(("room-1", "msg-1", 0.0)) + try: + caplog.set_level(logging.INFO, logger="band.adapters.pydantic_ai") + _log_native_progress("run_completed") + assert "phase=run_completed" not in caplog.text + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + _log_native_progress("run_completed") + finally: + _native_turn.reset(token) + assert "phase=run_completed" in caplog.text + + class TestAdvertisedToolSchemas: """Per-argument text fidelity is asserted in test_tool_text_drift. @@ -2419,7 +2600,10 @@ def test_swallow_matches_the_wording_pydantic_ai_actually_raises(self) -> None: @pytest.mark.asyncio async def test_empty_output_after_tool_is_benign( - self, sample_message, mock_pydantic_agent + self, + sample_message, + mock_pydantic_agent, + caplog: pytest.LogCaptureFixture, ): """Output-retry exhaustion after the turn replied is swallowed.""" adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) @@ -2433,6 +2617,7 @@ async def test_empty_output_after_tool_is_benign( work=lambda: tools.send_message("hi", ["@Alice"]), ) ) + caplog.set_level(logging.WARNING, logger="band.adapters.pydantic_ai") # Must not raise: the reply already went out via the tool this turn. await adapter.on_message( @@ -2457,6 +2642,7 @@ async def test_empty_output_after_tool_is_benign( for part in preserved[-1].parts ) assert reported_failures(tools) == [] + assert "Exceeded maximum output retries (1)" in caplog.text @pytest.mark.asyncio async def test_empty_output_preserves_full_captured_turn( diff --git a/tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py b/tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py index 84480a9b3..c2cb619de 100644 --- a/tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py +++ b/tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py @@ -31,6 +31,8 @@ from __future__ import annotations +import logging + import pytest from band.client.streaming import DeliveryStatus @@ -53,6 +55,13 @@ from tests.e2e.baseline.toolkit.user_ops import UserOps +@pytest.fixture(autouse=True) +def turn_diagnostics(caplog: pytest.LogCaptureFixture) -> None: + """Keep boundary observations in pytest's existing failure report.""" + for name in ("band.runtime.execution", "band.adapters.pydantic_ai"): + caplog.set_level(logging.DEBUG, logger=name) + + @per_adapter(runs_tool_loop=True, prompt=REPLY_PROMPT, **EXECUTION_REPORTING) @flaky_model("cold-boot recall is model-non-deterministic") @pytest.mark.timeout(extra=300) # several run-1 turns + two agent boots diff --git a/tests/e2e/baseline/toolkit/capture.py b/tests/e2e/baseline/toolkit/capture.py index cb4180d0a..0402cb306 100644 --- a/tests/e2e/baseline/toolkit/capture.py +++ b/tests/e2e/baseline/toolkit/capture.py @@ -77,6 +77,8 @@ logger = logging.getLogger(__name__) DEFAULT_DEADLINE_S = 60.0 +DELIVERY_SNAPSHOT_TIMEOUT_S = 5.0 +DELIVERY_SNAPSHOT_HISTORY_LIMIT = 16 def _parse_status(raw: Any) -> DeliveryStatus | None: @@ -171,13 +173,12 @@ async def durable_delivery_statuses( """Read current REST state after a barrier, independent of observer ordering.""" requested = set(message_ids) statuses: dict[str, DeliveryStatus | None] = {} - for message in await self._require_user_ops().list_messages(self.room_id): - if message.id in requested: - delivery = ( - metadata_to_dict(message.metadata).get("delivery_status") or {} - ) - recipient = delivery.get(recipient_id) or {} - statuses[message.id] = _parse_status(recipient.get("status")) + for message in await self._require_user_ops().find_messages( + self.room_id, requested + ): + delivery = metadata_to_dict(message.metadata).get("delivery_status") or {} + recipient = delivery.get(recipient_id) or {} + statuses[message.id] = _parse_status(recipient.get("status")) assert statuses.keys() == requested, "requested messages missing from room" return statuses @@ -218,6 +219,39 @@ def _delivery_error(self, message_id: str, recipient_id: str) -> str: ) return f" (last error: {last_error})" if last_error else "" + async def _log_delivery_timeout(self, message_id: str, recipient_id: str) -> None: + """Compare the observer with REST before teardown removes the evidence.""" + observed = self.delivery_status(message_id, recipient_id) + history = [ + status.value + for status in self.delivery_history(message_id, recipient_id)[ + -DELIVERY_SNAPSHOT_HISTORY_LIMIT: + ] + ] + durable = None + error = "unavailable" + if self._user_ops is not None: + error = None + try: + async with asyncio.timeout(DELIVERY_SNAPSHOT_TIMEOUT_S): + statuses = await self.durable_delivery_statuses( + [message_id], recipient_id + ) + durable = statuses[message_id] + except Exception as exc: # noqa: BLE001 — diagnostics cannot replace the timeout + error = type(exc).__name__ + logger.warning( + "Delivery timeout snapshot room=%s message=%s recipient=%s " + "websocket=%s history=%s rest=%s rest_error=%s", + self.room_id, + message_id, + recipient_id, + observed.value if observed else None, + history, + durable.value if durable else None, + error, + ) + async def wait_until( self, predicate: Callable[[list[MessageCreatedPayload]], bool], @@ -295,12 +329,14 @@ async def wait_for_processed( ) except TimeoutError: last = self.delivery_status(message_id, recipient_id) - raise TimeoutError( + error = TimeoutError( f"{recipient_id} did not process message {message_id} in room " f"{self.room_id}; last delivery status: " f"{last.value if last else 'none'}" f"{self._delivery_error(message_id, recipient_id)}" - ) from None + ) + await self._log_delivery_timeout(message_id, recipient_id) + raise error from None async def wait_for_reply( self, diff --git a/tests/e2e/baseline/toolkit/user_ops.py b/tests/e2e/baseline/toolkit/user_ops.py index 2134d0d4d..3912f74b8 100644 --- a/tests/e2e/baseline/toolkit/user_ops.py +++ b/tests/e2e/baseline/toolkit/user_ops.py @@ -8,9 +8,10 @@ from __future__ import annotations -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Iterable from contextlib import asynccontextmanager from datetime import datetime +from typing import Any import httpx from band_rest import ( @@ -29,6 +30,12 @@ from band.core.types import MessageType +MESSAGE_PAGE_SIZE = 100 +# Newest-first lookup of a recent message. One turn's tool events can push it +# past the first page; beyond this the id is not in recent history, and further +# pages only walk a long-lived room. +MESSAGE_LOOKUP_PAGE_LIMIT = 10 + class UserOps: """Drive platform actions as the test user via the Human API.""" @@ -218,7 +225,7 @@ async def list_messages( *, message_type: MessageType | None = None, since: datetime | None = None, - limit: int = 100, + limit: int = MESSAGE_PAGE_SIZE, ) -> list[ChatMessage]: """List a room's messages/events, optionally filtered by type and time. @@ -235,6 +242,39 @@ async def list_messages( ) return list(reversed(response.data or [])) + async def find_messages( + self, room_id: str, message_ids: Iterable[str] + ) -> list[ChatMessage]: + """Read cursor pages until the requested messages are found. + + Stops after ``MESSAGE_LOOKUP_PAGE_LIMIT`` pages so a missing id cannot + walk the rest of a long-lived room. + """ + missing = set(message_ids) + found: list[ChatMessage] = [] + options: dict[str, Any] = {} + pages = 0 + while missing: + if pages >= MESSAGE_LOOKUP_PAGE_LIMIT: + raise ValueError( + f"Message lookup exceeded {MESSAGE_LOOKUP_PAGE_LIMIT} pages" + ) + pages += 1 + response = await self._client.human_api_messages.list_my_chat_messages( + room_id, limit=MESSAGE_PAGE_SIZE, **options + ) + for message in response.data: + if message.id in missing: + found.append(message) + missing.remove(message.id) + if not missing or not response.metadata.has_more: + break + cursor = response.metadata.next_cursor + if not cursor or cursor == options.get("cursor"): + raise ValueError("Message pagination did not advance") + options["cursor"] = cursor + return found + async def delete_room(self, room_id: str) -> None: """Soft-delete a room. diff --git a/tests/runtime/test_execution.py b/tests/runtime/test_execution.py index 12e7a29b5..694330403 100644 --- a/tests/runtime/test_execution.py +++ b/tests/runtime/test_execution.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from datetime import UTC, datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -27,6 +28,7 @@ ResyncOutcome, TurnOutcome, _error_label, + _observe_phase, ) from band.runtime.tools import AgentTools from band.runtime.types import ConversationContext, PlatformMessage, SessionConfig @@ -2743,3 +2745,88 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: ("newer", "processing"), ("newer", "processed"), ] + + +@pytest.mark.parametrize("cancel", [False, True], ids=["complete", "cancel"]) +async def test_turn_diagnostics_distinguish_running_handler_from_ack( + caplog: pytest.LogCaptureFixture, cancel: bool +) -> None: + peer = LifecyclePlatform() + entered = asyncio.Event() + release = asyncio.Event() + + async def handler(ctx: ExecutionContext, event: Any) -> None: + entered.set() + await release.wait() + + caplog.set_level(logging.DEBUG, logger="band.runtime.execution") + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id="agent-123", api_key="test") + link.rest = rest + ctx = ExecutionContext( + "room-123", + link, + handler, + config=SessionConfig(enable_context_hydration=False), + ) + async with asyncio.TaskGroup() as tasks: + task = tasks.create_task( + ctx._process_event( + make_message_event( + room_id="room-123", + msg_id="observed-turn", + content="private prompt", + ) + ) + ) + await entered.wait() + assert peer.marked("processing") == ["observed-turn"] + assert peer.requested("processed") == [] + assert "phase=handler state=started" in caplog.text + assert "phase=handler state=returned" not in caplog.text + assert "phase=ack" not in caplog.text + if cancel: + task.cancel() + else: + release.set() + + if cancel: + assert task.cancelled() + assert peer.requested("processed") == [] + assert "phase=handler state=cancelled" in caplog.text + else: + assert peer.marked("processed") == ["observed-turn"] + assert "phase=handler state=returned" in caplog.text + assert ( + "Ack room=room-123 message=observed-turn accepted=True" in caplog.text + ) + assert "private prompt" not in caplog.text + + +class TestPhaseOutcome: + """Boundary traces stay at DEBUG and name interrupts that are not Exceptions.""" + + def test_completed_phase_is_debug_without_a_monotonic_stamp( + self, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.INFO, logger="band.runtime.execution") + with _observe_phase("handler", "room-1", "msg-1"): + pass + assert "phase=handler" not in caplog.text + + caplog.clear() + caplog.set_level(logging.DEBUG, logger="band.runtime.execution") + with _observe_phase("handler", "room-1", "msg-1"): + pass + assert "phase=handler state=started room=room-1 message=msg-1" in caplog.text + assert "phase=handler state=returned room=room-1 message=msg-1" in caplog.text + + @pytest.mark.parametrize("interrupt", [KeyboardInterrupt, SystemExit]) + def test_interrupt_is_named( + self, caplog: pytest.LogCaptureFixture, interrupt: type[BaseException] + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.runtime.execution") + with pytest.raises(interrupt), _observe_phase("handler", "room-1", "msg-1"): + raise interrupt + assert f"phase=handler state={interrupt.__name__}" in caplog.text + assert "state=returned" not in caplog.text diff --git a/tests/websocket/test_reply_capture_delivery.py b/tests/websocket/test_reply_capture_delivery.py index 97bac99e2..34cffb3ce 100644 --- a/tests/websocket/test_reply_capture_delivery.py +++ b/tests/websocket/test_reply_capture_delivery.py @@ -12,10 +12,14 @@ import asyncio +import httpx import pytest from band.client.streaming import DeliveryStatus, MessageCreatedPayload +from tests.e2e.baseline.toolkit import capture as capture_module from tests.e2e.baseline.toolkit.capture import ReplyCapture +from tests.e2e.baseline.toolkit.user_ops import MESSAGE_LOOKUP_PAGE_LIMIT, UserOps +from tests.runtime.helpers import rest_client_over MSG = "msg-1" AGENT = "agent-1" @@ -138,3 +142,165 @@ async def test_wait_for_processed_timeout_reports_last_status_and_error() -> Non message = str(excinfo.value) assert "failed" in message # last status surfaced assert "kaboom" in message # attempt error surfaced + + +async def test_timeout_reports_durable_completion_without_passing_stale_observer( + caplog: pytest.LogCaptureFixture, +) -> None: + content = "private message body" + message = _delivery_update("processed").model_dump() + message.update(content=content, inserted_at=None, updated_at=None) + async with rest_client_over( + lambda _: httpx.Response( + 200, + json={"data": [message], "metadata": {"has_more": False, "limit": 100}}, + ) + ) as rest: + capture = ReplyCapture("room-1", user_ops=UserOps(rest)) + capture._on_message_updated(_delivery_update("processing")) + + with pytest.raises(TimeoutError, match="last delivery status: processing"): + await capture.wait_for_processed(MSG, AGENT, deadline_s=0.01) + + assert "websocket=processing history=['processing'] rest=processed" in caplog.text + assert content not in caplog.text + assert capture.delivery_status(MSG, AGENT) is DeliveryStatus.PROCESSING + + +@pytest.mark.parametrize("newer_type", ["tool_result", "text"]) +async def test_timeout_snapshot_finds_note_beyond_first_message_page( + newer_type: str, caplog: pytest.LogCaptureFixture +) -> None: + note = _delivery_update("failed").model_dump() + note.update(content="private note", inserted_at=None, updated_at=None) + messages = [ + {**note, "id": f"other-{index}", "message_type": newer_type} + for index in range(205) + ] + messages[105] = note + cursors: list[str | None] = [] + + def answer(request: httpx.Request) -> httpx.Response: + cursor = request.url.params.get("cursor") + cursors.append(cursor) + start = int(cursor) if cursor else 0 + end = start + int(request.url.params["limit"]) + return httpx.Response( + 200, + json={ + "data": messages[start:end], + "metadata": { + "has_more": end < len(messages), + "next_cursor": str(end), + "limit": end - start, + }, + }, + ) + + async with rest_client_over(answer) as rest: + capture = ReplyCapture("room-1", user_ops=UserOps(rest)) + capture._on_message_updated(_delivery_update("processing")) + with pytest.raises(TimeoutError, match="last delivery status: processing"): + await capture.wait_for_processed(MSG, AGENT, deadline_s=0.01) + + assert "rest=failed rest_error=None" in caplog.text + assert "private note" not in caplog.text + assert cursors == [None, "100"] + + +@pytest.mark.parametrize("status,error", [(400, "ApiError"), (200, "ParsingError")]) +async def test_snapshot_http_failure_preserves_delivery_timeout_and_omits_body( + status: int, + error: str, + caplog: pytest.LogCaptureFixture, +) -> None: + body = "private provider exception body" + async with rest_client_over( + lambda _: httpx.Response(status, json={"message": body}) + ) as rest: + capture = ReplyCapture("room-1", user_ops=UserOps(rest)) + capture._on_message_updated(_delivery_update("processing")) + + with pytest.raises(TimeoutError, match="last delivery status: processing"): + await capture.wait_for_processed(MSG, AGENT, deadline_s=0.01) + + assert f"rest=None rest_error={error}" in caplog.text + assert body not in caplog.text + + +async def test_snapshot_deadline_preserves_delivery_timeout( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + monkeypatch.setattr(capture_module, "DELIVERY_SNAPSHOT_TIMEOUT_S", 0.02) + cancelled = asyncio.Event() + + async def stalled_response(_request: httpx.Request) -> httpx.Response: + try: + await asyncio.Event().wait() + finally: + cancelled.set() + raise AssertionError("unreachable") + + async with rest_client_over(stalled_response) as rest: + capture = ReplyCapture("room-1", user_ops=UserOps(rest)) + with pytest.raises(TimeoutError, match="last delivery status: none"): + await asyncio.wait_for( + capture.wait_for_processed(MSG, AGENT, deadline_s=0.01), timeout=1 + ) + + assert cancelled.is_set() + assert "rest=None rest_error=TimeoutError" in caplog.text + + +async def test_message_lookup_stops_after_page_limit() -> None: + """A missing id stops at the page ceiling instead of walking the room.""" + requests = 0 + + def answer(request: httpx.Request) -> httpx.Response: + nonlocal requests + requests += 1 + cursor = request.url.params.get("cursor") + nxt = "1" if cursor is None else str(int(cursor) + 1) + message = _delivery_update("processed").model_dump() + message.update(id=f"other-{nxt}", inserted_at=None, updated_at=None) + # Stay paged through the ceiling. One page past it ends, so removing + # the limit returns no match instead of following a cursor forever. + return httpx.Response( + 200, + json={ + "data": [message], + "metadata": { + "has_more": requests <= MESSAGE_LOOKUP_PAGE_LIMIT, + "next_cursor": nxt, + "limit": 100, + }, + }, + ) + + async with rest_client_over(answer) as rest: + with pytest.raises(ValueError, match="pages"): + await UserOps(rest).find_messages("room-1", ["missing"]) + + assert requests == MESSAGE_LOOKUP_PAGE_LIMIT + + +async def test_cancellation_during_snapshot_propagates() -> None: + requested = asyncio.Event() + + async def stalled_response(_request: httpx.Request) -> httpx.Response: + requested.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async with rest_client_over(stalled_response) as rest: + capture = ReplyCapture("room-1", user_ops=UserOps(rest)) + waiter = asyncio.create_task( + capture.wait_for_processed(MSG, AGENT, deadline_s=0.01) + ) + try: + await asyncio.wait_for(requested.wait(), timeout=1) + finally: + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await waiter From ed329fcde3d8e50d90c62a05fdf81d7087d11d11 Mon Sep 17 00:00:00 2001 From: Natan Zalkin Date: Wed, 14 Oct 2026 10:59:24 +0000 Subject: [PATCH 06/13] feat: own Codex, ACP and Claude room lifecycles through one retained-room contract (#709) * feat: own accepted room work and release causes in retention core Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: report unfinished execution settlement from stop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: keep failed room cleanup owned and fence its readmission Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: surface incomplete host cleanup and keep it retryable Agent, PlatformRuntime and OneShotInvoker keep failed or cancelled cleanup, and the single-instance guard, until a retry succeeds, while preserving the primary error during unwind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: retain shared MCP backend until its final stop succeeds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * docs: describe shared retention ownership rules Also updates the Agent lifecycle tests to the honest-stop contract. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: own room startup with retirement and never block presence Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: join one owned OneShot shutdown across callers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: let owned work that requests cleanup unwind uncancelled Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: keep cumulative graceful result across agent stop retries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: hold the platform runtime until its whole stop succeeds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: coordinate OneShot startup and shutdown ownership Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: drop stop intent raised during failed-start unwind Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: drain accepted OneShot events before shutdown disposes Shutdown now refuses new events synchronously and waits, without a new timeout, for every accepted handle_event (turns, acks, room cleanup, cancellation unwinding) to settle before cleanup_all and disconnect. A shutdown requested from inside an accepted event reports CleanupIncompleteError instead of joining itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: resync /next after every routed room admission Events that arrive before a first execution is routed are dropped unclaimed, so every still-desired admission resyncs to recover them; an obsolete admission neither routes nor resyncs. Startup stays unbounded: leave and stop wait for it, then retire it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: split room retention into single-concern modules Config, residency vocabulary, clock, owned-cleanup reporting, one room's lifecycle and the adapter-wide owner each get their own module; the package re-exports the same public surface. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: run agent cleanup phases through one loop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: split AgentRuntime room lifecycle and control routing RoomExecutions owns each room's admission, routing and retirement; band.runtime.control owns control dedup, targeting and dispatch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: drop unused control dedup capacity parameter Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * test: split long lifecycle test files into one-concern packages Each former module becomes a package with a conftest for fixtures, a kit for shared helpers and one file per test class or concern. The room cleanup double used by both runtime suites now lives in one helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: replace runtime asserts with non-optional values by construction Task-starting helpers return their task, initialize() returns its link, and the runtime and link are passed to the helpers that need them, so no production path relies on assert for narrowing. The MCP backend's final close names the backend it claims instead of re-checking it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * test: one test class per file, one concern per class Every split lifecycle test file now holds a single Test class; loose module-level tests became classes and helper classes moved to each package's kit. AGENTS.md states the rule for new and touched tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: drive room lifecycle as a signal-driven state machine Each room runs at most one step (start or retire); a signal or that step's completion advances it once through a match on state, intent and ownership. Waiters await a per-convergence future instead of re-running a reconcile loop, and a per-signal step budget fails loudly instead of spinning. Table-row tests cover each transition and the breaker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * refactor: drive retention waits and idle scheduling by signals The idle scheduler is one timer armed for the earliest deadline and re-armed on every room change instead of a polling loop. Disposal waits on a drained signal, and settling and becoming resident run bounded step sequences that wait on a settleable signal and fail loudly at their budget instead of looping forever. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * test: add multistage scenarios over a real link, presence and rooms Seven end-to-end flows run the real BandLink, RoomPresence, RoomExecutions, control path and ExecutionContext against a loopback Phoenix server and a stateful REST peer, asserting each room's ordered story: rejoin during cleanup, reconnect during admission, the stop/play control cycle, agent-wide interrupt fan-out, shutdown retry, one-shot drain, and a two-room retention lifecycle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: retire a start that fails after withdrawal and keep early room events A start that raises after its room was withdrawn or the runtime began stopping now retires what it built instead of failing the stop. Events for a wanted room that is not routed yet are held, bounded, and delivered in order before live routing, so participant and room events from the startup window are no longer lost; withdrawal discards them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * test: one class per file for the remaining touched suites Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: re-check room intent after held-event delivery before routing Delivering held events awaits the execution, so a leave (and rejoin) could land between the startup intent check and routing, leaving a withdrawn start routed and, after a rejoin, serving instead of replaced. Intent is now re-read with no await between the check and routing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: bind held-event delivery to the admission it was held for Delivery now re-reads the originating admission after every on_event await and stops when a leave, stop or rejoin invalidates it, so events held for a rejoined membership stay for the fresh execution instead of being consumed by the stale one. The stale start is left unrouted for the owner to retire before a replacement starts. Barrier regressions cover leave, stop and leave-then-rejoin during held delivery. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmiQGxJR2FrTVuRP85Y1dq * fix: keep recovery, startup events, and working-state reports owned An idle release that finished while recovery was recorded cleared that cause and strict-resumed the crashed residency. Startup delivery could skip or drop events that arrived while a full hold queue drained, and a still-legal working:false report could fail execution settlement. Co-authored-by: Cursor * test: expect a startup flood past the delivery cap to be dropped A full queue's accepted events are delivered, including ones that arrive while it drains. The two events past the delivery cap are dropped for /next, not left held. Co-authored-by: Cursor * fix: keep a stronger release pending until it actually runs Disposal during an in-flight idle release was committing the idle close and erasing the room without release(FINAL). A stronger cause recorded during a release now stays pending, and a withdrawn admission logs the delivery batch it drops. Co-authored-by: Cursor * fix: say stop joins a deferred working:false report An inactive reporter is the deferred-POST case. The await returns immediately only when no false send is in flight. Co-authored-by: Cursor * refactor: one owned-task helper logs every orphaned lifecycle failure OneShot lifecycle, retention shutdown, room residency transitions and the final MCP stop each created a task and attached a near-identical failure logger. spawn_owned in band.core.retention.cleanup is now the one place that does it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0127u9A1eQy9JiCBt8yHNotB * fix: give retirements during stop the caller's grace AgentRuntime.stop destroys rooms one at a time. A room whose start finished before stop reached it was retired by the state machine with the leave default (cancel immediately), so a turn it began got no grace and stop reported graceful=False. RoomExecutions.close records the shutdown timeout with the accepting flag, and every retirement after close uses it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0127u9A1eQy9JiCBt8yHNotB * fix: record a control signal as seen only once it reached a room A signal for a room that was wanted but not yet routed was dropped and its correlation id recorded, so a later copy after routing was ignored too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0127u9A1eQy9JiCBt8yHNotB * docs: state OneShot shutdown's unbounded wait and the guard kept on failed disconnect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0127u9A1eQy9JiCBt8yHNotB * feat: compose Codex rooms with retained room residency Each Codex room is a RetainedRoom: acquire starts and owns the app-server before its first await, a lost process is recovered by generation and its thread restored strictly (exact threadId, no history, no new-thread fallback, settings and tools verified), and leaving is final erasure. Native events and requests are routed to the turn that started them; autonomous native work is refused rather than charged to a Band turn. Work is serialized per room. Idle release stays refused (BUSY). Shared retention behavioral host and peer tests cover Codex and Claude. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * test: one class per file for the touched Codex and lifecycle suites Pure move: test_codex_adapter.py, test_workspace_lifecycle.py and the Codex adapter flow tests become packages with a kit, fixtures and one concern class per file. The flow tests reuse the shared Codex doubles instead of their own copies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: keep early native events ahead of later ones Owned events that arrived before turn/start answered are queued before refusing unowned ones, since a refusal awaits and the reader could otherwise queue the turn's later events, even its completion, first. The lifecycle host records each child's workspace itself so the overlap guard holds for Claude children too, and one helper reads thread and turn ids from Codex results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: pin resumed threads to the room workspace and bound turn leftovers A thread resumed from Band history now sends the room's workspace and sandbox like a strict restore. Without a cwd, Codex reports the thread's recorded one, and a later restore after a process loss refused it as a changed setting, leaving the room stuck until it was left. A finished turn answers its leftover native requests within turn_settle_timeout_s, so a process that stopped reading cannot hold the turn, and with it the room, open. The shared lifecycle host takes each adapter's retained residency as a hook instead of reading Codex internals, and the Claude double honours a held start like the Codex one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: keep archive reachable and stop asking rooms for a lost process Local commands lease the room's Codex process only when they ask it (/model, /models, /reasoning), so /thread archive still works after Codex refuses to restore the thread, as the docs promise. A turn whose process was lost no longer shows that process's queued requests to the room, and an answer that fails because the process exited no longer masks the lost-process failure. The routing doc names the turnId it actually routes by. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * feat: own ACP room residency through one RoomRetention for OMP, Cursor and Copilot Each ACP room is an ACPRoom composed with a RetainedRoom. Detached work is owned before it runs, prompts bind tools under the room lease, and rooms progress independently. A deliberate wake strictly reloads the exact session and reapplies MCP and the model selection; refusals keep the identity. Closure keeps the process handle until __aexit__ succeeds, and final cleanup shares one transition budget with the shared Band MCP server. Cursor sessionless extension requests bind to the originating room process and generation. All three providers stay ProviderActivity.UNKNOWN, so idle release remains off pending native qualification. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * fix: address ACP room ownership review findings - Spawn a room's process under its turn's lease, not inside the bounded acquisition, so a slow agent start no longer loops through recovery. - Report and recover session-setup failures like prompt failures. - Reloading a room's own session after a crash keeps the session and the selection on a configuration refusal; only bootstrap candidates close. - Confirm closure at stdout EOF, so a descendant holding the pipes keeps the room closing even when the agent exited first or the retry path runs. - Bound session/cancel so a wedged agent cannot hold disposal forever. - Cursor: register each turn before it can start, hand the message queue back when a turn must wait for its room, cancel every accepted turn on interrupt, and cap pending decisions per room. - Copilot switch maps a stopping retention to the mid-switch config error. - Tests: fix assertions that could not fail, run the pipe holder under the base interpreter on Windows, and cover each fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * fix: hand the queue back for a Cursor turn behind a waking one A turn that started after the room's holder released the reservation, but before the queued turn ahead of it took it, saw a free lock and stayed on the room's message queue. If the turn ahead then parked on a decision, the answer could not arrive until the decision timed out. A turn now also waits off the queue whenever an earlier accepted turn is still ahead of it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * feat: own Claude SDK rooms through shared room retention Compose each room's Claude CLI session with one RetainedRoom under a single adapter RoomRetention, replacing the adapter-wide serial command queue. Turns are owned before their first await and lease the room through query, stream, approvals and outcome; tools and MCP routing bind before a CLI starts. Client and transport are retained from before connect until a confirmed close; startup failure, crashes, interrupted turns and Band MCP endpoint changes recover through the owner. Strict resume never falls back to a new session. One continuous receive_messages reader spans turns and the timeout drain. cleanup_all closes rooms, then the shared MCP server, within one transition_timeout_s budget. Idle retention stays disabled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: retire Claude CLIs whose output ends outside a turn The single reader now hands a CLI whose output ended while no turn was listening to the room's owner, so the next message never waits on a dead stream. A timed-out turn whose drain reaches the stream's end without a result retires its CLI, and the bootstrap fallback no longer starts a new CLI while a failed one it could not close is still held. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * refactor: state the reader's retirement reason once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: retire Claude CLIs whose output ends after a turn's result A turn stops listening once its result arrives, so an end of output the turn will never read is retired by the reader with the crash policy. The fresh-session fallback keeps the conversation until a new one runs, a permission decision is re-checked against a retiring residency, and Claude's retention budget defaults to the SDK's own CLI startup bound. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: follow the room lifecycle contract in ACP startup and failures - Start a room's ACP process inside its acquisition, so a start that outlasts transition_timeout_s is abandoned and recovered by the owner instead of a second startup path; the room's generation now reads from its RetainedRoom. - Report the failure of a detached Cursor turn that ended before its prompt, so a queued message is never lost silently. - Revert a refused remembered selection on a crash reload, as bootstrap does; only a deliberate wake keeps it. The room keeps its session identity. - Keep one capped decision registry per Cursor room. - Log a failed agent exit, a forced kill, and a descendant still holding the agent's stdout; own the closure and session-close tasks with spawn_owned. - Bound the strict session/load like the fallback load. - Remove the unused lazy respawn in ensure_connection and the test-only bind_session. - Tests: queued-turn start failure, crash reload revert, sessionless Cursor updates, real-process failed-exit retry, shared cleanup budget, restart refusal while rooms close, and a live (not yet run) ACP rejoin smoke. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * fix: readmit late-disposed rooms and own sub-agent requests per turn - Keep a leaving room registered until its disposal is confirmed, so a leave reported unfinished can be retried and lift the room's fence. - A Band turn owns requests from sub-agent threads its collab items name; requests from threads not yet named wait for that item, and are refused when the turn ends. - Give Codex its own 60s acquisition budget and report a start that runs over as "Codex process did not become ready in time". - Own the event reader with spawn_owned and log each process loss once, with the transport's reason. - /status reads room state without registering a room. - One builder each for tool and approval responses, event params, and a room's thread binding. - Tests: routing while a turn runs, sub-agent attribution, late disposal, slow start, idle refusal, Codex workspace after rejoin, deterministic early-event fakes, exact restore error types. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: keep the Claude session on read failures and share the startup budget The reader forgets a session only when the CLI process is gone, matching the in-turn crash policy; a read failure from a running CLI keeps it. The CLI startup budget is one session-manager constant that both the manager and the adapter config default to. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: attribute multi_agent_v2 sub-agents and log each process loss once - A turn also learns its sub-agent threads from multi_agent_v2's subAgentActivity item, which replaces the public collab tool call when that feature is on; the item is routing-only and never rendered. - A lost process's leftover requests are no longer answered into it. - The reader logs each process loss once, with its reason or traceback; a process-not-ready timeout logs the bound that actually expired. - /status reads the active room without registering it. - Tests: v2 attribution in both orders, a held request that another sub-agent's naming leaves held, a lost process's leftovers, /status's room model, and a slow start's close and fresh restart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: log a process-not-ready timeout with its traceback A close timeout re-raised through the residency carries no message. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * fix: tie Cursor failure reports to the room lease and bound ACP startup A Cursor turn now holds its room through a holder slot set and cleared inside its lease, and reports its failure before the lease passes on, so a queued turn never takes the room while the failed one still answers for it. Failures are posted as generic runtime turn failures, once. ACP agent startup is bounded by a new start_timeout_s setting (10s), because the room owner never cancels an acquisition its caller stopped waiting for; a start that never answers now fails and recovers. Also: one exit-failure traceback naming the pid, a held-stdout warning that covers the SDK's own exit, read-only decision lookups, removal of the dead respawn path, and tests for each (strict-load timeout, held stdout warning, exit/kill logs, leak-free failing-exit wrapper). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * refactor: simplify Claude turn binding and retired-session denial logs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: report Claude turn failures as already reported A failure _converse has already posted to the room is raised as TurnResultAlreadyReported, as the timeout path and the Codex adapter do, so the runtime neither logs it again at ERROR nor treats it as unreported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: refuse unqualified Claude idle releases before teardown The release callback returns BUSY for IDLE with the CLI, transport, reader and session id intact, matching the other unqualified providers; recovery and final releases still close, or keep the CLI for a retry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: seal Codex shutdown admission and keep the stopped owner until restart cleanup_all refuses new rooms, turns and native commands before its first await, then reports one owned shutdown (turn settlement plus room disposal) within a single transition deadline. Callers join that shutdown, a failed one is retried, and an unfinished or failed shutdown keeps every room fenced with its process and workspace. A confirmed shutdown stays sealed until on_started opens a new residency owner. The common lifecycle suite now discovers each provider's bridge from its own lifecycleprofile.py, so provider PRs register without editing shared files; the failed-cleanup loss scenario asserts the retained CLEANUP_PENDING room, no replacement, and a successful retry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GZfyciFpDDVFBfkWkktU6E * test: join the Claude adapter to the common room lifecycle suite The profile reads residency from the room's RetainedRoom and the native id from its ClaudeRoomSession; a lost transport fails the turn as TurnResultAlreadyReported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Veg5G5xrvCPKGZw66SX1pv * fix: serialize ACP settings with prompts and own direct-stop MCP close A model switch now takes the room's reservation before its lease, so it queues behind the room's running and queued prompts instead of changing the model under one; other rooms never wait on it, and a room's leave cancels a waiting switch. A detached Cursor turn that took the room reports its failure only inside its lease; a report lost there is not retried after the lease, so the next queued turn never starts while the failed one still reports. Turns that never took the room still report once. A direct stop closes the shared Band MCP server with the same owned, deadline-bounded close as final cleanup, and reopens it and the residency owner only after the close is confirmed, so a cancelled, failed or overdue close is retried rather than forgotten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * test: bridge OMP, Cursor and Copilot ACP into the common lifecycle suite tests/integrations/acp/lifecycleprofile.py registers the three ACP profiles with the shared Room lifecycle suite from #709, which discovers it; that kit is not on main yet, so this bridge needs #709. Each room process is a real stdio ACP agent (workspacepeer.py) speaking the suite's roompeer vocabulary, started through the adapter's production runtime. Only the spawn is wrapped, to hold its start and hold or refuse its exit. A lost transport surfaces as ConnectionError after the turn reported it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UEoSJo9ZG9p4i3Vkp6CwqC * fix: refuse work for a closing room as InputNotAccepted RetainedRoom admission and RoomRetention.register now raise RoomClosingError, an InputNotAccepted that is still a RuntimeError, so the host keeps a message refused before any provider execution instead of reporting a failed turn. Adapters no longer need to tell that refusal apart from a failed cleanup by message text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix: retry an unfinished ACP stop on restart and end a cancelled Cursor delivery's turn on_started finishes the previous owner's stop before renewing it: a room whose close still fails keeps its process and raises again, and the owner and shared MCP server reopen only once every room closed. Cancelling a Cursor delivery before its turn detaches now cancels and joins that turn, as an inline OMP or Copilot turn ends with its handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: run the common lifecycle suite's failed-close cases on all five profiles A refused close now fails one attempt: the suite allows the close before the room retries it, since ACP retries a failed exit by killing the process it still holds. Each case still proves the failed close keeps ownership, starts no replacement while the old process runs, and confirms its exit before one. A held close joined by a second leave covers cleanup that stays pending across callers for every profile. A stopped Claude adapter refuses a message as InputNotAccepted, and Codex relies on core's admission refusal instead of re-wrapping it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix(core): recover a residency whose abandoned acquisition never finishes An operation that stopped waiting for its own acquisition, because it timed out or was cancelled (a /stop), only requested recovery, and recovery waited for the acquisition to finish. A start that never returns, such as an ACP agent that never answers initialize, therefore held the room until dispose: every later turn timed out joining it. The initiator now cancels the acquisition it abandons while recovery is the pending cause. The acquire's existing failure path records it and the recovery release closes the half-started handle. A start that resists cancellation stays owned and is never closed while it runs; a pending FINAL still leaves the acquisition to disposal. Another operation that was joining the cancelled acquisition re-evaluates the room instead of failing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: simplify lifecycle ownership code and test kits Share the room-thread teardown between disposal and /thread archive, drop Claude SDK's write-only per-room session context, give retention snapshots one "disposing" rule for ACP and Claude rooms, pass the retained ACP session into its restore instead of re-checking it, and reuse one agent_input/ROOM_ID/Hold.wait in the test kits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: one owned-task rejoin rule, one cleanup deadline, one authority rule - rejoin_owned in band.core.retention.cleanup joins a running or settled owned task and restarts it only after a failure; RoomRetention shutdown, Codex cleanup_all and ACPRuntime closure use it. - cleanup_deadline and finish_by give Claude and ACP cleanup_all one adapter-wide deadline that counts only its own overrun as unfinished; a TimeoutError the MCP close raises itself now propagates in ACP too. - RetainedRoom.has_authority is public, and the Claude session's speaks_for delegates to it instead of restating the rule. - docs/idle-room-release.md lists the Claude SDK and ACP integrations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix: refuse a Cursor message while its room's turn runs; reload once per ACP process Cursor detached a message queued behind the room's turn before it owned anything, which settled its delivery: the runtime acknowledged it processed, so a STOP, leave or shutdown dropped it for good, and a later session_busy refusal was posted as a provider failure. A Cursor message that arrives while the room's turn still runs is now not accepted (InputNotAccepted), so the host keeps it unacknowledged and retries it; decision replies reach the parked turn through try_handle_control. The turn is registered before its task exists and waits for its enrollment, so an eager task factory cannot run it unowned. Turn activation (_accept/_take_room) and decision cancellation each have one path. After a recovery, a remembered selection refused on the reloaded session left it loaded but non-live, so the next turn sent session/load again on the same process. Such a refusal now also recovers the process, and the next turn reloads the session on a new one. Also: restore_session_response is the one session/load path and load_session_response its lenient wrapper; ACPRoom.live_record owns the live-session test; ACP agent errors log their room and generation; ACP tests pin the refusal type, exact errors, the switch refusal branches and the idle BUSY refusal, share stop_budget/SHORT_TRANSITION_S/TEST_GUARD_S, and use one send_to helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix: name Codex close overruns and derive the room's residency generation - A Codex process close past client_close_timeout_s raises a TimeoutError naming the room and bound, since it becomes the leave's error. - RoomCodexClient.generation reads the residency snapshot instead of a hand-kept copy. - Tests: a ConnectionError from a live process fails the turn; a stale process loss leaves the replacement serving (asserted by process starts/closes); a process that stops reading still admits the next turn; the STOP retention test is split into two behaviors over a kit client and fixture; autonomous native requests and the fake's thread/model ids are named once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: drive Codex bootstrap turns through the kit and name shared ids - The repeated nine-line bootstrap deliver_message block becomes send_bootstrap (which now takes the message content), and "room-1" becomes ROOM_ID across the Codex adapter test files. - The /status model and transport-closed notices use LISTED_MODEL and TRANSPORT_CLOSED instead of retyped literals. - The token-usage cleanup test drops the stale shared-client setup. - A fileChange session-key docstring states the rule, not its history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: split the Codex adapter grab-bag test classes by behavior TestCodexAdapter and TestReviewFixes covered unrelated concerns under one class each. Their tests now live in one class per behavior: config, turn delivery, approval notification failures, room process close, raw task events, /status, /model and /reasoning, self-config tools, sandbox settings, process-loss recovery, turn timeout, custom tools and execution reporting. The review-fix tests joined the concern files they belong to (slash commands, types, session keying, structured errors, turn lifecycle events). The shutdown suite's TwoRooms double and refused() helper moved to the kit, and its fixture to conftest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: move the fake-client Codex flow tests into the adapter suite The tests under tests/integrations/codex/ for approvals, item forwarding and thread resumption drove the same FakeCodexClient as the adapter unit tests and never shut their adapters down. The approval-id flow now runs on codex_room in the sequential-room approval suite, with no wall-clock poll and CodexApprovalMethod instead of a retyped method. The item-forwarding flow joined the item forwarding suite, asserting through events_of_type with a comment that says what the final agentMessage becomes. Thread resumption moved to tests/adapters/codex/, starting each adapter through a started_codex fixture that shuts it down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: split the ACP room-ownership suite into one concern per module tests/integrations/acp/roomownership/ holds one Test class per file; the shared agents, sessions, bounds and helpers move to its kit.py, and the two real-subprocess closure tests become TestProcessClosure. Assertions are unchanged (46 tests before and after). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: split the ACPClientAdapter suite and share its room-process fixtures tests/integrations/acp/clientadapter/ holds one Test class per file, with connection, adapter and event helpers in kit.py. The identical per-class mock_spawn/adapter_with_mocks fixtures become one conftest pair built on adapter_on, and one room_runtime helper replaces each class's _runtime. The module-level config test joins TestACPClientAdapterConfig. Assertions are unchanged (93 tests before and after). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: one Claude turn-failure path, one retention default, no dead closing fence - _fail_turn recovers the CLI, reports the one generic turn failure and marks it reported; both failing arms of _converse and the startup failure share _TURN_FAILURE. - _compose_prompt logs and appends bootstrap history in one branch. - default_claude_retention is the single Claude retention default, used by the adapter config and the session manager. - on_message no longer checks session.closing: _admit_turn's closing-room fence and RetainedRoom admission already refuse it first, so it never ran. A held leave now pins that refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: split Claude room-ownership tests by concern; virtual shutdown budget One class per file for parked-turn ownership, retired-CLI authority, conversation continuity and the shutdown budget. The shared-budget test runs on looptime and asserts the budget ran out during the MCP close, which a retry then finishes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: split ClaudeSessionManager tests into a package by concern conftest.py holds the fake-CLI fixture and kit.py the shared doubles (TaskRecordingPeer, Signal) and builders; each concern has one test class. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: share the Hold barrier from a provider-neutral module The common lifecycle suite, the Codex profile and the ACP lifecycle bridge imported Hold from the Claude CLI fake, tying them to a provider's private test module and its extra. Hold now lives in tests/adapters/hold.py, which fakecli imports too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: name the lifecycle peers' protocol once The start marker, the refused-start message and the refused-close message were typed separately in every peer and test. roompeer.py now defines STARTING_MARKER, STARTUP_FAILURE and CLEANUP_FAILURE, and the ACP workspace peer reuses its start steps instead of reimplementing them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: pin each provider's failed-start error and bound the start wait LifecycleProfile now declares the exact error a refused start reports, and the one reported when that start's close is refused too, so the suite asserts the type and message per provider instead of accepting either failure from any profile. RetentionHost.start_reached replaces two unbounded polls on the peer's start marker with a bounded wait that names the room. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: run room independence on every profile; move Codex-only lifecycle tests The shared lifecycle directory claims to run against every registered provider, but room independence and several ownership tests were pinned to Codex. Room independence now runs on all five profiles. The Codex-only native-continuity and leave-ownership tests move to tests/adapters/codex/lifecycle/, whose conftest starts the common host on the Codex profile through the shared `hosted` context manager. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: cover Codex and Claude SDK room rejoin in the live baseline Codex and Claude SDK leave and rejoin now go through the same RetainedRoom disposal path as ACP, so the remove-and-re-add smoke runs on them too and is renamed to a provider-neutral test. Expected cells are updated for the new node id and the two added adapters. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * feat: final disposal settles accepted work within a grace, admission closed RoomRetention takes settle_grace_s (default 0, the existing cancel-at-once behaviour): a room's final disposal, from a leave or an owner stop, closes admission at once, lets accepted work finish on its own until that grace, timed by the owner's clock, expires, then cancels what remains once and joins it. A retry after a failed release gets no second grace. The caller still learns the outcome within transition_timeout_s, and an unfinished disposal stays owned for a retry to join. RetainedRoom.closing is the one "admits no more work" projection; the ACP room and the Claude session read it instead of restating it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: Codex leaves and stops through the residency owner's settle grace Codex no longer keeps admission state beside its RetainedRooms. The _shutdown task and _closing_rooms are gone: the owner's settle grace (turn_settle_timeout_s) gives a leaving or stopping room's turn time to wind down with admission already closed, and RoomRetention.stop is the one joinable shutdown. on_cleanup clears the room's approvals and disposes it; cleanup_all clears every room's approvals and stops the owner; on_started finishes a previous stop before renewing the owner. Approvals asked during the grace, and the judging of a turn wound down by cleanup, read the room's RetainedRoom.closing. ApprovalInterruptMixin._admit_turn refuses only a running turn; a closing room is refused by its owner. The owner's final cleanup no longer re-cancels work its own owner already cancelled, so a Codex turn's TurnTask cleanup runs uninterrupted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: pin that final cleanup leaves already-cancelled work's cleanup alone Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: Claude leaves and stops through its residency owner; one retirement shape Claude no longer keeps _closing_all, _closing_rooms or _room_cleanup beside its RetainedRooms. cleanup_all stops the session manager first, which closes admission for every room at once, then the shared MCP server, under one cleanup deadline; on_cleanup clears the room's approvals, disposes its session and drops its state; on_started finishes the previous stop before starting. ApprovalInterruptMixin keeps only the running-turn refusal and interrupts: leave, stop and readmission belong to the residency owner, as for ACP, so Codex, Claude and ACP leave, stop and restart the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix: report a failed Claude start before its refused close ClaudeSDKClient.connect closes the CLI before re-raising a failed start, so a refused close escaped in the start's place. The session now closes the failed start itself; when that close is refused too, it keeps the handles for recovery and raises the start's own failure, as the Codex and ACP adapters do. The common lifecycle suite pins the same failed-start error for all five profiles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * fix: refuse closing-room work without failing it; one renewal, deadline and enrollment rule Codex turns whose lease a leaving room refuses re-raise InputNotAccepted instead of posting a failure, and a disposed Codex room forgets its record inside its disposal, so a later message can no longer re-claim a released workspace. Cursor extension requests from a residency recovery retired are refused. A failed Claude start reports its own failure whichever close of it fails. The residency owner now owns the shared rules: RoomRetention.renewed() replaces a stopped owner only once its stop closed every room, RetainedRoom.start_owned() runs detached work only once enrolled (Codex turns, Cursor turns, the ACP model switch), RetainedRoom.generation and closing are read directly, and SharedBandMCPBackend.close_after() stops rooms then MCP under one deadline for ACP and Claude. Unjoined transition failures keep their traceback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: pin the settle-grace rows and keep touched test files one class each The disposal-grace tests now observe what their names claim (owned disposal outliving its cancelled or timed-out caller, timer cancelled on drain, cancelled once). Touched module-level test files are wrapped in one class, branching parametrized tests are split, the Cursor delivery helpers moved into the runtime kit, the room-reader retirement test waits on residency state instead of log text, and the Claude shutdown-budget test runs on loop time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * test: bind the OMP form-policy client to a room residency The probe test from main builds its client through _runtime_client_factory, which takes the room and residency generation the client is bound to. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw Signed-off-by: Alexander Zaikman * refactor: simplify lifecycle ownership code Inline single-use wrappers (RoomRetention._owned_shutdown, ClaudeSessionManager.stopping, an ACP switch local), drop a restating comment, correct two stale Codex docstrings, and dedupe test kit wait loops and the Cursor question filter. No behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share one time-bounded until() wait across provider suites Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: give the ACP session_busy refusal one source of truth Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert retention states on observed progress, not loop turns The stop-authority check now asks for the stop from inside a turn, where stop() returns before its shutdown runs, instead of counting one loop turn. The joining-operation check waits with until(), and the cancel-resisting start is checked while it is provably parked at its opening barrier. Review items 16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: log an owned task's failure only when no caller reports it Every caller that joins an owned retention task re-raises its failure, and the caller then logs it, so the done-callback log duplicated it: one failed acquire gave two tracebacks, one failed release during stop gave five. Joiners now wait through await_owned, which marks them as the task's reporters; the done callback logs only a failure nobody waits on, so unjoined idle and recovery releases still log once. An abandoned start cancelled for recovery now logs a warning, and its initiator's timeout says the start was abandoned rather than still running. Review items 64, 6. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: drop the unused room-scoped DecisionRegistry API Adapters keep one registry per room, so cancel_room, unclaimed_in_room and register_minted's room_id had no src caller. The teardown test now drives cancel_all on a room's own registry. Review items 4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: drive Codex workspace isolation through the shared kit patch_codex_client serves an ordered sequence of processes, so a process-replacement test no longer hand-patches _build_client. The per-room thread workspace test opens two rooms through the kit instead of an inline client and the private _ensure_thread, and the two synchronous workspace tests are plain functions. Review items 49, 50, 51. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: keep a Codex room's model across process loss The room's selected model is the room's choice, but _release cleared it as if it belonged to the process. A recovered process then re-picked a model from its own model/list (or the default when that failed), and the strictly restored thread's next turn/start silently switched models. _release now leaves it, _acquire selects only when the room has none, and _dispose already erases it with the room. Review items 55. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: one workspace-claim rollback for room registration RoomWorkspaces.claiming claims a room's workspace and gives it back if the setup inside raises. Codex _room_client and ACP _room both register through it, replacing two hand-written rollbacks that had drifted (one released by workspace, one without). Codex's RetainedRoom field is now named retained, as in ACP and Claude. Review items 2. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: leave idle-release refusal to the residency owner Codex, ACP and Claude each refused an IDLE release inside their release callback, but none of those guards could run: the owner refuses an idle release unless the provider reported QUIESCENT, and all three configs reject idle_timeout_s. The owner's rule plus the config validators are the one policy. The three tests that called the callbacks directly to pin the dead guard go with it. Review items 1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * docs: pin the Codex start budget and its failure text The Codex page restated CODEX_TRANSITION_TIMEOUT_S and PROCESS_NOT_READY_ERROR in prose with nothing checking them; a runnable block now asserts both, like the Claude SDK page. Review items 61. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: bound Codex test waits by time, not loop turns answer_to, disposed and FakeCodexClient.read_by_adapter wait through the shared until() instead of counting 200 sleep(0) polls, so an extra await on the adapter's reader or close path cannot exhaust the budget. MAX_LOOP_POLLS goes away. Review items 25. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: cover Codex's refusal of unknown server requests An unknown server request is answered with method-not-found inside a turn and outside any turn, and a refusal the process cannot take is logged while the room keeps serving. Nothing else stops such a request from waiting forever. Review items 24. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: a Codex turn timeout whose interrupt fails still reports once FakeCodexClient takes an interrupt_error, and a timeout row with a failing turn/interrupt asserts the timeout is the turn's one failure and the room's next message still reaches Codex. Review items 54. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: give the Codex close-timeout test its own bounded host The test built its bound by reassigning the running adapter's frozen config twice. A close_bound_host fixture now installs the adapter with a named PEER_CLOSE_BOUND_S, long enough for a real peer to close but always overrun by a held close; the host fixtures share one codex_host builder. Review items 27. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: prove Codex recovery keeps usage through /thread info The recovery test planted a private _token_usage entry and asserted it back. It now scripts a thread/tokenUsage/updated event before the process exits and reads the usage and thread id the room sees from /thread info, with the thread id taken from the thread/resume request. Review items 28. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: give the Codex peer's rollout refusal and home one source roompeer.py now defines NO_ROLLOUT and CODEX_HOME beside its other protocol strings; the peer's refusal and --home default use them, and lifecycleprofile and the continuity and ownership tests import them instead of retyping the literals. --home keeps its default: the integration cleanup test and the Claude peer launch roompeer without it. Review items 30. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: name the Codex tests' approval, hang and close bounds PATIENT_APPROVAL_S, HANG_GUARD_S and CLOSE_BOUND_S in the Codex kit replace ten bare approval_wait_timeout_s=3600, four timeout=5 guards and a 0.5s close bound that was retyped inside its own match string, now built with re.escape from the constant. Review items 29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: one DECLINED/ACCEPTED answer for the Codex tests The kit defines the approval answers once and builds REFUSED_AUTONOMOUS_TURN from DECLINED; the leave-grace, ownership, sequential-room and session-validation tests use them. The leave-grace helper that calls cleanup_all is named shut_down, since it is adapter shutdown, not a room STOP. Review items 31. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: pin the Codex shutdown failure inside its exception group The failed-close shutdown test accepted any ExceptionGroup. It now expects a group holding exactly the held room's RuntimeError, matched on the kit's CLOSE_FAILURE, which CountingCloseClient raises. Review items 32. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: one residency_of lookup for the Codex tests The kit's residency_of(adapter, room_id) is the single reach into the adapter's private room map: lifecycleprofile's bridge and the retirement, ownership and status tests use it instead of each indexing _room_clients on its own. Review items 33. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: one FailingNoticeTools double for the Codex tests The kit's FailingNoticeTools rejects every notice with NOTICE_REJECTED; the approval-notification and slash-command tests use it in place of four identical inline classes, and match the rejection on the constant. Review items 34. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: let a closing room's refusal of a model switch reach its caller Enroll the switch before the mid-switch handler, so a room that is already closing raises RoomClosingError instead of 'ACP session ended mid-switch'. Review items 56. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: refuse a Cursor permission from a retired residency _active_turn_for now judges the room holder through _turn_of, so a permission request from a process recovery retired is refused like a question, instead of reaching the room or auto-allowing a Band tool. The direct permission tests now activate their turn on a resident room. Review items 66. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: state the Cursor detached-turn reason once in on_message Review items 60. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: make an ACP stop final until on_started serves again cleanup_all() and stop() only tear down. on_started finishes a pending stop, renews the residency owner and reopens the shared Band MCP server, as Codex and Claude do, so a stop() racing Agent.stop() can no longer reopen the server or admit turns after a final teardown. The final flag and its stale docstring are gone. Review items 5, 59. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: bound an ACP start only by the residency owner Drop start_timeout_s and its own asyncio.timeout around runtime.start(). ACPClientAdapterConfig's retention now defaults transition_timeout_s to ACP_TRANSITION_TIMEOUT_S (30s), and a lease whose start or stop outlasts it fails the turn with PROCESS_NOT_READY_ERROR, as Codex does. docs/acp.md pins the default with a runnable block. Review items 61, 62. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: probe Copilot's persisted selection on a registered room Build the probe runtime from spawner._room(room_id) and its generation instead of a hand-made ACPRoom whose retained owner is never set. Review items 52. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: forget a Claude room from its owned disposal A leave whose caller was cancelled or whose report timed out skipped forgetting the room's adapter state, although the owned disposal still finished later; a rejoined room then reused the old dedup cache and suppressed a repeated reply. The session manager now reports each confirmed disposal, and the adapter forgets the room there, as Codex and ACP do in their dispose callbacks. Review items 63, 67. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: start Claude's detached turn through start_owned on_message hand-wrote create_task plus own(), the enroll-before-run contract RetainedRoom.start_owned already owns for Codex and Cursor. owner_ready now only hands the turn its TurnTask. Review items 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: define the Claude OpenRoom alias once Review items 42. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: read Claude room internals once through ClaudeRoom ClaudeRoom exposes the session manager and Band MCP backend and restarts its adapter with one set of agent metadata; the shutdown budget test asserts the backend still runs instead of its identity, and the shutdown race drops a loop yield that create_task ordering already guarantees. Review items 37, 39, 40. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share the scripted Claude CLI and the real-child launcher scripted_claude() patches the session manager's transport factory by attribute for both suites, and launch_children() replaces four copies of the build-append-monkeypatch peer launcher. Review items 35, 36. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: name the fake Claude CLI's failures once The fake raises STARTUP_EXITED and CLEANUP_FAILED, which the tests match with re.escape; a real child's refused close matches roompeer's CLEANUP_FAILURE, so each test pins which failure fired. The fake's model name is imported instead of retyped. Review items 41. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: check the workspace a Claude room's options bind The room-options test claimed the workspace binding without passing workspaces= or asserting the CLI's cwd. Review items 38. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: tidy the Claude session-manager suite Signal.logged() awaits its own event under a time bound instead of counting loop turns, the room-reader test no longer shadows the retired() helper, and the failed-startup cleanup test moves out of the release callback class into its own file. Review items 43, 44, 45. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: run the Claude turn-timeout tests on the virtual clock Review items 47. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: cover Claude's participant and contact prompt framing ClaudeRoom.send carries the runtime's participants_msg and contacts_msg, and one test asserts both reach the CLI as [System] lines before the message. Review items 48. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert the workspace-claim refusal by its own message One workspace_still_claimed() helper matches the claim refusal's distinctive prefix instead of the bare "both" substring at seven sites. Review items 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: one spawn-level exit-failure seam and no dead ACP toolkit scripting FailingExits now builds on LifecycleChild's refused close; the duplicated room-independence cases go (the common lifecycle suite covers them); fails_next_initialize had no caller; AGENT_EXIT_FAILURE names the fake's exit error once. Review items 10, 11, 17. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: ACP room-ownership tests wait on state and share their scripting Polls use the shared time-bounded until(); a closing-room check holds the exit at a barrier. Kit helpers replace hand-rolled prompt counters, the inline gate protocol, the retyped mode question and answer command, and the repeated held-switch setup. The recovered-turn test asserts the stale question was cancelled; the retirement tests use DEFAULT_ROOM and kit.cursor. Review items 8, 9, 12, 13, 14, 15, 16, 18, 19. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: tear down the mocked ACP adapter's rooms after each test Review items 21. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: hold the Cursor identity test in one class Review item 65. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: give ACP and Claude turns a settle grace on leave and stop ACP rooms and Claude sessions built their residency owner without a settle grace, so final disposal cancelled a running turn in the same tick that cleanup woke it: a Cursor turn whose parked question was cancelled never reported the agent's error, and its message had already been handed back. ACP and Claude now take `turn_settle_timeout_s` like Codex, all three defaulting to `TURN_SETTLE_GRACE_S` from band.core.turn_lifecycle. Claude's CLI reader keeps feeding the running turn while its closing room settles it (`RetainedRoom.may_finish`), so the grace can actually end the turn. Review items 57. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: report a released turn its room's closing cancels A turn released early (parked on a human decision) handed its message back, so when its room's leave or shutdown cancelled it after the settle grace, nothing answered for that message. Codex additionally skipped judging a released turn that ended while its room was closing. Codex, Claude and Cursor now start releasable turns through `start_releasable_turn` (band.core.turn_lifecycle), which judges a released turn its closing room cancels through the existing `judge_detached_turn` path; an interrupt still reports nothing, and a turn that replied, settled or already reported stays silent. Codex judges a released turn at its end even while its room closes. The pinned "cancelled detached turn posts nothing" tests now assert the single missing-reply failure. Review items 57. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: split the Cursor ACP adapter tests into one concern per file Review item 65. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert Claude workspace claims and closing refusals by their exact error Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share the Claude write prompt and room-closing strategies in helpers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: hold the behaviour-edited test files to one class per concern Split the Claude on_cleanup tests into one file per concern, wrap the manual-approval, session-persistence, decision-registry and OMP form-policy tests in one class each, and move the decision-room doubles into their own helper module. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: frame Claude's system updates from the shared prefix The Claude adapter now prefixes participant and contact updates with SYSTEM_UPDATE_PREFIX, and the framing test builds its expected lines with system_updates(), so the prefix has one source. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: wait on Claude reader state and logs with the shared bounded until Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: say why the Claude startup fixture sets an unparsable stream timeout Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: narrow the room-rejoin smoke's docstring to what it proves Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: build the runtime peer's one-page listings with a shared helper Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: reuse the shared no-settle-grace config in the MCP replacement test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert Codex leave refusals by type and the shared workspace matcher Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share the Codex continuity tests' lose-the-process setup Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: name the Codex peer's rollout path and the room peer script once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: address the Codex test room through the kit's ROOM_ID Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: bootstrap Codex test rooms through the kit's send_bootstrap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert the exact single failure a lost Codex process reports Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: read a Codex room's thread through a kit projection Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: name the Codex command approval method through its enum Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: keep each Codex and lifecycle test's rationale in its class docstring Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: drop a doubled comment on the Codex process-exit notice Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: keep the Codex room's model in one place selected_model is the room's choice and outlives its process, so the separate model_override field and its _select_model branch were dead. The recovery test now covers a /model choice surviving a lost process. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: leave an owned failure to the caller that saw it finish When a wait's timeout and the task's failure land in one loop step, the done callback ran after the caller stopped waiting and logged a failure the caller also raised. await_owned now records the tasks it saw finish. The idle-release logging test reads exc_info from the retention record. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: report a failed ACP or MCP close once, by its caller ACPRuntime._closed and SharedBandMCPBackend.close joined their owned closure through asyncio.shield, so the done callback logged a failure the caller also raised. They join it with report_owned now. The ACP spawn exit no longer logs its traceback before re-raising, and a failed start logs its suppressed close failure itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: log a Codex event-reader failure once The reader logs its failure with the room id and hands it to the waiting turn, which logged it again as an unexpected processing error. The turn now records the cause that lost its process and does not log that one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: keep the cause of ACP not-ready and mid-switch failures The not-ready warning names which bound expired (an abandoned start or a transition still running). A model switch that fails as its room closes still reports 'ended mid-switch', now chained to and logged with the original failure instead of discarding it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: type every adapter's settle grace as one non-negative field Codex typed turn_settle_timeout_s as a plain float, so a negative grace was accepted and expired before leftover native requests were answered. TurnSettleTimeout in band.core.turn_lifecycle now types and describes the field for the Codex, Claude SDK and ACP configs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: name the Codex turn-processing error and request events once TURN_PROCESSING_ERROR replaces the literal typed at both sites of the turn's failure branch, and RpcEvent.is_request replaces the four raw kind == "request" comparisons that route native events. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * docs: say why the settle grace default sits at each config field Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share one switch sweep bound that reaches the finished switch Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: share one spin bound for nothing-happened checks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: name the room peer's startup flags and read reply once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: assert closing-room refusals by RoomClosingError Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: tear down every scripted ACP client adapter a test builds Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: build the cross-room accept from room_message and the command words Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: ask the shared mode question and name the cleanup closing shut_down Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: recognise Cursor decision prompts by one captured-word parser Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * refactor: name OMP's workspace flag once as OMP_CWD_FLAG Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: give retention hosts one async-context lifecycle Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: cover concurrent stops and a leave joining one held close Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: bound every wait for a held step and share one Hold Hold.arrived() waits at most UNTIL_TIMEOUT_S and fails naming the hold; the retention kit's Barrier, a copy of Hold, is replaced by it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: log an owned failure its cancelled caller never saw A caller cancelled in the loop step its task failed still counted as the reporter when the done callback ran, so the failure was never logged. reporting() now claims finished tasks only when its caller leaves without being cancelled, and logs an unclaimed failure itself otherwise. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: leave a mid-switch failure's cause to the switch's caller A switch that fails as its room closes raises 'ended mid-switch' chained to the original failure, so the caller already reports it; the extra warning logged the same failure twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * test: pin concurrent stops to one shared shutdown by its failure log Every caller stops waiting before the held disposal fails, so a stop that spawned its own shutdown would log the failure once per caller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012ifWtjnzfd5YPdEgrPTdcw * fix: stabilize native room closure and approval retirement * test: use displayed Codex approval tokens through Slack * test: cover complex Cursor approval and room lifecycle flows * test: diagnose stalled Codex subprocess cleanup in CI * refactor: reuse canonical lifecycle error event type * fix: enroll ACP prompts in retained-room cleanup * docs: correct provider idle-release refusal boundary --------- Signed-off-by: Alexander Zaikman Co-authored-by: Alexander Zaikman Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- docs/acp.md | 84 +- docs/adapters/claude_sdk.md | 53 + docs/adapters/codex.md | 47 + docs/idle-room-release.md | 16 +- docs/turn-outcome.md | 6 +- src/band/adapters/claude_sdk.py | 581 +- src/band/adapters/codex.py | 1607 ++-- src/band/adapters/cursor_acp.py | 397 +- src/band/adapters/omp_acp.py | 6 +- src/band/core/exceptions.py | 6 + src/band/core/retention/cleanup.py | 107 +- src/band/core/retention/config.py | 3 +- src/band/core/retention/owner.py | 58 +- src/band/core/retention/room.py | 163 +- src/band/core/turn_lifecycle.py | 98 +- src/band/integrations/acp/client_adapter.py | 857 +- src/band/integrations/acp/client_profiles.py | 34 +- src/band/integrations/acp/client_runtime.py | 209 +- src/band/integrations/acp/client_types.py | 83 + src/band/integrations/acp/collecting.py | 7 +- src/band/integrations/acp/sessions.py | 104 +- .../claude_sdk/session_manager.py | 753 +- src/band/integrations/codex/__init__.py | 4 + src/band/integrations/codex/rpc_base.py | 12 +- src/band/integrations/codex/types.py | 5 + src/band/integrations/mcp/backends.py | 36 +- src/band/integrations/omp.py | 4 +- src/band/runtime/decisions.py | 17 +- src/band/workspaces.py | 13 +- .../approvals/test_approval_modes.py | 10 +- .../approvals/test_manual_approval.py | 601 +- tests/adapters/claude_sdk/conftest.py | 19 +- .../failures/test_cli_connection_error.py | 102 +- .../failures/test_error_handling.py | 64 +- .../failures/test_turn_failure_surfacing.py | 5 +- .../claude_sdk/failures/test_turn_timeout.py | 243 +- tests/adapters/claude_sdk/fakecli.py | 159 +- tests/adapters/claude_sdk/helpers.py | 102 +- .../adapters/claude_sdk/lifecycle/conftest.py | 22 + .../lifecycle/test_active_mcp_replacement.py | 86 + .../lifecycle/test_approval_retirement.py | 85 + .../lifecycle/test_cancelled_message.py | 39 + .../lifecycle/test_closing_admission.py | 33 + .../lifecycle/test_conversation_continuity.py | 42 + .../lifecycle/test_detached_turn_on_close.py | 41 + .../lifecycle/test_failed_start_close.py | 28 + .../lifecycle/test_fixture_clock.py | 39 + .../lifecycle/test_mcp_backend_replacement.py | 224 +- .../claude_sdk/lifecycle/test_on_cleanup.py | 118 - .../claude_sdk/lifecycle/test_on_message.py | 5 +- .../claude_sdk/lifecycle/test_on_started.py | 6 +- .../lifecycle/test_parked_turn_ownership.py | 42 + .../lifecycle/test_rejoin_after_leave.py | 33 + .../lifecycle/test_retired_cli_authority.py | 30 + .../lifecycle/test_shutdown_budget.py | 73 + .../lifecycle/test_teardown_mid_turn.py | 43 + tests/adapters/claude_sdk/lifecycleprofile.py | 79 + tests/adapters/claude_sdk/process.py | 47 +- tests/adapters/claude_sdk/test_cli_options.py | 9 +- tests/adapters/claude_sdk/test_config.py | 5 +- .../adapters/claude_sdk/test_host_options.py | 5 +- .../claude_sdk/test_prompt_framing.py | 28 + .../claude_sdk/test_send_message_dedup.py | 6 +- .../claude_sdk/test_session_persistence.py | 147 +- .../claude_sdk/test_unfinished_leave.py | 49 + tests/adapters/claude_sdk/test_workspaces.py | 470 +- .../claude_sdk/tools/test_custom_tools.py | 6 +- .../claude_sdk/tools/test_room_binding.py | 5 +- .../claude_sdk/tools/test_tool_names.py | 6 +- tests/adapters/codex/__init__.py | 0 tests/adapters/codex/conftest.py | 124 + tests/adapters/codex/kit.py | 782 ++ tests/adapters/codex/lifecycle/__init__.py | 0 tests/adapters/codex/lifecycle/conftest.py | 64 + .../codex/lifecycle/test_leave_ownership.py | 126 + .../test_native_conversation_continuity.py | 119 + tests/adapters/codex/lifecycleprofile.py | 99 + .../adapters/codex/test_accept_for_session.py | 53 + tests/adapters/codex/test_adapter_config.py | 30 + tests/adapters/codex/test_adapter_shutdown.py | 130 + .../codex/test_approval_audit_recording.py | 29 + .../test_approval_from_a_sequential_room.py | 170 + .../test_approval_notification_failures.py | 126 + .../codex/test_approval_retirement.py | 178 + tests/adapters/codex/test_audit_cap.py | 69 + tests/adapters/codex/test_cleanup.py | 41 + tests/adapters/codex/test_codex_types.py | 211 + .../codex/test_config_env_sourcing.py | 65 + .../adapters/codex/test_context_compaction.py | 70 + .../adapters/codex/test_custom_tool_effect.py | 71 + tests/adapters/codex/test_custom_tools.py | 100 + .../codex/test_detached_turn_outcome.py | 90 + tests/adapters/codex/test_diff_byte_cap.py | 65 + .../codex/test_diffs_and_token_usage.py | 199 + .../codex/test_double_emit_startup_warning.py | 51 + .../adapters/codex/test_enriched_approvals.py | 159 + .../codex/test_execution_reporting.py | 216 + .../adapters/codex/test_history_injection.py | 614 ++ .../codex/test_interrupted_turn_outcome.py | 20 + .../codex/test_item_completed_forwarding.py | 804 ++ tests/adapters/codex/test_leave_grace.py | 46 + .../codex/test_malformed_payload_tolerance.py | 135 + .../codex/test_manual_approval_races.py | 130 + .../codex/test_model_after_recovery.py | 55 + .../test_model_and_reasoning_commands.py | 165 + .../codex/test_native_message_thoughts.py | 151 + .../codex/test_native_text_authority.py | 57 + .../codex/test_native_turn_ownership.py | 402 + tests/adapters/codex/test_network_context.py | 45 + tests/adapters/codex/test_no_reply.py | 57 + .../codex/test_per_turn_token_usage.py | 121 + .../adapters/codex/test_plan_and_lifecycle.py | 183 + .../codex/test_plan_steps_robustness.py | 23 + .../codex/test_process_loss_recovery.py | 174 + .../adapters/codex/test_process_residency.py | 53 + tests/adapters/codex/test_raw_task_events.py | 121 + .../test_read_room_file_image_passthrough.py | 64 + .../adapters/codex/test_realtime_streaming.py | 197 + .../adapters/codex/test_room_process_close.py | 76 + tests/adapters/codex/test_room_retirement.py | 155 + tests/adapters/codex/test_sandbox_settings.py | 78 + .../adapters/codex/test_self_config_tools.py | 114 + .../codex/test_session_approval_keying.py | 118 + .../codex/test_session_approval_validation.py | 40 + .../codex/test_session_auto_approval.py | 121 + tests/adapters/codex/test_skill_roots.py | 107 + .../codex/test_slash_command_coverage.py | 189 + .../codex/test_slash_command_extraction.py | 64 + tests/adapters/codex/test_status_command.py | 64 + .../codex/test_structured_error_detail_cap.py | 94 + .../codex/test_structured_error_mappings.py | 54 + .../test_structured_error_normalization.py | 27 + .../adapters/codex/test_structured_errors.py | 160 + .../adapters/codex/test_thread_resumption.py | 157 + .../test_token_usage_counter_monotonicity.py | 39 + ...est_token_usage_cumulative_monotonicity.py | 37 + .../codex/test_token_usage_emission.py | 45 + .../codex/test_transport_contract_prompt.py | 61 + tests/adapters/codex/test_turn_delivery.py | 272 + .../codex/test_turn_lifecycle_events.py | 77 + .../test_turn_lifecycle_events_disabled.py | 41 + tests/adapters/codex/test_turn_timeout.py | 114 + .../codex/test_unhandled_server_request.py | 104 + tests/adapters/codexturns.py | 127 +- tests/adapters/cursor_acp/__init__.py | 0 tests/adapters/cursor_acp/conftest.py | 91 + tests/adapters/cursor_acp/kit.py | 220 + tests/adapters/cursor_acp/process.py | 106 + .../cursor_acp/test_band_tool_approval.py | 209 + tests/adapters/cursor_acp/test_config.py | 70 + .../cursor_acp/test_control_messages.py | 198 + tests/adapters/cursor_acp/test_decisions.py | 751 ++ .../adapters/cursor_acp/test_detached_turn.py | 126 + tests/adapters/cursor_acp/test_launch.py | 66 + .../cursor_acp/test_project_workflow.py | 122 + .../test_session_setup_interrupt.py | 35 + .../cursor_acp/test_subprocess_lifecycle.py | 175 + tests/adapters/hold.py | 42 + tests/adapters/lifecycle/__init__.py | 0 tests/adapters/lifecycle/conftest.py | 41 + tests/adapters/lifecycle/kit.py | 289 + tests/adapters/lifecycle/runtime.py | 38 + .../lifecycle/test_closing_admission.py | 147 + tests/adapters/lifecycle/test_process_loss.py | 61 + .../lifecycle/test_room_independence.py | 31 + .../adapters/lifecycle/test_room_ownership.py | 191 + .../lifecycle/test_runtime_closing.py | 68 + tests/adapters/roompeer.py | 280 +- tests/adapters/test_codex_adapter.py | 7288 ----------------- tests/adapters/test_copilot_acp_adapter.py | 37 +- tests/adapters/test_cursor_acp_adapter.py | 1704 ---- tests/adapters/test_omp_acp_adapter.py | 48 +- .../adapters/test_room_workspace_isolation.py | 329 +- tests/adapters/test_workspace_lifecycle.py | 380 - tests/adapters/waiting.py | 30 + tests/core/retention/conftest.py | 32 +- tests/core/retention/kit.py | 76 +- tests/core/retention/test_acquisition.py | 7 +- tests/core/retention/test_authority.py | 15 + tests/core/retention/test_cleanup_deadline.py | 32 + tests/core/retention/test_disposal.py | 30 +- tests/core/retention/test_disposal_grace.py | 237 + tests/core/retention/test_failed_close.py | 7 +- tests/core/retention/test_failure_logging.py | 126 + tests/core/retention/test_idle_scheduling.py | 7 +- tests/core/retention/test_owned_work.py | 52 +- tests/core/retention/test_recovery.py | 78 +- tests/core/retention/test_renewal.py | 44 + tests/core/retention/test_shutdown.py | 63 +- tests/e2e/baseline/README.md | 2 +- tests/e2e/baseline/expected-cells.json | 45 + .../smoke/adapters/test_copilot_acp.py | 6 +- .../smoke/behavior/test_room_rejoin.py | 82 + tests/e2e/baseline/toolkit/lifecycle.py | 42 + .../test_adapter_conformance.py | 2 +- .../integrations/acp/acp_toolkit/__init__.py | 18 + tests/integrations/acp/acp_toolkit/agent.py | 148 +- tests/integrations/acp/acp_toolkit/harness.py | 224 +- .../integrations/acp/acp_toolkit/lifecycle.py | 170 + tests/integrations/acp/acp_toolkit/peer.py | 30 +- .../acp/acp_toolkit/workspacepeer.py | 197 + .../acp/clientadapter/__init__.py | 1 + .../acp/clientadapter/conftest.py | 46 + tests/integrations/acp/clientadapter/kit.py | 79 + .../acp/clientadapter/test_cleanup.py | 167 + .../acp/clientadapter/test_config.py | 133 + .../test_cursor_profile_extensions.py | 319 + .../test_dead_connection_recovery.py | 128 + .../acp/clientadapter/test_emit_support.py | 43 + .../clientadapter/test_local_mcp_config.py | 142 + .../acp/clientadapter/test_on_message.py | 546 ++ .../acp/clientadapter/test_on_started.py | 119 + .../clientadapter/test_permission_handler.py | 406 + .../clientadapter/test_resolve_launcher.py | 32 + .../acp/clientadapter/test_shutdown.py | 52 + .../acp/clientadapter/test_stop.py | 48 + .../acp/clientadapter/test_transport.py | 55 + tests/integrations/acp/lifecycleprofile.py | 24 + .../acp/roomownership/__init__.py | 7 + .../acp/roomownership/cursorflow.py | 38 + tests/integrations/acp/roomownership/kit.py | 283 + .../test_cursor_mixed_decisions.py | 125 + .../acp/roomownership/test_cursor_origin.py | 109 + .../test_cursor_permission_scope.py | 98 + .../test_cursor_recovered_turn.py | 51 + .../test_cursor_recovery_sequences.py | 173 + .../test_cursor_session_retirement.py | 67 + .../acp/roomownership/test_cursor_turns.py | 223 + .../roomownership/test_decision_retirement.py | 84 + .../acp/roomownership/test_deliberate_wake.py | 122 + .../test_detached_turn_on_close.py | 70 + .../acp/roomownership/test_direct_stop.py | 131 + .../acp/roomownership/test_final_cleanup.py | 177 + .../test_inline_prompt_cleanup.py | 90 + .../test_interrupted_settings.py | 77 + .../acp/roomownership/test_process_closure.py | 121 + .../acp/roomownership/test_recovery.py | 161 + .../acp/roomownership/test_settings.py | 160 + .../acp/roomownership/test_startup.py | 109 + tests/integrations/acp/test_client_adapter.py | 2314 ------ .../acp/test_client_adapter_behavior.py | 59 +- tests/integrations/acp/test_client_runtime.py | 19 +- .../integrations/acp/test_cursor_identity.py | 134 +- .../acp/test_cursor_message_delivery.py | 63 + .../acp/test_runtime_close_reporting.py | 79 + tests/integrations/claude_sdk/conftest.py | 51 + .../claude_sdk/sessionmanager/__init__.py | 0 .../claude_sdk/sessionmanager/conftest.py | 15 + .../claude_sdk/sessionmanager/kit.py | 97 + .../sessionmanager/test_disposal.py | 67 + .../sessionmanager/test_release_callbacks.py | 38 + .../test_residency_authority.py | 38 + .../sessionmanager/test_resume_policy.py | 100 + .../sessionmanager/test_room_independence.py | 36 + .../sessionmanager/test_room_options.py | 58 + .../sessionmanager/test_room_reader.py | 108 + .../sessionmanager/test_sdk_task_affinity.py | 54 + .../sessionmanager/test_startup_cleanup.py | 42 + .../claude_sdk/test_session_manager.py | 367 - tests/integrations/claude_sdk/test_startup.py | 90 +- .../claude_sdk/test_workspaces.py | 150 +- tests/integrations/codex/__init__.py | 0 tests/integrations/codex/cleanup.py | 58 + tests/integrations/codex/test_adapter_e2e.py | 529 -- tests/integrations/codex/test_cleanup.py | 16 +- .../mcpbackends/test_final_close_ownership.py | 4 +- tests/integrations/slack/conftest.py | 5 + tests/integrations/slack/test_controls.py | 97 +- tests/paths.py | 2 + tests/runtime/decisionroom.py | 101 + tests/runtime/helpers.py | 45 +- tests/runtime/test_decisions.py | 426 +- tests/toolkit/test_omp_form_policy.py | 243 +- 273 files changed, 26583 insertions(+), 16798 deletions(-) create mode 100644 tests/adapters/claude_sdk/lifecycle/conftest.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_active_mcp_replacement.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_approval_retirement.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_cancelled_message.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_closing_admission.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_conversation_continuity.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_detached_turn_on_close.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_failed_start_close.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_fixture_clock.py delete mode 100644 tests/adapters/claude_sdk/lifecycle/test_on_cleanup.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_parked_turn_ownership.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_rejoin_after_leave.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_retired_cli_authority.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_shutdown_budget.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_teardown_mid_turn.py create mode 100644 tests/adapters/claude_sdk/lifecycleprofile.py create mode 100644 tests/adapters/claude_sdk/test_prompt_framing.py create mode 100644 tests/adapters/claude_sdk/test_unfinished_leave.py create mode 100644 tests/adapters/codex/__init__.py create mode 100644 tests/adapters/codex/conftest.py create mode 100644 tests/adapters/codex/kit.py create mode 100644 tests/adapters/codex/lifecycle/__init__.py create mode 100644 tests/adapters/codex/lifecycle/conftest.py create mode 100644 tests/adapters/codex/lifecycle/test_leave_ownership.py create mode 100644 tests/adapters/codex/lifecycle/test_native_conversation_continuity.py create mode 100644 tests/adapters/codex/lifecycleprofile.py create mode 100644 tests/adapters/codex/test_accept_for_session.py create mode 100644 tests/adapters/codex/test_adapter_config.py create mode 100644 tests/adapters/codex/test_adapter_shutdown.py create mode 100644 tests/adapters/codex/test_approval_audit_recording.py create mode 100644 tests/adapters/codex/test_approval_from_a_sequential_room.py create mode 100644 tests/adapters/codex/test_approval_notification_failures.py create mode 100644 tests/adapters/codex/test_approval_retirement.py create mode 100644 tests/adapters/codex/test_audit_cap.py create mode 100644 tests/adapters/codex/test_cleanup.py create mode 100644 tests/adapters/codex/test_codex_types.py create mode 100644 tests/adapters/codex/test_config_env_sourcing.py create mode 100644 tests/adapters/codex/test_context_compaction.py create mode 100644 tests/adapters/codex/test_custom_tool_effect.py create mode 100644 tests/adapters/codex/test_custom_tools.py create mode 100644 tests/adapters/codex/test_detached_turn_outcome.py create mode 100644 tests/adapters/codex/test_diff_byte_cap.py create mode 100644 tests/adapters/codex/test_diffs_and_token_usage.py create mode 100644 tests/adapters/codex/test_double_emit_startup_warning.py create mode 100644 tests/adapters/codex/test_enriched_approvals.py create mode 100644 tests/adapters/codex/test_execution_reporting.py create mode 100644 tests/adapters/codex/test_history_injection.py create mode 100644 tests/adapters/codex/test_interrupted_turn_outcome.py create mode 100644 tests/adapters/codex/test_item_completed_forwarding.py create mode 100644 tests/adapters/codex/test_leave_grace.py create mode 100644 tests/adapters/codex/test_malformed_payload_tolerance.py create mode 100644 tests/adapters/codex/test_manual_approval_races.py create mode 100644 tests/adapters/codex/test_model_after_recovery.py create mode 100644 tests/adapters/codex/test_model_and_reasoning_commands.py create mode 100644 tests/adapters/codex/test_native_message_thoughts.py create mode 100644 tests/adapters/codex/test_native_text_authority.py create mode 100644 tests/adapters/codex/test_native_turn_ownership.py create mode 100644 tests/adapters/codex/test_network_context.py create mode 100644 tests/adapters/codex/test_no_reply.py create mode 100644 tests/adapters/codex/test_per_turn_token_usage.py create mode 100644 tests/adapters/codex/test_plan_and_lifecycle.py create mode 100644 tests/adapters/codex/test_plan_steps_robustness.py create mode 100644 tests/adapters/codex/test_process_loss_recovery.py create mode 100644 tests/adapters/codex/test_process_residency.py create mode 100644 tests/adapters/codex/test_raw_task_events.py create mode 100644 tests/adapters/codex/test_read_room_file_image_passthrough.py create mode 100644 tests/adapters/codex/test_realtime_streaming.py create mode 100644 tests/adapters/codex/test_room_process_close.py create mode 100644 tests/adapters/codex/test_room_retirement.py create mode 100644 tests/adapters/codex/test_sandbox_settings.py create mode 100644 tests/adapters/codex/test_self_config_tools.py create mode 100644 tests/adapters/codex/test_session_approval_keying.py create mode 100644 tests/adapters/codex/test_session_approval_validation.py create mode 100644 tests/adapters/codex/test_session_auto_approval.py create mode 100644 tests/adapters/codex/test_skill_roots.py create mode 100644 tests/adapters/codex/test_slash_command_coverage.py create mode 100644 tests/adapters/codex/test_slash_command_extraction.py create mode 100644 tests/adapters/codex/test_status_command.py create mode 100644 tests/adapters/codex/test_structured_error_detail_cap.py create mode 100644 tests/adapters/codex/test_structured_error_mappings.py create mode 100644 tests/adapters/codex/test_structured_error_normalization.py create mode 100644 tests/adapters/codex/test_structured_errors.py create mode 100644 tests/adapters/codex/test_thread_resumption.py create mode 100644 tests/adapters/codex/test_token_usage_counter_monotonicity.py create mode 100644 tests/adapters/codex/test_token_usage_cumulative_monotonicity.py create mode 100644 tests/adapters/codex/test_token_usage_emission.py create mode 100644 tests/adapters/codex/test_transport_contract_prompt.py create mode 100644 tests/adapters/codex/test_turn_delivery.py create mode 100644 tests/adapters/codex/test_turn_lifecycle_events.py create mode 100644 tests/adapters/codex/test_turn_lifecycle_events_disabled.py create mode 100644 tests/adapters/codex/test_turn_timeout.py create mode 100644 tests/adapters/codex/test_unhandled_server_request.py create mode 100644 tests/adapters/cursor_acp/__init__.py create mode 100644 tests/adapters/cursor_acp/conftest.py create mode 100644 tests/adapters/cursor_acp/kit.py create mode 100644 tests/adapters/cursor_acp/process.py create mode 100644 tests/adapters/cursor_acp/test_band_tool_approval.py create mode 100644 tests/adapters/cursor_acp/test_config.py create mode 100644 tests/adapters/cursor_acp/test_control_messages.py create mode 100644 tests/adapters/cursor_acp/test_decisions.py create mode 100644 tests/adapters/cursor_acp/test_detached_turn.py create mode 100644 tests/adapters/cursor_acp/test_launch.py create mode 100644 tests/adapters/cursor_acp/test_project_workflow.py create mode 100644 tests/adapters/cursor_acp/test_session_setup_interrupt.py create mode 100644 tests/adapters/cursor_acp/test_subprocess_lifecycle.py create mode 100644 tests/adapters/hold.py create mode 100644 tests/adapters/lifecycle/__init__.py create mode 100644 tests/adapters/lifecycle/conftest.py create mode 100644 tests/adapters/lifecycle/kit.py create mode 100644 tests/adapters/lifecycle/runtime.py create mode 100644 tests/adapters/lifecycle/test_closing_admission.py create mode 100644 tests/adapters/lifecycle/test_process_loss.py create mode 100644 tests/adapters/lifecycle/test_room_independence.py create mode 100644 tests/adapters/lifecycle/test_room_ownership.py create mode 100644 tests/adapters/lifecycle/test_runtime_closing.py delete mode 100644 tests/adapters/test_codex_adapter.py delete mode 100644 tests/adapters/test_cursor_acp_adapter.py delete mode 100644 tests/adapters/test_workspace_lifecycle.py create mode 100644 tests/adapters/waiting.py create mode 100644 tests/core/retention/test_cleanup_deadline.py create mode 100644 tests/core/retention/test_disposal_grace.py create mode 100644 tests/core/retention/test_failure_logging.py create mode 100644 tests/core/retention/test_renewal.py create mode 100644 tests/e2e/baseline/smoke/behavior/test_room_rejoin.py create mode 100644 tests/e2e/baseline/toolkit/lifecycle.py create mode 100644 tests/integrations/acp/acp_toolkit/lifecycle.py create mode 100644 tests/integrations/acp/acp_toolkit/workspacepeer.py create mode 100644 tests/integrations/acp/clientadapter/__init__.py create mode 100644 tests/integrations/acp/clientadapter/conftest.py create mode 100644 tests/integrations/acp/clientadapter/kit.py create mode 100644 tests/integrations/acp/clientadapter/test_cleanup.py create mode 100644 tests/integrations/acp/clientadapter/test_config.py create mode 100644 tests/integrations/acp/clientadapter/test_cursor_profile_extensions.py create mode 100644 tests/integrations/acp/clientadapter/test_dead_connection_recovery.py create mode 100644 tests/integrations/acp/clientadapter/test_emit_support.py create mode 100644 tests/integrations/acp/clientadapter/test_local_mcp_config.py create mode 100644 tests/integrations/acp/clientadapter/test_on_message.py create mode 100644 tests/integrations/acp/clientadapter/test_on_started.py create mode 100644 tests/integrations/acp/clientadapter/test_permission_handler.py create mode 100644 tests/integrations/acp/clientadapter/test_resolve_launcher.py create mode 100644 tests/integrations/acp/clientadapter/test_shutdown.py create mode 100644 tests/integrations/acp/clientadapter/test_stop.py create mode 100644 tests/integrations/acp/clientadapter/test_transport.py create mode 100644 tests/integrations/acp/lifecycleprofile.py create mode 100644 tests/integrations/acp/roomownership/__init__.py create mode 100644 tests/integrations/acp/roomownership/cursorflow.py create mode 100644 tests/integrations/acp/roomownership/kit.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_mixed_decisions.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_origin.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_permission_scope.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_recovered_turn.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_recovery_sequences.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_session_retirement.py create mode 100644 tests/integrations/acp/roomownership/test_cursor_turns.py create mode 100644 tests/integrations/acp/roomownership/test_decision_retirement.py create mode 100644 tests/integrations/acp/roomownership/test_deliberate_wake.py create mode 100644 tests/integrations/acp/roomownership/test_detached_turn_on_close.py create mode 100644 tests/integrations/acp/roomownership/test_direct_stop.py create mode 100644 tests/integrations/acp/roomownership/test_final_cleanup.py create mode 100644 tests/integrations/acp/roomownership/test_inline_prompt_cleanup.py create mode 100644 tests/integrations/acp/roomownership/test_interrupted_settings.py create mode 100644 tests/integrations/acp/roomownership/test_process_closure.py create mode 100644 tests/integrations/acp/roomownership/test_recovery.py create mode 100644 tests/integrations/acp/roomownership/test_settings.py create mode 100644 tests/integrations/acp/roomownership/test_startup.py delete mode 100644 tests/integrations/acp/test_client_adapter.py create mode 100644 tests/integrations/acp/test_cursor_message_delivery.py create mode 100644 tests/integrations/acp/test_runtime_close_reporting.py create mode 100644 tests/integrations/claude_sdk/conftest.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/__init__.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/conftest.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/kit.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_disposal.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_release_callbacks.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_residency_authority.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_resume_policy.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_room_independence.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_room_options.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_room_reader.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_sdk_task_affinity.py create mode 100644 tests/integrations/claude_sdk/sessionmanager/test_startup_cleanup.py delete mode 100644 tests/integrations/claude_sdk/test_session_manager.py create mode 100644 tests/integrations/codex/__init__.py create mode 100644 tests/integrations/codex/cleanup.py delete mode 100644 tests/integrations/codex/test_adapter_e2e.py create mode 100644 tests/integrations/slack/conftest.py create mode 100644 tests/runtime/decisionroom.py diff --git a/docs/acp.md b/docs/acp.md index adb7768a0..2ec237dba 100644 --- a/docs/acp.md +++ b/docs/acp.md @@ -111,6 +111,71 @@ migrate before upgrading. TypeScript parity is a separate change. - TCP and custom transports are rejected: one remote process cannot be proven to serve exactly one room. +## Room ownership + +Each room is one `ACPRoom`: its process, connection and session, composed with a +`RetainedRoom` from the adapter's single `RoomRetention`. Detached work (a Cursor +turn, a Copilot model switch) is owned by the room before it runs. A prompt binds +its tools under the room's lease, and a model switch takes the room's reservation +in turn with its prompts, so an independent room never waits on another room's +start, prompt, switch or exit. The room's acquisition starts its process within +`retention.transition_timeout_s`, which defaults to `ACP_TRANSITION_TIMEOUT_S` for +every ACP profile. A start that outlasts it fails that turn with +`PROCESS_NOT_READY_ERROR` and is recovered; raise that one bound for a slow agent. +A turn that stops waiting for a start, at its bound or on `/stop`, cancels it, so +the room's next message gets a new process. A leave or stop closes admission at +once and gives the room's running turn and model switch `turn_settle_timeout_s` +(`TURN_SETTLE_GRACE_S` by default) to finish on their own before cancelling them. + +```python +from band.core.retention import RoomRetentionConfig +from band.core.turn_lifecycle import TURN_SETTLE_GRACE_S +from band.integrations.acp.client_adapter import ( + ACP_TRANSITION_TIMEOUT_S, + ACPClientAdapterConfig, +) + +config = ACPClientAdapterConfig(command="my-agent") +assert config.retention.transition_timeout_s == ACP_TRANSITION_TIMEOUT_S +assert config.turn_settle_timeout_s == TURN_SETTLE_GRACE_S + +slow = ACPClientAdapterConfig( + command="my-agent", retention=RoomRetentionConfig(transition_timeout_s=120) +) +assert slow.retention.transition_timeout_s == 120 +``` + +- **A deliberate wake reloads the exact session.** It calls `session/load` with a + fresh Band MCP endpoint, then reapplies the room's model selection and verifies + the result. A load or selection refusal fails the wake and keeps the session id and + selection; it never creates a replacement session. Idle release is off, so no + wake happens yet; this is the path a qualified provider will take. +- **A failed turn recovers its room.** A session-setup or prompt error, or a + timeout, releases that room's process for its generation; the next turn starts a + new process and reloads the same session. If the agent no longer has it, the turn + falls back to a fresh session with a transcript replay. A refused remembered + selection fails that turn only and reverts to the configured one; the room keeps + its session, and when that refusal follows a reload, the room's process is + recovered too, so the next turn reloads the session on a new process rather + than twice on the same one. Only a bootstrap resume candidate is closed when its configuration + is refused. +- **Closure is confirmed, not assumed.** The process handle stays on the room until + `__aexit__` succeeds and the agent's stdout reaches EOF, so a descendant that + inherited it keeps the room closing until it exits, with a warning naming the + agent's pid. A failed or cancelled exit leaves the room owned, and a retry kills + and waits for the same process. `cleanup_all()` and its alias `stop()` share one + `transition_timeout_s` budget across the rooms and the shared Band MCP server, + and keep that server open while any room is still held. A cancelled, failed or + overdue server close stays owned and a retry joins it. A stop is final: later + turns are refused until `on_started` (an `Agent.start()`) finishes it and serves + again. +- **Idle release stays off.** OMP, Cursor and Copilot report `ProviderActivity.UNKNOWN`, + so an idle release always answers BUSY. Native qualification is still owed: OMP + background jobs (including suppressed or acknowledged work), Cursor work beyond + subagent drain, and Copilot scheduled `/after` prompts. Band MCP calls and ACP + permission or extension requests carry no prompt id, so a call arriving after its + turn is served with the room's current tools. + ## Session config `resolve_session_config` runs after each new or restored session and before its first @@ -131,13 +196,22 @@ failure fails that room turn visibly instead of falling back. an active turn. If Cursor offers no allow-once option, the configured approval policy applies instead. Late Band permission requests are refused after the turn closes or is interrupted. Cursor omits the session id on its - extension notifications, so the adapter holds a turn lock and binds them to that turn's - session; Cursor turns are serialized. Decision prompts, timeout notices and `/cursor` - replies post through `send_notice`, so they never count as the model's reply, and a + extension requests, so each room process gets its own client profile and binds them to + that room's active turn on that process; a request from a replaced process opens no + decision. Rooms run in parallel, one turn per room; a message that arrives while its + room's turn still runs is not accepted, so the host keeps it unacknowledged and + retries it once the room is free, and a `/cursor` reply still reaches the parked + turn. Only the turn holding the room's lease answers Cursor's extension requests, + and a failed turn reports its failure before the lease passes on, once, whether + it failed starting or prompting. An interrupt or `/stop` cancels the room's + accepted turn, whether it is starting, prompting or parked. `max_pending_decisions` caps each room + separately. Decision prompts, timeout notices and `/cursor` replies post through + `send_notice`, so they never count as the model's reply, and a `/cursor` message settles its own turn without reaching the agent. A turn parked on a decision releases its message early (`tools.turn.detach()`), so it is judged when the - ACP turn completes normally; a failed or cancelled turn is never reported as a missing - reply. + ACP turn ends: a failed turn reports its own failure, an interrupt reports nothing, + and a turn its room's leave or stop cancels after the settle grace is reported once + as a missing reply. The adapter does not pick a plan/agent mode itself; a caller selects one through `resolve_session_config`, which reads each session's advertised catalog before the first prompt. The live `backends` lane pins the Cursor CLI and passes `E2E_CURSOR_API_KEY` diff --git a/docs/adapters/claude_sdk.md b/docs/adapters/claude_sdk.md index 7ca61aa5e..42a742a92 100644 --- a/docs/adapters/claude_sdk.md +++ b/docs/adapters/claude_sdk.md @@ -122,3 +122,56 @@ its original tools; handling a control never rebinds them to the new message. Only a local rejection before `query()` qualifies as `InputNotAccepted`: transport errors and timeouts do not establish that Claude rejected the input. The shared room scheduler owns later attempts. + +## Room ownership + +Each room's session is owned through that room's residency +(`band.core.retention`): its connect, close and recovery run independently, +so a slow CLI in one room never holds up another. + +- **A turn owns the room until it settles.** The message's turn is enrolled + before it starts and keeps the room leased through its query, tool calls, + approval waits and outcome, including after `on_message` returns early for a + manual approval. Leaving the room, or `cleanup_all`, closes admission at + once, gives that turn `turn_settle_timeout_s` (`TURN_SETTLE_GRACE_S` by + default) to finish on its own, then cancels and joins it, and only then + closes the CLI. A turn released for an approval and cancelled this way is + reported once as a missing reply. +- **A CLI is held until its close succeeds.** A failed or cancelled startup, a + crashed CLI, a turn that stopped before its result and a replaced Band MCP + server all retire the CLI through the room's owner once the turn unwinds. + The next message waits for that close, then resumes the session in the same + workspace; a crashed CLI starts a new one. A permission request from a + retired CLI is denied without reaching the room. +- **One reader spans turns.** CLI output that arrives while no turn is + listening, such as background work finishing after a result, is logged and + dropped instead of being read by the next turn. Output read after the next + turn starts listening is attributed to that turn, because the CLI tags its + messages with neither the turn nor the work that produced them. +- **CLI startup and close are bounded.** A turn waits for its room's CLI to + start, and leaving waits for it to close, for at most + `retention.transition_timeout_s`. Claude defaults it to + `CLI_STARTUP_TIMEOUT_S` (60 s), the least time the SDK itself waits for a CLI + to initialize; a lower budget fails turns whose CLI starts slower and + discards that start. +- **Shutdown shares one budget.** `cleanup_all` closes every room, then the + shared Band MCP server, within one `retention.transition_timeout_s`. An + unfinished or failed step raises, stays owned, and a retry joins it; the + server stays up while any room's CLI may still dial it. + +```python +import pytest + +from band.adapters.claude_sdk import CLI_STARTUP_TIMEOUT_S, ClaudeSDKAdapterConfig +from band.core.retention import RoomRetentionConfig +from band.core.turn_lifecycle import TURN_SETTLE_GRACE_S + +assert ClaudeSDKAdapterConfig().retention.transition_timeout_s == CLI_STARTUP_TIMEOUT_S +assert ClaudeSDKAdapterConfig().turn_settle_timeout_s == TURN_SETTLE_GRACE_S + +config = ClaudeSDKAdapterConfig(retention=RoomRetentionConfig(transition_timeout_s=30)) +assert config.retention.idle_timeout_s is None + +with pytest.raises(ValueError, match="idle retention is unavailable"): + ClaudeSDKAdapterConfig(retention=RoomRetentionConfig(idle_timeout_s=60)) +``` diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 0d937d10a..1377ce05d 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -35,6 +35,31 @@ For existing integrations, follow the [SDK 4.x to 5.x migration](../acp.md#upgra close errors and close timeouts retain the claim for retry. A timed-out or cancelled caller does not abandon subprocess cleanup; retry room cleanup to finish releasing ownership. Shutdown attempts every room and reports failures. +- **Leaving is final.** Leaving a room stops it admitting work at once. A + leave that fails or runs over keeps the process, thread and workspace claim, + and the room refuses work until a retried leave succeeds; a later rejoin + starts a new thread. +- **A lost process keeps its thread.** When a room's process exits or its + transport fails, the turn fails and the process is closed once that turn + ends. The next message starts a new process and resumes exactly the same + thread, with its private context and saved tools. If Codex refuses that + resume (for example "no rollout found" for a thread that never ran a turn), + the turn fails and the thread is kept: the adapter never starts a different + thread in its place. Send `/thread archive` to start a new thread instead. +- **Starting a process has its own budget.** Starting a room's process, + refreshing `model/list` and resuming its thread must finish within + `retention.transition_timeout_s`, which Codex defaults to + `CODEX_TRANSITION_TIMEOUT_S` (60 s). A start that runs over fails the turn + with `PROCESS_NOT_READY_ERROR` and is cancelled and closed; raise the bound + for slow starts. +- **Native work answers to the turn that started it.** Each tool call or + approval request is handled for the Band turn whose native turn sent it, on + the process that sent it. Requests from a sub-agent the turn delegated to + belong to that turn once one of its items names the sub-agent's thread: a + collab tool call, or `multi_agent_v2`'s sub-agent activity. + A request from a native turn no Band turn started gets a failed tool result + or a declined approval, and late events of a finished turn are dropped + rather than reaching the next turn. - **Band tools own replies and declines.** Native completed text is optional thought telemetry without mentions; it never settles a turn. A native-only turn fails the shared missing-reply verdict. `Emit.THOUGHTS` controls visibility. @@ -44,6 +69,8 @@ For existing integrations, follow the [SDK 4.x to 5.x migration](../acp.md#upgra - **Tool filters apply to fresh threads.** Include/category/exclude filters narrow the dynamic platform tools advertised when starting a thread. Resumed threads retain their saved registrations: start a fresh thread after changing filters. + After a lost process, a turn whose tools differ from the ones its restored + thread was started with is refused, since Codex would silently ignore them. First-prompt context refreshes communication instructions, not saved tool schemas. - **`reasoning_effort` is not validated.** The valid values depend on the model and the Codex CLI version, and the backend rejects unknown ones. @@ -53,8 +80,28 @@ For existing integrations, follow the [SDK 4.x to 5.x migration](../acp.md#upgra rejects the request, and the room fails to start rather than running without the skills. Roots must be absolute paths; Codex accepts ones that don't exist. +```python +from band.adapters.codex import ( + CODEX_TRANSITION_TIMEOUT_S, + PROCESS_NOT_READY_ERROR, + CodexAdapterConfig, +) + +assert ( + CodexAdapterConfig().retention.transition_timeout_s + == CODEX_TRANSITION_TIMEOUT_S + == 60 +) +assert PROCESS_NOT_READY_ERROR == "Codex process did not become ready in time" +``` + While an accepted turn awaits approval, newer ordinary room work remains outstanding. The runtime can deliver approval and status controls ahead of that pending work through Codex's existing authorization rules. The active turn keeps its original tools; an ordinary input rejected before provider invocation is retried by the shared room scheduler after that turn releases the session. + +Approval commands use the token displayed in the room prompt. Native Codex +request and approval IDs stay internal; a fresh request gets a new chat token +after completion or room cleanup, even when Codex reuses its native ID. A +redelivery of a still-pending request keeps its displayed token. diff --git a/docs/idle-room-release.md b/docs/idle-room-release.md index 9ca598b1c..46b54f1cc 100644 --- a/docs/idle-room-release.md +++ b/docs/idle-room-release.md @@ -77,12 +77,24 @@ cause and handles retained. A crashed or abandoned residency goes through `recover()`, which revokes its event authority and releases once the reporting operation unwinds. `transition_timeout_s` bounds how long a caller waits to learn an outcome; an unfinished transition stays owned and is never treated as -closed. +closed. Recovery cancels an acquisition whose caller stopped waiting for it, so a +start that never finishes cannot hold the room. These ownership rules hold with retention disabled. Room, execution, adapter and shared MCP cleanup that fails or is cancelled stays owned, with the agent's single-instance guard, and the next `stop()` or `shutdown()` retries it instead -of reporting success. +of reporting success. Adapter shutdown refuses new rooms and turns before its +first wait, and settles running turns and closes rooms within one reporting +deadline. Every caller joins that same shutdown. The adapter admits work again +only when the next start has confirmed it. + +## Provider integration + +| Adapter | Ownership integration | Idle release | +| --- | --- | --- | +| Codex | Each room's process, thread and workspace compose one `RetainedRoom`. Turns are owned before they run and leased through approvals and outcomes. A lost process goes through `recover()`; the next process strictly restores the same thread and verifies its provider, workspace and tools. One reader per process routes each native callback by its `turnId` to the Band turn that started it and refuses work no Band turn started. | Rejected by configuration; activity stays `UNKNOWN`, so the shared owner refuses idle release before invoking the release callback. Examined Codex shutdown paths cancel background work. | +| Claude SDK | Each room's CLI session, workspace and native session id compose one `RetainedRoom`. Turns are owned before they run and leased through approvals and outcomes. A crashed CLI goes through `recover()`; callbacks act only for the residency generation that registered them. | Rejected by configuration; activity stays `UNKNOWN`, so the shared owner refuses idle release before invoking the release callback. | +| OMP, Cursor and Copilot ACP | Each room's process, connection and session compose one `RetainedRoom`. Detached work is owned before it runs; a prompt or model switch takes the room's reservation. A failed turn recovers its room, and a deliberate wake strictly reloads the same session. | Rejected by configuration; activity stays `UNKNOWN`, so the shared owner refuses idle release before invoking the release callback. | The shared mechanics do not certify a provider's release callback. Production integration still needs authoritative native inactivity or a demonstrated diff --git a/docs/turn-outcome.md b/docs/turn-outcome.md index f217bcec0..25e13b229 100644 --- a/docs/turn-outcome.md +++ b/docs/turn-outcome.md @@ -289,8 +289,10 @@ agent log. Agno, Google ADK and Gemini follow this rule. An adapter whose turn outlives `on_message` (one parked on a human approval) calls `tools.turn.detach()` when it releases the turn early. `run_judged_turn` then -leaves the verdict to the adapter, which reports at the turn's normal end. A -cancelled turn reports nothing: +leaves the verdict to the adapter, which reports at the turn's normal end. Such a +turn starts through `start_releasable_turn` from `band.core.turn_lifecycle`: if its +room's leave or shutdown cancels it after the settle grace, it is judged then, since +its message was already handed back. An interrupt that cancels it reports nothing: ```python import asyncio diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index f0a57224c..432898145 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -15,12 +15,13 @@ import logging import re import warnings -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from functools import partial -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn, Self, cast try: from claude_agent_sdk import ( # type: ignore[import-not-found] @@ -92,7 +93,14 @@ from band.core.retention import RoomRetentionConfig, require_disabled_retention from band.core.simple_adapter import SimpleAdapter from band.core.turn import judge_detached_turn -from band.core.turn_lifecycle import ApprovalInterruptMixin, TurnTask, bind_turn_task +from band.core.turn_lifecycle import ( + TURN_SETTLE_GRACE_S, + ApprovalInterruptMixin, + TurnSettleTimeout, + TurnTask, + bind_turn_task, + start_releasable_turn, +) from band.core.types import ( ApprovalMode, Capability, @@ -108,8 +116,11 @@ ) from band.integrations.claude_sdk.prompts import generate_claude_sdk_agent_prompt from band.integrations.claude_sdk.session_manager import ( + CLI_STARTUP_TIMEOUT_S, # noqa: F401 -- re-exported for adapter configs + ClaudeRoomSession, ClaudeSessionManager, - ClaudeSessionManagerStoppedError, + TurnStream, + default_claude_retention, ) from band.integrations.mcp import ( BandMCPBackendSettings, @@ -126,6 +137,7 @@ Timeout, ) from band.runtime.formatters import format_tokens, strip_leading_mentions +from band.runtime.prompts import SYSTEM_UPDATE_PREFIX from band.runtime.tools import ( ALL_TOOL_NAMES, BAND_MCP_SERVER_NAME, @@ -164,6 +176,8 @@ CLAUDE_SDK_MAX_BUFFER_BYTES = MAX_INLINE_IMAGE_BYTES * 2 _PROVIDER = "claude_sdk" +#: What a Claude turn that failed in the provider reports to the room. +_TURN_FAILURE = AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) # Flags the SDK emits from the options the adapter builds (pinned against the # SDK's real command by test_cli_options.py). @@ -328,6 +342,9 @@ class ClaudeSDKAdapterConfig(BaseAdapterConfig): ``"haiku"``, ``"inherit"``). ``None`` pins ``band.core.defaultmodels.ANTHROPIC_MODEL``. fallback_model: Model the CLI uses when ``model`` is unavailable. + retention: Room residency settings. Idle retention is unavailable; + ``transition_timeout_s`` bounds each CLI startup and close a caller + waits on, and defaults to ``CLI_STARTUP_TIMEOUT_S``. custom_section: Extra instructions appended to the system prompt. max_thinking_tokens: Extended-thinking budget; ``None`` disables it. effort: Response effort level; ``None`` uses the model default. @@ -352,7 +369,7 @@ class ClaudeSDKAdapterConfig(BaseAdapterConfig): model: str | None = None fallback_model: str | None = None - retention: RoomRetentionConfig = Field(default_factory=RoomRetentionConfig) + retention: RoomRetentionConfig = Field(default_factory=default_claude_retention) custom_section: str | None = None max_thinking_tokens: PositiveInt | None = None effort: EffortLevel | None = None @@ -360,6 +377,7 @@ class ClaudeSDKAdapterConfig(BaseAdapterConfig): cwd: DirectoryPath | None = None setting_sources: tuple[SettingSource, ...] = () turn_timeout_s: PositiveFloat | None = None + turn_settle_timeout_s: TurnSettleTimeout = TURN_SETTLE_GRACE_S send_message_dedup_ttl_seconds: NonNegativeFloat = DEFAULT_DEDUP_TTL_SECONDS cli: ClaudeCLIOptions = Field(default_factory=ClaudeCLIOptions) approvals: ClaudeApprovalOptions | None = None @@ -429,6 +447,8 @@ class ClaudeSDKCommand(StrEnum): "Unknown approval token `{token}`. Available: {available}." ) APPROVAL_UNAUTHORIZED_MESSAGE = "You are not authorized to approve or decline tool use." +# A permission request from a CLI whose residency was retired or replaced. +RETIRED_SESSION_DENIAL = "This Claude session was retired" # Commands recognised as local (not forwarded to Claude) _APPROVAL_CMDS = frozenset( @@ -526,6 +546,20 @@ class PendingApproval: requester: dict[str, str] +async def _fail_turn( + session: ClaudeRoomSession, + tools: AgentToolsProtocol, + error: Exception, + *, + reason: str, + forget_session: bool = False, +) -> NoReturn: + """Recover the room's CLI, report the turn's failure, and mark it reported.""" + session.recover(reason, forget_session=forget_session) + await tools.send_failure(_TURN_FAILURE) + raise TurnResultAlreadyReported(str(error)) from error + + def __getattr__(name: str) -> Any: if name == "BAND_TOOLS": warnings.warn( @@ -615,11 +649,9 @@ def __init__( self._room_tools: dict[str, AgentToolsProtocol] = {} self._mcp_room_tools: dict[str, AgentToolsProtocol] = {} - # Per-room session context (text history for Claude SDK) - self._session_context: dict[str, str] = {} - - # Per-room session IDs (for SDK session resume) - self._session_ids: dict[str, str] = {} + # The session id each room last recorded as a task event; the room's + # ClaudeRoomSession owns the id it resumes. + self._recorded_session_ids: dict[str, str] = {} # Custom tools (user-provided) self._custom_tools: list[CustomToolDef] = additional_tools or [] @@ -654,11 +686,10 @@ def __init__( # {room_id: the current turn's release future} self._turn_release: dict[str, asyncio.Future[None]] = {} # {room_id: the owner of the current turn's background worker}, so - # on_message can refuse to start a second concurrent turn on the same client. + # on_message can refuse to start a second concurrent turn on the same + # client. The TurnTask cancels it for STOP and interrupts; leaving the + # room cancels it through the RetainedRoom, which fences admission first. self._turn_tasks: dict[str, TurnTask] = {} - self._room_cleanup: dict[str, asyncio.Task[None]] = {} - self._closing_rooms: set[str] = set() - self._closing_all = False @property def _approvals(self) -> ClaudeApprovalOptions: @@ -667,6 +698,10 @@ def _approvals(self) -> ClaudeApprovalOptions: raise RuntimeError("chat-based approvals are disabled") return approvals + async def _stop_sessions(self) -> None: + if self._session_manager is not None: + await self._session_manager.stop() + @property def _approval_label(self) -> str: approvals = self.config.approvals @@ -675,9 +710,10 @@ def _approval_label(self) -> str: # --- Adapted from BandClaudeSDKAgent._on_started --- async def on_started(self, agent_name: str, agent_description: str) -> None: """Create MCP server and session manager after agent metadata is fetched.""" - await super().on_started(agent_name, agent_description) + # A stop is terminal for its manager: finish it before starting anew. if self._session_manager is not None: await self.cleanup_all() + await super().on_started(agent_name, agent_description) await self._mcp.reopen() mcp_backend = await self._mcp.ensure() @@ -743,12 +779,14 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: can_use_tool_factory = ( self._make_can_use_tool if self.config.approvals is not None else None ) - self._closing_all = False self._session_manager = ClaudeSessionManager( sdk_options, can_use_tool_factory=can_use_tool_factory, mcp_servers_factory=self._room_mcp_servers, workspaces=self._workspaces, + retention=self.config.retention, + settle_grace_s=self.config.turn_settle_timeout_s, + on_disposed=self._forget_room, ) logger.info( @@ -820,8 +858,8 @@ async def on_message( """ Handle incoming message. - - Store tools for MCP server access - - Get or create ClaudeSDKClient for this room + - Reserve the room's turn task before anything awaits + - Bind the turn's tools, then reuse or start the room's CLI - Stream response and log events (tools execute via MCP) """ logger.debug("Handling message %s in room %s", msg.id, room_id) @@ -832,90 +870,20 @@ async def on_message( ) self._admit_turn(room_id, "Claude") + session = self._session_manager.room(room_id) if self.config.approvals is not None: self._room_last_sender[room_id] = { "id": msg.sender_id, "name": msg.sender_name or msg.sender_type, } - # The running turn's approvals and MCP calls keep their original tools. - self._room_tools[room_id] = tools - await self._bind_mcp_tools(room_id, tools) - await self._mcp.ensure() - - # The manager only resumes when it has to create the client: on - # bootstrap, or after a retired client (see _retire_client). - stored_session_id = ( - history.session_id if is_session_bootstrap else None - ) or self._session_ids.get(room_id) - - try: - client = await self._open_session( - manager=self._session_manager, - room_id=room_id, - resume_session_id=stored_session_id, - ) - except ClaudeSessionManagerStoppedError: - raise - except Exception: - logger.exception("Room %s: Session creation failed", room_id) - await tools.send_failure( - AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) - ) - raise - - # Initialize history for this room on first message - if is_session_bootstrap: - if history.text: # Already converted to text by SimpleAdapter - self._session_context[room_id] = history.text - logger.info( - "Room %s: Loaded historical context (%s chars)", - room_id, - len(history.text), - ) - else: - self._session_context[room_id] = "" - elif room_id not in self._session_context: - # Safety: ensure context exists even if not first message - self._session_context[room_id] = "" - - # Build message with context - messages_to_send = [] - - # Include historical context on first message. Frame it as authoritative - # memory, not a passive quote: under the claude_code preset the model treats a - # "previous context" aside weakly, so a fact another participant stated (or that - # you stated) while you were offline gets missed on recall. Tell it plainly this - # is its own memory of the room and to answer from it. - if is_session_bootstrap and self._session_context.get(room_id): - messages_to_send.append( - "Your memory of this room so far — real earlier messages from you and " - "from other participants and agents, including ones sent while you were " - "offline. Treat these as facts you know and answer questions about the " - f"conversation directly from them:\n" - f"{self._session_context[room_id]}" - ) - - # Inject participants message if changed - if participants_msg: - messages_to_send.append(f"[System]: {participants_msg}") - logger.info("Room %s: Participants updated", room_id) - - # Inject contacts message if present - if contacts_msg: - messages_to_send.append(f"[System]: {contacts_msg}") - logger.info("Room %s: Contacts broadcast received", room_id) - - messages_to_send.append(msg.format_for_llm()) - - # Send combined message to Claude - full_message = "\n\n".join(messages_to_send) - - logger.info( - "Room %s: Sending query to Claude SDK (first_msg=%s, parts=%s)", - room_id, - is_session_bootstrap, - len(messages_to_send), + full_message = self._compose_prompt( + msg, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, ) # Run the turn as a detached task and only await its *release* -- @@ -923,25 +891,27 @@ async def on_message( # early (see _resolve_manual_approval / _release_turn) so on_message # can return and Band's strictly-sequential per-room message loop can # dispatch the reply that will eventually resolve the approval. When - # nothing needs a human, the turn finishes before release fires and - # this is equivalent to the previous synchronous await. - self._require_running() + # nothing needs a human, the turn finishes before release fires. loop = asyncio.get_running_loop() release_future: asyncio.Future[None] = loop.create_future() - self._turn_release[room_id] = release_future - # An eager task factory runs the turn before create_task returns. + # The turn's own TurnTask exists only once its task does. owner_ready: asyncio.Future[TurnTask] = loop.create_future() - turn_task = asyncio.create_task( - self._run_turn( + turn_task = start_releasable_turn( + session.retained, + partial( + self._run_turn, owner_ready, - client, - room_id, + session, tools, full_message, msg.id, release_future, - ) + resume_from=history.session_id if is_session_bootstrap else None, + ), + tools=tools, + room_id=room_id, ) + self._turn_release[room_id] = release_future self._turn_tasks[room_id] = owner = bind_turn_task(tools, turn_task) owner_ready.set_result(owner) try: @@ -950,8 +920,7 @@ async def on_message( ) if turn_task.done(): # The turn finished before release fired (the common, no-approval - # case) -- await it so a failure still propagates through - # on_message exactly as it did before this turn ran detached. + # case) -- await it so a failure propagates through on_message. await turn_task except asyncio.CancelledError: await self._cancel_turn(room_id) @@ -960,88 +929,162 @@ async def on_message( if self._turn_release.get(room_id) is release_future: del self._turn_release[room_id] - async def _open_session( - self, manager: ClaudeSessionManager, room_id: str, resume_session_id: str | None - ) -> ClaudeSDKClient: - """The room's client, starting a fresh session when the resume fails.""" - try: - return await manager.get_or_create_session( - room_id, resume_session_id=resume_session_id - ) - except ClaudeSessionManagerStoppedError: - raise - except Exception as resume_exc: - if not resume_session_id: - raise - logger.warning( - "Room %s: Session resume failed (session_id=%s): %s. " - "Creating new session", + def _compose_prompt( + self, + msg: PlatformMessage, + history: ClaudeSDKSessionState, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + room_id: str, + ) -> str: + """The text sent to Claude: bootstrap memory, system updates, message.""" + messages_to_send = [] + + # Include historical context on first message. Frame it as authoritative + # memory, not a passive quote: under the claude_code preset the model treats a + # "previous context" aside weakly, so a fact another participant stated (or that + # you stated) while you were offline gets missed on recall. Tell it plainly this + # is its own memory of the room and to answer from it. + if is_session_bootstrap and history.text: # Already text via SimpleAdapter + logger.info( + "Room %s: Loaded historical context (%s chars)", room_id, - resume_session_id, - resume_exc, + len(history.text), + ) + messages_to_send.append( + "Your memory of this room so far — real earlier messages from you and " + "from other participants and agents, including ones sent while you were " + "offline. Treat these as facts you know and answer questions about the " + f"conversation directly from them:\n" + f"{history.text}" ) - self._require_running() - return await manager.get_or_create_session(room_id, resume_session_id=None) - def _require_running(self) -> None: - if self._closing_all: - raise ClaudeSessionManagerStoppedError("Claude adapter is stopping") + if participants_msg: + messages_to_send.append(f"{SYSTEM_UPDATE_PREFIX}{participants_msg}") + logger.info("Room %s: Participants updated", room_id) + + if contacts_msg: + messages_to_send.append(f"{SYSTEM_UPDATE_PREFIX}{contacts_msg}") + logger.info("Room %s: Contacts broadcast received", room_id) + + messages_to_send.append(msg.format_for_llm()) + + logger.info( + "Room %s: Sending query to Claude SDK (first_msg=%s, parts=%s)", + room_id, + is_session_bootstrap, + len(messages_to_send), + ) + return "\n\n".join(messages_to_send) async def _run_turn( self, owner_ready: asyncio.Future[TurnTask], - client: ClaudeSDKClient, - room_id: str, + session: ClaudeRoomSession, tools: AgentToolsProtocol, full_message: str, msg_id: str, release_future: asyncio.Future[None], + *, + resume_from: str | None, ) -> None: - """Run one turn to completion; always releases ``release_future``.""" + """Run one turn under the room's lease; always releases ``release_future``.""" owner = await owner_ready try: - try: - await self._query_within_deadline( - owner, client, room_id, tools, full_message - ) + async with self._turn_lease(session, tools, resume_from) as client: + await self._converse(owner, session, client, tools, full_message) + logger.debug("Message %s processed successfully", msg_id) + finally: + self._release_turn(session.room_id, release_future) + if self._turn_tasks.get(session.room_id) is owner: + del self._turn_tasks[session.room_id] - except TurnResultAlreadyReported: - # The failure was already reported via send_failure deeper in - # the call stack; propagate without reporting it a second time. - raise - - except CLIConnectionError as e: - # CLI process is dead — evict the cached session so the next - # message creates a fresh one instead of reusing the corpse. - logger.error( - "Room %s: CLI process terminated: %s — invalidating session", - room_id, - e, - ) - await self._invalidate_session(room_id) + @asynccontextmanager + async def _turn_lease( + self, + session: ClaudeRoomSession, + tools: AgentToolsProtocol, + resume_from: str | None, + ) -> AsyncIterator[ClaudeSDKClient]: + """Lease the room for one turn: bind its tools, then reuse or start + the CLI. The room hears here about a session that failed to start; + failures inside the turn are reported where they happen.""" + started = False + try: + # Before the lease: a replaced server retires the room's CLI, and + # that recovery runs only while no operation leases the room. + await self._mcp.ensure() + session.retire_outdated() + prepare = partial(self._bind_turn, session, tools, resume_from) + async with session.using(prepare) as client: + started = True + yield client + except Exception: + if not started and not session.retained.closing: + logger.exception("Room %s: Session creation failed", session.room_id) + await tools.send_failure(_TURN_FAILURE) + raise - await tools.send_failure( - AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) - ) - raise + async def _bind_turn( + self, + session: ClaudeRoomSession, + tools: AgentToolsProtocol, + resume_from: str | None, + ) -> None: + """Bind a leased turn before the CLI that will call its tools starts.""" + session.adopt(resume_from) + # The running turn's approvals and MCP calls keep their original tools. + self._room_tools[session.room_id] = tools + await self._bind_mcp_tools(session.room_id, tools) - except Exception: - logger.exception("Error processing message") - await tools.send_failure( - AgentFailure(_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) - ) - await self._retire_client(room_id) - raise + async def _converse( + self, + owner: TurnTask, + session: ClaudeRoomSession, + client: ClaudeSDKClient, + tools: AgentToolsProtocol, + full_message: str, + ) -> None: + """Query and stream one turn; a turn that stops short of its result + retires the CLI once the turn's lease unwinds.""" + try: + await self._query_within_deadline( + owner, session, client, tools, full_message + ) - except asyncio.CancelledError: - await self._retire_client(room_id) - raise + except TurnResultAlreadyReported: + # The failure was already reported via send_failure deeper in + # the call stack; propagate without reporting it a second time. + raise - logger.debug("Message %s processed successfully", msg_id) - finally: - self._release_turn(room_id, release_future) - if self._turn_tasks.get(room_id) is owner: - del self._turn_tasks[room_id] + except CLIConnectionError as e: + # The CLI process is dead: the next message starts a new session. + logger.error( + "Room %s: CLI process terminated: %s — retiring session", + session.room_id, + e, + ) + await _fail_turn( + session, + tools, + e, + reason=f"CLI process terminated: {e}", + forget_session=True, + ) + + except Exception as e: + logger.exception( + "Room %s: error processing message (generation %s)", + session.room_id, + session.generation, + ) + await _fail_turn(session, tools, e, reason="Turn failed before its result") + + except asyncio.CancelledError: + session.recover("Turn cancelled before its result") + raise def _release_turn( self, room_id: str, release_future: asyncio.Future[None] | None = None @@ -1064,8 +1107,8 @@ def _release_turn( async def _query_within_deadline( self, owner: TurnTask, + session: ClaudeRoomSession, client: ClaudeSDKClient, - room_id: str, tools: AgentToolsProtocol, full_message: str, ) -> None: @@ -1075,31 +1118,40 @@ async def _query_within_deadline( propagates as an ordinary turn failure. """ deadline: asyncio.Timeout | None = None - try: - async with owner.timed(self.config.turn_timeout_s) as deadline: - await client.query(full_message) - # MCP tools handle execution; this dispatches the stream. - await self._process_response(client, room_id, tools) - except TimeoutError: - if deadline is None or not deadline.expired(): - raise - detail = await self._abandon_timed_out_turn(owner, client, room_id) - await tools.send_failure( - AgentFailure(_PROVIDER, detail, FAILURE_CODE_TIMEOUT) - ) - raise TurnResultAlreadyReported(detail) from None + with session.listening() as stream: + try: + async with owner.timed(self.config.turn_timeout_s) as deadline: + await client.query(full_message) + # MCP tools handle execution; this dispatches the stream. + await self._process_response(stream, session, tools) + except TimeoutError: + if deadline is None or not deadline.expired(): + raise + detail = await self._abandon_timed_out_turn( + owner, session, client, stream + ) + await tools.send_failure( + AgentFailure(_PROVIDER, detail, FAILURE_CODE_TIMEOUT) + ) + raise TurnResultAlreadyReported(detail) from None async def _abandon_timed_out_turn( - self, owner: TurnTask, client: ClaudeSDKClient, room_id: str + self, + owner: TurnTask, + session: ClaudeRoomSession, + client: ClaudeSDKClient, + stream: TurnStream, ) -> str: """Interrupt a turn that outlived ``turn_timeout_s``; return the notice. The interrupted turn still streams a final ResultMessage, which the next turn on this client would otherwise read as its own. It is - drained here. A client whose process died is evicted; one that is - still running but will not stop is closed, keeping any captured - session id so the next message resumes it on a fresh process. + drained here from the same reader. A client whose process died is + retired with its session; one that is still running but will not stop + is retired keeping its session id, so the next message resumes it on + a fresh process. """ + room_id = session.room_id logger.warning( "Room %s: Claude turn timed out after %ss", room_id, @@ -1108,23 +1160,24 @@ async def _abandon_timed_out_turn( try: async with owner.timed(_TIMEOUT_DRAIN_SECONDS, cleanup=True): await client.interrupt() - async for sdk_message in client.receive_response(): - if isinstance(sdk_message, ResultMessage): - break + async for _ in stream.until_result(): + pass + if not stream.completed: + raise CLIConnectionError(self._stream_ended_without_result_error()) except CLIConnectionError: logger.warning( - "Room %s: timed-out Claude turn's CLI is gone; invalidating session", + "Room %s: timed-out Claude turn's CLI is gone; retiring session", room_id, exc_info=True, ) - await self._invalidate_session(room_id) + session.recover("Timed-out turn's CLI is gone", forget_session=True) except Exception: logger.warning( "Room %s: timed-out Claude turn did not stop; closing its client", room_id, exc_info=True, ) - await self._retire_client(room_id) + session.recover("Timed-out turn did not stop") return f"Claude turn timed out after {self.config.turn_timeout_s}s" async def _bind_mcp_tools(self, room_id: str, tools: AgentToolsProtocol) -> None: @@ -1153,36 +1206,18 @@ async def _bind_mcp_tools(self, room_id: str, tools: AgentToolsProtocol) -> None ), ) - async def _retire_client(self, room_id: str) -> None: - """Close a live client whose turn stopped before its ResultMessage. - - The CLI keeps that turn's remaining messages queued, and the next - ``receive_response`` would read them as its own. The session id stays, - so the next client resumes the conversation. - """ - if self._session_manager: - await self._session_manager.cleanup_session( - room_id, release_workspace=False - ) - - async def _invalidate_session(self, room_id: str) -> None: - """Evict the cached session and client so the next message for this - room creates a fresh one instead of reusing a corpse.""" - if self._session_manager: - await self._session_manager.invalidate_session(room_id) - self._session_ids.pop(room_id, None) - async def _process_response( - self, client: ClaudeSDKClient, room_id: str, tools: AgentToolsProtocol + self, stream: TurnStream, session: ClaudeRoomSession, tools: AgentToolsProtocol ) -> None: """Dispatch the turn's streamed messages until its terminal ResultMessage. MCP tools handle actual execution - we log and optionally report events here. """ + room_id = session.room_id # The room's pending-call map (see __init__) — persists across turns # so a result replayed by a resumed session still resolves its name. pending_tool_names = self._pending_tool_names.setdefault(room_id, {}) - async for sdk_message in client.receive_response(): + async for sdk_message in stream.until_result(): match sdk_message: case AssistantMessage(): await self._on_assistant_message( @@ -1209,20 +1244,19 @@ async def _process_response( self.config.permission_mode, ) case ResultMessage(): - await self._on_turn_complete(sdk_message, room_id, tools) + await self._on_turn_complete(sdk_message, session, tools) return - # ``receive_response`` is documented to terminate only after its - # ResultMessage. Reaching EOF first means the CLI transport died. + # The stream ended before the turn's ResultMessage: the CLI died. if tools.turn.complete: # The turn already answered or did its work. Failing it would mark # the message FAILED, which a later process or context runs again, # repeating that work. Drop the dead session; the turn is done. logger.warning( "Room %s: CLI stream ended without a result after the turn " - "completed — invalidating session", + "completed — retiring session", room_id, ) - await self._invalidate_session(room_id) + session.recover("CLI stream ended after the turn", forget_session=True) return # The turn is incomplete: use the normal dead-client path so the # runtime marks it failed and the cached client is not reused. @@ -1379,7 +1413,7 @@ async def _narrate_tool_call( async def _on_turn_complete( self, sdk_message: ResultMessage, - room_id: str, + session: ClaudeRoomSession, tools: AgentToolsProtocol, ) -> None: """Close out the turn: persist session id, emit usage, surface failure. @@ -1387,6 +1421,7 @@ async def _on_turn_complete( A turn released early for an approval is judged here, at its real end; any other turn is judged by ``run_judged_turn`` once ``on_message`` returns. """ + room_id = session.room_id logger.info( "Room %s: Complete - %sms, $%.4f", room_id, @@ -1394,7 +1429,7 @@ async def _on_turn_complete( sdk_message.total_cost_usd or 0, ) if sdk_message.session_id: - await self._persist_session_id(sdk_message.session_id, room_id, tools) + await self._persist_session_id(sdk_message.session_id, session, tools) # The ResultMessage carries the turn's total usage (the SDK runs # its own tool loop internally, so this is already aggregated). await self.emit_usage(tools, self._usage_from_result(sdk_message)) @@ -1464,15 +1499,16 @@ def _record_notified_decline(self, room_id: str, tool_use_id: str) -> None: self._notified_declines.setdefault(room_id, set()).add(tool_use_id) async def _persist_session_id( - self, session_id: str, room_id: str, tools: AgentToolsProtocol + self, session_id: str, session: ClaudeRoomSession, tools: AgentToolsProtocol ) -> None: - """Cache the session id for resume and, when it changed, persist it as + """Keep the session id for resume and, when it changed, persist it as a task event (best-effort).""" - prev_session_id = self._session_ids.get(room_id) - self._session_ids[room_id] = session_id + room_id = session.room_id + session.session_id = session_id logger.debug("Room %s: Captured session_id %s", room_id, session_id) - if session_id == prev_session_id: + if self._recorded_session_ids.get(room_id) == session_id: return + self._recorded_session_ids[room_id] = session_id try: await tools.send_event( content="Claude SDK session", @@ -1571,20 +1607,22 @@ def _usage_from_result(sdk_message: ResultMessage) -> TurnUsage: ) async def on_cleanup(self, room_id: str) -> None: - """Clean up Claude SDK session and stored tools when agent leaves a room.""" - await self._retire_room( - room_id, - settle=self._cancel_turn, - close=partial(self._close_room, room_id), - ) + """Leave a room: admission closes at once, its turn gets + ``turn_settle_timeout_s`` to wind down before it and any startup are + cancelled, its CLI closes, then its state is dropped. A failed + cleanup keeps everything for a retry to join; an unfinished one still + drops the state once it finishes.""" + self._clear_pending_approvals_for_room(room_id) + manager = self._session_manager + if manager is None or manager.session(room_id) is None: + self._forget_room(room_id) + return + await manager.cleanup_session(room_id) - async def _close_room(self, room_id: str) -> None: - if self._session_manager: - await self._session_manager.cleanup_session(room_id) + def _forget_room(self, room_id: str) -> None: self._room_tools.pop(room_id, None) self._mcp_room_tools.pop(room_id, None) - self._session_context.pop(room_id, None) - self._session_ids.pop(room_id, None) + self._recorded_session_ids.pop(room_id, None) self._room_last_sender.pop(room_id, None) self._notified_declines.pop(room_id, None) self._pending_tool_names.pop(room_id, None) @@ -1592,35 +1630,18 @@ async def _close_room(self, room_id: str) -> None: logger.debug("Room %s: Cleaned up Claude SDK session", room_id) async def cleanup_all(self) -> None: - """Cleanup all sessions (call on stop).""" - self._closing_all = True - # Decline all pending approvals across rooms + """Stop the session manager, then the shared Band MCP server, within + one ``retention.transition_timeout_s`` budget. + + Stopping closes admission for every room at once; rooms close first + because their CLIs dial the server until they do. An unfinished or + failed step stays owned for a retry. + """ for room_id in list(self._pending_approvals): self._clear_pending_approvals_for_room(room_id) - rooms = set(self._room_tools) | set(self._turn_tasks) | self._closing_rooms - results = await asyncio.gather( - *(self.on_cleanup(room_id) for room_id in rooms), return_exceptions=True + await self._mcp.close_after( + self._stop_sessions(), retention=self.config.retention ) - errors: list[Exception] = [] - for result in results: - if isinstance(result, asyncio.CancelledError): - raise result - if isinstance(result, Exception): - errors.append(result) - if errors: - raise ExceptionGroup("Claude session cleanup failed", errors) - if self._session_manager: - await self._session_manager.stop() - await self._mcp.close(final=True) - self._room_tools.clear() - self._mcp_room_tools.clear() - self._session_context.clear() - self._session_ids.clear() - self._room_last_sender.clear() - self._notified_declines.clear() - self._pending_tool_names.clear() - self._turn_release.clear() - self._turn_tasks.clear() # ------------------------------------------------------------------ # Chat-based approval flow @@ -1638,17 +1659,29 @@ def _semantic_tool_name(sdk_tool_name: str) -> str: """ return sdk_tool_name.removeprefix(MCP_TOOL_PREFIX) - def _make_can_use_tool(self, room_id: str) -> CanUseTool: - """Return a room-bound ``can_use_tool`` callback for the Claude SDK.""" + def _make_can_use_tool( + self, session: ClaudeRoomSession, generation: int + ) -> CanUseTool: + """A ``can_use_tool`` callback bound to one residency of one room; a + retired residency's late request is denied without reaching the room.""" async def _can_use_tool( tool_name: str, tool_input: dict[str, Any], context: ToolPermissionContext, ) -> PermissionResultAllow | PermissionResultDeny: - return await self._resolve_tool_permission( - room_id, tool_name, tool_input, context - ) + # Checked again after deciding: the residency may retire meanwhile. + if session.speaks_for(generation): + decision = await self._resolve_tool_permission( + session.room_id, tool_name, tool_input, context + ) + if session.speaks_for(generation): + return decision + why = "decided after its Claude session retired" + else: + why = "requested by a retired Claude session" + logger.warning("Room %s: denied %s, %s", session.room_id, tool_name, why) + return PermissionResultDeny(message=RETIRED_SESSION_DENIAL) return _can_use_tool @@ -1999,10 +2032,10 @@ async def _handle_status_command( sender: dict[str, str], ) -> None: """Handle the ``/status`` command.""" - session_count = ( - self._session_manager.get_session_count() if self._session_manager else 0 - ) - session_id = self._session_ids.get(room_id, "—") + manager = self._session_manager + session_count = manager.get_session_count() if manager else 0 + session = manager.session(room_id) if manager else None + session_id = (session.session_id if session else None) or "—" registry = self._pending_approvals.get(room_id) pending_count = registry.unclaimed_count() if registry else 0 diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 7453158d2..b9115e676 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -7,9 +7,10 @@ import logging import time as _time from collections import OrderedDict -from collections.abc import Callable +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager, nullcontext from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, fields from dataclasses import field as dataclass_field from datetime import UTC, datetime from enum import StrEnum @@ -42,6 +43,7 @@ deliver_notice, reraise_delivery_cause, ) +from band.core.exceptions import InputNotAccepted from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -49,11 +51,26 @@ TurnResultAlreadyReported, send_event_safe, ) -from band.core.retention import RoomRetentionConfig, require_disabled_retention +from band.core.retention import ( + ReleaseCause, + ReleaseOutcome, + RetainedRoom, + RoomRetention, + RoomRetentionConfig, + require_disabled_retention, +) +from band.core.retention.cleanup import spawn_owned from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas from band.core.turn import judge_detached_turn -from band.core.turn_lifecycle import ApprovalInterruptMixin, TurnTask, bind_turn_task +from band.core.turn_lifecycle import ( + TURN_SETTLE_GRACE_S, + ApprovalInterruptMixin, + TurnSettleTimeout, + TurnTask, + bind_turn_task, + start_releasable_turn, +) from band.core.types import ( AgentInput, ApprovalMode, @@ -65,6 +82,7 @@ TurnUsage, ) from band.integrations.codex import ( + TRANSPORT_CLOSED, CodexJsonRpcError, CodexStdioClient, RpcEvent, @@ -72,6 +90,7 @@ from band.integrations.codex.types import ( CODEX_APPROVAL_METHODS, CODEX_PROVIDER, + DYNAMIC_TOOL_CALL_METHOD, ApprovalAuditEntry, CodexApprovalMethod, CodexItemType, @@ -88,7 +107,12 @@ find_custom_tool, format_validation_error, ) -from band.runtime.decisions import DecisionEntry, DecisionRegistry, Timeout +from band.runtime.decisions import ( + DecisionEntry, + DecisionRegistry, + Registration, + Timeout, +) from band.runtime.formatters import strip_leading_mentions from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS, render_system_prompt from band.runtime.tools import ( @@ -157,6 +181,10 @@ class CodexCommand(StrEnum): CodexCommand.DECLINE: "decline", } _APPROVAL_COMMANDS = frozenset({*_APPROVAL_COMMAND_DECISIONS, CodexCommand.APPROVALS}) +# Local commands that ask the room's Codex process. +_PROCESS_COMMANDS = frozenset( + {CodexCommand.MODEL, CodexCommand.MODELS, CodexCommand.REASONING} +) # Membership by plain string: Python 3.11's Enum rejects `"word" in CodexCommand`. _COMMAND_WORDS = frozenset(CodexCommand) @@ -258,6 +286,15 @@ class CodexSandboxMode(StrEnum): {CodexItemType.USER_MESSAGE, CodexItemType.AGENT_MESSAGE} ) +# Read only to attribute a sub-agent's requests to its turn; never rendered. +_ROUTING_ITEM_TYPES: frozenset[CodexItemType] = frozenset( + {CodexItemType.SUB_AGENT_ACTIVITY} +) + +_UNRENDERED_ITEM_TYPES = ( + _REQUESTED_TOOL_ITEM_TYPES | _MESSAGE_ITEM_TYPES | _ROUTING_ITEM_TYPES +) + # item/completed "type" values gated on Emit.THOUGHTS; dispatched in # _extract_thought_text. _THOUGHT_ITEM_TYPES: frozenset[CodexItemType] = frozenset( @@ -340,6 +377,54 @@ async def respond_error( async def close(self) -> None: ... +class CodexContinuityError(RuntimeError): + """A retained Codex thread cannot continue as it was; its id is kept.""" + + +_METHOD_NOT_FOUND = -32601 +# How a turn whose Codex process was lost reports its failure. +TRANSPORT_LOST_ERROR = "Codex transport closed unexpectedly" +# How a turn that failed for an unexpected reason reports its failure. +TURN_PROCESSING_ERROR = "Internal error during turn processing" +# How a turn reports a room process that did not start or stop within +# ``retention.transition_timeout_s``. +PROCESS_NOT_READY_ERROR = "Codex process did not become ready in time" +# One acquisition starts the process, refreshes ``model/list`` online and +# strictly resumes the thread's whole rollout. +CODEX_TRANSITION_TIMEOUT_S = 60.0 +_UNOWNED_TOOL_CALL_TEXT = ( + "No Band turn started this Codex turn, so Band tools are unavailable to it." +) + + +def _text_item(text: str) -> dict[str, Any]: + return {"type": "inputText", "text": text} + + +def _tool_response(items: list[dict[str, Any]], *, success: bool) -> dict[str, Any]: + """A dynamic tool call's response.""" + return {"contentItems": items, "success": success} + + +def _tool_result(text: str, *, success: bool) -> dict[str, Any]: + """A dynamic tool call's response carrying one text item.""" + return _tool_response([_text_item(text)], success=success) + + +def _approval_response(decision: ApprovalDecision) -> dict[str, Any]: + return {"decision": decision} + + +async def _refuse_unhandled_request( + client: CodexClientProtocol, *, request_id: int | str, method: str +) -> None: + await client.respond_error( + request_id, + code=_METHOD_NOT_FOUND, + message=f"Unhandled server request: {method}", + ) + + @dataclass class PendingApproval: request_id: int | str @@ -348,6 +433,7 @@ class PendingApproval: created_at: datetime future: asyncio.Future[ApprovalDecision] session_key: str = "" + native_key: str = "" @dataclass @@ -375,26 +461,220 @@ class TurnResult: turn_error: str = "" +def _event_params(event: RpcEvent) -> dict[str, Any]: + return event.params if isinstance(event.params, dict) else {} + + +def _native_turn_id(event: RpcEvent) -> str | None: + """The native turn ``event`` belongs to: its ``turnId``, or the ``turn.id`` + that ``turn/started`` and ``turn/completed`` carry instead.""" + params = _event_params(event) + turn_id = params.get("turnId") + if turn_id is None and isinstance(turn := params.get("turn"), dict): + turn_id = turn.get("id") + return str(turn_id) if turn_id else None + + +def _loss_reason(cause: RpcEvent | Exception) -> str: + """Why a process's events stopped: the transport's stated reason, if any.""" + if isinstance(cause, Exception): + return repr(cause) + params = _event_params(cause) + return str(params.get("reason") or cause.method) + + +def _thread_id(event: RpcEvent) -> str | None: + params = _event_params(event) + thread_id = params.get("threadId") + return str(thread_id) if thread_id else None + + +def _delegated_threads(event: RpcEvent) -> set[str]: + """Sub-agent threads an item says the turn spawned or addressed.""" + item = _event_params(event).get("item") + if not isinstance(item, dict): + return set() + match item.get("type"): + case CodexItemType.COLLAB_AGENT_TOOL_CALL: + threads = item.get("receiverThreadIds") or () + case CodexItemType.SUB_AGENT_ACTIVITY: + threads = (item.get("agentThreadId"),) + case _: + return set() + return {str(thread) for thread in threads if thread} + + +@dataclass +class NativeTurn: + """The Band turn that started one native turn, and the events addressed to it. + + Codex names the turn only when ``turn/start`` answers, so events that + arrive first wait in ``early`` until ``turn_id`` is known. Sub-agents + the turn delegates to ask under their own thread and turn, and Codex + names their thread only in the turn's own items, so their requests + wait in ``unattributed`` until one does. + """ + + client: CodexClientProtocol + generation: int + thread_id: str | None = None + turn_id: str | None = None + # An exception is the reader's own failure, which ends the turn. + events: asyncio.Queue[RpcEvent | Exception] = dataclass_field( + default_factory=asyncio.Queue + ) + early: list[RpcEvent] = dataclass_field(default_factory=list) + sub_agents: set[str] = dataclass_field(default_factory=set) + unattributed: list[RpcEvent] = dataclass_field(default_factory=list) + # Why the process stopped -- its closing notice or the reader's failure, + # queued behind its requests. + loss: RpcEvent | Exception | None = None + + @property + def lost(self) -> bool: + return self.loss is not None + + def owns(self, event: RpcEvent) -> bool: + # Every event a turn handles names its turn; thread-scoped ones do not. + if _native_turn_id(event) == self.turn_id: + return True + # Only a sub-agent's requests: its own turn/completed must not end this turn. + return event.is_request and _thread_id(event) in self.sub_agents + + def may_own(self, event: RpcEvent) -> bool: + """A request from another thread, which a later collab item may name.""" + return event.is_request and _thread_id(event) not in ( + None, + self.thread_id, + ) + + def deliver(self, event: RpcEvent) -> None: + """Queue an owned event, then the held requests it names a sub-agent for.""" + self.events.put_nowait(event) + if not (receivers := _delegated_threads(event) - self.sub_agents): + return + self.sub_agents |= receivers + held, self.unattributed = self.unattributed, [] + for request in held: + if self.owns(request): + self.events.put_nowait(request) + else: + self.unattributed.append(request) + + def claim(self, event: RpcEvent) -> bool: + """Deliver or hold ``event``; False when this turn can never own it.""" + if self.owns(event): + self.deliver(event) + elif self.may_own(event): + self.unattributed.append(event) + else: + return False + return True + + +def _result_id(result: Any, key: str) -> str: + """The ``id`` of ``result[key]``, or "" when Codex returned none.""" + entry = result.get(key) if isinstance(result, dict) else None + return str(entry.get("id") or "") if isinstance(entry, dict) else "" + + +def _undelivered(native: NativeTurn) -> list[RpcEvent]: + """Events still waiting for the native turn when its Band turn ends.""" + queued = [native.events.get_nowait() for _ in range(native.events.qsize())] + return [ + *native.early, + *(item for item in queued if isinstance(item, RpcEvent)), + *native.unattributed, + ] + + +def _tools_by_name(tools: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {str(tool.get("name")): tool for tool in tools} + + +@dataclass(frozen=True) +class ThreadSettings: + """Settings Codex reports for a thread that ``turn/start`` does not re-send; + a restored thread must report the ones it was started with.""" + + model_provider: str | None = None + cwd: str | None = None + + @classmethod + def reported_by(cls, result: dict[str, Any]) -> ThreadSettings: + provider, cwd = result.get("modelProvider"), result.get("cwd") + return cls( + model_provider=provider if isinstance(provider, str) else None, + cwd=cwd if isinstance(cwd, str) else None, + ) + + def changes_in(self, restored: ThreadSettings) -> list[str]: + return [ + f"{setting.name} {retained!r} became {current!r}" + for setting in fields(self) + if (retained := getattr(self, setting.name)) is not None + and (current := getattr(restored, setting.name)) != retained + ] + + @dataclass class RoomCodexClient: - """The process and protocol state owned by one Band room.""" + """One Band room's Codex process and what its thread was started with. + + ``retained`` owns admission and every physical transition. ``client`` and + ``reader`` are cleared only once the process is confirmed closed; the + room's thread id stays in the adapter's ``_room_threads`` across process + replacement until the room is finally disposed. + """ + room_id: str workspace: str + retained: RetainedRoom = dataclass_field(init=False) client: CodexClientProtocol | None = None - initialized: bool = False - unusable: bool = False - model_override: str | None = None + reader: asyncio.Task[None] | None = None + turn: NativeTurn | None = None selected_model: str | None = None reasoning_effort: str | None = None reasoning_summary: str | None = None - # Serializes this room's turn processing so only one turn/RPC call is in - # flight at a time for this room. A pending manual approval blocks - # further turns in this room only, for up to ``approval_wait_timeout_s`` - # (300s default) -- other rooms are unaffected. Approval resolution - # commands (/approve, /decline) are handled outside this lock in - # ``on_message`` so they can unblock a waiting turn. + # The dynamic tools the thread was started with: resume restores these and + # ignores any others. None when the thread came from Band history. + registration: list[dict[str, Any]] | None = None + settings: ThreadSettings = ThreadSettings() + # The thread was resumed into this process; a turn confirms its tools. + restored: bool = False + # Serializes this room's turns, local commands and their preparation; a + # turn parked on a manual approval blocks only this room. Approval + # replies are handled without it so they can resolve that wait. rpc_lock: asyncio.Lock = dataclass_field(default_factory=asyncio.Lock) + @property + def generation(self) -> int: + """The residency the room's current process belongs to.""" + return self.retained.generation + + def bind_thread( + self, *, registration: list[dict[str, Any]] | None, settings: ThreadSettings + ) -> None: + """Record what the thread this process now runs was started with.""" + self.registration = registration + self.settings = settings + self.restored = False + + def forget_thread(self) -> None: + self.bind_thread(registration=None, settings=ThreadSettings()) + + def require_client(self) -> CodexClientProtocol: + if self.client is None: + raise RuntimeError(f"Room {self.room_id} has no Codex process") + return self.client + + async def stop_reader(self) -> None: + if (reader := self.reader) is None: + return + reader.cancel() + await asyncio.wait({reader}) + self.reader = None + class _WithoutCwdBinding(PydanticBaseSettingsSource): """Drops ``cwd``'s implicit env binding from a settings source. @@ -457,7 +737,11 @@ class CodexAdapterConfig(EnvAdapterConfig): ) transport: TransportKind = "stdio" - retention: RoomRetentionConfig = Field(default_factory=RoomRetentionConfig) + retention: RoomRetentionConfig = Field( + default_factory=lambda: RoomRetentionConfig( + transition_timeout_s=CODEX_TRANSITION_TIMEOUT_S + ) + ) model: str | None = None # Not validated here: the supported efforts vary by model and Codex CLI # version (model/list reports them), and the backend rejects unknown ones. @@ -512,17 +796,15 @@ class CodexAdapterConfig(EnvAdapterConfig): # long-lived room that approves many distinct commands can't grow this # set without bound. max_session_approved_per_room: int = 100 - # Upper bound for the transport client's close() during on_cleanup so - # _rpc_lock can't be held indefinitely if the underlying subprocess or - # socket is unresponsive. ``None`` disables the bound (legacy behavior). + # Upper bound for one attempt at closing the transport client; a close + # that runs over stays owned and the next cleanup retries it. ``None`` + # disables the bound (legacy behavior); ``retention.transition_timeout_s`` + # still bounds how long a caller waits to learn the outcome. client_close_timeout_s: float | None = 10.0 - # Upper bound for letting a released, still-running turn wind down on its - # own during cleanup (see _settle_turn) before it's force-cancelled. - # Deliberately not Optional like client_close_timeout_s above: that - # field's "disable the bound" is a legacy allowance for a rarely-slow - # transport close, not something a turn possibly stuck on real work - # should ever be allowed to defeat. - turn_settle_timeout_s: float = 10.0 + # Also bounds answering a finished turn's leftover native requests. Not + # Optional like client_close_timeout_s: a turn possibly stuck on real work + # must never defeat its bound. + turn_settle_timeout_s: TurnSettleTimeout = TURN_SETTLE_GRACE_S # Matching rules for session-level auto-approval (``/approve-session``). # # ``"full_command"`` (default, more restrictive) — the key is the exact @@ -654,6 +936,11 @@ def __init__( "custom Codex clients cannot guarantee room process isolation" ) self._room_clients: dict[str, RoomCodexClient] = {} + # Final disposal gives a room's turn ``turn_settle_timeout_s`` to wind + # down before cancelling it. + self._retention = RoomRetention( + self.config.retention, settle_grace_s=self.config.turn_settle_timeout_s + ) self._workspaces = RoomWorkspaces(self.config.workspace_for_room) self._active_room: ContextVar[str | None] = ContextVar( "codex_active_room", default=None @@ -663,16 +950,13 @@ def __init__( self._prompt_injected_rooms: set[str] = set() self._room_task_titles: dict[str, OrderedDict[str, str]] = {} self._max_task_titles: int = _MAX_TASK_TITLES + # Keep each registry's minting state across room disposal and restart. self._pending_approvals: dict[str, DecisionRegistry[PendingApproval]] = {} - # Rooms in on_cleanup: their turns decline approvals instead of - # parking on a human while holding the _rpc_lock cleanup needs. - self._closing_rooms: set[str] = set() # A turn runs as a detached task so a manual approval can release # on_message: Band dispatches one message per room at a time, so the # reply that resolves the approval arrives only after on_message returns. self._turn_release: dict[str, asyncio.Future[None]] = {} self._turn_tasks: dict[str, TurnTask] = {} - self._room_cleanup: dict[str, asyncio.Task[None]] = {} self._raw_history_by_room: dict[str, list[dict[str, Any]]] = {} self._needs_history_injection: set[str] = set() # Token usage tracking per thread @@ -686,18 +970,25 @@ def __init__( # Per-room sandbox overrides (set via /sandbox command) self._sandbox_overrides: dict[str, str] = {} - def _release_room_workspace(self, room: RoomCodexClient, room_id: str) -> None: - """Release this room's workspace claim (see ``release_room_workspace``).""" - self._workspaces.release(room_id, room.workspace) + def _room_closing(self, room_id: str) -> bool: + """Whether the room admits no more work; a forgotten room is closed.""" + room = self._room_clients.get(room_id) + return room is None or room.retained.closing def _room_client(self, room_id: str) -> RoomCodexClient: room = self._room_clients.get(room_id) - if room is None: - workspace = self._workspaces.claim(room_id) - room = RoomCodexClient(workspace=workspace) - self._room_clients[room_id] = room - else: - self._workspaces.claim(room_id, room.workspace) + if room is not None: + return room + self._retention.require_admission() + with self._workspaces.claiming(room_id) as workspace: + room = RoomCodexClient(room_id=room_id, workspace=workspace) + room.retained = self._retention.register( + room_id, + acquire=partial(self._acquire, room), + release=partial(self._release, room), + dispose=partial(self._dispose, room), + ) + self._room_clients[room_id] = room return room def _active_client_state(self) -> RoomCodexClient | None: @@ -717,34 +1008,6 @@ def _task_titles_by_id(self) -> OrderedDict[str, str]: raise RuntimeError("Codex task state requires a room context") return self._room_task_titles.setdefault(room_id, OrderedDict()) - @property - def _client(self) -> CodexClientProtocol | None: - state = self._active_client_state() - if state is None: - raise RuntimeError("Codex client requires a room context") - return state.client - - @_client.setter - def _client(self, value: CodexClientProtocol | None) -> None: - state = self._active_client_state() - if state is None: - raise RuntimeError("Codex client requires a room context") - state.client = value - - @property - def _initialized(self) -> bool: - state = self._active_client_state() - if state is None: - raise RuntimeError("Codex client requires a room context") - return state.initialized - - @_initialized.setter - def _initialized(self, value: bool) -> None: - state = self._active_client_state() - if state is None: - raise RuntimeError("Codex client requires a room context") - state.initialized = value - @property def _selected_model(self) -> str | None: state = self._active_client_state() @@ -779,7 +1042,6 @@ def _build_self_config_tools(self) -> list[CustomToolDef]: def _handle_set_model(inp: SetModelInput) -> str: if not adapter._rpc_lock.locked(): raise RuntimeError("_handle_set_model must run under _rpc_lock") - adapter._require_active_client_state().model_override = inp.model adapter._selected_model = inp.model return f"Model changed to {inp.model} for subsequent turns." @@ -815,8 +1077,9 @@ def _handle_set_reasoning(inp: SetReasoningInput) -> str: ] async def on_started(self, agent_name: str, agent_description: str) -> None: - if self._room_clients: + if self._retention.stopping: await self.cleanup_all() + self._retention = self._retention.renewed() await super().on_started(agent_name, agent_description) self._build_system_prompt() self._log_startup_config(agent_name) @@ -874,14 +1137,11 @@ async def try_handle_control( self, msg: PlatformMessage, tools: AgentToolsProtocol ) -> bool: command = self._extract_local_command(msg.content) - if command is None or ( - command[0] not in _APPROVAL_COMMANDS and command[0] != CodexCommand.STATUS - ): + if command is None: return False room_id = msg.room_id - self._room_client(room_id) - self._active_room.set(room_id) - if command is not None and command[0] in _APPROVAL_COMMANDS: + if command[0] in _APPROVAL_COMMANDS: + # Resolves a decision its turn already owns: no room or process. try: handled = await self._handle_approval_command( tools=tools, @@ -894,20 +1154,20 @@ async def try_handle_control( reraise_delivery_cause(e) if handled: tools.turn.settle() - return True - - if command is not None and command[0] == CodexCommand.STATUS: - await self._handle_local_command( - tools=tools, - msg=msg, - history=CodexSessionState(), - room_id=room_id, - command=command[0], - args=command[1], - ) - tools.turn.settle() - return True - return False + return handled + if command[0] != CodexCommand.STATUS: + return False + self._active_room.set(room_id) + await self._handle_local_command( + tools=tools, + msg=msg, + history=CodexSessionState(), + room_id=room_id, + command=command[0], + args=command[1], + ) + tools.turn.settle() + return True async def on_message( self, @@ -921,24 +1181,28 @@ async def on_message( room_id: str, ) -> None: self._admit_turn(room_id, "Codex") - self._room_client(room_id) + room = self._room_client(room_id) self._active_room.set(room_id) command = self._extract_local_command(msg.content) release: asyncio.Future[None] = asyncio.get_running_loop().create_future() - self._turn_release[room_id] = release - turn = asyncio.create_task( - self._run_turn( + turn = start_releasable_turn( + room.retained, + partial( + self._run_turn, + room=room, msg=msg, tools=tools, history=history, participants_msg=participants_msg, contacts_msg=contacts_msg, is_session_bootstrap=is_session_bootstrap, - room_id=room_id, command=command, - ) + ), + tools=tools, + room_id=room_id, ) + self._turn_release[room_id] = release self._turn_tasks[room_id] = bind_turn_task(tools, turn) turn.add_done_callback(lambda task: self._forget_turn(room_id, task)) try: @@ -956,217 +1220,252 @@ async def on_message( async def _run_turn( self, *, + room: RoomCodexClient, msg: PlatformMessage, tools: AgentToolsProtocol, history: CodexSessionState, participants_msg: str | None, contacts_msg: str | None, is_session_bootstrap: bool, - room_id: str, command: tuple[CodexCommand, str] | None, ) -> None: - """Run one turn to completion under this room's RPC lock.""" - async with self._rpc_lock: - thread_id: str | None = None - turn_id: str | None = None + """Run one turn to completion, leasing its room's process throughout.""" + async with room.rpc_lock: try: - await self._ensure_client_ready() - if self._client is None: - raise RuntimeError( - "Codex client not initialized after _ensure_client_ready" - ) - - if command is not None: - handled = await self._handle_local_command( - tools=tools, + if command is not None and await self._run_local_command( + room=room, msg=msg, tools=tools, history=history, command=command + ): + return + async with room.retained.use(): + await self._serve_turn( + room=room, msg=msg, + tools=tools, history=history, - room_id=room_id, - command=command[0], - args=command[1], + participants_msg=participants_msg, + contacts_msg=contacts_msg, + is_session_bootstrap=is_session_bootstrap, ) - if handled: - tools.turn.settle() - return - - thread_id = await self._ensure_thread( - room_id=room_id, - history=history, - tools=tools, - is_session_bootstrap=is_session_bootstrap, + except DeliveryFailedError as e: + reraise_delivery_cause(e) + except (TurnResultAlreadyReported, InputNotAccepted): + # A lease refused by a closing room is no failed turn: the + # host keeps the message. + raise + except (CodexJsonRpcError, CodexContinuityError) as e: + # A structured RPC error from the app-server (e.g. "model not + # available") or a refused restore is safe, curated text -- + # unlike an arbitrary caught exception, worth showing verbatim. + await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(e))) + raise + except TimeoutError as timeout: + # Only waiting on the room's process start or stop raises + # this; an abandoned start stays owned until recovery closes it. + logger.warning( + "Room %s: Codex process not ready", room.room_id, exc_info=timeout ) - - turn_input, has_pending_prompt_injection = self._build_turn_input( - msg=msg, - participants_msg=participants_msg, - contacts_msg=contacts_msg, - room_id=room_id, + await tools.send_failure( + AgentFailure(CODEX_PROVIDER, PROCESS_NOT_READY_ERROR) ) - - turn_params: dict[str, Any] = { - "threadId": thread_id, - "input": turn_input, - } - self._apply_turn_overrides(turn_params, room_id=room_id) - - turn_started = await self._start_turn(turn_params) - if has_pending_prompt_injection: - self._prompt_injected_rooms.add(room_id) - turn = ( - turn_started.get("turn") if isinstance(turn_started, dict) else {} + raise + except Exception: + logger.exception( + "Unexpected error in Codex turn (room=%s)", room.room_id + ) + await tools.send_failure( + AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) - turn_id = str((turn or {}).get("id") or "") + raise - if ( - Emit.TASK_EVENTS in self.features.emit - and self.config.emit_turn_task_markers - ): - await send_event_safe( - tools, - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_room_id": room_id, - }, - log_label="turn started task event", - log_level=logging.DEBUG, - ) + async def _run_local_command( + self, + *, + room: RoomCodexClient, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: CodexSessionState, + command: tuple[CodexCommand, str], + ) -> bool: + """Handle a local command, leasing the room's process only for those + that ask it: the rest must work while it cannot start, as + ``/thread archive`` must after Codex refuses to restore the thread.""" + name, args = command + lease = room.retained.use() if name in _PROCESS_COMMANDS else nullcontext() + async with lease: + handled = await self._handle_local_command( + tools=tools, + msg=msg, + history=history, + room_id=room.room_id, + command=name, + args=args, + ) + if handled: + tools.turn.settle() + return handled - # Phase 2: Turn STARTED lifecycle event with input summary - if ( - self.config.emit_turn_lifecycle_events - and Emit.TASK_EVENTS in self.features.emit - ): - input_summary = (msg.content or "")[:200] - await send_event_safe( - tools, - content=self._build_task_event_content( - task_id=turn_id or None, - task="Codex turn lifecycle", - status="started", - summary=f"Thread: {thread_id}", - ), - message_type="task", - metadata={ - "codex_event_type": "turn_lifecycle", - "codex_room_id": room_id, - "codex_thread_id": thread_id, - "codex_turn_id": turn_id or None, - "codex_turn_status": "started", - "codex_input_summary": input_summary, - }, - log_label="turn started lifecycle event", - log_level=logging.DEBUG, - ) + async def _serve_turn( + self, + *, + room: RoomCodexClient, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: CodexSessionState, + participants_msg: str | None, + contacts_msg: str | None, + is_session_bootstrap: bool, + ) -> None: + room_id = room.room_id + thread_id = await self._ensure_thread( + room=room, + history=history, + tools=tools, + is_session_bootstrap=is_session_bootstrap, + ) + turn_input, has_pending_prompt_injection = self._build_turn_input( + msg=msg, + participants_msg=participants_msg, + contacts_msg=contacts_msg, + room_id=room_id, + ) + turn_params: dict[str, Any] = {"threadId": thread_id, "input": turn_input} + self._apply_turn_overrides(turn_params, room_id=room_id) + + async with self._native_turn(room) as native: + await self._start_native_turn(room, native, turn_params) + if has_pending_prompt_injection: + self._prompt_injected_rooms.add(room_id) + await self._emit_turn_started( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=native.turn_id, + ) - # Reset per-turn token deltas for the new turn. - usage_obj = self._token_usage.get(thread_id) - if usage_obj is not None: - usage_obj.reset_turn_deltas() - - # perf_counter (not monotonic): highest-resolution clock, so a fast - # turn still measures a non-zero duration on Windows, where - # monotonic()'s coarse tick can round an instant turn to 0.0. - _turn_start = _time.perf_counter() - try: - result = await self._process_turn_events( - tools=tools, - msg=msg, - room_id=room_id, - thread_id=thread_id, - turn_id=turn_id or None, - turn_start=_turn_start, - ) - except TurnResultAlreadyReported: - # A nested handler (e.g. the turn-timeout branch) already - # reported this failure via send_failure; propagate it - # without emitting a friendly _emit_turn_outcome reply or - # a generic "Internal error" fallback. - raise - except CodexJsonRpcError as error: - result = TurnResult( - turn_status="failed", - turn_error=str(error), - ) - await self._emit_failed_turn_outcome( - tools=tools, - msg=msg, - room_id=room_id, - thread_id=thread_id, - turn_id=turn_id or None, - result=result, - turn_start=_turn_start, - ) - raise - except Exception: - logger.exception( - "Unexpected error during Codex turn event processing " - "(thread=%s, turn=%s)", - thread_id, - turn_id, - ) - result = TurnResult( - turn_status="failed", - turn_error="Internal error during turn processing", - ) - await tools.send_failure( - AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) - ) - await self._emit_failed_turn_outcome( - tools=tools, - msg=msg, - room_id=room_id, - thread_id=thread_id, - turn_id=turn_id or None, - result=result, - turn_start=_turn_start, - ) - raise TurnResultAlreadyReported( - "Internal error during turn processing" - ) from None + usage_obj = self._token_usage.get(thread_id) + if usage_obj is not None: + usage_obj.reset_turn_deltas() - _turn_duration_s = _time.perf_counter() - _turn_start - await self._emit_turn_outcome( + # perf_counter (not monotonic): highest-resolution clock, so a fast + # turn still measures a non-zero duration on Windows, where + # monotonic()'s coarse tick can round an instant turn to 0.0. + turn_start = _time.perf_counter() + try: + result = await self._process_turn_events( + native=native, tools=tools, msg=msg, room_id=room_id, thread_id=thread_id, - turn_id=turn_id or None, - turn_status=result.turn_status, - turn_error=result.turn_error, - native_messages=result.native_messages, - duration_s=_turn_duration_s, + turn_start=turn_start, ) - # A turn wound down by room cleanup was aborted, not missed. - if room_id not in self._closing_rooms: - await judge_detached_turn(tools, room_id=room_id) - except DeliveryFailedError as e: - reraise_delivery_cause(e) except TurnResultAlreadyReported: + # A nested handler (e.g. the turn-timeout branch) already + # reported this failure via send_failure; propagate it + # without emitting a friendly _emit_turn_outcome reply or + # a generic "Internal error" fallback. raise - except CodexJsonRpcError as e: - # A structured RPC error from the app-server (e.g. "model not - # available") is safe, curated text -- unlike an arbitrary - # caught exception, it's worth showing verbatim. - await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(e))) - raise - except Exception: - logger.exception( - "Unexpected error in Codex on_message (thread=%s, turn=%s)", - thread_id, - turn_id, + except CodexJsonRpcError as error: + await self._emit_failed_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=native.turn_id, + result=TurnResult(turn_status="failed", turn_error=str(error)), + turn_start=turn_start, ) + raise + except Exception as error: + # The event reader already logged the failure that lost its process. + if error is not native.loss: + logger.exception( + "Unexpected error during Codex turn event processing " + "(thread=%s, turn=%s)", + thread_id, + native.turn_id, + ) await tools.send_failure( AgentFailure(CODEX_PROVIDER, GENERIC_PROVIDER_FAILURE_MESSAGE) ) - raise + await self._emit_failed_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=native.turn_id, + result=TurnResult( + turn_status="failed", + turn_error=TURN_PROCESSING_ERROR, + ), + turn_start=turn_start, + ) + raise TurnResultAlreadyReported(TURN_PROCESSING_ERROR) from None + + await self._emit_turn_outcome( + tools=tools, + msg=msg, + room_id=room_id, + thread_id=thread_id, + turn_id=native.turn_id, + turn_status=result.turn_status, + turn_error=result.turn_error, + native_messages=result.native_messages, + duration_s=_time.perf_counter() - turn_start, + ) + await judge_detached_turn(tools, room_id=room_id) + + async def _emit_turn_started( + self, + *, + tools: AgentToolsProtocol, + msg: PlatformMessage, + room_id: str, + thread_id: str, + turn_id: str | None, + ) -> None: + if Emit.TASK_EVENTS not in self.features.emit: + return + if self.config.emit_turn_task_markers: + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex turn", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + "codex_room_id": room_id, + }, + log_label="turn started task event", + log_level=logging.DEBUG, + ) + if self.config.emit_turn_lifecycle_events: + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=turn_id, + task="Codex turn lifecycle", + status="started", + summary=f"Thread: {thread_id}", + ), + message_type="task", + metadata={ + "codex_event_type": "turn_lifecycle", + "codex_room_id": room_id, + "codex_thread_id": thread_id, + "codex_turn_id": turn_id, + "codex_turn_status": "started", + "codex_input_summary": (msg.content or "")[:200], + }, + log_label="turn started lifecycle event", + log_level=logging.DEBUG, + ) def _release_turn(self, room_id: str, tools: AgentToolsProtocol) -> None: """Let on_message return while the room's turn waits on a human; the @@ -1181,30 +1480,6 @@ def _forget_turn(self, room_id: str, task: asyncio.Task[None]) -> None: if owner is not None and owner.task is task: del self._turn_tasks[room_id] - async def _settle_turn(self, room_id: str) -> None: - """Let the room's detached turn wind down, then force-cancel it. - - Once released, the turn is out of reach of the runtime's cancellation - of on_message, and it holds the RPC lock closing the room needs -- - so this bound must always apply, unlike client_close_timeout_s's - legacy "None disables it" allowance for a separate, rarer stall. - """ - turn = self._turn_tasks.get(room_id) - if turn is None: - return - # An already terminated turn keeps its original grace instead. - if turn.deadline is None: - done, _pending = await asyncio.wait( - {turn.task}, timeout=self.config.turn_settle_timeout_s - ) - if not done: - logger.warning( - "Room %s: turn still running after %ss; cancelling it", - room_id, - self.config.turn_settle_timeout_s, - ) - await self._cancel_turn(room_id) - async def _emit_failed_turn_outcome( self, *, @@ -1233,17 +1508,15 @@ async def _emit_failed_turn_outcome( async def _process_turn_events( self, *, + native: NativeTurn, tools: AgentToolsProtocol, msg: PlatformMessage, room_id: str, thread_id: str, - turn_id: str | None, turn_start: float, ) -> TurnResult: - """Consume the Codex event stream for a single turn and return the result.""" - if self._client is None: - raise RuntimeError("CodexAdapter client is None during turn event loop") - + """Consume the events addressed to one native turn and return its result.""" + turn_id = native.turn_id result = TurnResult() failure_reported = False try: @@ -1252,17 +1525,16 @@ async def _process_turn_events( 0.0, self.config.turn_timeout_s - (_time.perf_counter() - turn_start), ) - event = await self._client.recv_event(timeout_s=_remaining) - if event.kind == "request": - await self._handle_server_request( - tools=tools, - msg=msg, - room_id=room_id, - event=event, + event = await asyncio.wait_for(native.events.get(), timeout=_remaining) + if isinstance(event, Exception): + raise event + if event.is_request: + await self._answer_native_request( + native, tools=tools, msg=msg, room_id=room_id, event=event ) continue - params = event.params if isinstance(event.params, dict) else {} + params = _event_params(event) if event.method in { "codex/event/task_started", @@ -1464,18 +1736,9 @@ async def _process_turn_events( ) continue - if event.method == "transport/closed": + if event.method == TRANSPORT_CLOSED: result.turn_status = "failed" - result.turn_error = "Codex transport closed unexpectedly" - logger.warning( - "Room %s: %s (thread=%s turn=%s)", - room_id, - result.turn_error, - thread_id, - turn_id, - ) - # EOF or a failed reader does not prove the child exited. - await self._retire_client(self._require_active_client_state()) + result.turn_error = TRANSPORT_LOST_ERROR # Skipped when an earlier "error" notification in this same # turn already reported one, so one incident isn't posted # twice -- but the turn still fails either way. @@ -1493,9 +1756,6 @@ async def _process_turn_events( if isinstance(params.get("turn"), dict) else {} ) - event_turn_id = str(turn_payload.get("id") or "") - if turn_id and event_turn_id and event_turn_id != turn_id: - continue result.turn_status = str(turn_payload.get("status") or "failed") result.turn_error = self._extract_turn_error(turn_payload) if result.turn_status != "failed": @@ -1532,17 +1792,16 @@ async def _process_turn_events( thread_id, turn_id, ) - if turn_id: - try: - await self._client.request( - CodexRequestMethod.TURN_INTERRUPT, - {"threadId": thread_id, "turnId": turn_id}, - ) - except Exception: - logger.warning( - "Failed to send turn/interrupt after timeout", - exc_info=True, - ) + try: + await native.client.request( + CodexRequestMethod.TURN_INTERRUPT, + {"threadId": thread_id, "turnId": turn_id}, + ) + except Exception: + logger.warning( + "Failed to send turn/interrupt after timeout", + exc_info=True, + ) timeout_message = ( f"Codex turn timed out after {self.config.turn_timeout_s}s" ) @@ -1570,88 +1829,318 @@ async def _process_turn_events( raise TurnResultAlreadyReported(timeout_message) return result + async def _answer_native_request( + self, + native: NativeTurn, + *, + tools: AgentToolsProtocol, + msg: PlatformMessage, + room_id: str, + event: RpcEvent, + ) -> None: + """Answer the turn's request while its process can still read the + answer; a lost process's requests go neither to the room nor to it.""" + if native.lost: + return + try: + await self._handle_server_request( + client=native.client, + tools=tools, + msg=msg, + room_id=room_id, + event=event, + ) + except ConnectionError: + if not native.lost: + raise + async def on_cleanup(self, room_id: str) -> None: + """Leave a room: admission closes at once, its turn gets + ``turn_settle_timeout_s`` to wind down, then its process closes and the + room is forgotten. + + Unfinished or failed cleanup keeps the room registered, refusing work, + with its process, thread and workspace claim for a retry to join. + """ room = self._room_clients.get(room_id) if room is None: return - self._active_room.set(room_id) - # Physical transport closing has its own configured bound. - await self._retire_room( - room_id, - settle=self._settle_turn, - close=partial(self._close_room, room, room_id), - ) + self._clear_pending_approvals_for_room(room_id) + await room.retained.dispose() - async def _close_room(self, room: RoomCodexClient, room_id: str) -> None: - async with self._rpc_lock: - await self._retire_client(room) - self._prompt_injected_rooms.discard(room_id) - self._needs_history_injection.discard(room_id) - self._approval_audit.pop(room_id, None) - self._session_approved.pop(room_id, None) - self._sandbox_overrides.pop(room_id, None) - self._room_task_titles.pop(room_id, None) - self._room_clients.pop(room_id, None) - self._release_room_workspace(room, room_id) - - async def _retire_client(self, room: RoomCodexClient) -> None: - """Retain an unusable client and its claim until cleanup succeeds.""" - room.unusable = True - room.initialized = False - room.selected_model = None - # A replacement process cannot use the retired process's thread. - if (room_id := self._active_room.get()) is not None: - thread_id = self._room_threads.pop(room_id, None) - self._raw_history_by_room.pop(room_id, None) - if thread_id: - self._token_usage.pop(thread_id, None) + async def cleanup_all(self) -> None: + """Stop the residency owner: admission closes at once, every room's + turn gets ``turn_settle_timeout_s`` to wind down, then its process + closes, all reported within one ``retention.transition_timeout_s``. + + Callers join the same stop, and a failed one is retried. An unfinished + or failed stop keeps every room fenced with its process, thread and + workspace. Success stays sealed until ``on_started``. + """ + for room_id in list(self._room_clients): self._clear_pending_approvals_for_room(room_id) - if room.client is not None: - await asyncio.wait_for( - room.client.close(), timeout=self.config.client_close_timeout_s + await self._retention.stop() + + async def _acquire( + self, room: RoomCodexClient, generation: int, resume: bool + ) -> None: + """Start the room's process and strictly restore the thread it retains. + + A crash keeps the thread id and the room's model, so ``resume`` + changes nothing: a retained thread is always restored on its model. + The reader runs before ``thread/resume``, which can start native work. + """ + room.client = client = self._build_client(room.workspace) + await client.connect() + room.reader = spawn_owned( + self._read_native_events(room, client, generation), + label=f"Room {room.room_id} Codex event reader", + ) + await client.initialize( + client_name=self.config.client_name, + client_title=self.config.client_title, + client_version=self.config.client_version, + experimental_api=self.config.experimental_api, + ) + await self._register_skill_roots(client) + if room.selected_model is None: + room.selected_model = await self._select_model(client) + logger.info( + "Room %s: Codex model selected: %s", room.room_id, room.selected_model ) - room.client = None - room.unusable = False + if (thread_id := self._room_threads.get(room.room_id)) is not None: + await self._restore_thread(room, client, thread_id) - async def cleanup_all(self) -> None: - """Attempt every room, retaining failed handles and claims for retry.""" - results = await asyncio.gather( - *(self.on_cleanup(room_id) for room_id in list(self._room_clients)), - return_exceptions=True, + def _resume_params(self, room: RoomCodexClient, thread_id: str) -> dict[str, Any]: + """``thread/resume`` overrides pinning the thread to the room's workspace. + + Without ``cwd`` Codex reports the thread's recorded cwd, which a later + strict restore would see as a changed setting. + """ + params: dict[str, Any] = { + "threadId": thread_id, + "cwd": room.workspace, + "personality": self.config.personality, + } + self._apply_thread_sandbox(params, room_id=room.room_id) + return params + + async def _restore_thread( + self, room: RoomCodexClient, client: CodexClientProtocol, thread_id: str + ) -> None: + """Resume exactly ``thread_id``; refusal keeps it for the next attempt. + + Sends only overrides ``thread/resume`` supports -- never ``path``, + ``history`` or tools, which resume silently ignores -- and never + starts a new thread in its place. + """ + result = await client.request( + CodexRequestMethod.THREAD_RESUME, self._resume_params(room, thread_id) ) - errors: list[Exception] = [] - for result in results: - if isinstance(result, asyncio.CancelledError): - raise result - if isinstance(result, Exception): - errors.append(result) - if errors: - raise ExceptionGroup("Codex session cleanup failed", errors) - - async def _ensure_client_ready(self) -> None: - room = self._require_active_client_state() - if room.unusable: - await self._retire_client(room) - if room.client is None: - room.client = self._build_client(self.config) - client = room.client - if room.initialized: + resumed = _result_id(result, "thread") + if resumed != thread_id: + raise CodexContinuityError( + f"Codex resumed thread {resumed or 'none'!r} instead of {thread_id!r}" + ) + if changes := room.settings.changes_in(ThreadSettings.reported_by(result)): + raise CodexContinuityError( + f"Codex restored thread {thread_id} with changed settings: " + + "; ".join(changes) + ) + room.restored = True + + async def _read_native_events( + self, room: RoomCodexClient, client: CodexClientProtocol, generation: int + ) -> None: + """The process's one event reader, from connect until release. + + An event goes to the Band turn that started its native turn. Work no + Band turn started -- a late item, an autonomous native turn -- is + refused rather than handled with another turn's tools. + """ + while True: + try: + event = await client.recv_event() + except Exception as error: + logger.exception( + "Room %s: Codex event reader failed; recovering generation %s", + room.room_id, + generation, + ) + self._lose_process(room, generation, error) + return + if event.method == TRANSPORT_CLOSED: + logger.warning( + "Room %s: Codex transport closed (%s); recovering generation %s", + room.room_id, + _loss_reason(event), + generation, + ) + self._lose_process(room, generation, event) + return + if ( + unowned := self._route_native_event(room, generation, event) + ) is not None: + await self._refuse_unowned(room, client, unowned) + + @staticmethod + def _route_native_event( + room: RoomCodexClient, generation: int, event: RpcEvent + ) -> RpcEvent | None: + """Hand ``event`` to its Band turn; return it when no turn owns it.""" + native = room.turn + if native is None or native.generation != generation: + return event + if native.turn_id is None: + native.early.append(event) + return None + return None if native.claim(event) else event + + def _lose_process( + self, room: RoomCodexClient, generation: int, cause: RpcEvent | Exception + ) -> None: + """Hand a process whose events stopped to recovery, and end the turn + waiting on them with ``cause``: the transport's closing notice, or the + reader's own failure. Recovery closes the process once that turn unwinds. + """ + room.retained.recover( + generation=generation, + reason=f"Codex events stopped: {_loss_reason(cause)}", + ) + native = room.turn + if native is not None and native.generation == generation: + native.loss = cause + # Approvals the lost process asked for can never be answered. + self._clear_pending_approvals_for_room(room.room_id) + native.events.put_nowait(cause) + + async def _refuse_unowned( + self, room: RoomCodexClient, client: CodexClientProtocol, event: RpcEvent + ) -> None: + """Answer native work no Band turn started, without lending it one's tools.""" + if not event.is_request or event.id is None: + logger.debug( + "Room %s: no Band turn owns Codex %s; dropped", + room.room_id, + event.method, + ) return + logger.warning( + "Room %s: refusing Codex %s from native turn %s, which no Band turn started", + room.room_id, + event.method, + _native_turn_id(event), + ) try: - await client.connect() - await client.initialize( - client_name=self.config.client_name, - client_title=self.config.client_title, - client_version=self.config.client_version, - experimental_api=self.config.experimental_api, - ) - await self._register_skill_roots(client) - room.selected_model = await self._select_model() - logger.info("Codex model selected: %s", room.selected_model) - room.initialized = True - except BaseException: - await self._retire_client(room) - raise + if event.method == DYNAMIC_TOOL_CALL_METHOD: + await client.respond( + event.id, _tool_result(_UNOWNED_TOOL_CALL_TEXT, success=False) + ) + elif event.method in CODEX_APPROVAL_METHODS: + await client.respond(event.id, _approval_response("decline")) + else: + await _refuse_unhandled_request( + client, request_id=event.id, method=event.method + ) + except Exception: + logger.warning( + "Room %s: could not refuse Codex %s", + room.room_id, + event.method, + exc_info=True, + ) + + @asynccontextmanager + async def _native_turn(self, room: RoomCodexClient) -> AsyncIterator[NativeTurn]: + """Bind the native turn this Band turn starts to it until the turn ends. + + Whatever is still undelivered then belongs to no Band turn. + """ + room.turn = native = NativeTurn( + client=room.require_client(), generation=room.generation + ) + try: + yield native + finally: + room.turn = None + await self._refuse_undelivered(room, native) + + async def _refuse_undelivered( + self, room: RoomCodexClient, native: NativeTurn + ) -> None: + """Refuse a finished turn's leftovers, within the turn's settle bound so + a process that stopped reading cannot hold the room's turn open.""" + if native.lost: + # The process can never read a refusal. + return + try: + async with asyncio.timeout(self.config.turn_settle_timeout_s): + for event in _undelivered(native): + await self._refuse_unowned(room, native.client, event) + except TimeoutError: + logger.warning( + "Room %s: Codex stopped reading; left native requests unanswered", + room.room_id, + ) + + async def _start_native_turn( + self, room: RoomCodexClient, native: NativeTurn, params: dict[str, Any] + ) -> None: + """Start the native turn and claim the events that arrived before its id.""" + native.thread_id = params["threadId"] + started = await native.client.request(CodexRequestMethod.TURN_START, params) + turn_id = _result_id(started, "turn") + if not turn_id: + raise RuntimeError("Codex turn/start returned no turn id") + native.turn_id = turn_id + early, native.early = native.early, [] + # Claim every early event before any await, or the reader could + # queue newer ones ahead of them. + refused = [event for event in early if not native.claim(event)] + for event in refused: + await self._refuse_unowned(room, native.client, event) + + async def _release( + self, room: RoomCodexClient, cause: ReleaseCause + ) -> ReleaseOutcome: + """Close the room's process; its thread id stays for the next process.""" + await room.stop_reader() + if room.client is not None: + timeout = self.config.client_close_timeout_s + try: + await asyncio.wait_for(room.client.close(), timeout=timeout) + except TimeoutError as error: + # This becomes the leave's error, so it names what overran. + raise TimeoutError( + f"Codex process for room {room.room_id} did not close " + f"within {timeout}s" + ) from error + room.client = None + room.restored = False + return ReleaseOutcome.RELEASED + + async def _dispose(self, room: RoomCodexClient) -> None: + """Erase what only a final leave ends: thread, settings, workspace claim.""" + room_id = room.room_id + self._forget_room_thread(room_id) + self._clear_pending_approvals_for_room(room_id) + self._approval_audit.pop(room_id, None) + self._session_approved.pop(room_id, None) + self._sandbox_overrides.pop(room_id, None) + self._room_task_titles.pop(room_id, None) + self._workspaces.release(room_id, room.workspace) + if self._room_clients.get(room_id) is room: + del self._room_clients[room_id] + + def _forget_room_thread(self, room_id: str) -> str | None: + """Drop the room's thread mapping and per-thread state; return the thread id.""" + thread_id = self._room_threads.pop(room_id, None) + if thread_id is not None: + self._token_usage.pop(thread_id, None) + self._prompt_injected_rooms.discard(room_id) + self._needs_history_injection.discard(room_id) + self._raw_history_by_room.pop(room_id, None) + return thread_id async def _register_skill_roots(self, client: CodexClientProtocol) -> None: roots = self.config.skill_roots @@ -1664,27 +2153,19 @@ async def _register_skill_roots(self, client: CodexClientProtocol) -> None: except CodexJsonRpcError as exc: raise RuntimeError(f"Codex rejected skill_roots {roots}: {exc}") from exc - def _build_client(self, config: CodexAdapterConfig) -> CodexClientProtocol: - state = self._active_client_state() - if state is None: - raise RuntimeError("Codex client creation requires a room context") + def _build_client(self, workspace: str) -> CodexClientProtocol: return CodexStdioClient( - command=config.codex_command, - cwd=state.workspace, - env=config.codex_env, + command=self.config.codex_command, + cwd=workspace, + env=self.config.codex_env, ) - async def _select_model(self) -> str: - state = self._require_active_client_state() - if state.model_override: - return state.model_override + async def _select_model(self, client: CodexClientProtocol) -> str: if self.config.model: return self.config.model - if self._client is None: - raise RuntimeError("Codex client not initialized") try: - result = await self._client.request(CodexRequestMethod.MODEL_LIST, {}) + result = await client.request(CodexRequestMethod.MODEL_LIST, {}) except Exception: logger.warning( "model/list failed; using default Codex model", @@ -1701,83 +2182,127 @@ async def _select_model(self) -> str: async def _ensure_thread( self, *, - room_id: str, + room: RoomCodexClient, history: CodexSessionState, tools: AgentToolsProtocol, is_session_bootstrap: bool, ) -> str: - thread_id = self._room_threads.get(room_id) - if thread_id: + """The room's thread: the retained one, one Band history names, or new.""" + room_id = room.room_id + if (thread_id := self._room_threads.get(room_id)) is not None: + if room.restored: + self._confirm_restored_tools(room, tools, thread_id) return thread_id + client = room.require_client() + if is_session_bootstrap and (named := history.thread_id): + resumed = await self._resume_from_history( + room=room, client=client, named=named, tools=tools + ) + if resumed is not None: + return resumed + else: + self._raw_history_by_room.pop(room_id, None) + return await self._start_thread(room=room, client=client, tools=tools) - if self._client is None: - raise RuntimeError("Codex client not initialized") + def _confirm_restored_tools( + self, room: RoomCodexClient, tools: AgentToolsProtocol, thread_id: str + ) -> None: + """Refuse a turn whose tools differ from the restored thread's. - if is_session_bootstrap and history.has_thread(): - try: - result = await self._client.request( - CodexRequestMethod.THREAD_RESUME, - { - "threadId": history.thread_id, - "personality": self.config.personality, - }, - ) - resumed = result.get("thread", {}) if isinstance(result, dict) else {} - thread_id = str(resumed.get("id") or history.thread_id or "") - if thread_id: - self._room_threads[room_id] = thread_id - self._raw_history_by_room.pop(room_id, None) - if Emit.TASK_EVENTS in self.features.emit: - await send_event_safe( - tools, - content=self._build_task_event_content( - task_id=thread_id, - task="Codex thread", - status="resumed", - summary=f"Room: {room_id}", - ), - message_type="task", - metadata={ - "codex_thread_id": thread_id, - "codex_room_id": room_id, - "codex_resumed": True, - }, - log_label="thread resumed task event", - log_level=logging.DEBUG, - ) - return thread_id - except CodexJsonRpcError as exc: - logger.warning( - "thread/resume failed for room %s thread %s: %s", - room_id, - history.thread_id, - exc, + ``thread/resume`` restores the tools a thread was started with and + ignores any others, so new ones would silently never reach the model. + """ + if room.registration is not None: + offered = _tools_by_name(self._build_dynamic_tools(tools)) + registered = _tools_by_name(room.registration) + if offered != registered: + changed = sorted(offered.keys() ^ registered.keys()) + raise CodexContinuityError( + f"Codex thread {thread_id} keeps the tools it was started " + f"with; this turn's tools differ ({', '.join(changed) or 'schemas'}). " + f"Send `/{CodexCommand.THREAD} {CodexSubcommand.ARCHIVE}` " + "to start a new thread." ) - if self.config.inject_history_on_resume_failure: - self._needs_history_injection.add(room_id) - else: - # Not a bootstrap resume — clean up any stashed history - self._raw_history_by_room.pop(room_id, None) + room.restored = False + + async def _resume_from_history( + self, + *, + room: RoomCodexClient, + client: CodexClientProtocol, + named: str, + tools: AgentToolsProtocol, + ) -> str | None: + """Resume the thread Band history names; None falls back to a new one.""" + room_id = room.room_id + try: + result = await client.request( + CodexRequestMethod.THREAD_RESUME, + self._resume_params(room, named), + ) + except CodexJsonRpcError as exc: + logger.warning( + "thread/resume failed for room %s thread %s: %s", + room_id, + named, + exc, + ) + if self.config.inject_history_on_resume_failure: + self._needs_history_injection.add(room_id) + return None + thread_id = _result_id(result, "thread") or named + self._room_threads[room_id] = thread_id + # Band history does not record which tools the thread was started with. + room.bind_thread(registration=None, settings=ThreadSettings.reported_by(result)) + self._raw_history_by_room.pop(room_id, None) + if Emit.TASK_EVENTS in self.features.emit: + await send_event_safe( + tools, + content=self._build_task_event_content( + task_id=thread_id, + task="Codex thread", + status="resumed", + summary=f"Room: {room_id}", + ), + message_type="task", + metadata={ + "codex_thread_id": thread_id, + "codex_room_id": room_id, + "codex_resumed": True, + }, + log_label="thread resumed task event", + log_level=logging.DEBUG, + ) + return thread_id + async def _start_thread( + self, + *, + room: RoomCodexClient, + client: CodexClientProtocol, + tools: AgentToolsProtocol, + ) -> str: + room_id = room.room_id dynamic_tools = self._build_dynamic_tools(tools) start_params: dict[str, Any] = { - "model": self._selected_model, - "cwd": self._room_client(room_id).workspace, + "model": room.selected_model, + "cwd": room.workspace, "approvalPolicy": self.config.approval_policy, "personality": self.config.personality, "dynamicTools": dynamic_tools, } self._apply_thread_sandbox(start_params, room_id=room_id) - started = await self._client.request( - CodexRequestMethod.THREAD_START, start_params - ) - thread = started.get("thread") if isinstance(started, dict) else {} - thread_id = str((thread or {}).get("id") or "") + started = await client.request(CodexRequestMethod.THREAD_START, start_params) + thread_id = _result_id(started, "thread") if not thread_id: raise RuntimeError("Codex thread/start returned no thread id") + # Recorded at once: a process lost from here on restores this thread. self._room_threads[room_id] = thread_id + room.bind_thread( + registration=dynamic_tools, settings=ThreadSettings.reported_by(started) + ) if Emit.TASK_EVENTS in self.features.emit: await send_event_safe( @@ -1931,34 +2456,29 @@ def _format_history_context(self, raw: list[dict[str, Any]]) -> str | None: async def _handle_server_request( self, *, + client: CodexClientProtocol, tools: AgentToolsProtocol, msg: PlatformMessage, room_id: str, event: RpcEvent, ) -> None: - """Dispatch a server-initiated request (tool call, approval). - - Concurrency model: this coroutine mutates room-scoped adapter state - (``_pending_approvals``, ``_session_approved``, ``_approval_audit``, - ``_task_titles_by_id``) without an explicit lock. It is safe - because the only call site is the turn-processing loop in - ``_process_turn_events``, which already serializes this room's turns - via its room's ``_rpc_lock`` -- and every structure it touches is + """Dispatch a request the Band turn's native turn sent (tool call, approval). + + ``client`` is the process the request came from; the answer goes back + there. Concurrency model: this coroutine mutates room-scoped adapter + state (``_pending_approvals``, ``_session_approved``, + ``_approval_audit``, ``_task_titles_by_id``) without an explicit lock. + It is safe because it runs only inside a turn, which holds its room's + ``rpc_lock`` -- and every structure it touches is keyed (or routed via ``_active_room``) per room, so a concurrent turn in a different room never touches the same state. - - Because asyncio runs one coroutine at a time, every synchronous - span inside this method is atomic. If a new caller is ever added - from a different thread, this routine must be re-audited. """ - if self._client is None: - raise RuntimeError("CodexAdapter client is None — was on_started() called?") if event.id is None: return - params = event.params if isinstance(event.params, dict) else {} + params = _event_params(event) - if event.method == "item/tool/call": + if event.method == DYNAMIC_TOOL_CALL_METHOD: tool_name = str(params.get("tool") or "") arguments = params.get("arguments") if not isinstance(arguments, dict): @@ -2011,13 +2531,9 @@ async def _handle_server_request( if isinstance(result, str) else json.dumps(result, default=str) ) - content_items = [{"type": "inputText", "text": text_result}] - await self._client.respond( - event.id, - { - "contentItems": content_items, - "success": success, - }, + content_items = [_text_item(text_result)] + await client.respond( + event.id, _tool_response(content_items, success=success) ) if should_report: await tools.send_event( @@ -2034,13 +2550,7 @@ async def _handle_server_request( errors = format_validation_error(exc) error_text = f"Invalid arguments for {tool_name}: {errors}" logger.error("Validation error for tool %s: %s", tool_name, exc) - await self._client.respond( - event.id, - { - "contentItems": [{"type": "inputText", "text": error_text}], - "success": False, - }, - ) + await client.respond(event.id, _tool_result(error_text, success=False)) if should_report: await tools.send_event( content=json.dumps( @@ -2055,13 +2565,7 @@ async def _handle_server_request( except Exception as exc: error_text = f"Error: {exc}" logger.exception("Tool execution failed for %s", tool_name) - await self._client.respond( - event.id, - { - "contentItems": [{"type": "inputText", "text": error_text}], - "success": False, - }, - ) + await client.respond(event.id, _tool_result(error_text, success=False)) if should_report: await tools.send_event( content=json.dumps( @@ -2078,6 +2582,7 @@ async def _handle_server_request( if event.method in CODEX_APPROVAL_METHODS: await self._handle_approval_request( + client=client, tools=tools, msg=msg, room_id=room_id, @@ -2086,15 +2591,14 @@ async def _handle_server_request( ) return - await self._client.respond_error( - event.id, - code=-32601, - message=f"Unhandled server request: {event.method}", + await _refuse_unhandled_request( + client, request_id=event.id, method=event.method ) async def _handle_approval_request( self, *, + client: CodexClientProtocol, tools: AgentToolsProtocol, msg: PlatformMessage, room_id: str, @@ -2106,8 +2610,6 @@ async def _handle_approval_request( Splits off from :meth:`_handle_server_request` for readability. Same concurrency guarantees apply — see the docstring there. """ - if self._client is None: - raise RuntimeError("CodexAdapter client is None — was on_started() called?") if event.id is None: return @@ -2121,7 +2623,7 @@ async def _handle_approval_request( if session_hit: decision: ApprovalDecision = "acceptForSession" decided_by = "session_policy" - elif room_id in self._closing_rooms: + elif self._room_closing(room_id): decision = "decline" decided_by = "room_cleanup" elif self.config.approval_mode == "manual": @@ -2147,7 +2649,7 @@ async def _handle_approval_request( ) decided_by = f"policy:{self.config.approval_mode}" - await self._client.respond(event.id, {"decision": decision}) + await client.respond(event.id, _approval_response(decision)) audit_entry = self._record_approval_audit( room_id=room_id, @@ -2449,7 +2951,7 @@ async def _emit_item_event( ) return - if item_type in _REQUESTED_TOOL_ITEM_TYPES or item_type in _MESSAGE_ITEM_TYPES: + if item_type in _UNRENDERED_ITEM_TYPES: return logger.debug("Unhandled item/completed type: %s", item_type) @@ -2645,7 +3147,6 @@ async def _resolve_manual_approval( mention = self._sender_mention(msg) if event.id is None: raise RuntimeError("approval request must have an id") - token = self._approval_token(event.id, params) loop = asyncio.get_running_loop() pending = PendingApproval( request_id=event.id, @@ -2654,20 +3155,22 @@ async def _resolve_manual_approval( created_at=datetime.now(UTC), future=loop.create_future(), session_key=self._session_approval_key(event.method, params), + native_key=self._native_approval_key(event.id, params), ) registry = self._pending_approvals.setdefault( room_id, DecisionRegistry(max_pending=self.config.max_pending_approvals_per_room), ) - if (registration := registry.register_keyed(pending, key=token)) is None: + if (registration := self._register_approval(registry, pending)) is None: logger.warning( "Approval %s in room %s was redelivered after a reply claimed it; " "declining the redelivery", - token, + pending.native_key, room_id, ) return "decline" entry = registration.entry + token = entry.token for removed in registration.removed: removed.payload.future.set_result("decline") if (evicted := registration.evicted) is not None: @@ -3144,10 +3647,12 @@ async def _handle_local_command( usage.format_summary() if usage and usage.total_tokens > 0 else "none" ) session_approvals = len(self._session_approved.get(room_id) or ()) + room = self._active_client_state() + selected_model = room.selected_model if room is not None else None status_text = ( "Codex status:\n" f"- transport: {self.config.transport}\n" - f"- selected_model: {self._selected_model or 'unknown'}\n" + f"- selected_model: {selected_model or 'unknown'}\n" f"- configured_model: {self.config.model or 'auto'}\n" f"- room_id: {room_id}\n" f"- thread_id: {mapped_thread or 'not mapped'}\n" @@ -3179,9 +3684,8 @@ async def _handle_local_command( return True if model_arg.lower() in _MODEL_LIST_WORDS: - if self._client is None: - raise RuntimeError("Codex client not initialized") - result = await self._client.request(CodexRequestMethod.MODEL_LIST, {}) + client = self._require_active_client_state().require_client() + result = await client.request(CodexRequestMethod.MODEL_LIST, {}) models = self._visible_model_ids(result) if models: preview = ", ".join(models[:10]) @@ -3200,9 +3704,7 @@ async def _handle_local_command( ) return True - # Model selection belongs to the room's process, never adapter-wide config. - state = self._require_active_client_state() - state.model_override = model_arg + # The room's choice outlives its process; never adapter-wide config. self._selected_model = model_arg await deliver_notice( tools, @@ -3374,11 +3876,8 @@ async def _handle_local_command( return True if subcommand == CodexSubcommand.ARCHIVE: - mapped_thread = self._room_threads.pop(room_id, None) - self._prompt_injected_rooms.discard(room_id) - self._token_usage.pop(mapped_thread or "", None) - self._raw_history_by_room.pop(room_id, None) - self._needs_history_injection.discard(room_id) + mapped_thread = self._forget_room_thread(room_id) + self._require_active_client_state().forget_thread() await deliver_notice( tools, f"Thread `{mapped_thread or 'none'}` archived. " @@ -3554,11 +4053,6 @@ def _apply_turn_overrides( params["summary"] = state.reasoning_summary or self.config.reasoning_summary self._apply_turn_sandbox(params, room_id=room_id) - async def _start_turn(self, params: dict[str, Any]) -> dict[str, Any]: - if self._client is None: - raise RuntimeError("CodexAdapter client is None — was on_started() called?") - return await self._client.request(CodexRequestMethod.TURN_START, params) - def _apply_thread_sandbox( self, params: dict[str, Any], *, room_id: str | None = None ) -> None: @@ -3865,13 +4359,22 @@ def _extract_local_command(content: str) -> tuple[CodexCommand, str] | None: return CodexCommand(word), parts[1].strip() if len(parts) > 1 else "" @staticmethod - def _approval_token(request_id: int | str, params: dict[str, Any]) -> str: + def _native_approval_key(request_id: int | str, params: dict[str, Any]) -> str: for key in ("approvalId", "approval_id", "itemId", "callId"): value = params.get(key) if isinstance(value, str) and value: return value return f"req-{request_id}" + @staticmethod + def _register_approval( + registry: DecisionRegistry[PendingApproval], pending: PendingApproval + ) -> Registration[PendingApproval] | None: + for token, existing in registry.items(): + if existing.native_key == pending.native_key: + return registry.register_keyed(pending, key=token) + return registry.register_minted(pending) + def _open_approval_count(self, room_id: str) -> int: registry = self._pending_approvals.get(room_id) return registry.unclaimed_count() if registry else 0 @@ -3883,11 +4386,9 @@ def _clear_pending_approval( if registry is None: return registry.forget(entry) - if len(registry) == 0: - self._pending_approvals.pop(room_id, None) def _clear_pending_approvals_for_room(self, room_id: str) -> None: - registry = self._pending_approvals.pop(room_id, None) + registry = self._pending_approvals.get(room_id) if registry is None: return for entry in registry.cancel_all(): @@ -3931,11 +4432,11 @@ def _supported_efforts(result: dict[str, Any], model_id: str) -> list[str]: async def _current_model_efforts(self) -> tuple[str, list[str]]: """The room's model and the efforts Codex reports for it (empty if unknown).""" - if self._client is None: - raise RuntimeError("Codex client not initialized") - model_id = self._selected_model or await self._select_model() + room = self._require_active_client_state() + client = room.require_client() + model_id = room.selected_model or await self._select_model(client) try: - result = await self._client.request(CodexRequestMethod.MODEL_LIST, {}) + result = await client.request(CodexRequestMethod.MODEL_LIST, {}) except Exception: logger.warning( "model/list failed; supported reasoning efforts are unknown", diff --git a/src/band/adapters/cursor_acp.py b/src/band/adapters/cursor_acp.py index 41a55195d..bcf3e249a 100644 --- a/src/band/adapters/cursor_acp.py +++ b/src/band/adapters/cursor_acp.py @@ -4,7 +4,10 @@ import asyncio import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import dataclass, field +from functools import partial from typing import ClassVar, Literal, Self from band_sdk_core import is_authorized_sender @@ -12,8 +15,14 @@ from typing_extensions import Unpack from band.client.streaming import ControlMode -from band.core.protocols import AgentToolsProtocol +from band.core.exceptions import InputNotAccepted +from band.core.protocols import ( + TURN_FAILURE_PROVIDER, + AgentToolsProtocol, + generic_provider_failure, +) from band.core.turn import judge_detached_turn +from band.core.turn_lifecycle import start_releasable_turn from band.core.types import ApprovalMode, FeatureKwargs, PlatformMessage from band.integrations.acp.client_adapter import ( ACPClientAdapter, @@ -28,7 +37,7 @@ parse_cursor_questions, ) from band.integrations.acp.client_runtime import ACPRuntime -from band.integrations.acp.client_types import ACPClientSessionState +from band.integrations.acp.client_types import ACPClientSessionState, ACPRoom from band.integrations.acp.cursor import ( CURSOR_CLI_BINARY, DECISION_NOT_PENDING_TEMPLATE, @@ -95,8 +104,8 @@ class CursorACPAdapterConfig(ACPClientAdapterConfig): ``turn_timeout_s``. turn_timeout_s: Turn deadline, with headroom above ``decision_timeout_s``. - max_pending_decisions: Open manual decisions kept; the oldest is - dropped when a new one would exceed it. + max_pending_decisions: Open manual decisions kept per room; the + room's oldest is dropped when a new one would exceed it. decision_authorized_senders: Sender ids allowed to resolve decisions. ``None`` admits any room participant. """ @@ -129,12 +138,18 @@ def _check_auth_and_decision_deadline(self) -> Self: @dataclass class CursorTurn: - """The room context for the one Cursor extension-capable prompt.""" + """One room message's prompt, from acceptance until its actual end. - room_id: str + ``generation`` is the residency it prompts on, set when it takes the + room; only the room's holder is extension-capable. + """ + + room: ACPRoom tools: AgentToolsProtocol requester_id: str | None release: asyncio.Future[None] + task: asyncio.Task[None] | None = None + generation: int | None = None session_id: str | None = None closed: bool = False # Cursor sends parallel tool calls' permission requests together and does @@ -155,6 +170,33 @@ class PendingDecision: multi_select: frozenset[str] = frozenset() +def _close_turn(turn: CursorTurn) -> None: + turn.closed = True + turn.session_id = None + + +def _cancel(turn: CursorTurn) -> None: + _close_turn(turn) + if turn.task is not None: + turn.task.cancel() + + +async def _report_unreported_failure( + tools: AgentToolsProtocol, error: Exception +) -> None: + """Report a detached turn's failure that nothing reported yet, as the + runtime reports a failed attached turn.""" + if tools.turn.detached and not tools.turn.complete: + await tools.send_failure(generic_provider_failure(TURN_FAILURE_PROVIDER, error)) + + +def _detach(turn: CursorTurn) -> None: + """Hand the room's message queue back while the turn keeps running.""" + if not turn.release.done(): + turn.tools.turn.detach() + turn.release.set_result(None) + + class CursorACPAdapter(ACPClientAdapter[CursorACPAdapterConfig]): """Band adapter for Cursor's native ACP stdio server.""" @@ -177,17 +219,17 @@ def __init__( resolve_session_config: Picks session config options. """ config = config or CursorACPAdapterConfig() - self._cursor_profile = CursorACPClientProfile(self._resolve_extension_method) - self._turn_lock = asyncio.Lock() - self._active_turn: CursorTurn | None = None - self._pending_decisions: DecisionRegistry[PendingDecision] = DecisionRegistry( - max_pending=config.max_pending_decisions - ) + # Each room's one accepted turn, from acceptance until it actually + # ends; independent rooms prompt concurrently. + self._turns: dict[str, CursorTurn] = {} + # The turn holding each room's lease, set and cleared inside that lease. + self._holders: dict[str, CursorTurn] = {} + # One capped registry per room, so no room evicts another's decision. + self._pending_decisions: dict[str, DecisionRegistry[PendingDecision]] = {} super().__init__( config, additional_tools=additional_tools, workspace_for_room=workspace_for_room, - profile=self._cursor_profile, resolve_session_config=resolve_session_config, resolve_permission=self._resolve_cursor_permission, **features, @@ -214,57 +256,78 @@ async def on_message( is_session_bootstrap: bool, room_id: str, ) -> None: - if await self._handle_control_message(msg, tools, room_id): - tools.turn.settle() + if await self.try_handle_control(msg, tools): return - - # ext_method routing needs _turn_lock held for the whole turn (only - # one room's session_id is bound on the shared _cursor_profile at a - # time), but a decision reply for THIS room must never queue behind - # it -- _handle_control_message above already bypasses the lock - # unconditionally. So the turn body runs detached: on_message - # returns the moment a decision opens (or immediately, if none ever - # does), releasing the room's message queue for that reply while the - # lock, and the turn, keep going in _run_turn. - await self._turn_lock.acquire() - release: asyncio.Future[None] = asyncio.get_running_loop().create_future() + # A message never waits behind the room's turn: that turn may be parked + # on a decision whose reply arrives after it, so the host keeps the + # message and retries it once the room is free. + if room_id in self._turns: + raise InputNotAccepted("The room already has an active Cursor turn") + + # The turn runs detached for the same reason: on_message returns the + # moment a decision opens, while the task keeps the room's reservation + # and lease through the turn's actual end. + room = self._room(room_id) + loop = asyncio.get_running_loop() + release: asyncio.Future[None] = loop.create_future() turn = CursorTurn( - room_id=room_id, + room=room, tools=tools, requester_id=msg.sender_id, release=release, ) - self._active_turn = turn - turn_task = asyncio.create_task( - self._run_turn( + turn_task = turn.task = start_releasable_turn( + room.retained, + partial( + self._run_turn, turn, msg, - tools, history, participants_msg, contacts_msg, is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) - ) - self._background_tasks.add(turn_task) - turn_task.add_done_callback(lambda task: self._on_turn_task_done(task, room_id)) - done, _ = await asyncio.wait( - {release, turn_task}, return_when=asyncio.FIRST_COMPLETED + ), + tools=tools, + room_id=room_id, ) + # Registered before it runs, so an interrupt during startup reaches it. + self._accept(turn) + turn_task.add_done_callback(partial(self._on_turn_task_done, turn=turn)) + try: + done, _ = await asyncio.wait( + {release, turn_task}, return_when=asyncio.FIRST_COMPLETED + ) + except asyncio.CancelledError: + # Until it detaches, the turn belongs to this delivery and ends + # with it, as an inline OMP or Copilot turn does. + _cancel(turn) + await asyncio.wait({turn_task}) + raise if turn_task in done: - # No decision ever opened -- surface completion/failure exactly - # as before this turn body ran detached. + # No decision ever opened: completion or failure is this turn's. await turn_task - def _on_turn_task_done(self, task: asyncio.Task[None], room_id: str) -> None: - """Like the inherited generic background-task sink, but room-tagged. + async def try_handle_control( + self, msg: PlatformMessage, tools: AgentToolsProtocol + ) -> bool: + """Resolve or list the room's Cursor decisions without starting work.""" + if not await self._handle_control_message(msg, tools, msg.room_id): + return False + tools.turn.settle() + return True - A turn that's still running when its room cleans up (see - ``on_cleanup``) keeps going detached; if it then fails, the generic - sink's log line carries no room/session context to debug from. - """ - self._background_tasks.discard(task) + def _accept(self, turn: CursorTurn) -> None: + """Make ``turn`` its room's one accepted turn.""" + self._turns[turn.room.room_id] = turn + + def _forget_turn(self, turn: CursorTurn) -> None: + if self._turns.get(turn.room.room_id) is turn: + del self._turns[turn.room.room_id] + + def _on_turn_task_done(self, task: asyncio.Task[None], *, turn: CursorTurn) -> None: + """Forget a turn that never ran and log a failure with its room.""" + self._forget_turn(turn) + room_id = turn.room.room_id if task.cancelled(): return error = task.exception() @@ -279,93 +342,123 @@ async def _run_turn( self, turn: CursorTurn, msg: PlatformMessage, - tools: AgentToolsProtocol, history: ACPClientSessionState, participants_msg: str | None, contacts_msg: str | None, *, is_session_bootstrap: bool, - room_id: str, ) -> None: + room = turn.room try: - await super().on_message( - msg, - tools, - history, - participants_msg, - contacts_msg, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) - # A failed or cancelled turn never gets here. - await judge_detached_turn(tools, room_id=room_id) + async with ( + self._using_room(room, turn.tools) as runtime, + self._holding(turn), + ): + await self._prompt_room( + room, + runtime, + msg, + turn.tools, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + ) + # A failed or cancelled turn never gets here. + await judge_detached_turn(turn.tools, room_id=room.room_id) finally: - self._cursor_profile.bind_session(None) - if self._active_turn is turn: - self._active_turn = None - self._cancel_room_decisions(room_id) + _close_turn(turn) + self._forget_turn(turn) if not turn.release.done(): turn.release.set_result(None) - self._turn_lock.release() async def _get_or_create_session( self, + room: ACPRoom, runtime: ACPRuntime, - room_id: str, history: ACPClientSessionState | None, - ) -> tuple[str, bool]: - session_id, created = await super()._get_or_create_session( - runtime, room_id, history - ) - turn = self._active_turn - if turn is not None and turn.room_id == room_id: + ) -> str: + session_id = await super()._get_or_create_session(room, runtime, history) + turn = self._holders.get(room.room_id) + if turn is not None and turn.room is room: if turn.closed: raise asyncio.CancelledError turn.session_id = session_id - self._cursor_profile.bind_session(session_id) - return session_id, created + return session_id + + def _client_profile(self, room: ACPRoom, generation: int) -> CursorACPClientProfile: + """A profile bound to one residency: Cursor's extension requests carry + no session id, so their origin is the process that sent them.""" + return CursorACPClientProfile( + partial(self._resolve_extension_method, room, generation), + active_session=partial(self._extension_session, room, generation), + ) - async def on_cleanup( - self, room_id: str, *, expected_runtime: ACPRuntime | None = None - ) -> None: - if expected_runtime is not None: - async with self._session_lock: - if self._runtimes.get(room_id) is not expected_runtime: - return - # Wakes any decision _run_turn is parked on; the task itself keeps - # running detached and winds down on its own (via _on_background_task_done) - # once the runtime this stops out from under it closes the connection. - turn = self._active_turn - if turn is not None and turn.room_id == room_id: - turn.closed = True - turn.session_id = None - self._cursor_profile.bind_session(None) - self._cancel_room_decisions(room_id) - await super().on_cleanup(room_id, expected_runtime=expected_runtime) + @asynccontextmanager + async def _holding(self, turn: CursorTurn) -> AsyncIterator[None]: + """Make ``turn`` its room's holder for exactly its lease. + + Its failure is reported before the lease ends, so the next turn + never takes the room while this one still answers for it. + """ + room_id = turn.room.room_id + self._take_room(turn) + try: + yield + except Exception as error: + await _report_unreported_failure(turn.tools, error) + raise + finally: + self._cancel_room_decisions(room_id) + del self._holders[room_id] + + def _take_room(self, turn: CursorTurn) -> None: + """Make ``turn`` the holder of its room's current residency.""" + turn.generation = turn.room.generation + self._holders[turn.room.room_id] = turn + + def _turn_of(self, room: ACPRoom, generation: int) -> CursorTurn | None: + """The open prompt of exactly this room holder and of a residency + recovery has not retired.""" + turn = self._holders.get(room.room_id) + if ( + turn is None + or turn.room is not room + or turn.generation != generation + or not room.retained.has_authority(generation) + or turn.closed + ): + return None + return turn + + def _extension_session(self, room: ACPRoom, generation: int) -> str | None: + turn = self._turn_of(room, generation) + return None if turn is None else turn.session_id - def _forget_session(self, session_id: str) -> None: - self._cursor_profile.forget_session(session_id) + async def on_cleanup(self, room_id: str) -> None: + # Wakes any decision the turn is parked on; disposal then settles the + # turn before the room's process closes. + if (turn := self._turns.get(room_id)) is not None: + _close_turn(turn) + self._cancel_room_decisions(room_id) + await super().on_cleanup(room_id) async def on_interrupt(self, room_id: str, mode: ControlMode) -> None: - """A room /stop or interrupt must also reach a turn parked on a - decision -- the runtime's own interrupt only cancels the task that - already returned once the decision prompt was posted. Matches - on_cleanup's choice not to hard-cancel the turn itself: waking its - pending decision is enough for it to wind down on its own. The - interrupt ends that turn, so it is settled and never reported as a - missing reply.""" - turn = self._active_turn - if turn is not None and turn.room_id == room_id: - turn.closed = True - turn.session_id = None - self._cursor_profile.bind_session(None) + """End the room's accepted turn, starting, prompting or parked. + + The runtime's own interrupt cancels only the handler task, which a + detached turn has already left. The turn is settled, so it is never + reported as a missing reply, and cancelled; a cancelled prompt sends + ``session/cancel``. + """ + if (turn := self._turns.get(room_id)) is not None: turn.tools.turn.settle() + _cancel(turn) self._cancel_room_decisions(room_id) - async def cleanup_all(self, *, final: bool = True) -> None: + async def cleanup_all(self) -> None: self._cancel_all_decisions() - self._cursor_profile.clear_sessions() - await super().cleanup_all(final=final) + await super().cleanup_all() async def _resolve_cursor_permission( self, request: ACPPermissionRequest @@ -422,9 +515,13 @@ async def _ask_room_permission( return result async def _resolve_extension_method( - self, method: str, params: dict[str, object] + self, + room: ACPRoom, + generation: int, + method: str, + params: dict[str, object], ) -> dict[str, object]: - turn = self._active_turn + turn = self._turn_of(room, generation) if turn is None or turn.session_id is None: return {"outcome": {"outcome": "cancelled"}} match method: @@ -518,14 +615,13 @@ async def _wait_for_decision( future: asyncio.Future[object] = asyncio.get_running_loop().create_future() pending = PendingDecision( kind=kind, - room_id=turn.room_id, + room_id=turn.room.room_id, future=future, choices=choices or {}, multi_select=multi_select, ) - registration = self._pending_decisions.register_minted( - pending, room_id=turn.room_id - ) + decisions = self._room_decisions(turn.room.room_id) + registration = decisions.register_minted(pending) if registration.evicted is not None: self._resolve_evicted_decision(registration.evicted) entry = registration.entry @@ -537,17 +633,15 @@ async def _wait_for_decision( except Exception: # noqa: BLE001 -- best-effort room notify; any failure (network, REST, unresolved mention) should not block the decision wait below logger.warning("Could not deliver Cursor %s decision prompt", kind) # A reply that claimed it meanwhile owns the answer; wait for it. - if self._pending_decisions.withdraw(entry): + if decisions.withdraw(entry): return None finally: # Release on_message here, not only in _run_turn's finally -- # the room needs its queue back the instant a decision is # outstanding, whether or not the prompt itself landed. - if not turn.release.done(): - turn.tools.turn.detach() - turn.release.set_result(None) + _detach(turn) - result = await self._pending_decisions.wait( + result = await decisions.wait( entry, future, timeout_s=self.config.decision_timeout_s ) if result is Timeout.TIMED_OUT: @@ -585,8 +679,9 @@ async def _handle_control_message( ) return True action, token = words[1].lower(), words[2] - pending = self._pending_decisions.get(token) - if pending is None or pending.room_id != room_id: + decisions = self._pending_decisions.get(room_id) + pending = decisions.get(token) if decisions else None + if decisions is None or pending is None: await tools.send_notice( DECISION_NOT_PENDING_TEMPLATE.format(token=token), mentions=mentions ) @@ -602,7 +697,7 @@ async def _handle_control_message( self.config.decision_authorized_senders, msg.sender_id ): reply = DECISION_UNAUTHORIZED_MESSAGE - elif self._pending_decisions.try_claim(token) is None: + elif decisions.try_claim(token) is None: reply = DECISION_NOT_PENDING_TEMPLATE.format(token=token) else: pending.future.set_result(result) @@ -680,13 +775,17 @@ def _question_summary(questions: tuple[CursorQuestion, ...]) -> str: ) def _active_turn_for(self, room_id: str, session_id: str) -> CursorTurn | None: - turn = self._active_turn - return ( - turn - if turn - and not turn.closed - and (turn.room_id, turn.session_id) == (room_id, session_id) - else None + """The open prompt serving ``session_id`` in ``room_id``, as ``_turn_of`` + judges its holder.""" + holder = self._holders.get(room_id) + if holder is None or holder.generation is None: + return None + turn = self._turn_of(holder.room, holder.generation) + return turn if turn is not None and turn.session_id == session_id else None + + def _room_decisions(self, room_id: str) -> DecisionRegistry[PendingDecision]: + return self._pending_decisions.setdefault( + room_id, DecisionRegistry(max_pending=self.config.max_pending_decisions) ) @staticmethod @@ -701,37 +800,41 @@ def _resolve_evicted_decision(evicted: DecisionEntry[PendingDecision]) -> None: evicted.payload.future.set_result(None) def _cancel_room_decisions(self, room_id: str) -> None: - for entry in self._pending_decisions.cancel_room(room_id): - logger.info( - "Cancelling pending Cursor %s decision `%s` in room %s (room cleanup)", - entry.payload.kind, - entry.token, - room_id, - ) - entry.payload.future.set_result(None) + decisions = self._pending_decisions.pop(room_id, None) + if decisions is not None: + _cancel_decisions(decisions, reason="room cleanup") def _cancel_all_decisions(self) -> None: - for entry in self._pending_decisions.cancel_all(): - logger.info( - "Cancelling pending Cursor %s decision `%s` in room %s " - "(adapter shutdown)", - entry.payload.kind, - entry.token, - entry.payload.room_id, - ) - entry.payload.future.set_result(None) + rooms, self._pending_decisions = self._pending_decisions, {} + for decisions in rooms.values(): + _cancel_decisions(decisions, reason="adapter shutdown") async def _list_decisions( self, tools: AgentToolsProtocol, room_id: str, *, mentions: list[str] ) -> None: + decisions = self._pending_decisions.get(room_id) pending = [ f"`{entry.token}` ({entry.payload.kind})" - for entry in self._pending_decisions.unclaimed_in_room(room_id) + for entry in (decisions.unclaimed() if decisions else []) ] content = "Pending Cursor decisions: " + (", ".join(pending) or "none") await tools.send_notice(content, mentions=mentions) +def _cancel_decisions( + decisions: DecisionRegistry[PendingDecision], *, reason: str +) -> None: + for entry in decisions.cancel_all(): + logger.info( + "Cancelling pending Cursor %s decision `%s` in room %s (%s)", + entry.payload.kind, + entry.token, + entry.payload.room_id, + reason, + ) + entry.payload.future.set_result(None) + + def _requester_mentions(turn: CursorTurn) -> list[str] | None: return [turn.requester_id] if turn.requester_id else None diff --git a/src/band/adapters/omp_acp.py b/src/band/adapters/omp_acp.py index 3448f568c..47f5e099d 100644 --- a/src/band/adapters/omp_acp.py +++ b/src/band/adapters/omp_acp.py @@ -25,6 +25,7 @@ PermissionResolver, SpawnProcess, ) +from band.integrations.acp.client_types import ACPRoom from band.integrations.acp.collecting import ACPCollectingClient from band.integrations.acp.permissions import ( ElicitationHandler, @@ -192,7 +193,10 @@ def _credential_env(self) -> dict[str, str]: return {} return omp_provider_env(model=self.config.model, api_key=self.config.api_key) - def _runtime_client_factory(self) -> OmpACPCollectingClient: + def _runtime_client_factory( + self, room: ACPRoom, generation: int + ) -> OmpACPCollectingClient: + del room, generation return OmpACPCollectingClient( own_tool_names=self._own_tool_names, canonicalize_tool_name=self._canonical_tool_name, diff --git a/src/band/core/exceptions.py b/src/band/core/exceptions.py index 4cc05351b..c348b758a 100644 --- a/src/band/core/exceptions.py +++ b/src/band/core/exceptions.py @@ -68,6 +68,12 @@ class InputNotAccepted(BandError): """ +class RoomClosingError(InputNotAccepted, RuntimeError): + """A closing room, or a stopping residency owner, refused work before it + started. Actionable by the host as ``InputNotAccepted``; distinct from a + cleanup failure, which the same calls raise as other errors.""" + + class ProviderRunError(BandError): """A provider or framework returned a failed run as data instead of raising. Actionable by the adapter: raise it inside the turn so the turn is reported diff --git a/src/band/core/retention/cleanup.py b/src/band/core/retention/cleanup.py index b34d10271..0a0822300 100644 --- a/src/band/core/retention/cleanup.py +++ b/src/band/core/retention/cleanup.py @@ -5,28 +5,57 @@ import asyncio import logging -from collections.abc import Coroutine -from functools import partial +import weakref +from collections import Counter +from collections.abc import Awaitable, Callable, Collection, Coroutine, Iterator +from contextlib import contextmanager from typing import Any from band.core.exceptions import CleanupIncompleteError +from band.core.retention.config import RoomRetentionConfig logger = logging.getLogger(__name__) +# Callers awaiting each owned task that re-raise its failure themselves. +_reporters: Counter[asyncio.Task[Any]] = Counter() +# Finished tasks whose failure a caller re-raised or this module logged. +_reported: weakref.WeakSet[asyncio.Task[Any]] = weakref.WeakSet() +_labels: weakref.WeakKeyDictionary[asyncio.Task[Any], str] = weakref.WeakKeyDictionary() + def spawn_owned( operation: Coroutine[object, object, None], *, label: str ) -> asyncio.Task[None]: - """Run work that outlives a cancelled caller; log its failure even when - nobody is left to await it.""" + """Run work that outlives a cancelled caller; log its failure only when + no caller is waiting to report it.""" task = asyncio.create_task(operation) - task.add_done_callback(partial(_log_failure, label=label)) + _labels[task] = label + task.add_done_callback(_log_unreported) return task -def _log_failure(task: asyncio.Task[None], *, label: str) -> None: - if not task.cancelled() and (error := task.exception()) is not None: - logger.warning("%s failed: %s", label, error) +def rejoin_owned( + current: asyncio.Task[None] | None, + operation: Callable[[], Coroutine[object, object, None]], + *, + label: str, +) -> asyncio.Task[None]: + """Join the running or settled owned task; start a new one only after it + failed, so concurrent callers share one attempt and a retry follows a + settled failure.""" + if current is None or (current.done() and task_failure(current) is not None): + return spawn_owned(operation(), label=label) + return current + + +def _log_unreported(task: asyncio.Task[Any]) -> None: + if task in _reporters or task in _reported or task.cancelled(): + return + if (label := _labels.get(task)) is None: + return + _reported.add(task) + if (error := task.exception()) is not None: + logger.warning("%s failed: %s", label, error, exc_info=error) def task_failure(task: asyncio.Task[Any]) -> BaseException | None: @@ -36,10 +65,66 @@ def task_failure(task: asyncio.Task[Any]) -> BaseException | None: return task.exception() -async def report_owned(task: asyncio.Task[Any], timeout: float) -> None: +@contextmanager +def reporting(tasks: Collection[asyncio.Task[Any]]) -> Iterator[None]: + """Mark the caller as the reporter of ``tasks``' failures while it waits. + + A caller cancelled while waiting reports nothing, so a failure it never + saw is logged here when the done callback already ran. + """ + _reporters.update(tasks) + cancelled = False + try: + yield + except asyncio.CancelledError: + cancelled = True + raise + finally: + _reporters.subtract(tasks) + for task in tasks: + if not _reporters[task]: + del _reporters[task] + if not task.done(): + continue + if cancelled: + _log_unreported(task) + else: + _reported.add(task) + + +async def await_owned( + tasks: Collection[asyncio.Task[Any]], timeout: float | None +) -> bool: + """Wait up to ``timeout`` for owned ``tasks`` without cancelling them, as + the caller that reports their failures; whether all finished.""" + with reporting(tasks): + done, _ = await asyncio.wait(tasks, timeout=timeout) + return len(done) == len(tasks) + + +async def report_owned(task: asyncio.Task[Any], timeout: float | None) -> None: """Report an owned cleanup's result within ``timeout`` without cancelling it.""" - done, _ = await asyncio.wait({task}, timeout=timeout) - if not done: + if not await await_owned({task}, timeout): raise CleanupIncompleteError(f"Cleanup still running after {timeout}s") if (error := task_failure(task)) is not None: raise error + + +def cleanup_deadline(config: RoomRetentionConfig) -> float: + """The one deadline an adapter's cleanup shares across rooms and MCP.""" + return asyncio.get_running_loop().time() + config.transition_timeout_s + + +async def finish_by(deadline: float, cleanup: Awaitable[None], *, what: str) -> None: + """Await ``cleanup`` until ``deadline``; an unfinished one stays with its + owner for a retry to join. A timeout ``cleanup`` raised itself propagates.""" + budget = asyncio.timeout_at(deadline) + try: + async with budget: + await cleanup + except TimeoutError as error: + if not budget.expired(): + raise + raise CleanupIncompleteError( + f"{what} still running after its transition budget" + ) from error diff --git a/src/band/core/retention/config.py b/src/band/core/retention/config.py index 2662467b7..bb88fb7b0 100644 --- a/src/band/core/retention/config.py +++ b/src/band/core/retention/config.py @@ -11,7 +11,8 @@ class RoomRetentionConfig(BaseAdapterConfig): """Automatic suspension is opt-in and requires a qualified provider. ``transition_timeout_s`` bounds how long a caller waits to learn the result - of one owned acquisition or cleanup; an unfinished one stays owned. + of one owned acquisition or cleanup; an unfinished one stays owned, and an + acquisition its caller stopped waiting for is cancelled for recovery. """ idle_timeout_s: float | None = Field(default=None, gt=0, allow_inf_nan=False) diff --git a/src/band/core/retention/owner.py b/src/band/core/retention/owner.py index 1aefcb7c0..e5947ca53 100644 --- a/src/band/core/retention/owner.py +++ b/src/band/core/retention/owner.py @@ -6,8 +6,13 @@ from collections.abc import Awaitable, Callable from functools import partial -from band.core.exceptions import CleanupIncompleteError -from band.core.retention.cleanup import report_owned, spawn_owned, task_failure +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from band.core.retention.cleanup import ( + await_owned, + rejoin_owned, + report_owned, + task_failure, +) from band.core.retention.clock import Cancellable, MonotonicClock, RetentionClock from band.core.retention.config import RoomRetentionConfig from band.core.retention.room import RetainedRoom @@ -22,10 +27,17 @@ class RoomRetention: """ def __init__( - self, config: RoomRetentionConfig, *, clock: RetentionClock | None = None + self, + config: RoomRetentionConfig, + *, + clock: RetentionClock | None = None, + settle_grace_s: float = 0.0, ) -> None: + """``settle_grace_s`` is how long final disposal lets accepted work + finish on its own, admission already closed, before cancelling it.""" self.config = config self.clock = clock or MonotonicClock() + self.settle_grace_s = settle_grace_s self.stopping = False self._rooms: dict[str, RetainedRoom] = {} self._timer: Cancellable | None = None @@ -39,8 +51,7 @@ def register( release: Callable[[ReleaseCause], Awaitable[ReleaseOutcome]], dispose: Callable[[], Awaitable[None]], ) -> RetainedRoom: - if self.stopping: - raise RuntimeError("Room retention is stopping") + self.require_admission() if room_id in self._rooms: raise ValueError(f"Room {room_id} already has a residency owner") room = RetainedRoom(self, room_id, acquire, release, dispose) @@ -48,6 +59,11 @@ def register( self.reschedule() return room + def require_admission(self) -> None: + """Refuse new work before it claims provider resources.""" + if self.stopping: + raise RoomClosingError("Room retention is stopping") + def _forget(self, room: RetainedRoom) -> None: if self._rooms.get(room.room_id) is room: del self._rooms[room.room_id] @@ -88,20 +104,34 @@ async def stop(self) -> None: ] self.stopping = True self.reschedule() - shutdown = self._owned_shutdown() + self._shutdown = shutdown = rejoin_owned( + self._shutdown, self._run_stop, label="Room retention shutdown" + ) if any(requested_from_inside): raise CleanupIncompleteError( "Room retention shutdown waits for the calling work to finish" ) await report_owned(shutdown, self.config.transition_timeout_s) - def _owned_shutdown(self) -> asyncio.Task[None]: - """Join the running or settled shutdown; restart one that failed.""" - shutdown = self._shutdown - if shutdown is None or (shutdown.done() and task_failure(shutdown) is not None): - shutdown = spawn_owned(self._run_stop(), label="Room retention shutdown") - self._shutdown = shutdown - return shutdown + def renewed(self) -> RoomRetention: + """The owner to serve with after a restart. + + A stop is terminal for its owner, so a stopped one is replaced by a + fresh owner with the same settings, and only once its stop confirmed + every room closed. + + Raises: + CleanupIncompleteError: The stop is unfinished or failed. + """ + if not self.stopping: + return self + if self._rooms: + raise CleanupIncompleteError( + "Room retention cannot renew while rooms from its stop remain" + ) + return RoomRetention( + self.config, clock=self.clock, settle_grace_s=self.settle_grace_s + ) async def _run_stop(self) -> None: pending = [ @@ -110,7 +140,7 @@ async def _run_stop(self) -> None: if (disposal := room._begin_disposal()) is not None ] if pending: - await asyncio.wait(pending) + await await_owned(pending, None) failures = [error for task in pending if (error := task_failure(task))] if failures: raise BaseExceptionGroup("Room cleanup remains incomplete", failures) diff --git a/src/band/core/retention/room.py b/src/band/core/retention/room.py index 317fab7af..fef3c34b8 100644 --- a/src/band/core/retention/room.py +++ b/src/band/core/retention/room.py @@ -7,10 +7,15 @@ from collections import Counter from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any - -from band.core.exceptions import CleanupIncompleteError -from band.core.retention.cleanup import report_owned, spawn_owned +from typing import TYPE_CHECKING, Any, TypeVar + +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from band.core.retention.cleanup import ( + await_owned, + report_owned, + reporting, + spawn_owned, +) from band.core.retention.states import ( BUSY_ACTIVITY_REASON, UNKNOWN_ACTIVITY_REASON, @@ -27,6 +32,8 @@ logger = logging.getLogger(__name__) +Result = TypeVar("Result") + # Circuit breaker. One step joins a transition, waits for leases, or runs one # release. Settle allows an in-flight join, a lease wait, that release, a # stronger cause recorded during it, a confirming pass, and one spare step. @@ -126,7 +133,7 @@ def observe( reason: str = "", ) -> bool: """Accept authoritative provider observations from this residency only.""" - if not self._has_authority(generation): + if not self.has_authority(generation): return False changed = self._activity != activity self._activity = activity @@ -136,14 +143,37 @@ def observe( self._schedule(reset=changed) return True - def _has_authority(self, generation: int) -> bool: + @property + def closing(self) -> bool: + """Whether admission is closed for good: the room is being disposed or + its owner is stopping.""" + return self._closing or self._owner.stopping + + @property + def generation(self) -> int: + """The current residency's generation; each acquisition starts a new one.""" + return self._generation + + def has_authority(self, generation: int) -> bool: + """Whether residency ``generation`` may still observe or act: the + current one, not closing, and not retired by recovery or final close.""" return ( generation == self._generation - and not self._closing + and not self.closing and self._cause in (None, ReleaseCause.IDLE) and self._state not in (Residency.SUSPENDED, Residency.CLOSED) ) + def may_finish(self, generation: int) -> bool: + """Whether accepted work may still hear from residency ``generation``: + with authority, or while its closing room settles that work before + the final release.""" + return self.has_authority(generation) or ( + self.closing + and generation == self._generation + and self._state == Residency.RESIDENT + ) + def own(self, task: asyncio.Task[Any]) -> None: """Enroll accepted detached work before its first await. @@ -151,14 +181,31 @@ def own(self, task: asyncio.Task[Any]) -> None: the task actually finishes. A refused enrollment cancels the task. """ try: - self._check_admission() - except RuntimeError: + self.require_admission() + except RoomClosingError: task.cancel() raise self._owned.add(task) task.add_done_callback(self._disown) self._schedule() + def start_owned( + self, operation: Callable[[], Coroutine[Any, Any, Result]] + ) -> asyncio.Task[Result]: + """Run ``operation`` as owned detached work that starts only once + enrolled; an eager task factory would otherwise run it before ``own`` + can enroll or refuse it.""" + enrolled = asyncio.get_running_loop().create_future() + + async def run() -> Result: + await enrolled + return await operation() + + task = asyncio.create_task(run()) + self.own(task) + enrolled.set_result(None) + return task + def _disown(self, task: asyncio.Task[Any]) -> None: self._owned.discard(task) self._unwinding.discard(task) @@ -171,7 +218,7 @@ def recover(self, *, generation: int, reason: str) -> bool: stays with the provider until a ``RECOVERY`` release confirms closure, which starts once no operation holds an active I/O lease. """ - if not self._has_authority(generation): + if not self.has_authority(generation): return False self._request(ReleaseCause.RECOVERY) self._recover_when_unleased = True @@ -196,13 +243,13 @@ async def use( included. An operation that abandons its acquisition leaves it for recovery rather than for a successor to adopt. """ - self._check_admission() + self.require_admission() holder = asyncio.current_task() self._holders[holder] += 1 self._schedule() try: await self._settle() - self._check_admission() + self.require_admission() self._leases += 1 try: if prepare is not None: @@ -216,14 +263,15 @@ async def use( self._schedule() self._start_requested_recovery() - def _check_admission(self) -> None: - if self._closing or self._owner.stopping: - raise RuntimeError(f"Room {self.room_id} is closing") + def require_admission(self) -> None: + """Refuse new work before waiting for provider serialization.""" + if self.closing: + raise RoomClosingError(f"Room {self.room_id} is closing") async def _settle(self) -> None: """Finish earlier transitions and pending releases without a lease.""" for _ in range(MAX_SETTLE_STEPS): - self._check_admission() + self.require_admission() match self._in_flight(), self._cause, self._leases: case asyncio.Task() as transition, _, _: await self._join(transition) @@ -237,7 +285,7 @@ async def _settle(self) -> None: async def _ensure_resident(self) -> None: for _ in range(MAX_RESIDENCY_STEPS): - self._check_admission() + self.require_admission() match self._in_flight(), self._state, self._cause: case asyncio.Task() as transition, _, _: await self._join(transition) @@ -266,20 +314,25 @@ async def _join( """Wait a bounded time for a transition without cancelling it.""" timeout = self._owner.config.transition_timeout_s try: - done, _ = await asyncio.wait({transition}, timeout=timeout) + done = await await_owned({transition}, timeout) except asyncio.CancelledError: if initiator: - self._abandon() + self._abandon_acquisition(transition) raise if not done: if initiator: - self._abandon() + self._abandon_acquisition(transition) + raise TimeoutError( + f"Room {self.room_id} start abandoned after {timeout}s" + ) raise TimeoutError( f"Room {self.room_id} transition still running after {timeout}s" ) if transition.cancelled(): - self._check_admission() - raise RuntimeError(f"Room {self.room_id} transition was cancelled") + # Closed admission means disposal cancelled it; otherwise recovery + # did, and the caller re-evaluates the room. + self.require_admission() + return if (error := transition.exception()) is not None: raise error @@ -290,6 +343,19 @@ def _abandon(self) -> None: if self._state == Residency.RESIDENT: self._state = Residency.CLEANUP_PENDING + def _abandon_acquisition(self, transition: asyncio.Task[None]) -> None: + """Recovery cancels a start its initiator stopped waiting for, so one + that never finishes cannot hold the room; a pending ``FINAL`` leaves + it to disposal.""" + self._abandon() + if self._state == Residency.ACQUIRING and self._cause == ReleaseCause.RECOVERY: + logger.warning( + "Room %s: abandoned acquisition (generation %s) cancelled for recovery", + self.room_id, + self._generation, + ) + transition.cancel(f"Room {self.room_id} acquisition was abandoned") + def _request(self, cause: ReleaseCause) -> None: self._cause = stronger_cause(self._cause, cause) @@ -307,10 +373,10 @@ def _start_acquire(self) -> asyncio.Task[None]: return self._transition async def _run_acquire(self) -> None: - if self._closing or self._owner.stopping: + if self.closing: self._state = Residency.SUSPENDED self._schedule() - self._check_admission() + self.require_admission() try: await self._acquire(self._generation, self._resume) except BaseException as error: @@ -358,7 +424,7 @@ async def _run_release( if cause == ReleaseCause.IDLE and not self._idle_release_allowed(automatic): self._refuse_idle_release(intact=previous == Residency.RESIDENT) if not automatic: - self._check_admission() + self.require_admission() raise RuntimeError( f"Room {self.room_id} cleanup pending: provider activity " f"is {self._activity}; {self._reason or 'waiting for quiescence'}" @@ -388,8 +454,7 @@ async def _run_release( def _idle_release_allowed(self, automatic: bool) -> bool: return ( self._cause == ReleaseCause.IDLE - and not self._closing - and not self._owner.stopping + and not self.closing and not self._leases and not (automatic and self._accepted) and self._activity == ProviderActivity.QUIESCENT @@ -437,8 +502,7 @@ def _rearm_deadline(self, *, reset: bool) -> None: def _idle_eligible(self) -> bool: return ( self._idle_candidate - and not self._closing - and not self._owner.stopping + and not self.closing and not self._leases and not self._accepted and self._activity == ProviderActivity.QUIESCENT @@ -457,7 +521,7 @@ def _idle_candidate(self) -> bool: return False def _notify(self) -> None: - settleable = not self._leases or self._closing or self._owner.stopping + settleable = not self._leases or self.closing _hold(self._settleable, settleable) _hold(self._drained, not self._accepted) self._owner.reschedule() @@ -512,7 +576,10 @@ async def _run_dispose(self) -> None: if self._transition is not None: await asyncio.wait({self._transition}) if self._state != Residency.SUSPENDED: - await self._start_release(ReleaseCause.FINAL, automatic=False) + # Awaited directly so cancelling disposal also cancels its release. + release = self._start_release(ReleaseCause.FINAL, automatic=False) + with reporting({release}): + await release self._state = Residency.DISPOSING try: await self._dispose() @@ -528,11 +595,41 @@ async def _run_dispose(self) -> None: self._schedule() async def _settle_accepted(self) -> None: - """Cancel accepted tasks and interrupted startup once, then join all.""" + """Give accepted work the owner's grace, then cancel what remains and + interrupted startup once, and join all.""" if not self._work_cancelled: + await self._grace() self._work_cancelled = True - for task in self._owned - self._unwinding: + for task in self._uncancelled_work(): task.cancel() if self._state == Residency.ACQUIRING and self._transition is not None: self._transition.cancel() await self._drained.wait() + + def _uncancelled_work(self) -> set[asyncio.Task[Any]]: + """Owned work final cleanup may still cancel: not a requester of this + cleanup, and not already told to cancel by its own owner.""" + return {task for task in self._owned - self._unwinding if not task.cancelling()} + + async def _grace(self) -> None: + """Let accepted work finish on its own, with admission closed, until + the owner's settle grace expires.""" + grace = self._owner.settle_grace_s + if not grace or not self._uncancelled_work(): + return + expired = asyncio.Event() + timer = self._owner.clock.call_at(self._owner.clock.now() + grace, expired.set) + drained = asyncio.ensure_future(self._drained.wait()) + expiry = asyncio.ensure_future(expired.wait()) + try: + await asyncio.wait({drained, expiry}, return_when=asyncio.FIRST_COMPLETED) + finally: + timer.cancel() + drained.cancel() + expiry.cancel() + if not self._drained.is_set(): + logger.warning( + "Room %s: accepted work still running after %ss; cancelling it", + self.room_id, + grace, + ) diff --git a/src/band/core/turn_lifecycle.py b/src/band/core/turn_lifecycle.py index b22e4b048..58657f596 100644 --- a/src/band/core/turn_lifecycle.py +++ b/src/band/core/turn_lifecycle.py @@ -3,21 +3,39 @@ ``TurnTask`` delivers cancellation and drains one handler or SDK-managed worker within a single absolute cleanup grace. Adapters whose manual-approval turn runs detached bind that worker to the originating turn scope and share -``ApprovalInterruptMixin`` for interrupts and room retirement. +``ApprovalInterruptMixin`` for interrupts. """ from __future__ import annotations import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Callable, Coroutine from contextlib import asynccontextmanager -from typing import Any +from typing import Annotated, Any + +from pydantic import Field, NonNegativeFloat from band.client.streaming import ControlMode -from band.core.exceptions import CleanupIncompleteError, InputNotAccepted -from band.core.retention import task_failure +from band.core.exceptions import InputNotAccepted +from band.core.protocols import AgentToolsProtocol +from band.core.retention import RetainedRoom +from band.core.turn import judge_detached_turn CYCLE_CANCEL_GRACE_SECONDS = 1.0 +# How long a room's final disposal lets its accepted turn wind down on its own +# before cancelling it. +TURN_SETTLE_GRACE_S = 10.0 +# The adapter config field that sets a room's settle grace. Each config assigns +# TURN_SETTLE_GRACE_S itself: type checkers miss a default inside Annotated. +TurnSettleTimeout = Annotated[ + NonNegativeFloat, + Field( + description=( + "Seconds a leave or stop lets the room's turn wind down on its own " + "before cancelling it." + ), + ), +] def terminal_deadline() -> float: @@ -135,6 +153,30 @@ async def timed( raise asyncio.CancelledError +def start_releasable_turn( + retained: RetainedRoom, + operation: Callable[[], Coroutine[Any, Any, None]], + *, + tools: AgentToolsProtocol, + room_id: str, +) -> asyncio.Task[None]: + """Start a turn that may be released before it ends as the room's owned work. + + A released turn cancelled by its room's final disposal is judged then, + since nothing else answers for its message; an interrupt ends it on purpose. + """ + + async def run() -> None: + try: + await operation() + except asyncio.CancelledError: + if retained.closing: + await judge_detached_turn(tools, room_id=room_id) + raise + + return retained.start_owned(run) + + def bind_turn_task(tools: object, task: asyncio.Task[None]) -> TurnTask: """Bind SDK-owned work when tools expose a runtime scope.""" owner = TurnTask(task) @@ -145,22 +187,22 @@ def bind_turn_task(tools: object, task: asyncio.Task[None]) -> TurnTask: class ApprovalInterruptMixin: - """Clear approvals, terminate the handler's worker, and retire its room.""" + """Clear approvals and terminate the handler's worker on an interrupt. + + A room's leave and an adapter's stop belong to its residency owner, which + refuses a closing room's work itself. + """ def _clear_pending_approvals_for_room(self, room_id: str) -> None: ... _turn_tasks: dict[str, TurnTask] - _room_cleanup: dict[str, asyncio.Task[None]] - _closing_rooms: set[str] def _turn_running(self, room_id: str) -> bool: owner = self._turn_tasks.get(room_id) return owner is not None and not owner.task.done() def _admit_turn(self, room_id: str, provider: str) -> None: - """Refuse new input while the room closes or its worker still runs.""" - if room_id in self._closing_rooms: - raise InputNotAccepted(f"The room's {provider} session is closing") + """Refuse new input while the room's worker still runs.""" if self._turn_running(room_id): raise InputNotAccepted(f"The room already has an active {provider} turn") @@ -170,40 +212,6 @@ async def _cancel_turn(self, room_id: str) -> None: owner.terminate() await owner.drain() - async def _retire_room( - self, - room_id: str, - *, - settle: Callable[[str], Awaitable[None]], - close: Callable[[], Awaitable[None]], - ) -> None: - """Fence the room, end its worker, then join the room's one close. - - A failed close is retried; only a successful close readmits the room. - """ - self._closing_rooms.add(room_id) - self._clear_pending_approvals_for_room(room_id) - await settle(room_id) - if self._turn_running(room_id): - raise CleanupIncompleteError(f"Room {room_id}: turn is still cleaning up") - task = self._room_cleanup.get(room_id) - if task is None or (task.done() and task_failure(task) is not None): - task = asyncio.create_task(self._close_room_once(room_id, close)) - task.add_done_callback(task_failure) - # An eager task factory can finish the close before it is joinable. - if not task.done(): - self._room_cleanup[room_id] = task - await asyncio.shield(task) - - async def _close_room_once( - self, room_id: str, close: Callable[[], Awaitable[None]] - ) -> None: - # Readmission belongs to the close itself, so a cancelled waiter cannot - # leave the room fenced after the close succeeded. - await close() - self._room_cleanup.pop(room_id, None) - self._closing_rooms.discard(room_id) - async def on_interrupt(self, room_id: str, mode: ControlMode) -> None: self._clear_pending_approvals_for_room(room_id) await self._cancel_turn(room_id) diff --git a/src/band/integrations/acp/client_adapter.py b/src/band/integrations/acp/client_adapter.py index 8e24fdd23..24e6dc98f 100644 --- a/src/band/integrations/acp/client_adapter.py +++ b/src/band/integrations/acp/client_adapter.py @@ -9,11 +9,10 @@ AsyncIterator, Awaitable, Callable, - Coroutine, Mapping, Sequence, ) -from contextlib import asynccontextmanager +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from enum import StrEnum from functools import partial @@ -33,23 +32,40 @@ SseMcpServer, ) from band_sdk_core import AgentFailure -from pydantic import Field, PositiveFloat, field_validator, model_validator +from pydantic import ( + Field, + PositiveFloat, + field_validator, + model_validator, +) from typing_extensions import TypeVar, Unpack from band.converters.acp_client import ACPClientHistoryConverter from band.converters.helpers import build_replay_messages from band.core.adapterconfig import BaseAdapterConfig from band.core.delivery import DeliveryFailedError, reraise_delivery_cause -from band.core.exceptions import BandConfigError, InputNotAccepted +from band.core.exceptions import ( + BandConfigError, + InputNotAccepted, +) from band.core.model_catalog import ModelSelection from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, AgentToolsProtocol, ) -from band.core.retention import RoomRetentionConfig, require_disabled_retention +from band.core.retention import ( + ProviderActivity, + ReleaseCause, + ReleaseOutcome, + RoomRetention, + RoomRetentionConfig, + require_disabled_retention, +) +from band.core.retention.cleanup import spawn_owned from band.core.simple_adapter import SimpleAdapter from band.core.tool_filter import filter_tool_schemas +from band.core.turn_lifecycle import TURN_SETTLE_GRACE_S, TurnSettleTimeout from band.core.types import ( AdapterFeatures, Capability, @@ -61,7 +77,10 @@ from band.integrations.acp.client_runtime import ACPRuntime from band.integrations.acp.client_types import ( ACPClientSessionState, + ACPRoom, BandACPClient, + RoomSession, + is_session_busy, ) from band.integrations.acp.collecting import ACPCollectingClient from band.integrations.acp.model_selection import ( @@ -91,7 +110,6 @@ SessionConfigSetter, apply_session_config_selections, ) -from band.integrations.acp.transport import ACPConnectionProtocol from band.integrations.acp.types import ACPToolCall from band.integrations.mcp import ( BandMCPBackend, @@ -143,28 +161,9 @@ class ACPPermissionRequest: options: tuple[PermissionOptionValue, ...] -@dataclass -class SessionInitializer: - """One room's shared, not-yet-published session setup.""" - - task: asyncio.Task[tuple[str, bool]] - waiters: int = 0 - - _PROVIDER = "acp" -@dataclass -class RoomSession: - """A room's ACP session and the Band MCP URL it dials, ``None`` when it - dials none.""" - - session_id: str - band_url: str | None - bootstrap_pending: bool = True - replay_pending: bool = True - - @dataclass(frozen=True) class SessionMcpServers: """The MCP servers a session is created or loaded with.""" @@ -201,6 +200,12 @@ class ACPTurnTimeoutError(TimeoutError): NEW_MESSAGE_MARKER_PREFIX = "[New Message" SESSION_CLOSE_TIMEOUT_SECONDS = 5.0 DEFAULT_TURN_TIMEOUT_SECONDS = 300.0 +# One acquisition launches the agent and runs initialize/authenticate, and a +# wake also reloads its session. +ACP_TRANSITION_TIMEOUT_S = 30.0 +# How a turn reports a room process that did not start or stop within +# ``retention.transition_timeout_s``. +PROCESS_NOT_READY_ERROR = "ACP agent process did not become ready in time" class SessionCloseReason(StrEnum): @@ -291,6 +296,9 @@ class ACPClientAdapterConfig(BaseAdapterConfig): Attributes: command: The agent's launch command; a single string is one argv element. + retention: Residency settings; ``transition_timeout_s`` + (``ACP_TRANSITION_TIMEOUT_S`` by default) bounds each start, so a + slower one fails its turn and its room recovers. env: Extra environment for the agent subprocess. cwd: Root under which each room gets its own workspace directory; exclusive with the adapter's ``workspace_for_room``. @@ -315,7 +323,11 @@ class ACPClientAdapterConfig(BaseAdapterConfig): ) command: tuple[str, ...] - retention: RoomRetentionConfig = Field(default_factory=RoomRetentionConfig) + retention: RoomRetentionConfig = Field( + default_factory=lambda: RoomRetentionConfig( + transition_timeout_s=ACP_TRANSITION_TIMEOUT_S + ) + ) env: dict[str, str] | None = None cwd: str | None = None mcp_servers: tuple[SessionMcpServer, ...] = () @@ -324,6 +336,7 @@ class ACPClientAdapterConfig(BaseAdapterConfig): custom_section: str = "" use_unstable_protocol: bool = False turn_timeout_s: PositiveFloat = DEFAULT_TURN_TIMEOUT_SECONDS + turn_settle_timeout_s: TurnSettleTimeout = TURN_SETTLE_GRACE_S model: str | None = None reasoning_effort: str | None = None @@ -437,17 +450,14 @@ def __init__( self._resolve_session_config = resolve_session_config self._resolve_permission = resolve_permission self._client_capabilities = client_capabilities - self._runtimes: dict[str, ACPRuntime] = {} self._workspaces = RoomWorkspaces(self._workspace_for_room) - - self._room_to_session: dict[str, RoomSession] = {} + self._retention = RoomRetention( + config.retention, settle_grace_s=config.turn_settle_timeout_s + ) + self._rooms: dict[str, ACPRoom] = {} # Outlives the room's sessions; see apply_model_selection. self._room_selections: dict[str, ModelSelection] = {} - self._session_initializers: dict[str, SessionInitializer] = {} - self._room_tools: dict[str, AgentToolsProtocol] = {} - self._background_tasks: set[asyncio.Task[None]] = set() self._mcp = SharedBandMCPBackend(self._mcp_settings) - self._session_lock = asyncio.Lock() @property def model_selection(self) -> ModelSelection: @@ -461,8 +471,10 @@ async def apply_model_selection( ) -> None: """Switch ``room_id``'s live ACP session, checked against its catalog. - Raises ``BandConfigError`` (an ``ACPConfigError`` naming what the - session offers, when it rejects ``selection``). Only a switch that + The switch queues behind the room's running and queued prompts, as one + more turn would; other rooms never wait on it. Raises + ``BandConfigError`` (an ``ACPConfigError`` naming what the session + offers, when it rejects ``selection``). Only a switch that returns counts: the room then keeps what its session runs for the adapter's lifetime, and every later session for it starts there until one refuses it. A switch that raises may leave the live session @@ -477,32 +489,56 @@ async def apply_model_selection( ) if selection.is_empty: return - session = await self._live_session(room_id) - if session is None: - raise BandConfigError(f"room {room_id} has no live ACP session to switch") - session_id, runtime = session - async with runtime.config_lock: - try: - await self._apply_model_selection(runtime, session_id, selection) - except ACPConfigError: - # A cleanup while this switch queued or ran fails it however - # the dead runtime answered; the ended session is the cause. - await self._ensure_session_live(room_id, session) - logger.warning( - "Switching room %s to %s failed; its session runs %s", - room_id, - selection, - self._running_selection(runtime, session_id), - ) + room = self._rooms.get(room_id) + if room is None: + raise _no_live_session(room_id) + # Owned by the room, so its disposal settles and joins the switch + # instead of closing the process under it. A closing room refuses the + # switch here, before it ever runs. + switch = room.retained.start_owned(partial(self._switch_room, room, selection)) + try: + await switch + except (asyncio.CancelledError, RuntimeError) as error: + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise + if not room.retained.closing: raise - await self._ensure_session_live(room_id, session) - self._remember_room_selection(room_id, session_id, runtime) + raise _session_ended_mid_switch(room_id) from error + + async def _switch_room(self, room: ACPRoom, selection: ModelSelection) -> None: + async def require_live_session() -> None: + # Runs before acquisition: a switch never starts a process. + if room.live_session is None: + raise _no_live_session(room.room_id) + + # Settings take the room in turn with its prompts. + async with room.reservation, room.retained.use(prepare=require_live_session): + session = room.live_session + if session is None: + raise _no_live_session(room.room_id) + session_id, runtime = session + async with runtime.config_lock: + try: + await self._apply_model_selection(runtime, session_id, selection) + except ACPConfigError: + # A session replaced while this switch queued or ran fails + # it however the old one answered; that is the cause. + self._ensure_session_live(room, session) + logger.warning( + "Switching room %s to %s failed; its session runs %s", + room.room_id, + selection, + self._running_selection(runtime, session_id), + ) + raise + self._ensure_session_live(room, session) + self._remember_room_selection(room.room_id, session_id, runtime) - async def _ensure_session_live( - self, room_id: str, session: tuple[str, ACPRuntime] - ) -> None: - if await self._live_session(room_id) != session: - raise BandConfigError(f"room {room_id}'s ACP session ended mid-switch") + @staticmethod + def _ensure_session_live(room: ACPRoom, session: tuple[str, ACPRuntime]) -> None: + if room.live_session != session: + raise _session_ended_mid_switch(room.room_id) def _running_selection( self, runtime: ACPRuntime, session_id: str @@ -523,14 +559,6 @@ def _remember_room_selection( def _room_selection(self, room_id: str) -> ModelSelection: return self._room_selections.get(room_id, self.model_selection) - async def _live_session(self, room_id: str) -> tuple[str, ACPRuntime] | None: - async with self._session_lock: - session = self._room_to_session.get(room_id) - runtime = self._runtimes.get(room_id) - if session is None or runtime is None: - return None - return session.session_id, runtime - def locate_model_options( self, options: Sequence[SessionConfigOption] ) -> ACPModelOptions: @@ -590,13 +618,14 @@ def _registered_tools(self) -> tuple[list[ToolDefinition], frozenset[str]]: ) return definitions, names - def _build_runtime(self, workspace: str | None = None) -> ACPRuntime: + def _build_runtime(self, room: ACPRoom, generation: int) -> ACPRuntime: + """The process and connection for one residency of ``room``.""" return ACPRuntime( - command=_resolve_launcher(self._spawn_command(workspace)), + command=_resolve_launcher(self._spawn_command(room.workspace)), env=self._spawn_env(), - cwd=self._spawn_cwd(workspace), + cwd=self._spawn_cwd(room.workspace), auth_method=self.config.auth_method, - client_factory=self._runtime_client_factory, + client_factory=partial(self._runtime_client_factory, room, generation), spawn_process=spawn_agent_process, client_capabilities=self._client_capabilities, use_unstable_protocol=self.config.use_unstable_protocol, @@ -620,25 +649,124 @@ def _spawn_cwd(self, workspace: str | None) -> str | None: """The subprocess-level cwd to launch the ACP agent with, for this room's workspace.""" return workspace - def _runtime_client_factory(self) -> ACPCollectingClient: + def _runtime_client_factory( + self, room: ACPRoom, generation: int + ) -> ACPCollectingClient: return BandACPClient( - profile=self._profile, + profile=self._client_profile(room, generation), canonicalize_tool_name=self._canonical_tool_name, ) - async def _runtime_for(self, room_id: str) -> ACPRuntime: - async with self._session_lock: - runtime = self._runtimes.get(room_id) - if runtime is None: - workspace = self._workspaces.claim(room_id) - runtime = self._build_runtime(workspace) - self._runtimes[room_id] = runtime - return runtime + def _client_profile( + self, room: ACPRoom, generation: int + ) -> ACPClientProfile | None: + """The extension profile for one residency's client.""" + del room, generation + return self._profile async def on_started(self, agent_name: str, agent_description: str) -> None: - await super().on_started(agent_name, agent_description) - # Agent.start() reuses this instance after cleanup_all(final=True). + # Agent.start() reuses this instance after cleanup_all(); a stop is + # terminal for its owner and MCP server, so only a start reopens them. + if self._retention.stopping: + await self.cleanup_all() + self._retention = self._retention.renewed() await self._mcp.reopen() + await super().on_started(agent_name, agent_description) + + def _room(self, room_id: str) -> ACPRoom: + """The room's holder, registered with the residency owner on first use.""" + room = self._rooms.get(room_id) + if room is not None: + return room + self._retention.require_admission() + with self._workspaces.claiming(room_id) as workspace: + room = ACPRoom(room_id=room_id, workspace=workspace) + room.retained = self._retention.register( + room_id, + acquire=partial(self._acquire_room, room), + release=partial(self._release_room, room), + dispose=partial(self._dispose_room, room), + ) + self._rooms[room_id] = room + return room + + async def _acquire_room(self, room: ACPRoom, generation: int, resume: bool) -> None: + """Start the room's process; a deliberate wake also restores its + session exactly. + + The runtime is held before its first await, so a failed or abandoned + start leaves it for the owner's recovery to close. + """ + # Unreachable while the owner keeps its contract: it acquires only after + # a confirmed release. Guards against replacing an unclosed process. + if room.runtime is not None: + raise RuntimeError( + f"Room {room.room_id}'s previous ACP process is not confirmed closed" + ) + runtime = room.runtime = self._build_runtime(room, generation) + await runtime.start() + room.retained.observe( + ProviderActivity.UNKNOWN, + generation=generation, + reason=self.config.retention_unavailable_reason, + ) + if resume and (session := room.session) is not None: + await self._restore_retained_session(room, runtime, session) + + async def _release_room(self, room: ACPRoom, cause: ReleaseCause) -> ReleaseOutcome: + """Close the room's process and keep its session identity.""" + await asyncio.gather(*room.session_closes, return_exceptions=True) + if (runtime := room.runtime) is not None: + await runtime.stop() + room.runtime = None + if room.session is not None: + room.session.live = False + return ReleaseOutcome.RELEASED + + async def _dispose_room(self, room: ACPRoom) -> None: + """Erase the room's session and workspace after its final release.""" + room.session = None + room.tools = None + self._workspaces.release(room.room_id) + if self._rooms.get(room.room_id) is room: + del self._rooms[room.room_id] + + @asynccontextmanager + async def _using_room( + self, room: ACPRoom, tools: AgentToolsProtocol + ) -> AsyncIterator[ACPRuntime]: + """Reserve the room, lease it, bind ``tools``, and yield its process. + + The reservation serializes the room's prompts from session setup to + their actual end. Tools bind under the lease, after earlier + transitions settle and before acquisition, so a startup never serves + another prompt's tools. + """ + + async def bind_tools() -> None: + if self.config.inject_band_tools: + room.tools = tools + + room.retained.require_admission() + async with room.reservation, AsyncExitStack() as lease: + try: + await lease.enter_async_context(room.retained.use(prepare=bind_tools)) + except TimeoutError as error: + await self._report_not_ready(room, tools, error) + raise + if room.runtime is None: + raise RuntimeError(f"Room {room.room_id} has no ACP process") + yield room.runtime + + @staticmethod + async def _report_not_ready( + room: ACPRoom, tools: AgentToolsProtocol, error: TimeoutError + ) -> None: + """Fail the turn by name: the room's process did not start or stop + within the owner's bound, and recovery owns what is left of it. The + turn's own error log carries the traceback.""" + logger.warning("Room %s: ACP agent process not ready: %s", room.room_id, error) + await tools.send_failure(AgentFailure(_PROVIDER, PROCESS_NOT_READY_ERROR)) async def on_message( self, @@ -651,33 +779,61 @@ async def on_message( is_session_bootstrap: bool, room_id: str, ) -> None: - runtime = await self._runtime_for(room_id) - await self._ensure_connection(runtime) + room = self._room(room_id) + + async def prompt_room() -> None: + async with self._using_room(room, tools) as runtime: + await self._prompt_room( + room, + runtime, + msg, + tools, + history, + participants_msg, + contacts_msg, + is_session_bootstrap=is_session_bootstrap, + ) - if self.config.inject_band_tools: - async with self._session_lock: - self._room_tools[room_id] = tools + await room.retained.start_owned(prompt_room) + async def _prompt_room( + self, + room: ACPRoom, + runtime: ACPRuntime, + msg: PlatformMessage, + tools: AgentToolsProtocol, + history: ACPClientSessionState, + participants_msg: str | None, + contacts_msg: str | None, + *, + is_session_bootstrap: bool, + ) -> None: + """Prompt the room's session under the caller's lease; a failure that + leaves the process unusable requests its recovery.""" + room_id, generation = room.room_id, room.generation try: - session_id, _ = await self._get_or_create_session( + session_id = await self._get_or_create_session( + room, runtime, - room_id, history if is_session_bootstrap else None, ) except ACPConfigError as error: - # An unanswered set may have left a dead connection the runtime - # never replaces; only a fresh runtime lets the next turn retry. + # An unanswered set may have left a dead connection; only a fresh + # process lets the next turn retry. if isinstance(error, ACPConfigUnreachableError): - await self.on_cleanup(room_id, expected_runtime=runtime) + room.retained.recover(generation=generation, reason=str(error)) await self._report_config_error(tools, error) return + except Exception as error: + await self._fail_turn(room, generation, tools, error) + raise runtime.reset_session(session_id) # A just-created session holds no remote context (a restored one does), # so seed it with the Band room's transcript. On bootstrap the converter - # carried it; a session minted later (the previous runtime was torn down + # carried it; a session minted later (the previous process was torn down # mid-run) re-fetches it. - session = self._room_to_session[room_id] + session = self._live_room_session(room) replay: list[str] | None = None if session.bootstrap_pending and session.replay_pending: replay = ( @@ -688,7 +844,7 @@ async def on_message( prompt_text = self._build_prompt_text( room_id=room_id, - session_id=session_id, + session=session, msg=msg, replay=replay, participants_msg=participants_msg, @@ -727,11 +883,7 @@ async def on_message( session.bootstrap_pending = False session.replay_pending = False except RequestError as error: - if ( - error.code == -32003 - and isinstance(error.data, dict) - and error.data.get("reason") == "session_busy" - ): + if is_session_busy(error): raise InputNotAccepted("ACP session is busy") from error raise except asyncio.CancelledError: @@ -751,8 +903,9 @@ async def on_message( await self._await_shielded( asyncio.create_task( self._handle_turn_timeout( + room, runtime, - room_id=room_id, + generation=generation, session_id=session_id, tools=tools, ) @@ -769,29 +922,54 @@ async def on_message( raise except InputNotAccepted: raise - except Exception as e: - logger.exception("ACP agent error") - await self.on_cleanup(room_id) - await tools.send_failure(_to_agent_failure(e)) + except Exception as error: + await self._fail_turn(room, generation, tools, error) raise + @staticmethod + async def _fail_turn( + room: ACPRoom, + generation: int, + tools: AgentToolsProtocol, + error: Exception, + ) -> None: + """Report an agent failure and retire the residency it left unusable.""" + logger.exception( + "ACP agent error (room=%s, generation=%s); recovering its process", + room.room_id, + generation, + ) + room.retained.recover(generation=generation, reason=f"ACP agent error: {error}") + await tools.send_failure(_to_agent_failure(error)) + + @staticmethod + def _live_room_session(room: ACPRoom) -> RoomSession: + session = room.live_record + if session is None: + raise RuntimeError(f"Room {room.room_id} has no live ACP session") + return session + async def _handle_turn_timeout( self, + room: ACPRoom, runtime: ACPRuntime, *, - room_id: str, + generation: int, session_id: str, tools: AgentToolsProtocol, ) -> None: - """Cancel and report a prompt that exceeded the adapter timeout.""" + """Cancel and report a prompt that exceeded the adapter timeout; its + process is recovered once the turn unwinds.""" logger.error( "ACP turn timed out after %ss (room=%s, session=%s)", self.config.turn_timeout_s, - room_id, + room.room_id, session_id, ) - await self._cancel_agent_turn(runtime, room_id=room_id, session_id=session_id) - await self.on_cleanup(room_id) + await self._cancel_agent_turn( + runtime, room_id=room.room_id, session_id=session_id + ) + room.retained.recover(generation=generation, reason="ACP turn timed out") await tools.send_failure( AgentFailure( _PROVIDER, @@ -1026,18 +1204,22 @@ def _canonical_tool_name(self, name: str) -> str: def _mcp_settings(self) -> BandMCPBackendSettings: return BandMCPBackendSettings( tool_definitions=self._tool_definitions, - get_tools=self._room_tools.get, + get_tools=self._bound_tools, additional_tools=self._custom_tools, room_bound=True, ) - def _forget_session(self, session_id: str) -> None: - """Drop per-session state a subclass keeps, once the session can - deliver no more updates: its connection stopped or it was closed. - Runs even when that stop or close is cancelled.""" + def _bound_tools(self, room_id: str) -> AgentToolsProtocol | None: + """The tools the room's latest prompt bound. + + A Band MCP call names only its room, so a background call that + outlives its prompt is served with a later prompt's tools. + """ + room = self._rooms.get(room_id) + return None if room is None else room.tools def _retire_stale_session( - self, runtime: ACPRuntime, room_id: str, session: RoomSession + self, room: ACPRoom, runtime: ACPRuntime, session: RoomSession ) -> None: """Close a session built against a Band MCP URL the backend no longer serves; the room gets a fresh one on the live URL. @@ -1049,200 +1231,164 @@ def _retire_stale_session( logger.info( "Band MCP server replaced; replacing ACP session %s for room %s", session.session_id, - room_id, + room.room_id, ) runtime.reset_session(session.session_id) - self._track_background_task( - self._close_session( - runtime, - session.session_id, - reason=SessionCloseReason.STALE_BAND_MCP_URL, - ) + self._close_session_later( + room, + runtime, + session.session_id, + reason=SessionCloseReason.STALE_BAND_MCP_URL, ) - async def _get_or_start_band_mcp_server(self, room_id: str) -> LocalMcpServerConfig: + async def _get_or_start_band_mcp_server( + self, room_id: str, transport: BandMCPTransport + ) -> LocalMcpServerConfig: backend = await self._mcp.ensure() - runtime = await self._runtime_for(room_id) - return self._build_local_mcp_server_config( - backend, runtime.agent_mcp_transport, room_id - ) + return self._build_local_mcp_server_config(backend, transport, room_id) async def _get_or_create_session( self, + room: ACPRoom, runtime: ACPRuntime, - room_id: str, history: ACPClientSessionState | None, - ) -> tuple[str, bool]: - """This room's ACP session id, plus whether it was created just now. + ) -> str: + """The room's live session id, restored or created when it has none. - A session is reused only while it dials the room's current Band MCP - URL. A just-created session is fresh and holds no conversation - context; the caller owes it a transcript replay. + A live session is reused only while it dials the room's current Band + MCP URL; a stale one is retired for a fresh session. A session kept + from a recovered process, or one persisted in bootstrap ``history``, + is reloaded when the agent still has it. A fresh session holds no + conversation context; the caller owes it a transcript replay. """ - mcp = await self._session_mcp_servers(room_id) - async with self._session_lock: - stale = self._room_to_session.get(room_id) - if stale is not None and stale.band_url == mcp.band_url: - return stale.session_id, False - if stale is not None: - del self._room_to_session[room_id] - # Never restore: the persisted id may be the one being retired. - history = None - initializer = self._session_initializers.get(room_id) - if initializer is not None and self._is_spent( - initializer, published_session_retired=stale is not None - ): - self._session_initializers.pop(room_id) - initializer = None - if initializer is None: - initializer = SessionInitializer( - task=asyncio.create_task( - self._initialize_session(runtime, room_id, history, mcp), - name=f"acp-session:{room_id}", - ) - ) - self._session_initializers[room_id] = initializer - initializer.waiters += 1 - - if stale is not None: - self._retire_stale_session(runtime, room_id, stale) - - try: - return await asyncio.shield(initializer.task) - finally: - await self._release_session_initializer(room_id, initializer) - - @staticmethod - def _is_spent( - initializer: SessionInitializer, *, published_session_retired: bool - ) -> bool: - """Whether a finished setup can't serve the next turn: it failed, or it - built the very session that was just retired.""" - task = initializer.task - if not task.done(): - return False - return ( - published_session_retired - or task.cancelled() - or task.exception() is not None - ) - - async def _release_session_initializer( - self, - room_id: str, - initializer: SessionInitializer, - ) -> None: - """Drop a completed setup or cancel one no turn is still awaiting.""" - async with self._session_lock: - if self._session_initializers.get(room_id) is not initializer: - return - initializer.waiters -= 1 - if initializer.waiters: - return - self._session_initializers.pop(room_id) - - if not initializer.task.done(): - initializer.task.cancel() - await asyncio.gather(initializer.task, return_exceptions=True) - - async def _initialize_session( - self, - runtime: ACPRuntime, - room_id: str, - history: ACPClientSessionState | None, - mcp: SessionMcpServers, - ) -> tuple[str, bool]: - """Restore or create one room session outside the shared state lock.""" - restored_session_id = await self._restore_session( - runtime, - room_id, - history, - mcp, - ) - if restored_session_id is not None: - return restored_session_id, False - - return await self._create_session(runtime, room_id, mcp), True + mcp = await self._session_mcp_servers(room, runtime) + session = room.session + restore_id: str | None = None + if session is not None and session.live: + if session.band_url == mcp.band_url: + return session.session_id + # Never restore: the retained id is the one being retired. + room.session = None + self._retire_stale_session(room, runtime, session) + elif session is not None: + restore_id = session.session_id + elif history is not None: + restore_id = history.room_to_session.get(room.room_id) + + restored = None + if restore_id is not None: + restored = await self._restore_session( + room, runtime, restore_id, mcp, retained=session is not None + ) + room.session = restored or await self._create_session(room, runtime, mcp) + return room.session.session_id async def _restore_session( self, + room: ACPRoom, runtime: ACPRuntime, - room_id: str, - history: ACPClientSessionState | None, + session_id: str, mcp: SessionMcpServers, - ) -> str | None: - """Restore and configure the persisted session for this room, if available.""" - session_id = history.room_to_session.get(room_id) if history else None - if session_id is None: - return None - + *, + retained: bool, + ) -> RoomSession | None: + """Reload and configure ``session_id``, or ``None`` when the agent + cannot restore it. + + A refused remembered selection reverts, so only this turn fails. A + ``retained`` id is still the room's and stays its identity through + that refusal, and its process is recovered: the session is loaded + there, and loading it again on the same process is undefined. A + bootstrap candidate is closed instead. + """ loaded = await runtime.load_session_response( - cwd=self._workspaces.workspace(room_id), + cwd=room.workspace, session_id=session_id, mcp_servers=mcp.servers, ) if loaded is None: logger.info( - "Persisted ACP session %s is unavailable for room %s; using a new session", + "ACP session %s is unavailable for room %s; using a new session", session_id, - room_id, + room.room_id, ) return None try: - await self._configure_session(runtime, room_id, session_id) - except BaseException: - await self._close_session( - runtime, session_id, reason=SessionCloseReason.UNCONFIGURED - ) + await self._configure_session(runtime, room.room_id, session_id) + except BaseException as error: + if retained: + room.retained.recover( + generation=room.generation, + reason=f"Reloaded ACP session {session_id} is unconfigured: {error!r}", + ) + else: + await self._close_session( + runtime, session_id, reason=SessionCloseReason.UNCONFIGURED + ) raise - await self._record_session( - room_id, - RoomSession( - session_id=session_id, band_url=mcp.band_url, replay_pending=False - ), + logger.debug("Loaded ACP session mapping: %s -> %s", room.room_id, session_id) + return RoomSession( + session_id=session_id, band_url=mcp.band_url, replay_pending=False ) - logger.debug("Loaded ACP session mapping: %s -> %s", room_id, session_id) - return session_id + + async def _restore_retained_session( + self, room: ACPRoom, runtime: ACPRuntime, session: RoomSession + ) -> None: + """Wake the room's retained session on a new process, exactly. + + The load gets fresh MCP servers and cwd and the acknowledged selection + is reapplied against the new connection's catalog. A refusal raises + and keeps both the session identity and the selection; it never falls + back to a new session or a transcript replay. + """ + mcp = await self._session_mcp_servers(room, runtime) + await runtime.restore_session_response( + cwd=room.workspace, + session_id=session.session_id, + mcp_servers=mcp.servers, + ) + await self._configure_session( + runtime, room.room_id, session.session_id, keep_refused_selection=True + ) + session.band_url = mcp.band_url + session.live = True async def _create_session( - self, runtime: ACPRuntime, room_id: str, mcp: SessionMcpServers - ) -> str: - """Create, configure, and publish a session for one room.""" - async with self._fresh_session(runtime, room_id, mcp.servers) as session: - await self._configure_session(runtime, room_id, session.session_id) - await self._record_session( - room_id, - RoomSession(session_id=session.session_id, band_url=mcp.band_url), - ) + self, room: ACPRoom, runtime: ACPRuntime, mcp: SessionMcpServers + ) -> RoomSession: + """Create and configure a session for one room.""" + async with self._fresh_session(room, runtime, mcp.servers) as session: + await self._configure_session(runtime, room.room_id, session.session_id) logger.info( "Created ACP session %s for room %s (mcp_servers=%d)", session.session_id, - room_id, + room.room_id, len(mcp.servers), ) - return session.session_id + return RoomSession(session_id=session.session_id, band_url=mcp.band_url) @asynccontextmanager async def _fresh_session( self, + room: ACPRoom, runtime: ACPRuntime, - room_id: str, mcp_servers: list[object], ) -> AsyncIterator[NewSessionResponse]: """Yield a new session, closing it unless initialization completes.""" session = await runtime.create_session_response( - cwd=self._workspaces.workspace(room_id), + cwd=room.workspace, mcp_servers=mcp_servers, ) try: yield session except asyncio.CancelledError: - self._track_background_task( - self._close_session( - runtime, session.session_id, reason=SessionCloseReason.UNCONFIGURED - ) + self._close_session_later( + room, + runtime, + session.session_id, + reason=SessionCloseReason.UNCONFIGURED, ) raise except BaseException: @@ -1251,11 +1397,6 @@ async def _fresh_session( ) raise - async def _record_session(self, room_id: str, session: RoomSession) -> None: - """Publish a fully initialized session to its room.""" - async with self._session_lock: - self._room_to_session[room_id] = session - async def _close_session( self, runtime: ACPRuntime, session_id: str, *, reason: SessionCloseReason ) -> None: @@ -1281,61 +1422,59 @@ async def _close_session( reason, exc_info=True, ) - finally: - self._forget_session(session_id) - - def _track_background_task(self, coro: Coroutine[Any, Any, None]) -> None: - """Run a fire-and-forget task that outlives its caller. - An untracked ``asyncio.create_task`` result can be garbage-collected - before it runs (the event loop only keeps a weak reference), silently - dropping the work. Keeping it here until it finishes also gives a - crash somewhere to be logged instead of vanishing. - """ - task = asyncio.create_task(coro) - self._background_tasks.add(task) - task.add_done_callback(self._on_background_task_done) - - def _on_background_task_done(self, task: asyncio.Task[None]) -> None: - self._background_tasks.discard(task) - if task.cancelled(): - return - error = task.exception() - if error is not None: - logger.warning("ACP background task failed", exc_info=error) - - async def _drain_background_tasks(self) -> None: - """Let in-flight fire-and-forget cleanup finish before the runtime it - depends on stops. - - Discards the awaited snapshot itself rather than relying on - ``_on_background_task_done`` to shrink the set: when every task in - the snapshot is already finished, ``asyncio.gather`` resolves - eagerly without ever suspending, so a callback-only removal would - spin here forever waiting for a yield that never happens. - """ - while self._background_tasks: - pending = tuple(self._background_tasks) - await asyncio.gather(*pending, return_exceptions=True) - self._background_tasks.difference_update(pending) + def _close_session_later( + self, + room: ACPRoom, + runtime: ACPRuntime, + session_id: str, + *, + reason: SessionCloseReason, + ) -> None: + """Close a retired session without delaying its caller; the room's + release waits for it before closing the process.""" + task = spawn_owned( + self._close_session(runtime, session_id, reason=reason), + label=f"ACP session {session_id} close", + ) + room.session_closes.add(task) + task.add_done_callback(room.session_closes.discard) - async def _session_mcp_servers(self, room_id: str) -> SessionMcpServers: + async def _session_mcp_servers( + self, room: ACPRoom, runtime: ACPRuntime + ) -> SessionMcpServers: """The MCP configuration supplied when creating or loading a session.""" servers: list[object] = list(self.config.mcp_servers) if not self.config.inject_band_tools: return SessionMcpServers(servers=servers, band_url=None) - band_server = await self._get_or_start_band_mcp_server(room_id) + band_server = await self._get_or_start_band_mcp_server( + room.room_id, runtime.agent_mcp_transport + ) return SessionMcpServers( servers=[*servers, band_server], band_url=band_server.url ) async def _configure_session( - self, runtime: ACPRuntime, room_id: str, session_id: str + self, + runtime: ACPRuntime, + room_id: str, + session_id: str, + *, + keep_refused_selection: bool = False, ) -> None: - """Apply caller-selected values from the session's live ACP catalog.""" + """Apply caller-selected values from the session's live ACP catalog. + + ``keep_refused_selection`` keeps a room's switched selection when the + session refuses it, for a wake that must restore it rather than revert. + """ selection = self._room_selection(room_id) if not selection.is_empty: - await self._apply_room_selection(runtime, room_id, session_id, selection) + if keep_refused_selection: + await self._apply_model_selection(runtime, session_id, selection) + else: + await self._apply_room_selection( + runtime, room_id, session_id, selection + ) elif self._resolve_session_config is not None: await self._apply_resolved_config( resolver=self._resolve_session_config, @@ -1464,7 +1603,7 @@ def _build_prompt_text( self, *, room_id: str, - session_id: str, + session: RoomSession, msg: PlatformMessage, replay: list[str] | None = None, participants_msg: str | None = None, @@ -1479,7 +1618,7 @@ def _build_prompt_text( live_message = msg.format_for_llm() system_updates = self._system_update_sections(participants_msg, contacts_msg) - if not self._room_to_session[room_id].bootstrap_pending: + if not session.bootstrap_pending: return "\n\n".join([*system_updates, live_message]) sections = [self._build_system_context(room_id, msg), *system_updates] @@ -1488,88 +1627,47 @@ def _build_prompt_text( logger.info( "Replaying %d room history lines into new ACP session %s for room %s", len(replay), - session_id, + session.session_id, room_id, ) else: sections.append(live_message) return "\n\n".join(sections) - async def on_cleanup( - self, room_id: str, *, expected_runtime: ACPRuntime | None = None - ) -> None: - async with self._session_lock: - if ( - expected_runtime is not None - and self._runtimes.get(room_id) is not expected_runtime - ): - return - session = self._room_to_session.pop(room_id, None) - initializer = self._session_initializers.pop(room_id, None) - self._room_tools.pop(room_id, None) - runtime = self._runtimes.pop(room_id, None) - self._workspaces.release(room_id) - - try: - await self._cancel_session_initializers(initializer) - if runtime is not None: - await runtime.stop() - finally: - if session is not None: - self._forget_session(session.session_id) + async def on_cleanup(self, room_id: str) -> None: + """Dispose the room: settle its work, close its process for good, then + erase its session and workspace. + Raises: + CleanupIncompleteError: The disposal outlived the transition + timeout, or was requested by the room's own work; the room keeps + its process and a retry joins the same disposal. + """ + room = self._rooms.get(room_id) + if room is not None: + await room.retained.dispose() logger.debug("Cleaned up ACP client resources for room %s", room_id) - @staticmethod - async def _stop_runtimes(runtimes: list[ACPRuntime]) -> None: - await asyncio.gather(*(runtime.stop() for runtime in runtimes)) - - async def cleanup_all(self, *, final: bool = True) -> None: + async def cleanup_all(self) -> None: """Adapter-wide teardown — the hook ``Agent.stop()`` invokes on shutdown. - Room-owned ACP subprocesses are released by ``on_cleanup``; this method - releases every remaining runtime and the shared local Band MCP server. - Idempotent — safe to call again from ``stop()``. - - ``final`` distinguishes real process shutdown (the default: no future turn - can arrive, so a still-parked one must fail rather than start resources - nothing will ever stop) from the ``on_message`` error path's use of this - same teardown to recover a wedged connection — there, a *later* turn on - any room is expected to self-heal by lazily respawning both the ACP - connection (``_ensure_connection``'s ``can_respawn``) and the MCP backend, - so ``final=False`` must leave that path open. + Disposes every room, then stops the shared Band MCP server, within one + ``retention.transition_timeout_s`` budget. Anything unfinished stays + owned and the next call resumes it. The MCP server stops only after + every room's process closed. Later turns are refused until + ``on_started`` serves again. + + Raises: + CleanupIncompleteError: A room or the MCP server is still closing. """ - async with self._session_lock: - initializers = tuple(self._session_initializers.values()) - self._session_initializers.clear() - self._room_to_session.clear() - self._room_tools.clear() - runtimes = list(self._runtimes.values()) - self._runtimes.clear() - for room_id in self._workspaces.rooms: - self._workspaces.release(room_id) - await self._cancel_session_initializers(*initializers) - await self._drain_background_tasks() - await self._mcp.close(final=final) - await self._stop_runtimes(runtimes) + await self._mcp.close_after( + self._retention.stop(), retention=self.config.retention + ) logger.info("ACP client adapter stopped") async def stop(self) -> None: - """Tear down now (used by the ``on_message`` error path); see ``cleanup_all``.""" - await self.cleanup_all(final=False) - - async def _cancel_session_initializers( - self, - *initializers: SessionInitializer | None, - ) -> None: - """Cancel in-flight setup before its runtime can be torn down.""" - pending = tuple( - initializer.task for initializer in initializers if initializer is not None - ) - for task in pending: - task.cancel() - if pending: - await asyncio.gather(*pending, return_exceptions=True) + """Tear down every room and the MCP server, as ``cleanup_all`` does.""" + await self.cleanup_all() async def _fetch_replay( self, @@ -1596,7 +1694,10 @@ async def _fetch_replay( raw = messages_before(context.get("data") or [], msg.id) return build_replay_messages([m for m in raw if m.get("id") != msg.id]) - async def _ensure_connection(self, runtime: ACPRuntime) -> ACPConnectionProtocol: - return await runtime.ensure_connection( - can_respawn=True, - ) + +def _no_live_session(room_id: str) -> BandConfigError: + return BandConfigError(f"room {room_id} has no live ACP session to switch") + + +def _session_ended_mid_switch(room_id: str) -> BandConfigError: + return BandConfigError(f"room {room_id}'s ACP session ended mid-switch") diff --git a/src/band/integrations/acp/client_profiles.py b/src/band/integrations/acp/client_profiles.py index 360cfa283..79219452c 100644 --- a/src/band/integrations/acp/client_profiles.py +++ b/src/band/integrations/acp/client_profiles.py @@ -81,6 +81,10 @@ class ACPClientProfile(Protocol): @property def extension_session_id(self) -> str | None: ... + def forget_session(self, session_id: str) -> None: + """Drop any state kept for a session the adapter closed.""" + ... + async def ext_method( self, method: str, @@ -101,6 +105,9 @@ class NoopACPClientProfile: def extension_session_id(self) -> None: return None + def forget_session(self, session_id: str) -> None: + del session_id + async def ext_method( self, method: str, @@ -121,28 +128,27 @@ async def ext_notification( class CursorACPClientProfile: """Cursor-specific ACP extension handling.""" - def __init__(self, resolve_method: CursorMethodResolver | None = None) -> None: + def __init__( + self, + resolve_method: CursorMethodResolver | None = None, + *, + active_session: Callable[[], str | None] | None = None, + ) -> None: + """``active_session`` names the session sessionless notifications + belong to; without it they are dropped.""" self._resolve_method = resolve_method - self._session_id: str | None = None + self._active_session = active_session self._todos_by_session: dict[str, dict[str, tuple[str, str]]] = {} @property def extension_session_id(self) -> str | None: """The session receiving Cursor notifications without a session id.""" - return self._session_id - - def bind_session(self, session_id: str | None) -> None: - """Bind extension notifications to the serialized Cursor turn's session.""" - self._session_id = session_id + return None if self._active_session is None else self._active_session() def forget_session(self, session_id: str) -> None: - """Drop todo state when the adapter releases its ACP session.""" + """Drop todo state when the adapter closes its ACP session.""" self._todos_by_session.pop(session_id, None) - def clear_sessions(self) -> None: - """Drop all todo state during adapter-wide teardown.""" - self._todos_by_session.clear() - async def ext_method( self, method: str, @@ -205,7 +211,9 @@ def _todo_chunks(self, params: dict[str, object]) -> list[CollectedChunk]: # notification's own id -- the same precedence ACPCollectingClient # already uses to route this chunk to a transcript. session_id = ( - params.get("sessionId") or params.get("session_id") or self._session_id + params.get("sessionId") + or params.get("session_id") + or self.extension_session_id ) if not isinstance(todos, list) or not isinstance(session_id, str): return [] diff --git a/src/band/integrations/acp/client_runtime.py b/src/band/integrations/acp/client_runtime.py index 516badefe..acae4704e 100644 --- a/src/band/integrations/acp/client_runtime.py +++ b/src/band/integrations/acp/client_runtime.py @@ -4,8 +4,8 @@ import asyncio import logging -from collections.abc import Callable -from contextlib import AbstractAsyncContextManager +from collections.abc import Callable, Iterator +from contextlib import AbstractAsyncContextManager, contextmanager, suppress from typing import Any, cast from acp import spawn_agent_process @@ -13,6 +13,7 @@ ClientCapabilities, ) +from band.core.retention.cleanup import rejoin_owned, report_owned from band.integrations.acp.collecting import ACPCollectingClient from band.integrations.acp.permissions import ( ALLOW_ALWAYS_KIND, @@ -73,6 +74,10 @@ logger = logging.getLogger(__name__) +STDOUT_DISCARD_CHUNK_BYTES = 65536 +INJECTED_TRANSPORT = "" +STDOUT_HELD_WARNING_SECONDS = 5.0 + class ACPRuntime(ACPSessionOperations): """Generic ACP subprocess runtime shared by outbound ACP bridges.""" @@ -105,40 +110,34 @@ def __init__( self._ctx: ( AbstractAsyncContextManager[tuple[ACPConnectionProtocol, object]] | None ) = None - self._stop_lock = asyncio.Lock() + self._transport: object = None + self._exit_attempted = False + self._closure: asyncio.Task[None] | None = None self._stderr = ACPStderrDrain(logger) - async def start(self, *, respawn: bool = False) -> None: - """Spawn or respawn the ACP agent subprocess.""" - async with self._stop_lock: - await self._start(respawn=respawn) - - async def _start(self, *, respawn: bool) -> None: + async def start(self) -> None: + """Spawn the ACP agent subprocess and initialize its connection.""" + if self._ctx is not None: + raise RuntimeError("ACP runtime is already started or not yet closed") self._connection_failed = False - logger.info( - "%s ACP agent subprocess", - "Respawning" if respawn else "Spawning", - ) + logger.info("Spawning ACP agent subprocess") - ctx = self._spawn_context() - self._ctx = ctx + self._ctx = self._spawn_context() try: - self._conn, transport = await ctx.__aenter__() - if isinstance(transport, asyncio.subprocess.Process): - self._stderr.start(transport) + self._conn, self._transport = await self._ctx.__aenter__() + if isinstance(self._transport, asyncio.subprocess.Process): + self._stderr.start(self._transport) await self._initialize_connection(self._conn) - except (asyncio.CancelledError, KeyboardInterrupt): - self._stderr.expect_exit(connection_failed=self._connection_failed) - await self._cleanup_failed_start(ctx, "init cancel") - raise - except Exception: - await self._cleanup_failed_start(ctx, "init failure") + except BaseException as error: + if isinstance(error, (asyncio.CancelledError, KeyboardInterrupt)): + self._stderr.expect_exit(connection_failed=self._connection_failed) + await self._close_failed_start() raise # A connect-only transport (e.g. TCP) carries no command; describe it # rather than logging a blank suffix. logger.info( "Connected to ACP agent: %s", - " ".join(self._command) or "", + " ".join(self._command) or INJECTED_TRANSPORT, ) def _spawn_context( @@ -174,56 +173,92 @@ async def _initialize_connection(self, conn: ACPConnectionProtocol) -> None: await conn.authenticate(method_id=self._auth_method) logger.info("Authenticated with method: %s", self._auth_method) - async def ensure_connection(self, *, can_respawn: bool) -> ACPConnectionProtocol: - async with self._stop_lock: - if self._conn is None: - if self._ctx is None and can_respawn: - await self._start(respawn=False) - else: - raise RuntimeError( - "ACP client not initialized. Call on_started first." - ) - - conn = self._conn + async def ensure_connection(self) -> ACPConnectionProtocol: + if self._conn is None: + raise RuntimeError("ACP client connection is not open") + return self._conn - if conn is None: - raise RuntimeError("ACP client connection dropped before prompt") - return conn + @property + def closed(self) -> bool: + """Whether no process or connection remains to close.""" + return self._ctx is None async def stop(self) -> None: - ctx: AbstractAsyncContextManager[tuple[ACPConnectionProtocol, object]] | None - async with self._stop_lock: - self._stderr.expect_exit(connection_failed=self._connection_failed) - ctx = self._ctx - self._ctx = None - self._conn = None - self._client = None - self._agent_supports_session_load = False - self._agent_supports_session_close = False - try: - if ctx is not None: - await ctx.__aexit__(None, None, None) - except Exception: - logger.exception("Error during ACP runtime shutdown") - finally: - await self._stderr.finish() - - async def _cleanup_failed_start( - self, - ctx: AbstractAsyncContextManager[tuple[ACPConnectionProtocol, object]], - reason: str, - ) -> None: + """Close the connection and confirm its process exited. + + The closure is one owned task: a cancelled caller leaves it running + and the next ``stop`` joins it. After a failed closure the handles stay + held and the next ``stop`` closes that same process again. + + Raises: + Exception: The closure failed; the runtime still holds its process. + """ + if self._ctx is None: + return + self._stderr.expect_exit(connection_failed=self._connection_failed) + await self._closed() + + async def _closed(self) -> None: + self._closure = rejoin_owned( + self._closure, self._close, label="ACP runtime closure" + ) + await report_owned(self._closure, None) + + async def _close(self) -> None: try: - await ctx.__aexit__(None, None, None) - except Exception: - logger.exception("Error cleaning up ACP subprocess after %s", reason) + await self._exit_transport() finally: await self._stderr.finish() + self._forget_connection() + + async def _exit_transport(self) -> None: + """Exit the spawn context once; after that exit failed, only the + process is left to confirm. + + Closure is confirmed at stdout EOF, not exit: ``Process.wait`` returns + at once for a process that already exited, while a descendant that + inherited stdout can still hold it. + """ + process = self._transport + with _warning_while_held(process): + if not self._exit_attempted and self._ctx is not None: + self._exit_attempted = True + try: + await self._ctx.__aexit__(None, None, None) + except Exception: + # The failure itself goes to whoever awaits the closure. + logger.warning( + "ACP agent subprocess %s failed to exit", _pid(process) + ) + raise + elif isinstance(process, asyncio.subprocess.Process): + await _kill(process) + if isinstance(process, asyncio.subprocess.Process) and process.stdout: + await _read_to_eof(process.stdout) + + def _forget_connection(self) -> None: self._ctx = None self._conn = None + self._client = None + self._transport = None + self._exit_attempted = False + self._closure = None self._agent_supports_session_load = False self._agent_supports_session_close = False + async def _close_failed_start(self) -> None: + """Close what a failed start opened; a failed close keeps it for + ``stop`` and is logged here, since the start raises its own error.""" + try: + await self._closed() + except Exception: + logger.warning( + "Closing ACP agent subprocess %s after a failed start failed; " + "stop retries it", + _pid(self._transport), + exc_info=True, + ) + @staticmethod def _select_mcp_transport(init_response: object) -> BandMCPTransport: capabilities = getattr(init_response, "agent_capabilities", None) @@ -246,3 +281,49 @@ def _select_session_close(init_response: object) -> bool: capabilities = getattr(init_response, "agent_capabilities", None) session_capabilities = getattr(capabilities, "session_capabilities", None) return getattr(session_capabilities, "close", None) is not None + + +def _pid(transport: object) -> int | str: + if isinstance(transport, asyncio.subprocess.Process): + return transport.pid + return INJECTED_TRANSPORT + + +async def _kill(process: asyncio.subprocess.Process) -> None: + if process.returncode is None: + logger.warning( + "Killing ACP agent subprocess %s after its exit failed", process.pid + ) + with suppress(ProcessLookupError): + process.kill() + await process.wait() + + +@contextmanager +def _warning_while_held(transport: object) -> Iterator[None]: + """Warn once when closing the agent outlasts ``STDOUT_HELD_WARNING_SECONDS``: + the SDK's exit and the EOF read both wait while a descendant holds stdout.""" + if not isinstance(transport, asyncio.subprocess.Process): + yield + return + timer = asyncio.get_running_loop().call_later( + STDOUT_HELD_WARNING_SECONDS, _warn_held, transport + ) + try: + yield + finally: + timer.cancel() + + +def _warn_held(process: asyncio.subprocess.Process) -> None: + logger.warning( + "ACP agent subprocess %s (exit code %s) has not closed its stdout; a " + "descendant may hold it, and its room stays owned until that closes", + process.pid, + process.returncode, + ) + + +async def _read_to_eof(stream: asyncio.StreamReader) -> None: + while await stream.read(STDOUT_DISCARD_CHUNK_BYTES): + pass diff --git a/src/band/integrations/acp/client_types.py b/src/band/integrations/acp/client_types.py index 6fda9fa3d..fa8b30311 100644 --- a/src/band/integrations/acp/client_types.py +++ b/src/band/integrations/acp/client_types.py @@ -2,10 +2,33 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field +from acp.exceptions import RequestError + +from band.core.protocols import AgentToolsProtocol +from band.core.retention import RetainedRoom +from band.integrations.acp.client_runtime import ACPRuntime from band.integrations.acp.collecting import ACPCollectingClient +# An agent's refusal of a prompt while its session runs another one. +SESSION_BUSY_CODE = -32003 +SESSION_BUSY_REASON = "session_busy" + + +def session_busy_error(message: str = "Session is busy") -> RequestError: + """The ``session_busy`` refusal an agent answers a concurrent prompt with.""" + return RequestError(SESSION_BUSY_CODE, message, {"reason": SESSION_BUSY_REASON}) + + +def is_session_busy(error: RequestError) -> bool: + return ( + error.code == SESSION_BUSY_CODE + and isinstance(error.data, dict) + and error.data.get("reason") == SESSION_BUSY_REASON + ) + @dataclass class ACPClientSessionState: @@ -22,6 +45,66 @@ class ACPClientSessionState: replay_messages: list[str] = field(default_factory=list) +@dataclass +class RoomSession: + """A room's ACP session and the Band MCP URL it dials, ``None`` when it + dials none. + + ``live`` is whether the session is loaded on the room's current process; + the identity outlives that process until the room is disposed. + """ + + session_id: str + band_url: str | None + bootstrap_pending: bool = True + replay_pending: bool = True + live: bool = True + + +@dataclass(eq=False) +class ACPRoom: + """One room's ACP residency, composed with the ``RetainedRoom`` that owns + its transitions. + + ``runtime`` is the room's process and connection, held until their closure + is confirmed. ``session`` is the native identity, held across a physical + release until final disposal. ``reservation`` serializes the room's prompts + from session setup through their actual end. ``session_closes`` are + best-effort closes of retired sessions, which finish before the process + is released. + """ + + room_id: str + workspace: str + retained: RetainedRoom = field(init=False) + runtime: ACPRuntime | None = None + session: RoomSession | None = None + tools: AgentToolsProtocol | None = None + reservation: asyncio.Lock = field(default_factory=asyncio.Lock) + session_closes: set[asyncio.Task[None]] = field(default_factory=set) + + @property + def generation(self) -> int: + """The residency the room's current process belongs to.""" + return self.retained.generation + + @property + def live_record(self) -> RoomSession | None: + """The room's session while it is loaded on the current process.""" + session = self.session + if session is None or not session.live or self.runtime is None: + return None + return session + + @property + def live_session(self) -> tuple[str, ACPRuntime] | None: + """The live session's id and the process it is loaded on, if any.""" + session, runtime = self.live_record, self.runtime + if session is None or runtime is None: + return None + return session.session_id, runtime + + class BandACPClient(ACPCollectingClient): """Compatibility alias for ``ACPCollectingClient``. diff --git a/src/band/integrations/acp/collecting.py b/src/band/integrations/acp/collecting.py index f8bc3ddd8..b576f7618 100644 --- a/src/band/integrations/acp/collecting.py +++ b/src/band/integrations/acp/collecting.py @@ -153,8 +153,13 @@ def record_config_options( def config_options(self, session_id: str) -> tuple[SessionConfigOption, ...]: return self._config_options.get(session_id, ()) - def forget_config_options(self, session_id: str) -> None: + def forget_session(self, session_id: str) -> None: + """Drop a closed session's catalog and any state its profile keeps.""" self._config_options.pop(session_id, None) + # A custom profile written before this hook has none. + forget = getattr(self._profile, ACPClientProfile.forget_session.__name__, None) + if forget is not None: + forget(session_id) def reset_session(self, session_id: str) -> None: self._stream.reset_session(session_id) diff --git a/src/band/integrations/acp/sessions.py b/src/band/integrations/acp/sessions.py index 460a4d186..7d92d470b 100644 --- a/src/band/integrations/acp/sessions.py +++ b/src/band/integrations/acp/sessions.py @@ -29,9 +29,20 @@ logger = logging.getLogger(__name__) ACP_SESSION_LOAD_TIMEOUT_SECONDS = 5.0 +# session/cancel is a notification; only a wedged agent that stops reading +# stdin can hold its write, and a turn unwinding behind it holds its room. +ACP_SESSION_CANCEL_TIMEOUT_SECONDS = 5.0 Response = TypeVar("Response") +class ACPSessionRestoreError(RuntimeError): + """A retained session could not be restored; its identity is kept.""" + + def __init__(self, session_id: str, reason: str) -> None: + super().__init__(f"ACP session {session_id} could not be restored: {reason}") + self.session_id = session_id + + class ACPSessionOperations(ABC): """Control sessions through a lifecycle-owned connection.""" @@ -44,7 +55,7 @@ def __init__(self) -> None: self._config_lock = asyncio.Lock() @abstractmethod - async def ensure_connection(self, *, can_respawn: bool) -> ACPConnectionProtocol: + async def ensure_connection(self) -> ACPConnectionProtocol: """Return the lifecycle owner's active connection.""" raise NotImplementedError @@ -55,7 +66,7 @@ async def create_session(self, *, cwd: str, mcp_servers: list[object]) -> str: async def create_session_response( self, *, cwd: str, mcp_servers: list[object] ) -> NewSessionResponse: - conn = await self.ensure_connection(can_respawn=False) + conn = await self.ensure_connection() response = cast( NewSessionResponse, await self._request(conn.new_session(cwd=cwd, mcp_servers=mcp_servers)), @@ -86,41 +97,59 @@ async def load_session_response( mcp_servers: list[object], ) -> LoadSessionResponse | None: """Load a persisted session, falling back on an unavailable or failed load.""" - if not self._agent_supports_session_load: - return None - - conn = await self.ensure_connection(can_respawn=False) try: - response = await asyncio.wait_for( - self._request( - conn.load_session( - cwd=cwd, - session_id=session_id, - mcp_servers=mcp_servers, - ) - ), - timeout=ACP_SESSION_LOAD_TIMEOUT_SECONDS, + return await self.restore_session_response( + cwd=cwd, session_id=session_id, mcp_servers=mcp_servers ) - except TimeoutError: - logger.warning( - "ACP session %s did not load within %s seconds", - session_id, - ACP_SESSION_LOAD_TIMEOUT_SECONDS, - ) - return None - except RequestError as error: + except ACPSessionRestoreError as error: # Any load failure is equally recoverable: the caller falls back to # a fresh session (with history replay) rather than letting a remote # protocol error kill the bootstrap turn. - if self._is_missing_session_error(error): - logger.info("ACP session %s is no longer available", session_id) - else: - logger.warning( - "ACP session/load for %s failed (%s); using a new session", - session_id, - error, - ) + match error.__cause__: + case None: + pass + case RequestError() as cause if self._is_missing_session_error(cause): + logger.info("ACP session %s is no longer available", session_id) + case _: + logger.warning("%s; using a new session", error) return None + + async def restore_session_response( + self, + *, + cwd: str, + session_id: str, + mcp_servers: list[object], + ) -> LoadSessionResponse: + """Load exactly ``session_id``; any refusal raises instead of falling back. + + Raises: + ACPSessionRestoreError: The connection does not advertise + ``loadSession`` (no cause), or the agent refused or did not + answer the load (its cause). + """ + if not self._agent_supports_session_load: + raise ACPSessionRestoreError( + session_id, "the connection does not advertise loadSession" + ) + conn = await self.ensure_connection() + try: + async with asyncio.timeout(ACP_SESSION_LOAD_TIMEOUT_SECONDS): + response = cast( + LoadSessionResponse, + await self._request( + conn.load_session( + cwd=cwd, session_id=session_id, mcp_servers=mcp_servers + ) + ), + ) + except TimeoutError as error: + raise ACPSessionRestoreError( + session_id, + f"no answer within {ACP_SESSION_LOAD_TIMEOUT_SECONDS} seconds", + ) from error + except RequestError as error: + raise ACPSessionRestoreError(session_id, str(error)) from error self._record_config_options(session_id, response) return response @@ -132,7 +161,7 @@ async def set_config_option( value: str, ) -> SetSessionConfigOptionResponse | None: """Set one advertised select option and return the refreshed catalog.""" - conn = await self.ensure_connection(can_respawn=False) + conn = await self.ensure_connection() response = await self._request( conn.set_config_option( session_id=session_id, @@ -162,10 +191,10 @@ def _record_config_options(self, session_id: str, response: object) -> None: async def close_session(self, session_id: str) -> None: """Close a session when the agent advertised lifecycle support.""" if self._client is not None: - self._client.forget_config_options(session_id) + self._client.forget_session(session_id) if not self._agent_supports_session_close: return - conn = await self.ensure_connection(can_respawn=False) + conn = await self.ensure_connection() await self._request(conn.close_session(session_id)) async def prompt( @@ -175,7 +204,7 @@ async def prompt( prompt_text: str, on_chunk: ChunkSink | None = None, ) -> list[CollectedChunk]: - conn = await self.ensure_connection(can_respawn=False) + conn = await self.ensure_connection() if on_chunk is not None and self._client is not None: self._client.set_sink(session_id, on_chunk) try: @@ -191,8 +220,9 @@ async def prompt( async def cancel_turn(self, session_id: str) -> None: """Tell the agent to stop a room's in-flight prompt.""" - conn = await self.ensure_connection(can_respawn=False) - await self._request(conn.cancel(session_id)) + conn = await self.ensure_connection() + async with asyncio.timeout(ACP_SESSION_CANCEL_TIMEOUT_SECONDS): + await self._request(conn.cancel(session_id)) async def _request(self, operation: Awaitable[Response]) -> Response: try: diff --git a/src/band/integrations/claude_sdk/session_manager.py b/src/band/integrations/claude_sdk/session_manager.py index 21f409586..5f663a87c 100644 --- a/src/band/integrations/claude_sdk/session_manager.py +++ b/src/band/integrations/claude_sdk/session_manager.py @@ -1,11 +1,11 @@ """ Session manager for Claude Agent SDK clients. -Maintains one ClaudeSDKClient instance per Band chat room to ensure -conversation continuity within each room. - -Uses a dedicated background task for all session operations to ensure -connect() and disconnect() are always called from the same task context. +Maintains one Claude CLI session per Band chat room, each composed with that +room's ``RetainedRoom``: connect, close and recovery are owned per room, so a +slow transition in one room never holds up another. The SDK runs its own +background work as detached tasks, so connect, query, the message reader and +disconnect may each run in a different task on the same event loop. """ from __future__ import annotations @@ -13,21 +13,36 @@ import asyncio import dataclasses import logging -from collections.abc import Callable +import traceback +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass +from pathlib import Path from typing import Any +from band.core.exceptions import RoomClosingError +from band.core.retention import ( + ReleaseCause, + ReleaseOutcome, + RetainedRoom, + RoomRetention, + RoomRetentionConfig, +) +from band.core.turn_lifecycle import TURN_SETTLE_GRACE_S from band.workspaces import RoomWorkspaces try: from claude_agent_sdk import ( # type: ignore[import-not-found] ClaudeAgentOptions, ClaudeSDKClient, + CLIConnectionError, + ResultMessage, ) from claude_agent_sdk._internal.transport import Transport from claude_agent_sdk.types import ( # type: ignore[import-not-found] CanUseTool, McpServerConfig, + Message, ) from band.integrations.claude_sdk.transport import create_transport @@ -38,426 +53,424 @@ logger = logging.getLogger(__name__) +# A room's CLI starts as an owned acquisition that its turn waits on for at +# most ``transition_timeout_s``; the SDK waits at least this long for the CLI +# to initialize, so a shorter budget would discard healthy starts. +CLI_STARTUP_TIMEOUT_S = 60.0 -class ClaudeSessionManagerStoppedError(RuntimeError): - """A session was requested from a manager that ``stop()`` shut down.""" +def default_claude_retention() -> RoomRetentionConfig: + """Claude's residency settings: a CLI start may take ``CLI_STARTUP_TIMEOUT_S``.""" + return RoomRetentionConfig(transition_timeout_s=CLI_STARTUP_TIMEOUT_S) -@dataclass -class SessionCommand: - """Command to be processed by the session manager task.""" - action: str # "create", "cleanup", "cleanup_all", "invalidate", "stop" - room_id: str | None = None - resume_session_id: str | None = None - result_future: asyncio.Future[Any] | None = None - release_workspace: bool = True +class ClaudeSessionManagerStoppedError(RoomClosingError): + """A session was requested from a manager that ``stop()`` shut down.""" -class ClaudeSessionManager: - """ - Manages ClaudeSDKClient instances per chat room. +@dataclass(frozen=True) +class StreamEnd: + """The CLI's output ended, cleanly or with ``error``.""" - Each room gets its own ClaudeSDKClient instance to maintain separate - conversation histories and sessions. + error: Exception | None - Key features: - - Lazy initialization (clients created on first message) - - Session reuse (same client for all messages in a room) - - Graceful cleanup on room leave/disconnect - - All session operations run in a single background task to avoid - cross-task asyncio issues with connect/disconnect - Example: - import logging - logger = logging.getLogger(__name__) +class TurnStream: + """One turn's share of its room's single CLI reader. - manager = ClaudeSessionManager(base_options) - await manager.start() # Start background task + The end of the CLI's output stays visible to every later read, so a + timed-out turn's drain never waits on a stream that already ended. + """ - # Get client for room (creates if doesn't exist) - client = await manager.get_or_create_session("room-123") + def __init__(self) -> None: + self._messages: asyncio.Queue[Message | StreamEnd] = asyncio.Queue() + self.completed = False + + def deliver(self, message: Message) -> None: + self._messages.put_nowait(message) + if isinstance(message, ResultMessage): + # The turn reads nothing past its result, so it stops listening here. + self.completed = True + + def end(self, error: Exception | None) -> None: + self._messages.put_nowait(StreamEnd(error)) + + async def until_result(self) -> AsyncIterator[Message]: + """Messages up to and including the turn's ``ResultMessage``; the + stream's end stops early, re-raising a read failure.""" + while not isinstance(item := await self._messages.get(), StreamEnd): + yield item + if isinstance(item, ResultMessage): + return + self._messages.put_nowait(item) + if item.error is not None: + raise item.error + + +def _start_failure(error: Exception) -> BaseException: + """The failure a failed ``ClaudeSDKClient.connect`` reports for its start. + + ``connect`` closes the CLI before re-raising a failed start, so a close + that fails there raises in the start's place, with the start's own + failure as its context. + """ + raised_by_close = any( + frame.f_code is ClaudeSDKClient.disconnect.__code__ + for frame, _ in traceback.walk_tb(error.__traceback__) + ) + if raised_by_close and isinstance(error.__context__, Exception): + return error.__context__ + return error - # Use client - await client.query("Hello") - async for msg in client.receive_response(): - logger.info("%s", msg) - # Cleanup when done - await manager.cleanup_session("room-123") - await manager.stop() # Stop background task +class ClaudeRoomSession: + """A room's Claude CLI session and everything it keeps until closure. + + ``client`` and ``transport`` are retained before connect starts and + cleared only once closing them succeeds. ``session_id`` is the native + conversation the next process resumes; final disposal erases it, and the + crash policy (``recover(forget_session=True)``) drops it once the crashed + process is closed. """ def __init__( - self, - base_options: ClaudeAgentOptions, - can_use_tool_factory: Callable[[str], CanUseTool] | None = None, - mcp_servers_factory: Callable[[str], dict[str, McpServerConfig]] | None = None, - workspaces: RoomWorkspaces | None = None, + self, manager: ClaudeSessionManager, room_id: str, retention: RoomRetention ) -> None: - """ - Initialize session manager. - - Args: - base_options: Base ClaudeAgentOptions to use for all clients. - These options are shared across all room sessions. - can_use_tool_factory: Optional factory that creates a room-specific - ``can_use_tool`` callback. When set, each new session receives - its own callback bound to the room_id. - mcp_servers_factory: Optional factory that returns a room's - ``mcp_servers``. When set, it replaces - ``base_options.mcp_servers`` for each new session. - workspaces: Adapter-local claims; omit to preserve base_options.cwd. - """ - self.base_options = base_options - self._can_use_tool_factory = can_use_tool_factory - self._mcp_servers_factory = mcp_servers_factory - self._sessions: dict[str, ClaudeSDKClient] = {} - self._transports: dict[str, Transport] = {} - self._unusable: set[str] = set() - self._workspaces = workspaces - self._command_queue: asyncio.Queue[SessionCommand] = asyncio.Queue() - self._task: asyncio.Task[None] | None = None - self._shutdown: asyncio.Task[None] | None = None - logger.info("ClaudeSessionManager initialized") + self.room_id = room_id + self.session_id: str | None = None + self.client: ClaudeSDKClient | None = None + self.transport: Transport | None = None + self._manager = manager + self._reader: asyncio.Task[None] | None = None + self._turn: TurnStream | None = None + self._forget_session = False + self.retained: RetainedRoom = retention.register( + room_id, acquire=self.acquire, release=self.release, dispose=self.dispose + ) @property - def _loop_running(self) -> bool: - return self._task is not None - - async def start(self) -> None: - """Start the background task that manages all sessions.""" - if self._shutdown is not None: - raise ClaudeSessionManagerStoppedError() - if self._loop_running: - return + def generation(self) -> int: + return self.retained.generation - self._task = asyncio.create_task(self._run_session_loop()) - logger.info("ClaudeSessionManager background task started") - - async def stop(self) -> None: - """Stop the background task and cleanup all sessions, for good: the - adapter builds a new manager when it starts again. Every caller - awaits the same shutdown, which a cancelled caller doesn't abandon.""" - if self._shutdown is None or ( - self._shutdown.done() and self._shutdown.exception() is not None - ): - self._shutdown = asyncio.create_task(self._shut_down()) - await asyncio.shield(self._shutdown) - - async def _shut_down(self) -> None: - if self._task is None: - return - result = asyncio.get_running_loop().create_future() - self._command_queue.put_nowait( - SessionCommand(action="stop", result_future=result) - ) - await result - await self._task - self._task = None - self._fail_pending_commands() - logger.info("ClaudeSessionManager background task stopped") - - def _fail_pending_commands(self) -> None: - """Settle every command still queued once the loop has exited, so no - caller waits forever on a future nothing will resolve. Teardown - commands succeed (stop already tore every session down); only a - ``create`` can't be honoured.""" - while not self._command_queue.empty(): - cmd = self._command_queue.get_nowait() - if cmd.result_future is None or cmd.result_future.done(): - continue - if cmd.action == "create": - cmd.result_future.set_exception(ClaudeSessionManagerStoppedError()) - else: - cmd.result_future.set_result(None) - - async def _run_session_loop(self) -> None: - """Background task that processes all session commands.""" - logger.debug("Session loop started") - - while True: - cmd: SessionCommand | None = None - try: - cmd = await self._command_queue.get() - - if cmd.action == "create": - client = await self._do_create_session( - cmd.room_id, cmd.resume_session_id - ) - if cmd.result_future is not None and not cmd.result_future.done(): - cmd.result_future.set_result(client) + @property + def holds_cli(self) -> bool: + return self.client is not None - elif cmd.action == "cleanup": - await self._do_cleanup_session( - cmd.room_id, release_workspace=cmd.release_workspace - ) - if cmd.result_future is not None and not cmd.result_future.done(): - cmd.result_future.set_result(None) - - elif cmd.action == "invalidate": - await self._do_invalidate_session(cmd.room_id) - if cmd.result_future is not None and not cmd.result_future.done(): - cmd.result_future.set_result(None) - - elif cmd.action == "cleanup_all": - await self._do_cleanup_all() - if cmd.result_future is not None and not cmd.result_future.done(): - cmd.result_future.set_result(None) - - elif cmd.action == "stop": - await self._do_cleanup_all() - if cmd.result_future is not None and not cmd.result_future.done(): - cmd.result_future.set_result(None) - break - - self._command_queue.task_done() - - except asyncio.CancelledError: - logger.debug("Session loop cancelled") - break - except Exception as e: - logger.exception("Error in session loop") - if cmd and cmd.result_future and not cmd.result_future.done(): - cmd.result_future.set_exception(e) - - logger.debug("Session loop exited") - - def _build_options( - self, room_id: str, resume_session_id: str | None = None - ) -> ClaudeAgentOptions: - """Build ``ClaudeAgentOptions`` for a specific room session. + def speaks_for(self, generation: int) -> bool: + """Whether a callback from residency ``generation`` may still act. - Uses ``dataclasses.replace()`` to shallow-copy all fields from - ``base_options``, then applies room-specific overrides. + Room id plus generation alone would collide after a rejoin, so the + callback also holds this session object, which a rejoin replaces. """ - overrides: dict[str, Any] = {} - if self._workspaces is not None: - overrides["cwd"] = self._workspaces.claim(room_id) + return self.retained.has_authority(generation) - if resume_session_id: - overrides["resume"] = resume_session_id + @asynccontextmanager + async def using( + self, prepare: Callable[[], Awaitable[None]] | None = None + ) -> AsyncIterator[ClaudeSDKClient]: + """Lease the room's resident client for one accepted operation. - if self._can_use_tool_factory: - overrides["can_use_tool"] = self._can_use_tool_factory(room_id) - - if self._mcp_servers_factory: - overrides["mcp_servers"] = self._mcp_servers_factory(room_id) - - return dataclasses.replace(self.base_options, **overrides) - - async def _do_create_session( - self, room_id: str | None, resume_session_id: str | None - ) -> ClaudeSDKClient: - """Create or get session (runs in background task).""" - if not room_id: - raise ValueError("room_id is required") - - if (client := self._sessions.get(room_id)) is not None and ( - room_id in self._unusable or not self._is_current(room_id, client) - ): - logger.info("Replacing unusable or outdated session for room %s", room_id) - await self._do_cleanup_session(room_id, release_workspace=False) - - if room_id not in self._sessions: - if resume_session_id: - logger.info( - "Resuming session %s for room: %s", resume_session_id, room_id - ) - else: - logger.info( - "Creating new ClaudeSDKClient session for room: %s", room_id - ) - - options = self._build_options(room_id, resume_session_id) - - transport = create_transport(options) - client = ClaudeSDKClient(options=options, transport=transport) - # Retain both handles before connect: SDK disconnect cannot close a - # subprocess when startup fails before its Query is constructed. - self._sessions[room_id] = client - self._transports[room_id] = transport - self._unusable.add(room_id) - try: - await client.connect() - except BaseException: - await self._do_cleanup_session(room_id, release_workspace=False) + ``prepare`` binds the operation before a CLI is started for it. + """ + async with self.retained.use(prepare): + if self.client is None: + raise RuntimeError(f"Room {self.room_id} is resident without a client") + yield self.client + + @contextmanager + def listening(self) -> Iterator[TurnStream]: + """Route the reader's messages to one turn until it stops listening.""" + if self._turn is not None: + raise RuntimeError(f"Room {self.room_id} already has a listening turn") + stream = self._turn = TurnStream() + try: + yield stream + finally: + self._turn = None + + def adopt(self, session_id: str | None) -> None: + """Resume the conversation a bootstrapping room persisted, unless a + CLI is already live.""" + if session_id is not None and self.client is None: + self.session_id = session_id + + def recover(self, reason: str, *, forget_session: bool = False) -> bool: + """Retire the resident CLI through the room's owner. + + It closes once no operation leases the room; until then its callbacks + lose authority. ``forget_session`` is the crash policy: the next + process starts a new conversation. + """ + return self._retire(self.generation, reason, forget_session=forget_session) + + def _retire(self, generation: int, reason: str, *, forget_session: bool) -> bool: + if not self.retained.recover(generation=generation, reason=reason): + return False + self._forget_session = self._forget_session or forget_session + return True + + def retire_outdated(self) -> None: + """A CLI dialing MCP servers the room no longer has resumes on the + current ones.""" + if self.client is None: + return + if self.client.options.mcp_servers != self._manager.mcp_servers(self.room_id): + logger.info("Room %s: MCP servers changed; replacing its CLI", self.room_id) + self.recover("Band MCP servers changed") + + async def acquire(self, generation: int, resume: bool) -> None: + """Start the room's CLI. ``resume`` restores the retained session + strictly; otherwise an unresumable earlier session gives way to a new + one, the bootstrap and crash policy, unless the room began closing.""" + try: + await self._connect(generation, resume=self.session_id) + except Exception as error: + if not self._may_start_fresh(resume): raise - self._unusable.discard(room_id) - - logger.info( - "Session created for room %s (total sessions: %s)", - room_id, - len(self._sessions), + logger.warning( + "Room %s: Session resume failed (session_id=%s): %s. " + "Creating new session", + self.room_id, + self.session_id, + error, ) - else: - logger.debug("Reusing existing session for room: %s", room_id) + # The conversation is dropped only once a new one is running. + await self._connect(generation, resume=None) + self.session_id = None + + def _may_start_fresh(self, resume: bool) -> bool: + """Whether a failed resume may give way to a new session: never on a + strict resume or once closing, and not while the failed CLI, which + could not be closed, is still held for recovery.""" + return ( + not resume + and self.session_id is not None + and not self.retained.closing + and self.client is None + ) - return self._sessions[room_id] + async def release(self, cause: ReleaseCause) -> ReleaseOutcome: + """Close the room's CLI for recovery or final cleanup.""" + await self._close() + if self._forget_session: + self.session_id = None + self._forget_session = False + logger.debug("Room %s: Claude CLI closed (%s)", self.room_id, cause) + return ReleaseOutcome.RELEASED + + async def dispose(self) -> None: + """Erase the room's continuation once its final release is confirmed.""" + self.session_id = None + self._manager.discard(self) + + async def _connect(self, generation: int, *, resume: str | None) -> None: + options = self._manager.options_for(self, generation, resume=resume) + transport = create_transport(options) + client = ClaudeSDKClient(options=options, transport=transport) + # Retain both before connect: SDK disconnect cannot close a subprocess + # when startup fails before its Query is constructed. + self.client, self.transport = client, transport + try: + await client.connect() + except Exception as error: + await self._close_failed_start() + if (failure := _start_failure(error)) is error: + raise + raise failure + self._reader = asyncio.create_task(self._read(client, generation)) + logger.info( + "Room %s: Claude session started (resume=%s)", self.room_id, options.resume + ) - def _is_current(self, room_id: str, client: ClaudeSDKClient) -> bool: - """Whether ``client`` still dials the MCP servers the room has now.""" - if self._mcp_servers_factory is None: - return True - return client.options.mcp_servers == self._mcp_servers_factory(room_id) + async def _close_failed_start(self) -> None: + """Close a CLI whose start failed; a refused close keeps the handles + for recovery to retry.""" + try: + await self._close() + except Exception: + logger.warning( + "Room %s: closing a failed Claude start failed; recovery retries it", + self.room_id, + exc_info=True, + ) - async def _do_invalidate_session(self, room_id: str | None) -> None: - """Evict an exited CLI while retaining the room's workspace.""" - if not room_id: + async def _close(self) -> None: + """Close the retained CLI; a failure keeps every handle for a retry.""" + if self.client is None or self.transport is None: return - if (transport := self._transports.get(room_id)) is not None: - self._unusable.add(room_id) - await transport.close() - del self._transports[room_id] - self._sessions.pop(room_id, None) - self._unusable.discard(room_id) - - async def _do_cleanup_session( - self, room_id: str | None, *, release_workspace: bool = True - ) -> None: - """Retain failed cleanup handles and claims so teardown can be retried.""" - if not room_id: + try: + await self.client.disconnect() + finally: + await self.transport.close() + if self._reader is not None: + await self._reader + self.client = self.transport = self._reader = None + + async def _read(self, client: ClaudeSDKClient, generation: int) -> None: + """The CLI's only reader, across turns, from connect to disconnect.""" + error: Exception | None = None + try: + async for message in client.receive_messages(): + if (turn := self._listener(generation)) is not None: + turn.deliver(message) + else: + logger.debug( + "Room %s: %s arrived outside any turn; not attributed", + self.room_id, + type(message).__name__, + ) + except Exception as failure: # noqa: BLE001 -- the listening turn re-raises it + error = failure + if (turn := self._listener(generation)) is not None: + turn.end(error) return - if (client := self._sessions.get(room_id)) is not None: - self._unusable.add(room_id) - try: - await client.disconnect() - finally: - if (transport := self._transports.get(room_id)) is not None: - await transport.close() - del self._sessions[room_id] - self._transports.pop(room_id, None) - self._unusable.discard(room_id) - if release_workspace and self._workspaces is not None: - self._workspaces.release(room_id) - - async def _do_cleanup_all(self) -> None: - """Attempt every room, reporting failures without discarding ownership.""" - rooms = dict.fromkeys(self._sessions) - if self._workspaces is not None: - rooms.update(dict.fromkeys(self._workspaces.rooms)) - errors: list[Exception] = [] - for room_id in rooms: - try: - await self._do_cleanup_session(room_id) - except Exception as error: # noqa: BLE001 -- every room needs a cleanup attempt - errors.append(error) - if errors: - raise ExceptionGroup("Claude session cleanup failed", errors) - - async def get_or_create_session( - self, room_id: str, resume_session_id: str | None = None - ) -> ClaudeSDKClient: - """ - Get existing ClaudeSDKClient for room or create new one. + # No turn will read this end. A dead process gets the crash policy, as + # inside a turn; a read failure from a running CLI keeps the session. + crashed = error is None or isinstance(error, CLIConnectionError) + reason = "CLI output ended outside a turn" + (f": {error}" if error else "") + if self._retire(generation, reason, forget_session=crashed): + logger.warning( + "Room %s: %s; retiring its CLI", self.room_id, reason, exc_info=error + ) + else: + logger.debug("Room %s: CLI output ended (%s)", self.room_id, error) - Calls for the same room_id return the same client instance while its - MCP servers are current; once they change (a replaced Band MCP - server), the client is replaced, resuming ``resume_session_id``. + def _listener(self, generation: int) -> TurnStream | None: + turn = self._turn + if turn is None or turn.completed or not self.retained.may_finish(generation): + return None + return turn - Args: - room_id: Band chat room ID (UUID) - resume_session_id: Optional session ID to resume from a previous - session. Only used when creating a new session. - Returns: - ClaudeSDKClient instance for this room - """ - await self.start() +class ClaudeSessionManager: + """ + Manages one Claude CLI session per chat room. - result_future: asyncio.Future[ClaudeSDKClient] = ( - asyncio.get_running_loop().create_future() - ) - await self._command_queue.put( - SessionCommand( - action="create", - room_id=room_id, - resume_session_id=resume_session_id, - result_future=result_future, - ) - ) - return await result_future + Each room's session is created on its first leased operation, kept across + messages, and owned by one ``RoomRetention`` that settles and closes every + room independently. ``stop()`` is final: the adapter builds a new manager + to start again. - async def cleanup_session( - self, room_id: str, *, release_workspace: bool = True + Example: + manager = ClaudeSessionManager(base_options) + session = manager.room("room-123") + async with session.using() as client: + with session.listening() as stream: + await client.query("Hello") + async for msg in stream.until_result(): + logger.info("%s", msg) + await manager.cleanup_session("room-123") + await manager.stop() + """ + + def __init__( + self, + base_options: ClaudeAgentOptions, + can_use_tool_factory: Callable[[ClaudeRoomSession, int], CanUseTool] + | None = None, + mcp_servers_factory: Callable[[str], dict[str, McpServerConfig]] | None = None, + workspaces: RoomWorkspaces | None = None, + retention: RoomRetentionConfig | None = None, + settle_grace_s: float = TURN_SETTLE_GRACE_S, + on_disposed: Callable[[str], None] | None = None, ) -> None: """ - Disconnect and remove session for a room. - - This should be called when: - - Agent is removed from the room - - Room is deleted - - Adapter is shutting down + Initialize session manager. Args: - room_id: Band chat room ID - release_workspace: False when replacing a client within the same membership. + base_options: Base ClaudeAgentOptions shared by every room session. + can_use_tool_factory: Optional factory for a ``can_use_tool`` + callback bound to one room session and its residency + generation. + mcp_servers_factory: Optional factory that returns a room's + ``mcp_servers``, replacing ``base_options.mcp_servers``. + workspaces: Adapter-local claims; omit to preserve base_options.cwd. + retention: Residency settings, including the transition reporting + budget, which defaults to ``CLI_STARTUP_TIMEOUT_S``; idle + release stays with the adapter's validation. + settle_grace_s: How long a leave or stop lets a room's turn wind + down on its own before cancelling it. + on_disposed: Called with a room's id once its final release is + confirmed, even if the leave that started it stopped waiting. """ - if not self._loop_running: - return + self.base_options = base_options + self._can_use_tool_factory = can_use_tool_factory + self._mcp_servers_factory = mcp_servers_factory + self._workspaces = workspaces + self._on_disposed = on_disposed + self._retention = RoomRetention( + retention or default_claude_retention(), + settle_grace_s=settle_grace_s, + ) + self._rooms: dict[str, ClaudeRoomSession] = {} - result_future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - await self._command_queue.put( - SessionCommand( - action="cleanup", - room_id=room_id, - release_workspace=release_workspace, - result_future=result_future, + def room(self, room_id: str) -> ClaudeRoomSession: + """The room's session record, registered on first use.""" + if self._retention.stopping: + raise ClaudeSessionManagerStoppedError() + if (session := self._rooms.get(room_id)) is None: + session = self._rooms[room_id] = ClaudeRoomSession( + self, room_id, self._retention ) - ) - await result_future + return session - async def invalidate_session(self, room_id: str) -> None: - """ - Evict a dead session without calling disconnect(). + def session(self, room_id: str) -> ClaudeRoomSession | None: + """The room's session record, if any; never registers one.""" + return self._rooms.get(room_id) - Use this when the CLI subprocess has already exited — calling - disconnect() on a dead process would raise or hang. After - invalidation, ``get_or_create_session`` will create a fresh client. + def mcp_servers(self, room_id: str) -> dict[str, McpServerConfig] | str | Path: + if self._mcp_servers_factory is None: + return self.base_options.mcp_servers + return self._mcp_servers_factory(room_id) - Args: - room_id: Band chat room ID + def options_for( + self, session: ClaudeRoomSession, generation: int, *, resume: str | None + ) -> ClaudeAgentOptions: + """``base_options`` with one room session's overrides applied. + + ``dataclasses.replace()`` shallow-copies every base field. """ - if not self._loop_running: - return + overrides: dict[str, Any] = {"mcp_servers": self.mcp_servers(session.room_id)} + if self._workspaces is not None: + overrides["cwd"] = self._workspaces.claim(session.room_id) + if resume: + overrides["resume"] = resume + if self._can_use_tool_factory: + overrides["can_use_tool"] = self._can_use_tool_factory(session, generation) + return dataclasses.replace(self.base_options, **overrides) - result_future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - await self._command_queue.put( - SessionCommand( - action="invalidate", - room_id=room_id, - result_future=result_future, - ) - ) - await result_future + def discard(self, session: ClaudeRoomSession) -> None: + """Forget a disposed room and release its workspace claim.""" + if self._rooms.get(session.room_id) is session: + del self._rooms[session.room_id] + if self._on_disposed is not None: + self._on_disposed(session.room_id) + if self._workspaces is not None: + self._workspaces.release(session.room_id) - async def cleanup_all(self) -> None: - """ - Disconnect all sessions. + async def cleanup_session(self, room_id: str) -> None: + """Settle the room's work, close its CLI and erase its continuation. - This should be called when the adapter is shutting down to ensure - all Claude SDK clients are properly disconnected. + Called when the agent leaves the room. Admission closes at once; a + failed or unfinished cleanup stays owned and a retry joins it. """ - if not self._loop_running: - return + if (session := self._rooms.get(room_id)) is not None: + await session.retained.dispose() - result_future: asyncio.Future[None] = asyncio.get_running_loop().create_future() - await self._command_queue.put( - SessionCommand( - action="cleanup_all", - result_future=result_future, - ) - ) - await result_future + async def stop(self) -> None: + """Dispose every room; a failed or unfinished room stays owned for a + retry, and the manager never serves again.""" + await self._retention.stop() def has_session(self, room_id: str) -> bool: - """Check if session exists for room.""" - return room_id in self._sessions + """Whether the room has a CLI process held.""" + session = self._rooms.get(room_id) + return session is not None and session.holds_cli def get_session_count(self) -> int: - """Get number of active sessions.""" - return len(self._sessions) + """Number of rooms holding a CLI process.""" + return len(self.get_active_rooms()) def get_active_rooms(self) -> list[str]: - """Get list of room IDs with active sessions.""" - return list(self._sessions.keys()) + """Room IDs holding a CLI process.""" + return [room for room, session in self._rooms.items() if session.holds_cli] diff --git a/src/band/integrations/codex/__init__.py b/src/band/integrations/codex/__init__.py index 1bbbd88f2..36dbf58d2 100644 --- a/src/band/integrations/codex/__init__.py +++ b/src/band/integrations/codex/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .rpc_base import ( + TRANSPORT_CLOSED, CodexJsonRpcError, OverloadRetryPolicy, RpcEvent, @@ -11,6 +12,7 @@ from .types import ( CODEX_APPROVAL_METHODS, CODEX_PROVIDER, + DYNAMIC_TOOL_CALL_METHOD, ApprovalAuditEntry, CodexApprovalMethod, CodexItemType, @@ -26,6 +28,8 @@ __all__ = [ "CODEX_APPROVAL_METHODS", "CODEX_PROVIDER", + "DYNAMIC_TOOL_CALL_METHOD", + "TRANSPORT_CLOSED", "ApprovalAuditEntry", "CodexApprovalMethod", "CodexItemType", diff --git a/src/band/integrations/codex/rpc_base.py b/src/band/integrations/codex/rpc_base.py index edbb78d96..2fd379ff3 100644 --- a/src/band/integrations/codex/rpc_base.py +++ b/src/band/integrations/codex/rpc_base.py @@ -16,6 +16,9 @@ JsonRpcId = int | str +# The notification a client enqueues when its transport is gone. +TRANSPORT_CLOSED = "transport/closed" + class CodexJsonRpcError(RuntimeError): """JSON-RPC error returned by codex app-server.""" @@ -60,6 +63,11 @@ class RpcEvent: id: JsonRpcId | None raw: dict[str, Any] + @property + def is_request(self) -> bool: + """The server awaits an answer to this event.""" + return self.kind == "request" + class BaseJsonRpcClient: """Abstract bidirectional JSON-RPC client with shared protocol logic. @@ -237,10 +245,10 @@ def _fail_pending(self, reason: str) -> None: self._events.put_nowait( RpcEvent( kind="notification", - method="transport/closed", + method=TRANSPORT_CLOSED, params={"reason": reason}, id=None, - raw={"method": "transport/closed", "params": {"reason": reason}}, + raw={"method": TRANSPORT_CLOSED, "params": {"reason": reason}}, ) ) except asyncio.QueueFull: diff --git a/src/band/integrations/codex/types.py b/src/band/integrations/codex/types.py index 9aebc84e7..641fb9430 100644 --- a/src/band/integrations/codex/types.py +++ b/src/band/integrations/codex/types.py @@ -29,6 +29,8 @@ class CodexItemType(StrEnum): WEB_SEARCH = "webSearch" IMAGE_VIEW = "imageView" COLLAB_AGENT_TOOL_CALL = "collabAgentToolCall" + # multi_agent_v2 announces sub-agents with this instead of a collab call. + SUB_AGENT_ACTIVITY = "subAgentActivity" DYNAMIC_TOOL_CALL = "dynamicToolCall" REASONING = "reasoning" PLAN = "plan" @@ -61,6 +63,9 @@ class CodexApprovalMethod(StrEnum): CODEX_APPROVAL_METHODS: frozenset[CodexApprovalMethod] = frozenset(CodexApprovalMethod) +# The server request that runs one of the adapter's dynamic tools. +DYNAMIC_TOOL_CALL_METHOD = "item/tool/call" + class CodexRequestMethod(StrEnum): """Client-to-server request methods the SDK sends to ``codex app-server``.""" diff --git a/src/band/integrations/mcp/backends.py b/src/band/integrations/mcp/backends.py index c83627f73..13648e9f2 100644 --- a/src/band/integrations/mcp/backends.py +++ b/src/band/integrations/mcp/backends.py @@ -5,13 +5,19 @@ import asyncio import logging -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass from enum import StrEnum from typing import Self from band.core.exceptions import CleanupIncompleteError -from band.core.retention.cleanup import spawn_owned +from band.core.retention.cleanup import ( + cleanup_deadline, + finish_by, + report_owned, + spawn_owned, +) +from band.core.retention.config import RoomRetentionConfig from band.integrations.mcp.engine import ( RoomToolResolver, build_resolved_band_mcp_tool_registrations, @@ -27,6 +33,10 @@ logger = logging.getLogger(__name__) +#: The cleanup steps ``close_after`` reports, as cleanup reports name them. +ROOMS_CLOSE = "Room shutdown" +MCP_FINAL_CLOSE = "Band MCP backend final close" + class BandMCPTransport(StrEnum): """The wire transports every Band MCP server serves.""" @@ -217,7 +227,21 @@ async def close(self, *, final: bool) -> None: await backend.stop() return if (stopping := await self._final_stop()) is not None: - await asyncio.shield(stopping) + await report_owned(stopping, None) + + async def close_after( + self, rooms_closed: Awaitable[None], *, retention: RoomRetentionConfig + ) -> None: + """Close for good once the rooms whose processes dial this server have + closed, both within one ``retention.transition_timeout_s`` deadline. + + Raises: + CleanupIncompleteError: A step outlived the deadline; it stays + owned for a retry. + """ + deadline = cleanup_deadline(retention) + await finish_by(deadline, rooms_closed, what=ROOMS_CLOSE) + await finish_by(deadline, self.close(final=True), what=MCP_FINAL_CLOSE) async def _final_stop(self) -> asyncio.Task[None] | None: async with self._lock: @@ -226,7 +250,7 @@ async def _final_stop(self) -> asyncio.Task[None] | None: return None if self._stopping is None or self._stopping.done(): self._stopping = spawn_owned( - self._stop_closing(closing), label="Band MCP final close" + self._stop_closing(closing), label=MCP_FINAL_CLOSE ) return self._stopping @@ -250,9 +274,7 @@ async def reopen(self) -> None: """ async with self._lock: if self._closing is not None: - raise CleanupIncompleteError( - "Band MCP backend final close is still pending" - ) + raise CleanupIncompleteError(f"{MCP_FINAL_CLOSE} is still pending") self._closed = False async def _start(self, *, replacing: BandMCPBackend | None) -> BandMCPBackend: diff --git a/src/band/integrations/omp.py b/src/band/integrations/omp.py index c83fbb818..a4370d697 100644 --- a/src/band/integrations/omp.py +++ b/src/band/integrations/omp.py @@ -21,6 +21,8 @@ OMP_APPROVAL_MODE_WRITE = "write" OMP_APPROVAL_MODE_YOLO = "yolo" OMP_ACP_SUBCOMMAND = "acp" +# The acp subcommand's flag for the room workspace, passed as ``--cwd=``. +OMP_CWD_FLAG = "--cwd" DEFAULT_OMP_ACP_COMMAND: tuple[str, ...] = ( "omp", @@ -165,7 +167,7 @@ def omp_command_in_workspace(command: Sequence[str], workspace: str) -> list[str f"got {list(command)!r}" ) split = list(command).index(OMP_ACP_SUBCOMMAND) + 1 - return [*command[:split], f"--cwd={workspace}", *command[split:]] + return [*command[:split], f"{OMP_CWD_FLAG}={workspace}", *command[split:]] def omp_elicitation_call_id(session_id: str) -> str: diff --git a/src/band/runtime/decisions.py b/src/band/runtime/decisions.py index f3b3092f5..fee965171 100644 --- a/src/band/runtime/decisions.py +++ b/src/band/runtime/decisions.py @@ -65,13 +65,11 @@ def __iter__(self) -> Iterator[str]: def __len__(self) -> int: return len(self._entries) - def register_minted( - self, payload: T, *, room_id: str | None = None - ) -> Registration[T]: - """Add ``payload`` under a freshly minted token, optionally scoped to - ``room_id``, evicting the oldest open ask first when at capacity.""" + def register_minted(self, payload: T) -> Registration[T]: + """Add ``payload`` under a freshly minted token, evicting the oldest + open ask first when at capacity.""" evicted = self._evict_oldest() - if (registered := self._core.register_minted(room_id)) is None: + if (registered := self._core.register_minted()) is None: raise RuntimeError("DecisionRegistry ran out of tickets") token, ticket = registered return Registration( @@ -141,9 +139,6 @@ def unclaimed(self) -> list[DecisionEntry[T]]: """Entries still awaiting an answer -- what a room should see as pending.""" return self._entries_for(self._core.unclaimed()) - def unclaimed_in_room(self, room_id: str) -> list[DecisionEntry[T]]: - return self._entries_for(self._core.unclaimed_in_room(room_id)) - def unclaimed_count(self) -> int: return self._core.unclaimed_count() @@ -165,10 +160,6 @@ def cancel_all(self) -> list[DecisionEntry[T]]: resolve -- a claimed entry's claimant still resolves it.""" return self._drop_cancelled(self._core.cancel_all()) - def cancel_room(self, room_id: str) -> list[DecisionEntry[T]]: - """:meth:`cancel_all`, limited to entries registered in ``room_id``.""" - return self._drop_cancelled(self._core.cancel_room(room_id)) - async def wait( self, entry: DecisionEntry[T], future: asyncio.Future[R], *, timeout_s: float ) -> R | Literal[Timeout.TIMED_OUT]: diff --git a/src/band/workspaces.py b/src/band/workspaces.py index 472756929..5885dd599 100644 --- a/src/band/workspaces.py +++ b/src/band/workspaces.py @@ -4,7 +4,8 @@ import logging import os -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from pathlib import Path logger = logging.getLogger(__name__) @@ -44,6 +45,16 @@ def release(self, room_id: str, workspace: str | None = None) -> None: release_room_workspace(room_id, path, self._owners) del self._paths[room_id] + @contextmanager + def claiming(self, room_id: str) -> Iterator[str]: + """Claim the room's workspace for a setup that gives it back if it raises.""" + workspace = self.claim(room_id) + try: + yield workspace + except BaseException: + self.release(room_id, workspace) + raise + def is_host_absolute(path: str) -> bool: """Whether ``path`` is absolute on the running OS -- on Windows that also diff --git a/tests/adapters/claude_sdk/approvals/test_approval_modes.py b/tests/adapters/claude_sdk/approvals/test_approval_modes.py index 60ab311c4..8706a5a02 100644 --- a/tests/adapters/claude_sdk/approvals/test_approval_modes.py +++ b/tests/adapters/claude_sdk/approvals/test_approval_modes.py @@ -2,17 +2,17 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest from band.adapters.claude_sdk import ( APPROVAL_POLICY_DECISION_TEMPLATE, ClaudeSDKAdapterConfig, ) -from tests.adapters.claude_sdk.helpers import WRITE_NOTE, ClaudeRoom, with_approvals - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] +from tests.adapters.claude_sdk.helpers import ( + WRITE_NOTE, + OpenRoom, + with_approvals, +) def policy_notice(decision: str) -> str: diff --git a/tests/adapters/claude_sdk/approvals/test_manual_approval.py b/tests/adapters/claude_sdk/approvals/test_manual_approval.py index 844a01fc0..44ddfe6e0 100644 --- a/tests/adapters/claude_sdk/approvals/test_manual_approval.py +++ b/tests/adapters/claude_sdk/approvals/test_manual_approval.py @@ -3,8 +3,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest from band.adapters.claude_sdk import ( @@ -18,13 +16,11 @@ from band.testing import MISSING_REPLY_FAILURE, failure_reports from tests.adapters.claude_sdk.helpers import ( WRITE_NOTE, - ClaudeRoom, + OpenRoom, with_approvals, ) from tests.baseline.decisions import ModelDecision, ToolCall -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - ADMIN = {"id": "admin-1", "name": "Admin"} DECLINED = "User declined tool use" LIST_FILES = ModelDecision.call("Bash", command="ls") @@ -38,303 +34,320 @@ def resolved(token: str, decision: str) -> str: return APPROVAL_RESOLVED_TEMPLATE.format(token=token, decision=decision) -async def test_a_room_answers_each_gated_tool_while_its_commands_stay_local( - claude_room: OpenRoom, -) -> None: - """A shell command and a file write (which acceptEdits would otherwise - auto-approve) both wait on the room; listing, status, a wrong token and a - bare approve are answered locally and never reach the model.""" - room = await claude_room(with_approvals()) - room.claude.script([LIST_FILES, WRITE_NOTE, room.model_reply("Listed, no note.")]) - - await room.send("List the files, then jot a note") - await room.send("/approvals") - await room.send("/status") - await room.send("/approve a-9") - await room.send("/approve") - await room.until_said("Approval requested", times=2) - await room.send("/decline a-2") - await room.settled() - - chat = room.chat - status = chat.pop(2) - assert chat == [ - prompt("a-1", "Bash: `ls`"), - "Pending approvals:\n- `a-1`: Bash: `ls` (0s ago)", - "Unknown approval token `a-9`. Available: `a-1`.", - resolved("a-1", "accept"), - prompt("a-2", "Write: notes.md"), - resolved("a-2", "decline"), - "Listed, no note.", - ] - assert "- approval_mode: `manual`\n- pending_approvals: 1" in status - assert room.tool_outputs["Bash"] == "Bash ran" - assert room.tool_outputs["Write"] == DECLINED - assert len(room.claude.prompts) == 1 - assert room.failures == [] - - -async def test_controls_preserve_a_parked_turn_and_busy_work_is_unaccepted( - claude_room: OpenRoom, -) -> None: - """Controls settle independently; unaccepted work cannot steal the reply.""" - room = await claude_room(with_approvals()) - room.claude.script([LIST_FILES, room.model_reply("Listed.")]) - parked, status, busy, approve = (room.fresh_tools() for _ in range(4)) - - await room.send("List the files", tools=parked) - prompted_turn_replied = parked.turn.replied - await room.send("/status", tools=status) - with pytest.raises(InputNotAccepted): - await room.send("And the hidden ones?", tools=busy) - await room.send("/approve", tools=approve) - await room.settled() - - chat = room.chat - del chat[1] # the status report - assert chat == [ - prompt("a-1", "Bash: `ls`"), - resolved("a-1", "accept"), - "Listed.", - ] - assert not busy.turn.complete - assert prompted_turn_replied is False - assert parked.turn.replied - for side_turn in (status.turn, approve.turn): - assert (side_turn.complete, side_turn.replied) == (True, False) - room.claude.script([room.model_reply("Hidden files listed.")]) - await room.send("And the hidden ones?", tools=busy) - await room.settled() - assert busy.turn.replied - assert len(room.claude.prompts) == 2 - - -async def test_stop_ends_a_parked_turn_and_frees_the_room( - claude_room: OpenRoom, -) -> None: - """The worker released to wait on an approval outlives its handler, so - STOP ends it before it can reply and the room accepts new work.""" - room = await claude_room(with_approvals()) - room.claude.script([LIST_FILES, room.model_reply("Listed.")]) - - await room.send("List the files") - await room.adapter.on_interrupt(room.room_id, ControlMode.STOP) - room.claude.script([room.model_reply("Hidden files listed.")]) - await room.send("And the hidden ones?") - await room.settled() - - assert room.chat == [prompt("a-1", "Bash: `ls`"), "Hidden files listed."] - - -async def test_a_released_turn_that_ends_unanswered_is_reported_at_its_end( - claude_room: OpenRoom, -) -> None: - """A turn released to wait on an approval hands its message back as - handled; when it later ends without answering, the room hears one - missing-reply failure from the runtime.""" - room = await claude_room(with_approvals()) - room.claude.script([LIST_FILES, ModelDecision.text_reply("Listed, in plain text.")]) - parked = room.fresh_tools() - - await room.send("List the files", tools=parked) - reported_at_release = list(room.failures) - await room.send("/approve") - await room.settled() - - assert reported_at_release == [] - assert failure_reports(parked) == [MISSING_REPLY_FAILURE] - - -async def test_a_busy_room_evicts_the_oldest_ask_and_asks_for_a_token( - claude_room: OpenRoom, -) -> None: - """Three parallel tool calls against room for two: the oldest is declined - without a notice, and a bare approve then names the two still open.""" - room = await claude_room(with_approvals(max_pending_per_room=2)) - room.claude.script( - [ - ModelDecision( - tool_calls=( - ToolCall("Bash", {"command": "ls"}), - ToolCall("Write", {"file_path": "notes.md", "content": "todo"}), - ToolCall( - "Edit", - {"file_path": "app.py", "old_string": "a", "new_string": "b"}, - ), - ) - ), - room.model_reply("Tidied up."), +class TestManualApproval: + async def test_a_room_answers_each_gated_tool_while_its_commands_stay_local( + self, + claude_room: OpenRoom, + ) -> None: + """A shell command and a file write (which acceptEdits would otherwise + auto-approve) both wait on the room; listing, status, a wrong token and a + bare approve are answered locally and never reach the model.""" + room = await claude_room(with_approvals()) + room.claude.script( + [LIST_FILES, WRITE_NOTE, room.model_reply("Listed, no note.")] + ) + + await room.send("List the files, then jot a note") + await room.send("/approvals") + await room.send("/status") + await room.send("/approve a-9") + await room.send("/approve") + await room.until_said("Approval requested", times=2) + await room.send("/decline a-2") + await room.settled() + + chat = room.chat + status = chat.pop(2) + assert chat == [ + prompt("a-1", "Bash: `ls`"), + "Pending approvals:\n- `a-1`: Bash: `ls` (0s ago)", + "Unknown approval token `a-9`. Available: `a-1`.", + resolved("a-1", "accept"), + prompt("a-2", "Write: notes.md"), + resolved("a-2", "decline"), + "Listed, no note.", ] - ) + assert "- approval_mode: `manual`\n- pending_approvals: 1" in status + assert room.tool_outputs["Bash"] == "Bash ran" + assert room.tool_outputs["Write"] == DECLINED + assert len(room.claude.prompts) == 1 + assert room.failures == [] + + async def test_controls_preserve_a_parked_turn_and_busy_work_is_unaccepted( + self, + claude_room: OpenRoom, + ) -> None: + """Controls settle independently; unaccepted work cannot steal the reply.""" + room = await claude_room(with_approvals()) + room.claude.script([LIST_FILES, room.model_reply("Listed.")]) + parked, status, busy, approve = (room.fresh_tools() for _ in range(4)) + + await room.send("List the files", tools=parked) + prompted_turn_replied = parked.turn.replied + await room.send("/status", tools=status) + with pytest.raises(InputNotAccepted): + await room.send("And the hidden ones?", tools=busy) + await room.send("/approve", tools=approve) + await room.settled() - await room.send("Tidy up") - await room.until_said("Approval requested", times=3) - await room.send("/approve") - await room.send("/approve a-1") - await room.send("/approve a-2") - await room.send("/decline a-3") - await room.settled() - - assert room.chat == [ - prompt("a-1", "Bash: `ls`"), - prompt("a-2", "Write: notes.md"), - prompt("a-3", "Edit: app.py"), - "Multiple pending approvals — please specify a token: `a-2`, `a-3`", - "Unknown approval token `a-1`. Available: `a-2`, `a-3`.", - resolved("a-2", "accept"), - resolved("a-3", "decline"), - "Tidied up.", - ] - assert room.tool_outputs["Bash"] == DECLINED - assert room.tool_outputs["Write"] == "Write ran" - assert room.tool_outputs["Edit"] == DECLINED - assert room.failures == [] - - -@pytest.mark.parametrize( - ("allowlist", "admin_decides"), - [({"admin-1"}, True), (set(), False)], - ids=["admins-only", "nobody"], -) -async def test_only_allowlisted_senders_decide_but_anyone_can_list( - claude_room: OpenRoom, allowlist: set[str], admin_decides: bool -) -> None: - """However a stranger names the approval, they are refused every time - rather than guided to a token; an empty allowlist admits nobody, not - everybody.""" - room = await claude_room(with_approvals(authorized_senders=allowlist)) - room.claude.script([LIST_FILES, room.model_reply("Listed.")]) - - await room.send("List the files") - await room.send("/decline a-1") - await room.send("/decline") - await room.send("/decline a-9") - await room.send("/approvals") - await room.send("/approve a-1", sender=ADMIN) - if admin_decides: + chat = room.chat + del chat[1] # the status report + assert chat == [ + prompt("a-1", "Bash: `ls`"), + resolved("a-1", "accept"), + "Listed.", + ] + assert not busy.turn.complete + assert prompted_turn_replied is False + assert parked.turn.replied + for side_turn in (status.turn, approve.turn): + assert (side_turn.complete, side_turn.replied) == (True, False) + room.claude.script([room.model_reply("Hidden files listed.")]) + await room.send("And the hidden ones?", tools=busy) + await room.settled() + assert busy.turn.replied + assert len(room.claude.prompts) == 2 + + async def test_stop_ends_a_parked_turn_and_frees_the_room( + self, + claude_room: OpenRoom, + ) -> None: + """The worker released to wait on an approval outlives its handler, so + STOP ends it before it can reply and the room accepts new work.""" + room = await claude_room(with_approvals()) + room.claude.script([LIST_FILES, room.model_reply("Listed.")]) + + await room.send("List the files") + await room.adapter.on_interrupt(room.room_id, ControlMode.STOP) + room.claude.script([room.model_reply("Hidden files listed.")]) + await room.send("And the hidden ones?") await room.settled() - admin_outcome = ( - [resolved("a-1", "accept"), "Listed."] - if admin_decides - else [APPROVAL_UNAUTHORIZED_MESSAGE] - ) - assert room.chat == [ - prompt("a-1", "Bash: `ls`"), - *[APPROVAL_UNAUTHORIZED_MESSAGE] * 3, - "Pending approvals:\n- `a-1`: Bash: `ls` (0s ago)", - *admin_outcome, - ] - - -@pytest.mark.looptime -@pytest.mark.parametrize( - ("decision", "bash_output"), - [("accept", "Bash ran"), ("decline", "Approval timed out, tool use declined")], -) -async def test_an_unanswered_ask_times_out_to_the_configured_decision( - claude_room: OpenRoom, decision: str, bash_output: str -) -> None: - """A reply landing while the timeout notice is still being sent is told - the token is gone, not "resolved".""" - room = await claude_room( - with_approvals(wait_timeout_s=60, timeout_decision=decision) - ) - room.claude.script([LIST_FILES, room.model_reply("Done waiting.")]) - timeout_notice = room.tools.hold_message("timed out") + assert room.chat == [prompt("a-1", "Bash: `ls`"), "Hidden files listed."] + + async def test_a_released_turn_that_ends_unanswered_is_reported_at_its_end( + self, + claude_room: OpenRoom, + ) -> None: + """A turn released to wait on an approval hands its message back as + handled; when it later ends without answering, the room hears one + missing-reply failure from the runtime.""" + room = await claude_room(with_approvals()) + room.claude.script( + [LIST_FILES, ModelDecision.text_reply("Listed, in plain text.")] + ) + parked = room.fresh_tools() + + await room.send("List the files", tools=parked) + reported_at_release = list(room.failures) + await room.send("/approve") + await room.settled() - await room.send("List the files") - async with timeout_notice: + assert reported_at_release == [] + assert failure_reports(parked) == [MISSING_REPLY_FAILURE] + + async def test_a_busy_room_evicts_the_oldest_ask_and_asks_for_a_token( + self, + claude_room: OpenRoom, + ) -> None: + """Three parallel tool calls against room for two: the oldest is declined + without a notice, and a bare approve then names the two still open.""" + room = await claude_room(with_approvals(max_pending_per_room=2)) + room.claude.script( + [ + ModelDecision( + tool_calls=( + ToolCall("Bash", {"command": "ls"}), + ToolCall("Write", {"file_path": "notes.md", "content": "todo"}), + ToolCall( + "Edit", + { + "file_path": "app.py", + "old_string": "a", + "new_string": "b", + }, + ), + ) + ), + room.model_reply("Tidied up."), + ] + ) + + await room.send("Tidy up") + await room.until_said("Approval requested", times=3) + await room.send("/approve") await room.send("/approve a-1") - await room.settled() - - assert room.chat == [ - prompt("a-1", "Bash: `ls`"), - "Unknown approval token `a-1`. Available: none.", - APPROVAL_TIMED_OUT_TEMPLATE.format(token="a-1", decision=decision), - "Done waiting.", - ] - assert room.tool_outputs["Bash"] == bash_output - - -async def test_answers_stand_however_a_flaky_room_connection_fares( - claude_room: OpenRoom, -) -> None: - """An undelivered prompt nobody answered declines at once and leaves - nothing pending; one answered mid-send honors the answer; and a - "resolved" notice that fails to send never undoes the accept.""" - room = await claude_room(with_approvals()) - network_down = RuntimeError("network down") - room.claude.script( - [LIST_FILES, room.model_reply("Could not ask.")], - [LIST_FILES, room.model_reply("Listed.")], - [LIST_FILES, room.model_reply("Listed again.")], - ) + await room.send("/approve a-2") + await room.send("/decline a-3") + await room.settled() - unheard_prompt = room.tools.hold_message("Approval requested", error=network_down) - delivery = room.send_in_background("List the files") - async with unheard_prompt: - pass - await delivery - await room.send("/approvals") - assert room.tool_outputs["Bash"] == ( - "Could not deliver approval prompt, tool use declined" + assert room.chat == [ + prompt("a-1", "Bash: `ls`"), + prompt("a-2", "Write: notes.md"), + prompt("a-3", "Edit: app.py"), + "Multiple pending approvals — please specify a token: `a-2`, `a-3`", + "Unknown approval token `a-1`. Available: `a-2`, `a-3`.", + resolved("a-2", "accept"), + resolved("a-3", "decline"), + "Tidied up.", + ] + assert room.tool_outputs["Bash"] == DECLINED + assert room.tool_outputs["Write"] == "Write ran" + assert room.tool_outputs["Edit"] == DECLINED + assert room.failures == [] + + @pytest.mark.parametrize( + ("allowlist", "admin_decides"), + [({"admin-1"}, True), (set(), False)], + ids=["admins-only", "nobody"], ) + async def test_only_allowlisted_senders_decide_but_anyone_can_list( + self, claude_room: OpenRoom, allowlist: set[str], admin_decides: bool + ) -> None: + """However a stranger names the approval, they are refused every time + rather than guided to a token; an empty allowlist admits nobody, not + everybody.""" + room = await claude_room(with_approvals(authorized_senders=allowlist)) + room.claude.script([LIST_FILES, room.model_reply("Listed.")]) + + await room.send("List the files") + await room.send("/decline a-1") + await room.send("/decline") + await room.send("/decline a-9") + await room.send("/approvals") + await room.send("/approve a-1", sender=ADMIN) + if admin_decides: + await room.settled() + + admin_outcome = ( + [resolved("a-1", "accept"), "Listed."] + if admin_decides + else [APPROVAL_UNAUTHORIZED_MESSAGE] + ) + assert room.chat == [ + prompt("a-1", "Bash: `ls`"), + *[APPROVAL_UNAUTHORIZED_MESSAGE] * 3, + "Pending approvals:\n- `a-1`: Bash: `ls` (0s ago)", + *admin_outcome, + ] - answered_prompt = room.tools.hold_message("Approval requested", error=network_down) - delivery = room.send_in_background("Try again") - async with answered_prompt: - await room.send("/approve") - await delivery - await room.settled() - assert room.tool_outputs["Bash"] == "Bash ran" - - failing_notice = room.tools.hold_message("resolved", error=network_down) - await room.send("Once more") - await room.send("/approve a-3") - async with failing_notice: - pass - await room.settled() - assert room.tool_outputs["Bash"] == "Bash ran" - - assert room.chat == [ - "Could not ask.", - "No pending approvals.", - resolved("a-2", "accept"), - "Listed.", - prompt("a-3", "Bash: `ls`"), - "Listed again.", - ] - assert room.failures == [] - - -async def test_leaving_a_room_drops_only_its_asks_and_tokens_keep_counting( - claude_room: OpenRoom, -) -> None: - """Tokens count per room; leaving one room closes its session without - touching the other's pending ask, a rejoin continues the room's token - sequence, and stopping the agent closes every session.""" - room = await claude_room(with_approvals()) - other = room.beside("room-2") - room.claude.script( - [LIST_FILES, room.model_reply("Listed.")], - [WRITE_NOTE, other.model_reply("Noted.")], - [LIST_FILES, room.model_reply("Listed after rejoining.")], + @pytest.mark.looptime + @pytest.mark.parametrize( + ("decision", "bash_output"), + [("accept", "Bash ran"), ("decline", "Approval timed out, tool use declined")], ) + async def test_an_unanswered_ask_times_out_to_the_configured_decision( + self, claude_room: OpenRoom, decision: str, bash_output: str + ) -> None: + """A reply landing while the timeout notice is still being sent is told + the token is gone, not "resolved".""" + room = await claude_room( + with_approvals(wait_timeout_s=60, timeout_decision=decision) + ) + room.claude.script([LIST_FILES, room.model_reply("Done waiting.")]) + timeout_notice = room.tools.hold_message("timed out") + + await room.send("List the files") + async with timeout_notice: + await room.send("/approve a-1") + await room.settled() - await room.send("List the files") - await other.send("Jot a note") - await room.leave() - await other.send("/approve") - await other.settled() - await room.send("List them again") - await room.adapter.cleanup_all() - - assert room.chat == [prompt("a-1", "Bash: `ls`"), prompt("a-2", "Bash: `ls`")] - assert other.chat == [ - prompt("a-1", "Write: notes.md"), - resolved("a-1", "accept"), - "Noted.", - ] - assert [session.alive for session in room.claude.sessions] == [False] * 3 - assert room.failures == other.failures == [] + assert room.chat == [ + prompt("a-1", "Bash: `ls`"), + "Unknown approval token `a-1`. Available: none.", + APPROVAL_TIMED_OUT_TEMPLATE.format(token="a-1", decision=decision), + "Done waiting.", + ] + assert room.tool_outputs["Bash"] == bash_output + + async def test_answers_stand_however_a_flaky_room_connection_fares( + self, + claude_room: OpenRoom, + ) -> None: + """An undelivered prompt nobody answered declines at once and leaves + nothing pending; one answered mid-send honors the answer; and a + "resolved" notice that fails to send never undoes the accept.""" + room = await claude_room(with_approvals()) + network_down = RuntimeError("network down") + room.claude.script( + [LIST_FILES, room.model_reply("Could not ask.")], + [LIST_FILES, room.model_reply("Listed.")], + [LIST_FILES, room.model_reply("Listed again.")], + ) + + unheard_prompt = room.tools.hold_message( + "Approval requested", error=network_down + ) + delivery = room.send_in_background("List the files") + async with unheard_prompt: + pass + await delivery + await room.send("/approvals") + assert room.tool_outputs["Bash"] == ( + "Could not deliver approval prompt, tool use declined" + ) + + answered_prompt = room.tools.hold_message( + "Approval requested", error=network_down + ) + delivery = room.send_in_background("Try again") + async with answered_prompt: + await room.send("/approve") + await delivery + await room.settled() + assert room.tool_outputs["Bash"] == "Bash ran" + + failing_notice = room.tools.hold_message("resolved", error=network_down) + await room.send("Once more") + await room.send("/approve a-3") + async with failing_notice: + pass + await room.settled() + assert room.tool_outputs["Bash"] == "Bash ran" + + assert room.chat == [ + "Could not ask.", + "No pending approvals.", + resolved("a-2", "accept"), + "Listed.", + prompt("a-3", "Bash: `ls`"), + "Listed again.", + ] + assert room.failures == [] + + async def test_leaving_a_room_drops_only_its_asks_and_tokens_keep_counting( + self, + claude_room: OpenRoom, + ) -> None: + """Tokens count per room; leaving one room declines only its own ask and + closes its session once that turn settles, a rejoin continues the room's + token sequence, and stopping the agent closes every session.""" + room = await claude_room(with_approvals()) + other = room.beside("room-2") + room.claude.script( + [LIST_FILES, room.model_reply("Listed.")], + [WRITE_NOTE, other.model_reply("Noted.")], + [LIST_FILES, room.model_reply("Listed after rejoining.")], + ) + + await room.send("List the files") + await other.send("Jot a note") + await room.leave() + await other.send("/approve") + await other.settled() + await room.send("List them again") + await room.adapter.cleanup_all() + + assert room.chat == [ + prompt("a-1", "Bash: `ls`"), + "Listed.", + prompt("a-2", "Bash: `ls`"), + "Listed after rejoining.", + ] + assert other.chat == [ + prompt("a-1", "Write: notes.md"), + resolved("a-1", "accept"), + "Noted.", + ] + assert [session.alive for session in room.claude.sessions] == [False] * 3 + assert room.failures == other.failures == [] diff --git a/tests/adapters/claude_sdk/conftest.py b/tests/adapters/claude_sdk/conftest.py index 7c590584f..9a63ba095 100644 --- a/tests/adapters/claude_sdk/conftest.py +++ b/tests/adapters/claude_sdk/conftest.py @@ -6,31 +6,20 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from contextlib import AbstractContextManager, nullcontext from typing import Any -from unittest.mock import patch import looptime import pytest import pytest_asyncio from band.adapters.claude_sdk import ClaudeSDKAdapter, ClaudeSDKAdapterConfig -from tests.adapters.claude_sdk.fakecli import FakeClaude -from tests.adapters.claude_sdk.helpers import ClaudeRoom +from tests.adapters.claude_sdk.fakecli import FakeClaude, scripted_claude +from tests.adapters.claude_sdk.helpers import AGENT_DESCRIPTION, AGENT_NAME, ClaudeRoom @pytest.fixture def claude() -> Iterator[FakeClaude]: - """The scripted Claude CLI behind every ``ClaudeSDKClient`` the test opens. - - The transport factory is the one seam patched: past it the SDK would spawn the - real CLI subprocess. - """ - claude = FakeClaude() - with patch( - "band.integrations.claude_sdk.session_manager.create_transport", - claude.transport, - ): + with scripted_claude() as claude: yield claude - claude.assert_done() # The adapter's session manager runs on the loop that started it, so setup, @@ -50,7 +39,7 @@ async def open_room( **adapter_kwargs: Any, ) -> ClaudeRoom: adapter = ClaudeSDKAdapter(config, **adapter_kwargs) - await adapter.on_started("Test Agent", "An agent under test") + await adapter.on_started(AGENT_NAME, AGENT_DESCRIPTION) loop = asyncio.get_running_loop() cleanup_clock = ( looptime.enabled(strict=True) diff --git a/tests/adapters/claude_sdk/failures/test_cli_connection_error.py b/tests/adapters/claude_sdk/failures/test_cli_connection_error.py index f1c69dd56..79a7f6a70 100644 --- a/tests/adapters/claude_sdk/failures/test_cli_connection_error.py +++ b/tests/adapters/claude_sdk/failures/test_cli_connection_error.py @@ -3,65 +3,65 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest -from claude_agent_sdk import CLIConnectionError -from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) from tests.adapters.claude_sdk.fakecli import Hangup -from tests.adapters.claude_sdk.helpers import ClaudeRoom +from tests.adapters.claude_sdk.helpers import OpenRoom from tests.baseline.decisions import ModelDecision -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -async def test_a_cli_that_dies_mid_turn_fails_only_an_unanswered_turn( - claude_room: OpenRoom, -) -> None: - """Dying after the reply went out completes the turn (failing it would - let a later process run the message again and answer twice); dying - before any reply fails it. Either way the next message gets a fresh CLI - process.""" - room = await claude_room() - room.claude.script( - [room.model_reply("Answered before the crash."), Hangup()], - [ - ModelDecision.call("mcp__band__band_get_participants"), - Hangup(), - ], - [room.model_reply("Back on a fresh process.")], - ) - await room.send("first") - with pytest.raises(CLIConnectionError, match="ended without a result"): - await room.send("second") - await room.send("third") +class TestCLIConnectionError: + async def test_a_cli_that_dies_mid_turn_fails_only_an_unanswered_turn( + self, + claude_room: OpenRoom, + ) -> None: + """Dying after the reply went out completes the turn (failing it would + let a later process run the message again and answer twice); dying + before any reply fails it. Either way the next message gets a fresh CLI + process.""" + room = await claude_room() + room.claude.script( + [room.model_reply("Answered before the crash."), Hangup()], + [ + ModelDecision.call("mcp__band__band_get_participants"), + Hangup(), + ], + [room.model_reply("Back on a fresh process.")], + ) - assert room.chat == ["Answered before the crash.", "Back on a fresh process."] - assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] - assert len(room.claude.sessions) == 3 - assert len(set(room.claude.session_workspaces)) == 1 + await room.send("first") + with pytest.raises(TurnResultAlreadyReported, match="ended without a result"): + await room.send("second") + await room.send("third") + assert room.chat == ["Answered before the crash.", "Back on a fresh process."] + assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] + assert len(room.claude.sessions) == 3 + assert len(set(room.claude.session_workspaces)) == 1 -async def test_a_cli_that_died_while_idle_is_replaced_on_the_next_message( - claude_room: OpenRoom, -) -> None: - """The process crashed between turns: the message that finds it dead - fails with a room-visible error, and the one after runs on a new one.""" - room = await claude_room() - room.claude.script( - [room.model_reply("Hello.")], - [room.model_reply("Hello again.")], - ) + async def test_a_cli_that_died_while_idle_is_replaced_on_the_next_message( + self, + claude_room: OpenRoom, + ) -> None: + """The process crashed between turns: the message that finds it dead + fails with a room-visible error, and the one after runs on a new one.""" + room = await claude_room() + room.claude.script( + [room.model_reply("Hello.")], + [room.model_reply("Hello again.")], + ) - await room.send("hi") - room.claude.sessions[-1].die() - with pytest.raises(CLIConnectionError): - await room.send("are you there?") - await room.send("hi again") + await room.send("hi") + room.claude.sessions[-1].die() + with pytest.raises(TurnResultAlreadyReported): + await room.send("are you there?") + await room.send("hi again") - assert room.chat == ["Hello.", "Hello again."] - assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] - assert len(room.claude.sessions) == 2 - assert len(set(room.claude.session_workspaces)) == 1 + assert room.chat == ["Hello.", "Hello again."] + assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] + assert len(room.claude.sessions) == 2 + assert len(set(room.claude.session_workspaces)) == 1 diff --git a/tests/adapters/claude_sdk/failures/test_error_handling.py b/tests/adapters/claude_sdk/failures/test_error_handling.py index 125f6703c..8b7e27a03 100644 --- a/tests/adapters/claude_sdk/failures/test_error_handling.py +++ b/tests/adapters/claude_sdk/failures/test_error_handling.py @@ -1,36 +1,36 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest -from claude_agent_sdk import ClaudeSDKError - -from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE -from tests.adapters.claude_sdk.fakecli import Raw -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -async def test_a_turn_the_sdk_cannot_parse_never_leaks_into_the_next( - claude_room: OpenRoom, -) -> None: - """A CLI message the SDK can't parse (e.g. version skew) aborts the turn - with a generic room-visible failure. The rest of that turn, which the CLI - still emits, is never read as the next message's answer: the next message - resumes the conversation in a fresh process.""" - room = await claude_room() - room.claude.script( - [room.model_reply("Hi.")], - [Raw({"type": "assistant", "message": {"model": "claude-fake"}})], - [room.model_reply("Recovered.")], - ) - - await room.send("hello") - with pytest.raises(ClaudeSDKError, match="Missing required field"): - await room.send("and now?") - await room.send("still there?") - assert room.chat == ["Hi.", "Recovered."] - assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] - assert room.claude.resumed == [None, "sess-1"] +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) +from tests.adapters.claude_sdk.fakecli import MODEL, Raw +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestErrorHandling: + async def test_a_turn_the_sdk_cannot_parse_never_leaks_into_the_next( + self, + claude_room: OpenRoom, + ) -> None: + """A CLI message the SDK can't parse (e.g. version skew) aborts the turn + with a generic room-visible failure. The rest of that turn, which the CLI + still emits, is never read as the next message's answer: the next message + resumes the conversation in a fresh process.""" + room = await claude_room() + room.claude.script( + [room.model_reply("Hi.")], + [Raw({"type": "assistant", "message": {"model": MODEL}})], + [room.model_reply("Recovered.")], + ) + + await room.send("hello") + with pytest.raises(TurnResultAlreadyReported, match="Missing required field"): + await room.send("and now?") + await room.send("still there?") + + assert room.chat == ["Hi.", "Recovered."] + assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] + assert room.claude.resumed == [None, "sess-1"] diff --git a/tests/adapters/claude_sdk/failures/test_turn_failure_surfacing.py b/tests/adapters/claude_sdk/failures/test_turn_failure_surfacing.py index fa261ab73..3bdbb0a38 100644 --- a/tests/adapters/claude_sdk/failures/test_turn_failure_surfacing.py +++ b/tests/adapters/claude_sdk/failures/test_turn_failure_surfacing.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable import pytest @@ -21,13 +20,11 @@ from tests.adapters.claude_sdk.helpers import ( MISSING_REPLY_TEXT, SEND_MESSAGE_MCP_NAME, - ClaudeRoom, + OpenRoom, with_approvals, ) from tests.baseline.decisions import ModelDecision -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - async def test_a_turn_that_answered_the_room_is_narrated_and_quiet( claude_room: OpenRoom, diff --git a/tests/adapters/claude_sdk/failures/test_turn_timeout.py b/tests/adapters/claude_sdk/failures/test_turn_timeout.py index 9bfe64254..aa05d5949 100644 --- a/tests/adapters/claude_sdk/failures/test_turn_timeout.py +++ b/tests/adapters/claude_sdk/failures/test_turn_timeout.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable import pytest @@ -12,120 +11,146 @@ from band.core.exceptions import CleanupIncompleteError, InputNotAccepted from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE from tests.adapters.claude_sdk.fakecli import Hold, StreamFails -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, OpenRoom # Short enough to abandon the held turn quickly, long enough for the next # turn's real loopback round trips to the Band MCP server on a slow runner. TURN_TIMEOUT_S = 2.0 -async def test_a_stuck_turn_is_interrupted_and_reported_as_a_timeout( - claude_room: OpenRoom, -) -> None: - """The interrupted turn's own result is drained, so the next turn on the - same CLI process reads its own answer.""" - room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) - room.claude.script([Hold()], [room.model_reply("Back again.")]) - - with pytest.raises(TurnResultAlreadyReported): - await room.send("take forever") - await room.send("and now?") - - assert [f["code"] for f in room.reported_failures] == ["timeout"] - assert room.failures == [f"Claude turn timed out after {TURN_TIMEOUT_S}s"] - assert room.chat == ["Back again."] - assert len(room.claude.sessions) == 1 - - -async def test_a_turn_that_ignores_the_interrupt_is_closed_and_resumed( - claude_room: OpenRoom, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr("band.adapters.claude_sdk._TIMEOUT_DRAIN_SECONDS", 0.05) - room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) - room.claude.ignore_interrupt = True - room.claude.script([Hold()], [room.model_reply("Fresh start.")]) - - with pytest.raises(TurnResultAlreadyReported): - await room.send("take forever") - await room.send("and now?") - - assert [f["code"] for f in room.reported_failures] == ["timeout"] - assert room.chat == ["Fresh start."] - assert [session.alive for session in room.claude.sessions] == [False, True] - - -async def test_stop_during_timeout_recovery_ends_the_turn_without_a_timeout( - claude_room: OpenRoom, -) -> None: - """STOP takes over the wedged recovery instead of waiting out its drain - bound, reports no timeout, and leaves no interrupted client behind.""" - room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) - room.claude.ignore_interrupt = True - room.claude.script([Hold()], [room.model_reply("Fresh start.")]) - sending = room.send_in_background("take forever") - - await room.claude.interrupted.wait() - await room.adapter.on_interrupt(room.room_id, ControlMode.STOP) - with pytest.raises(asyncio.CancelledError): - await sending - await room.send("and now?") - - assert room.reported_failures == [] - assert room.chat == ["Fresh start."] - assert [session.alive for session in room.claude.sessions] == [False, True] - - -async def test_a_timeout_from_the_cli_stream_is_an_ordinary_failure( - claude_room: OpenRoom, -) -> None: - """Only the turn deadline is reported as a timeout; any other - ``TimeoutError`` fails the turn and replaces the CLI process.""" - room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=30)) - room.claude.script( - [StreamFails(TimeoutError("read timed out"))], - [room.model_reply("Back on a fresh process.")], - ) - - with pytest.raises(TimeoutError): - await room.send("first") - await room.send("second") - - assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] - assert room.chat == ["Back on a fresh process."] - assert len(room.claude.sessions) == 2 - - -async def test_stop_retains_worker_until_session_cleanup_finishes( - claude_room: OpenRoom, -) -> None: - room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) - work, closing = Hold(), Hold() - room.claude.script([work]) - room.claude.closing = closing - sending = asyncio.create_task(room.send("work")) - try: - async with work: - sending.cancel() - await closing.reached.wait() - await asyncio.gather( - room.adapter.on_interrupt(room.room_id, ControlMode.STOP), - room.adapter.on_interrupt(room.room_id, ControlMode.STOP), - ) - with pytest.raises(CleanupIncompleteError): - await room.leave() - with pytest.raises(InputNotAccepted): - await room.send("overlap") - assert room.claude.sessions[0].alive - closing.released.set() +@pytest.mark.looptime +class TestTurnTimeout: + async def test_a_stuck_turn_is_interrupted_and_reported_as_a_timeout( + self, + claude_room: OpenRoom, + ) -> None: + """The interrupted turn's own result is drained, so the next turn on the + same CLI process reads its own answer.""" + room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) + room.claude.script([Hold()], [room.model_reply("Back again.")]) + + with pytest.raises(TurnResultAlreadyReported): + await room.send("take forever") + await room.send("and now?") + + assert [f["code"] for f in room.reported_failures] == ["timeout"] + assert room.failures == [f"Claude turn timed out after {TURN_TIMEOUT_S}s"] + assert room.chat == ["Back again."] + assert len(room.claude.sessions) == 1 + + async def test_a_turn_that_ignores_the_interrupt_is_closed_and_resumed( + self, claude_room: OpenRoom, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("band.adapters.claude_sdk._TIMEOUT_DRAIN_SECONDS", 0.05) + room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) + room.claude.ignore_interrupt = True + room.claude.script([Hold()], [room.model_reply("Fresh start.")]) + + with pytest.raises(TurnResultAlreadyReported): + await room.send("take forever") + await room.send("and now?") + + assert [f["code"] for f in room.reported_failures] == ["timeout"] + assert room.chat == ["Fresh start."] + assert [session.alive for session in room.claude.sessions] == [False, True] + + async def test_stop_during_timeout_recovery_ends_the_turn_without_a_timeout( + self, + claude_room: OpenRoom, + ) -> None: + """STOP takes over the wedged recovery instead of waiting out its drain + bound, reports no timeout, and leaves no interrupted client behind.""" + room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) + room.claude.ignore_interrupt = True + room.claude.script([Hold()], [room.model_reply("Fresh start.")]) + sending = room.send_in_background("take forever") + + await room.claude.interrupted.wait() + await room.adapter.on_interrupt(room.room_id, ControlMode.STOP) with pytest.raises(asyncio.CancelledError): await sending - await room.settled() - await room.leave() - assert not room.claude.sessions[0].alive + await room.send("and now?") + assert room.reported_failures == [] - finally: - closing.released.set() - work.released.set() - await asyncio.gather(sending, return_exceptions=True) + assert room.chat == ["Fresh start."] + assert [session.alive for session in room.claude.sessions] == [False, True] + + async def test_a_timeout_from_the_cli_stream_is_an_ordinary_failure( + self, + claude_room: OpenRoom, + ) -> None: + """Only the turn deadline is reported as a timeout; any other + ``TimeoutError`` fails the turn and replaces the CLI process.""" + room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=30)) + room.claude.script( + [StreamFails(TimeoutError("read timed out"))], + [room.model_reply("Back on a fresh process.")], + ) + + with pytest.raises(TurnResultAlreadyReported, match="read timed out"): + await room.send("first") + await room.send("second") + + assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] + assert room.chat == ["Back on a fresh process."] + assert len(room.claude.sessions) == 2 + + async def test_stop_retains_worker_until_session_cleanup_finishes( + self, + claude_room: OpenRoom, + ) -> None: + room = await claude_room( + ClaudeSDKAdapterConfig( + turn_timeout_s=TURN_TIMEOUT_S, retention=REPORTING_BUDGET + ) + ) + work, closing = Hold(), Hold() + room.claude.script([work]) + room.claude.closing = closing + sending = asyncio.create_task(room.send("work")) + try: + async with work: + sending.cancel() + await closing.arrived() + await asyncio.gather( + room.adapter.on_interrupt(room.room_id, ControlMode.STOP), + room.adapter.on_interrupt(room.room_id, ControlMode.STOP), + ) + with pytest.raises(CleanupIncompleteError): + await room.leave() + with pytest.raises(InputNotAccepted): + await room.send("overlap") + assert room.claude.sessions[0].alive + closing.released.set() + with pytest.raises(asyncio.CancelledError): + await sending + await room.settled() + await room.leave() + assert not room.claude.sessions[0].alive + assert room.reported_failures == [] + finally: + closing.released.set() + work.released.set() + await asyncio.gather(sending, return_exceptions=True) + + async def test_a_cli_that_exits_when_interrupted_is_replaced( + self, + claude_room: OpenRoom, + ) -> None: + """The interrupted CLI exits without the turn's result; draining that end + retires it as a crash, so the next message starts a new conversation + instead of writing to a dead process.""" + room = await claude_room(ClaudeSDKAdapterConfig(turn_timeout_s=TURN_TIMEOUT_S)) + room.claude.exit_on_interrupt = True + room.claude.script( + [room.model_reply("Hello.")], [Hold()], [room.model_reply("Fresh start.")] + ) + + await room.send("hi") + with pytest.raises(TurnResultAlreadyReported): + await room.send("take forever") + await room.send("and now?") + + assert room.failures == [f"Claude turn timed out after {TURN_TIMEOUT_S}s"] + assert room.chat == ["Hello.", "Fresh start."] + assert room.claude.resumed == [None, None] diff --git a/tests/adapters/claude_sdk/fakecli.py b/tests/adapters/claude_sdk/fakecli.py index 42a1f9e4a..7e851c9d6 100644 --- a/tests/adapters/claude_sdk/fakecli.py +++ b/tests/adapters/claude_sdk/fakecli.py @@ -14,22 +14,29 @@ import json import re from collections import deque -from collections.abc import AsyncIterator, Sequence -from contextlib import AbstractAsyncContextManager -from dataclasses import dataclass, field -from types import TracebackType +from collections.abc import AsyncIterator, Callable, Iterator, Sequence +from contextlib import AbstractAsyncContextManager, contextmanager +from dataclasses import dataclass from typing import Any +from unittest.mock import patch +import httpx from claude_agent_sdk import ClaudeAgentOptions, CLIConnectionError from claude_agent_sdk._internal.transport import Transport from mcp import ClientSession from band.adapters.claude_sdk import AUTO_FALLBACK_PERMISSION_MODE, ClaudePermissionMode +from band.integrations.claude_sdk import session_manager from band.integrations.mcp import BandMCPTransport +from tests.adapters.hold import Hold from tests.baseline.decisions import ModelDecision, ToolCall from tests.mcpclient import mcp_session MODEL = "claude-fake" +# What the fake raises when its startup or its close fails. +STARTUP_EXITED = "Claude CLI exited during startup" +CLEANUP_FAILED = "Claude CLI cleanup failed" +MCP_DISCONNECTED = "Band MCP endpoint disconnected" # acceptEdits auto-approves these file-writing tools. EDIT_TOOLS = frozenset({"Edit", "Write", "NotebookEdit"}) @@ -41,6 +48,13 @@ class Raw: message: dict[str, Any] +@dataclass(frozen=True) +class UnavailableMcp: + """A tool call whose advertised HTTP server is gone; the CLI fails the turn.""" + + decision: ModelDecision + + @dataclass(frozen=True) class Thinking: """An extended-thinking block.""" @@ -58,6 +72,31 @@ class EndTurn: api_error_status: int | None = None +@dataclass(frozen=True) +class Remember: + """The model keeps a private note in its session; the room never sees it.""" + + note: str + + +@dataclass(frozen=True) +class Recall: + """The model answers from every note its session holds; a session that + lost its conversation answers with none.""" + + answer: Callable[[str], ModelDecision] + + +@dataclass(frozen=True) +class AfterResult: + """A message the CLI emits after the turn's result, as background work + finishing between turns does; ``gate`` delays it until set. An exception + fails the read instead, as a malformed line does while the CLI runs on.""" + + message: dict[str, Any] | Exception + gate: asyncio.Event | None = None + + @dataclass(frozen=True) class Hangup: """The CLI process dies: the stream ends with no result.""" @@ -71,27 +110,19 @@ class StreamFails: error: Exception -@dataclass -class Hold: - """Parks the turn at this step: ``async with hold`` waits for the turn to - reach it and lets the turn continue on exit.""" - - reached: asyncio.Event = field(default_factory=asyncio.Event) - released: asyncio.Event = field(default_factory=asyncio.Event) - - async def __aenter__(self) -> None: - await self.reached.wait() - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc: BaseException | None, - tb: TracebackType | None, - ) -> None: - self.released.set() - - -Step = ModelDecision | Thinking | Raw | Hold | EndTurn | Hangup | StreamFails +Step = ( + ModelDecision + | UnavailableMcp + | Thinking + | Raw + | Hold + | EndTurn + | Remember + | Recall + | AfterResult + | Hangup + | StreamFails +) Turn = Sequence[Step] @@ -103,12 +134,16 @@ def __init__(self) -> None: self._session_ids = itertools.count(1) self.sessions: list[FakeCLISession] = [] self.prompts: list[str] = [] + # The CLI's session store: each conversation's private notes outlive + # the process that wrote them and come back with a resume. + self.notes: dict[str, list[str]] = {} self.unresumable: set[str] = set() # permissions.ask rules in the project's settings file, which the CLI # only reads when the options load the "project" setting source. self.project_ask_rules: list[str] = [] self.refuse_connect = False - # Parks every connect, e.g. to land a shutdown while a session starts. + # Parks the next connect, e.g. to land a shutdown while a session + # starts; the session that reaches it takes it. self.connecting: Hold | None = None # Modes the account or model can't run; the CLI falls back to # AUTO_FALLBACK_PERMISSION_MODE instead of failing. @@ -117,7 +152,10 @@ def __init__(self) -> None: # neither stops nor ends. self.ignore_interrupt = False self.interrupted = asyncio.Event() + # The process exits when interrupted, before the turn's result. + self.exit_on_interrupt = False self.errors: list[BaseException] = [] + # Parks the next close; the session that reaches it takes it. self.closing: Hold | None = None self.refuse_close = False @@ -154,6 +192,19 @@ def assert_done(self) -> None: assert not self._turns, f"{len(self._turns)} scripted turn(s) never ran" +@contextmanager +def scripted_claude() -> Iterator[FakeClaude]: + """The scripted Claude CLI behind every ``ClaudeSDKClient`` opened inside. + + The transport factory is the one seam patched: past it the SDK would + spawn the real CLI subprocess. + """ + claude = FakeClaude() + with patch.object(session_manager, "create_transport", claude.transport): + yield claude + claude.assert_done() + + class FakeCLISession(Transport): """One CLI process: the CLI side of the stream-json control protocol.""" @@ -162,21 +213,28 @@ def __init__(self, claude: FakeClaude, options: ClaudeAgentOptions) -> None: self.options = options self.permission_mode = options.permission_mode self.session_id = options.resume or claude.new_session_id() + self.notes = claude.notes.setdefault(self.session_id, []) + self.close_calls = 0 self._outbox: asyncio.Queue[dict[str, Any] | Exception | None] = asyncio.Queue() self._awaiting: dict[str, asyncio.Future[dict[str, Any]]] = {} self._ids = itertools.count(1) self._hooks: dict[str, list[dict[str, Any]]] = {} self._served: dict[str, set[str]] = {} self._turn: asyncio.Task[None] | None = None + self._background: set[asyncio.Task[None]] = set() self.alive = True self.refuse_close = False async def connect(self) -> None: if (hold := self.claude.connecting) is not None: - hold.reached.set() - await hold.released.wait() + self.claude.connecting = None + await hold.wait() if self.claude.refuse_connect or self.options.resume in self.claude.unresumable: - raise CLIConnectionError("Claude CLI exited during startup") + # Like the SDK transport, the startup error is raised from its cause. + try: + raise OSError("Claude CLI exited with status 1") + except OSError as exited: + raise CLIConnectionError(STARTUP_EXITED) from exited def is_ready(self) -> bool: return self.alive @@ -184,11 +242,12 @@ def is_ready(self) -> bool: async def end_input(self) -> None: ... async def close(self) -> None: + self.close_calls += 1 if (hold := self.claude.closing) is not None: - hold.reached.set() - await hold.released.wait() + self.claude.closing = None + await hold.wait() if self.claude.refuse_close or self.refuse_close: - raise RuntimeError("Claude CLI cleanup failed") + raise RuntimeError(CLEANUP_FAILED) self.alive = False if self._turn is not None: self._turn.cancel() @@ -236,12 +295,16 @@ def _answer_sdk(self, message: dict[str, Any]) -> None: self._interrupt() def _interrupt(self) -> None: - """Stop the running turn; like the real CLI it still ends with a result.""" + """Stop the running turn; like the real CLI it still ends with a result + unless the process exits instead.""" self.claude.interrupted.set() if self.claude.ignore_interrupt or self._turn is None or self._turn.done(): return self._turn.cancel() - self._emit_result(EndTurn(is_error=True, result="Request interrupted")) + if self.claude.exit_on_interrupt: + self.die() + else: + self._emit_result(EndTurn(is_error=True, result="Request interrupted")) def _emit(self, **message: Any) -> None: self._outbox.put_nowait(message) @@ -256,6 +319,7 @@ def _assistant(self, *content: dict[str, Any]) -> None: async def _play(self, turn: Turn) -> None: denials: list[dict[str, Any]] = [] ending = EndTurn() + later: list[AfterResult] = [] try: self._emit(type="system", subtype="init", session_id=self.session_id) if self.permission_mode in self.claude.unavailable_modes: @@ -272,6 +336,15 @@ async def _play(self, turn: Turn) -> None: match step: case ModelDecision(): denials += await self._decide(step) + case UnavailableMcp(): + unavailable = False + try: + await self._decide(step.decision) + except* httpx.ConnectError: + unavailable = True + if not unavailable: + raise AssertionError("the MCP endpoint was still reachable") + ending = EndTurn(is_error=True, result=MCP_DISCONNECTED) case Thinking(text=text): self._assistant( {"type": "thinking", "thinking": text, "signature": ""} @@ -279,10 +352,15 @@ async def _play(self, turn: Turn) -> None: case Raw(message=message): self._outbox.put_nowait(message) case Hold(): - step.reached.set() - await step.released.wait() + await step.wait() case EndTurn(): ending = step + case Remember(note=note): + self.notes.append(note) + case Recall(answer=answer): + denials += await self._decide(answer(" ".join(self.notes))) + case AfterResult(): + later.append(step) case Hangup(): self.alive = False self._outbox.put_nowait(None) @@ -296,6 +374,15 @@ async def _play(self, turn: Turn) -> None: self.claude.errors.append(error) ending = EndTurn(is_error=True, result=str(error)) self._emit_result(ending, denials) + for step in later: + task = asyncio.create_task(self._emit_after(step)) + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def _emit_after(self, step: AfterResult) -> None: + if step.gate is not None: + await step.gate.wait() + self._outbox.put_nowait(step.message) def _emit_result( self, ending: EndTurn, denials: list[dict[str, Any]] | None = None diff --git a/tests/adapters/claude_sdk/helpers.py b/tests/adapters/claude_sdk/helpers.py index d392333a3..27bd0e697 100644 --- a/tests/adapters/claude_sdk/helpers.py +++ b/tests/adapters/claude_sdk/helpers.py @@ -6,16 +6,21 @@ import copy import itertools import json +from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any from urllib.parse import urlsplit +from claude_agent_sdk.types import Message + from band.adapters.claude_sdk import ( + APPROVAL_REQUESTED_TEMPLATE, ClaudeApprovalOptions, ClaudeSDKAdapter, ClaudeSDKAdapterConfig, ) from band.converters.claude_sdk import SESSION_ID_METADATA_KEY +from band.core.retention import RoomRetentionConfig from band.core.turn import Turn from band.core.types import ( AgentInput, @@ -24,19 +29,44 @@ MessageType, PlatformMessage, ) +from band.integrations.claude_sdk.session_manager import ( + ClaudeRoomSession, + ClaudeSessionManager, +) +from band.integrations.mcp.backends import BandMCPBackend from band.runtime.tools import BAND_MCP_SERVER_NAME, MCP_TOOL_PREFIX from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools -from tests.adapters.claude_sdk.fakecli import FakeClaude +from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold from tests.adapters.dispatch import deliver_event from tests.baseline.decisions import ModelDecision from tests.mcpclient import crash_backend +# The agent metadata every test adapter is started with. +AGENT_NAME = "Test Agent" +AGENT_DESCRIPTION = "An agent under test" # The reply tool as the SDK namespaces it (MCP_TOOL_PREFIX + bare name). SEND_MESSAGE_MCP_NAME = "mcp__band__band_send_message" # What the runtime reports for a turn that ended without completing. MISSING_REPLY_TEXT = MISSING_REPLY_FAILURE[1] # A native file write, the tool call every approval path is asked about. WRITE_NOTE = ModelDecision.call("Write", file_path="notes.md", content="todo") +# The room's approval prompt for WRITE_NOTE as the first request of a turn. +WRITE_PROMPT = APPROVAL_REQUESTED_TEMPLATE.format( + summary="Write: notes.md", token="a-1" +) +# Short enough that a held transition reports quickly, long enough for a real +# CLI child to start and the Band MCP server to shut down on a slow runner. +REPORTING_BUDGET = RoomRetentionConfig(transition_timeout_s=1.0) +# A turn still running when its room closes is cancelled at once. +NO_SETTLE_GRACE = ClaudeSDKAdapterConfig(turn_settle_timeout_s=0) + + +async def exchange(session: ClaudeRoomSession, prompt: str) -> list[Message]: + """One leased query through the room's single reader, up to its result.""" + async with session.using() as client: + with session.listening() as stream: + await client.query(prompt) + return [message async for message in stream.until_result()] def with_approvals( @@ -178,14 +208,19 @@ async def crash_band_server(self) -> None: """The adapter's Band MCP server dies on its own, between turns.""" await crash_backend(self.adapter._mcp) + async def restart(self) -> None: + """Start the room's adapter again, as the runtime does after a stop.""" + await self.adapter.on_started(AGENT_NAME, AGENT_DESCRIPTION) + def beside(self, room_id: str) -> ClaudeRoom: """Another room served by the same adapter.""" return ClaudeRoom(self.adapter, self.claude, room_id) async def leave(self) -> None: - """The agent leaves the room; a later message bootstraps it afresh.""" - await self.adapter.on_cleanup(self.room_id) + """The agent leaves the room; a later message bootstraps it afresh, + even if this leave stops waiting before its cleanup finishes.""" self._bootstrapped = False + await self.adapter.on_cleanup(self.room_id) def model_call(self, tool: str, **arguments: Any) -> ModelDecision: """The model calling a Band-server tool, by bare name, for this room.""" @@ -207,6 +242,8 @@ async def send( history: tuple[dict[str, Any], ...] = (), session_id: str | None = None, tools: FakeAgentTools | None = None, + participants_msg: str | None = None, + contacts_msg: str | None = None, ) -> None: """Deliver one room message through the runtime's entry point, so the turn is judged as in production; returns once the adapter hands the @@ -214,7 +251,8 @@ async def send( ``history`` is the room's raw history, ``session_id`` a Claude session it persisted, and ``tools`` the message's own tools (``fresh_tools()`` - by default, as the runtime builds per message). + by default, as the runtime builds per message). ``participants_msg`` + and ``contacts_msg`` are the runtime's pending room updates. """ bootstrap, self._bootstrapped = not self._bootstrapped, True raw_history = [*history] @@ -236,8 +274,8 @@ async def send( ), tools=tools or self.fresh_tools(), history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, + participants_msg=participants_msg, + contacts_msg=contacts_msg, is_session_bootstrap=bootstrap, room_id=self.room_id, ), @@ -251,7 +289,59 @@ async def until_said(self, fragment: str, *, times: int = 1) -> None: """Wait until ``times`` room messages contain ``fragment``.""" await self.tools.until_said(fragment, times=times) + @property + def session_manager(self) -> ClaudeSessionManager: + manager = self.adapter._session_manager + assert manager is not None, "the adapter was never started" + return manager + + @property + def mcp_backend(self) -> BandMCPBackend: + backend = self.adapter._mcp.current + assert backend is not None, "no Band MCP server is running" + return backend + + @property + def disposed(self) -> bool: + """Whether the room's final cleanup finished and erased its session.""" + return self.session_manager.session(self.room_id) is None + + @property + def turn_running(self) -> bool: + """Whether the room's detached turn is still running.""" + return self.adapter._turn_running(self.room_id) + async def settled(self) -> None: """Wait for a turn still running after ``send`` returned.""" if (turn := self.adapter._turn_tasks.get(self.room_id)) is not None: await asyncio.wait([turn.task]) + + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + + +async def band_server_replaced(room: ClaudeRoom) -> None: + """Retire the room's CLI by crashing the Band MCP server it dials.""" + await room.crash_band_server() + + +async def left(room: ClaudeRoom) -> None: + """Close the room by leaving it.""" + await room.leave() + + +async def shut_down(room: ClaudeRoom) -> None: + """Close the room by shutting the whole adapter down.""" + await room.adapter.cleanup_all() + + +async def turn_cancelled(room: ClaudeRoom) -> None: + """Retire the room's CLI by cancelling the turn that holds it.""" + abandoned = Hold() + room.claude.script([abandoned, room.model_reply("never sent")]) + message = room.send_in_background("long job") + async with abandoned: + message.cancel() + await asyncio.wait({message}) + if not message.cancelled(): + raise AssertionError("the abandoned message was not cancelled") diff --git a/tests/adapters/claude_sdk/lifecycle/conftest.py b/tests/adapters/claude_sdk/lifecycle/conftest.py new file mode 100644 index 000000000..889cead0d --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/conftest.py @@ -0,0 +1,22 @@ +"""Claude-only lifecycle tests on the common suite's host, started on the +Claude profile alone.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio + +from tests.adapters.claude_sdk.lifecycleprofile import PROFILES +from tests.adapters.lifecycle.kit import RetentionHost, hosted + + +@pytest_asyncio.fixture(loop_scope="function") +async def peer_host( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + (claude,) = PROFILES + async with hosted(claude, tmp_path, monkeypatch) as host: + yield host diff --git a/tests/adapters/claude_sdk/lifecycle/test_active_mcp_replacement.py b/tests/adapters/claude_sdk/lifecycle/test_active_mcp_replacement.py new file mode 100644 index 000000000..727275739 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_active_mcp_replacement.py @@ -0,0 +1,86 @@ +"""Replacing shared MCP preserves an accepted turn and each room's reply binding.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.protocols import TurnResultAlreadyReported +from tests.adapters.claude_sdk.fakecli import MCP_DISCONNECTED, Hold, UnavailableMcp +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestActiveMcpReplacement: + async def test_an_active_room_finishes_then_resumes_on_the_replacement( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room() + other = room.beside("room-2") + active = Hold() + room.claude.script( + [room.model_reply("accepted work finished"), active], + [other.model_reply("other room served")], + [room.model_reply("original room resumed")], + ) + tools = room.fresh_tools() + sending = asyncio.create_task(room.send("long task", tools=tools)) + try: + async with active: + await room.crash_band_server() + await other.send("replace the backend") + assert other.chat == ["other room served"] + assert not sending.done() + await sending + resuming_tools = room.fresh_tools() + await room.send("continue", tools=resuming_tools) + finally: + active.released.set() + await asyncio.gather(sending, return_exceptions=True) + + first, second, resumed = room.session_band_ports + assert first != second == resumed + assert room.claude.resumed == [None, None, "sess-1"] + assert room.chat == ["accepted work finished", "original room resumed"] + assert tools.turn.complete and tools.turn.replied + assert resuming_tools.turn.complete and resuming_tools.turn.replied + assert other.chat == ["other room served"] + assert room.failures == other.failures == [] + assert room.claude.sessions[0].alive is False + + async def test_an_unreplied_turn_using_the_dead_endpoint_fails_once_and_resumes( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room() + other = room.beside("room-2") + active = Hold() + room.claude.script( + [active, UnavailableMcp(room.model_reply("unreachable reply"))], + [other.model_reply("replacement room served")], + [room.model_reply("failed room resumed")], + ) + failed_tools = room.fresh_tools() + sending = asyncio.create_task(room.send("long task", tools=failed_tools)) + try: + async with active: + assert not failed_tools.turn.replied + await room.crash_band_server() + await other.send("replace the backend") + assert not sending.done() + with pytest.raises(TurnResultAlreadyReported, match=MCP_DISCONNECTED): + await sending + assert room.chat == [] + assert len(room.reported_failures) == 1 + await room.send("recover") + finally: + active.released.set() + await asyncio.gather(sending, return_exceptions=True) + + first, replacement, resumed = room.session_band_ports + assert first != replacement == resumed + assert room.claude.resumed == [None, None, "sess-1"] + assert room.chat == ["failed room resumed"] + assert room.failures == [f"Claude SDK turn failed: {MCP_DISCONNECTED}"] + assert other.chat == ["replacement room served"] + assert other.failures == [] + assert room.claude.sessions[0].alive is False diff --git a/tests/adapters/claude_sdk/lifecycle/test_approval_retirement.py b/tests/adapters/claude_sdk/lifecycle/test_approval_retirement.py new file mode 100644 index 000000000..9bdeafd41 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_approval_retirement.py @@ -0,0 +1,85 @@ +"""Approval answers and callbacks stay with the residency that asked them.""" + +from __future__ import annotations + +from claude_agent_sdk.types import PermissionResultDeny, ToolPermissionContext + +from band.adapters.claude_sdk import RETIRED_SESSION_DENIAL +from tests.adapters.claude_sdk.helpers import WRITE_NOTE, OpenRoom, with_approvals + + +class TestApprovalRetirement: + async def test_a_claimed_answer_held_during_leave_cannot_finish_the_rejoined_turn( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room( + with_approvals().model_copy(update={"turn_settle_timeout_s": 0}) + ) + room.claude.script([WRITE_NOTE, room.model_reply("old turn finished")]) + await room.send("write the old note") + await room.until_said("Approval requested") + resolving = room.tools.hold_message("resolved as") + await room.send("/approve a-1") + + async with resolving: + await room.leave() + room.claude.script([WRITE_NOTE, room.model_reply("new turn finished")]) + await room.send("write the new note") + await room.until_said("Approval requested", times=2) + await room.send("/approve a-1") + + assert room.turn_running + assert "old turn finished" not in room.chat + assert room.tool_outputs == {} + await room.send("/approve a-2") + await room.settled() + + assert room.chat[-1] == "new turn finished" + assert room.tool_outputs["Write"] == "Write ran" + assert [cli.alive for cli in room.claude.sessions] == [False, True] + + async def test_old_callbacks_and_answers_leave_other_rooms_decisions_answerable( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room(with_approvals()) + other = room.beside("other-room") + room.claude.script( + [WRITE_NOTE, room.model_reply("old note declined")], + [WRITE_NOTE, other.model_reply("other note written")], + [WRITE_NOTE, room.model_reply("new note written")], + ) + await room.send("old note") + await room.until_said("Approval requested") + old_cli = room.claude.sessions[0] + old_session = room.session_manager.session(room.room_id) + assert old_session is not None + old_generation = old_session.retained.generation + await other.send("other note") + await other.until_said("Approval requested") + + await room.leave() + await room.send("new note") + await room.until_said("Approval requested", times=2) + new_session = room.session_manager.session(room.room_id) + assert new_session is not None + assert new_session.retained.generation == old_generation + assert old_cli.options.can_use_tool is not None + stale = await old_cli.options.can_use_tool( + "Write", + {"file_path": "stale.md"}, + ToolPermissionContext(tool_use_id="late"), + ) + await room.send("/approve a-1") + + assert stale == PermissionResultDeny(message=RETIRED_SESSION_DENIAL) + assert room.turn_running and other.turn_running + await other.send("/approve a-1") + await other.settled() + await room.send("/approve a-2") + await room.settled() + + assert other.chat[-1] == "other note written" + assert room.chat[-1] == "new note written" + assert other.tool_outputs["Write"] == "Write ran" + assert room.tool_outputs["Write"] == "Write ran" + assert room.failures == other.failures == [] diff --git a/tests/adapters/claude_sdk/lifecycle/test_cancelled_message.py b/tests/adapters/claude_sdk/lifecycle/test_cancelled_message.py new file mode 100644 index 000000000..454df3439 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_cancelled_message.py @@ -0,0 +1,39 @@ +"""A message the runtime cancels takes its turn and CLI process with it.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestCancelledMessage: + async def test_a_cancelled_message_never_leaks_its_turn_into_the_next( + self, claude_room: OpenRoom + ) -> None: + """The runtime cancelling a message cancels its detached turn and retires + that CLI process, so nothing the abandoned turn would still do reaches the + room or is read as the next message's answer; the next message resumes the + conversation in a fresh process.""" + room = await claude_room() + abandoned = Hold() + room.claude.script( + [room.model_reply("first")], + [abandoned, room.model_reply("abandoned reply")], + [room.model_reply("second")], + ) + + await room.send("quick") + message = asyncio.create_task(room.send("slow")) + async with abandoned: + message.cancel() + with pytest.raises(asyncio.CancelledError): + await message + await room.send("again") + + assert room.chat == ["first", "second"] + assert room.failures == [] + assert room.claude.resumed == [None, "sess-1"] diff --git a/tests/adapters/claude_sdk/lifecycle/test_closing_admission.py b/tests/adapters/claude_sdk/lifecycle/test_closing_admission.py new file mode 100644 index 000000000..0cd0491c5 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_closing_admission.py @@ -0,0 +1,33 @@ +"""A message that reaches a room while it is leaving is not accepted.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import RoomClosingError +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestClosingAdmission: + async def test_a_message_during_a_held_leave_is_refused_without_a_turn( + self, claude_room: OpenRoom + ) -> None: + """The host keeps the message for later; nothing runs on, or reports + from, the session that is closing.""" + room = await claude_room() + room.claude.script([room.model_reply("hi")]) + await room.send("hi") + closing = room.claude.closing = Hold() + leaving = asyncio.create_task(room.leave()) + + async with closing: + with pytest.raises(RoomClosingError): + await room.send("are you there?") + await leaving + + assert len(room.claude.sessions) == 1 + assert room.chat == ["hi"] + assert room.failures == [] diff --git a/tests/adapters/claude_sdk/lifecycle/test_conversation_continuity.py b/tests/adapters/claude_sdk/lifecycle/test_conversation_continuity.py new file mode 100644 index 000000000..cd676e06e --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_conversation_continuity.py @@ -0,0 +1,42 @@ +"""A replacement CLI continues the retired one's private conversation.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from tests.adapters.claude_sdk.fakecli import Recall, Remember +from tests.adapters.claude_sdk.helpers import ( + ClaudeRoom, + OpenRoom, + band_server_replaced, + turn_cancelled, +) + + +class TestConversationContinuity: + @pytest.mark.parametrize("retire", [band_server_replaced, turn_cancelled]) + async def test_a_replacement_cli_recalls_what_the_retired_one_kept_private( + self, + claude_room: OpenRoom, + retire: Callable[[ClaudeRoom], Awaitable[None]], + ) -> None: + """The note never reached the room, so only a resumed conversation can + answer with it, in the workspace the retired CLI used.""" + room = await claude_room() + room.claude.script( + [Remember("the code word is tulip"), room.model_reply("noted")] + ) + await room.send("remember the code word") + + await retire(room) + room.claude.script([Recall(room.model_reply)]) + await room.send("what was the code word?") + + first, *_, last = room.claude.sessions + assert room.chat == ["noted", "the code word is tulip"] + assert (first.alive, last.alive) == (False, True) + assert last.options.resume == first.session_id + assert last.options.cwd == first.options.cwd + assert room.failures == [] diff --git a/tests/adapters/claude_sdk/lifecycle/test_detached_turn_on_close.py b/tests/adapters/claude_sdk/lifecycle/test_detached_turn_on_close.py new file mode 100644 index 000000000..4e8998d23 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_detached_turn_on_close.py @@ -0,0 +1,41 @@ +"""A turn released on an approval still answers for its message when its room +closes: cancelled past its settle grace, it is reported once.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.claude_sdk import ClaudeApprovalOptions, ClaudeSDKAdapterConfig +from band.core.turn import MISSING_REPLY +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import ( + WRITE_NOTE, + WRITE_PROMPT, + ClaudeRoom, + OpenRoom, + left, + shut_down, +) + + +class TestDetachedTurnOnClose: + @pytest.mark.parametrize("closing", [left, shut_down]) + async def test_a_released_turn_cancelled_by_its_room_closing_is_reported_once( + self, claude_room: OpenRoom, closing: Callable[[ClaudeRoom], Awaitable[None]] + ) -> None: + room = await claude_room( + ClaudeSDKAdapterConfig( + approvals=ClaudeApprovalOptions(mode="manual"), + turn_settle_timeout_s=0, + ) + ) + room.claude.script([WRITE_NOTE, Hold()]) + await room.send("jot a note") + await room.until_said("Approval requested") + + await closing(room) + + assert room.chat == [WRITE_PROMPT] + assert room.failures == [MISSING_REPLY.message] diff --git a/tests/adapters/claude_sdk/lifecycle/test_failed_start_close.py b/tests/adapters/claude_sdk/lifecycle/test_failed_start_close.py new file mode 100644 index 000000000..c7455487f --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_failed_start_close.py @@ -0,0 +1,28 @@ +"""A failed Claude start reports its own failure, whichever close of it +fails.""" + +from __future__ import annotations + +import asyncio + +from tests.adapters.lifecycle.kit import RetentionHost, raises_exactly +from tests.adapters.roompeer import write_instruction + + +class TestFailedStartClose: + async def test_a_start_whose_first_close_fails_reports_its_own_failure( + self, peer_host: RetentionHost + ) -> None: + """The SDK's own close of the failed start fails and the session's + retry succeeds: the turn still reports the start failure.""" + host = peer_host + host.fail_startup.touch() + host.hold_startup.touch() + opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) + await host.start_reached("room-a") + host.children[0].refused_closes = 1 + host.hold_startup.unlink() + + with raises_exactly(host.failed_start): + await opening + assert host.exited == [True] diff --git a/tests/adapters/claude_sdk/lifecycle/test_fixture_clock.py b/tests/adapters/claude_sdk/lifecycle/test_fixture_clock.py new file mode 100644 index 000000000..7987f0029 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_fixture_clock.py @@ -0,0 +1,39 @@ +"""The ``claude_room`` fixture tears its server down on the clock it started on.""" + +from __future__ import annotations + +import asyncio + +import looptime +import pytest + +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestFixtureClock: + @pytest.mark.parametrize( + "virtual_clock", + [ + pytest.param(False, marks=pytest.mark.looptime(False), id="real-clock"), + pytest.param(True, marks=pytest.mark.looptime, id="virtual-clock"), + ], + ) + async def test_fixture_cleanup_keeps_the_clock_that_started_the_server( + self, + monkeypatch: pytest.MonkeyPatch, + claude_room: OpenRoom, + virtual_clock: bool, + ) -> None: + room = await claude_room() + room.claude.script([room.model_reply("done")]) + await room.send("hello") + loop = asyncio.get_running_loop() + assert isinstance(loop, looptime.LoopTimeEventLoop) + cleanup_all = room.adapter.cleanup_all + + async def cleanup_on_original_clock() -> None: + assert loop.looptime_on == virtual_clock + await cleanup_all() + + # Keep the real server cleanup, observing its clock during fixture teardown. + monkeypatch.setattr(room.adapter, "cleanup_all", cleanup_on_original_clock) diff --git a/tests/adapters/claude_sdk/lifecycle/test_mcp_backend_replacement.py b/tests/adapters/claude_sdk/lifecycle/test_mcp_backend_replacement.py index 43cd1ed2e..58a246ab4 100644 --- a/tests/adapters/claude_sdk/lifecycle/test_mcp_backend_replacement.py +++ b/tests/adapters/claude_sdk/lifecycle/test_mcp_backend_replacement.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable from pathlib import Path import pytest @@ -12,120 +11,119 @@ from band.integrations.claude_sdk.session_manager import ( ClaudeSessionManagerStoppedError, ) -from band.integrations.mcp import BandMCPBackendStoppedError from tests.adapters.claude_sdk.fakecli import Hold -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -async def test_a_new_room_after_a_crash_gets_a_live_band_endpoint( - claude_room: OpenRoom, -) -> None: - room = await claude_room() - other_room = room.beside("room-2") - room.claude.script( - [room.model_reply("before the crash")], - [other_room.model_reply("after the crash")], - ) - await room.send("hi") - - await room.crash_band_server() - await other_room.send("hi") - - first_port, second_port = room.session_band_ports - assert other_room.chat == ["after the crash"] - assert second_port != first_port - - -async def test_an_open_room_after_a_crash_resumes_on_the_new_endpoint( - claude_room: OpenRoom, - tmp_path: Path, -) -> None: - """The room's open session still dials the dead port, so it is replaced by - one that resumes the same conversation on the live one.""" - workspace = tmp_path / "original" - room = await claude_room(workspace_for_room=lambda _: str(workspace)) - room.claude.script( - [room.model_reply("before the crash")], - [room.model_reply("after the crash")], - ) - await room.send("hi") - - await room.crash_band_server() - workspace = tmp_path / "changed" - await room.send("still there?") - - first_session, _ = room.claude.sessions - first_port, second_port = room.session_band_ports - assert room.chat == ["before the crash", "after the crash"] - assert room.claude.resumed == [None, "sess-1"] - assert first_session.alive is False - assert second_port != first_port - assert room.claude.session_workspaces == [str(tmp_path / "original")] * 2 - - -async def test_a_message_after_shutdown_is_refused(claude_room: OpenRoom) -> None: - """Shutdown closes the backend for good: a late message can't start a - server nothing would stop.""" - room = await claude_room() - backend = room.adapter._mcp.current - assert backend is not None - await room.adapter.cleanup_all() - - with pytest.raises(BandMCPBackendStoppedError): +from tests.adapters.claude_sdk.helpers import NO_SETTLE_GRACE, OpenRoom + + +class TestMcpBackendReplacement: + async def test_a_new_room_after_a_crash_gets_a_live_band_endpoint( + self, + claude_room: OpenRoom, + ) -> None: + room = await claude_room() + other_room = room.beside("room-2") + room.claude.script( + [room.model_reply("before the crash")], + [other_room.model_reply("after the crash")], + ) await room.send("hi") - assert backend.is_running is False - assert room.claude.sessions == [] - - -async def test_a_message_caught_by_shutdown_restarts_nothing( - claude_room: OpenRoom, -) -> None: - """A message whose session request lands after the session manager - stopped is refused, not retried as a failed resume that would bring the - stopped manager back to life and report a failure mid-shutdown.""" - room = await claude_room() - manager = room.adapter._session_manager - assert manager is not None - await manager.stop() - - with pytest.raises(ClaudeSessionManagerStoppedError): - await room.send("hi", session_id="sess-earlier") - - assert room.reported_failures == [] - assert room.claude.sessions == [] - - -async def test_a_resume_cut_short_by_shutdown_reports_nothing( - claude_room: OpenRoom, -) -> None: - """The shutdown lands while a resume is connecting and the resume fails; - the fresh-session fallback must meet the stopped manager quietly.""" - room = await claude_room() - room.claude.unresumable.add("sess-earlier") - room.claude.connecting = connecting = Hold() - sending = asyncio.create_task(room.send("hi", session_id="sess-earlier")) - - async with connecting: - stopping = asyncio.create_task(room.adapter.cleanup_all()) - await asyncio.sleep(0) # cleanup_all reaches the manager's stop() - - with pytest.raises(ClaudeSessionManagerStoppedError): - await sending - await stopping - - assert room.reported_failures == [] - assert room.claude.resumed == ["sess-earlier"] - - -async def test_a_restarted_agent_serves_band_tools_again(claude_room: OpenRoom) -> None: - room = await claude_room() - room.claude.script([room.model_reply("back again")]) - await room.adapter.cleanup_all() + await room.crash_band_server() + await other_room.send("hi") + + first_port, second_port = room.session_band_ports + assert other_room.chat == ["after the crash"] + assert second_port != first_port + + async def test_an_open_room_after_a_crash_resumes_on_the_new_endpoint( + self, + claude_room: OpenRoom, + tmp_path: Path, + ) -> None: + """The room's open session still dials the dead port, so it is replaced by + one that resumes the same conversation on the live one.""" + workspace = tmp_path / "original" + room = await claude_room(workspace_for_room=lambda _: str(workspace)) + room.claude.script( + [room.model_reply("before the crash")], + [room.model_reply("after the crash")], + ) + await room.send("hi") - await room.adapter.on_started("Test Agent", "An agent under test") - await room.send("hi") + await room.crash_band_server() + workspace = tmp_path / "changed" + await room.send("still there?") + + first_session, _ = room.claude.sessions + first_port, second_port = room.session_band_ports + assert room.chat == ["before the crash", "after the crash"] + assert room.claude.resumed == [None, "sess-1"] + assert first_session.alive is False + assert second_port != first_port + assert room.claude.session_workspaces == [str(tmp_path / "original")] * 2 + + async def test_a_message_after_shutdown_is_refused( + self, claude_room: OpenRoom + ) -> None: + """Shutdown closes admission and the backend for good: a late message is + refused before it could start a server or CLI nothing would stop.""" + room = await claude_room() + backend = room.mcp_backend + await room.adapter.cleanup_all() + + with pytest.raises(ClaudeSessionManagerStoppedError): + await room.send("hi") + + assert backend.is_running is False + assert room.claude.sessions == [] + + async def test_a_message_caught_by_shutdown_restarts_nothing( + self, + claude_room: OpenRoom, + ) -> None: + """A message whose session request lands after the session manager + stopped is refused, not retried as a failed resume that would bring the + stopped manager back to life and report a failure mid-shutdown.""" + room = await claude_room() + await room.session_manager.stop() + + with pytest.raises(ClaudeSessionManagerStoppedError): + await room.send("hi", session_id="sess-earlier") + + assert room.reported_failures == [] + assert room.claude.sessions == [] + + async def test_a_resume_cut_short_by_shutdown_reports_nothing( + self, + claude_room: OpenRoom, + ) -> None: + """The shutdown lands while a resume is connecting and the turn has no + settle grace: it cancels the turn and the connect, so no fresh-session + fallback starts and nothing is reported mid-shutdown.""" + room = await claude_room(NO_SETTLE_GRACE) + room.claude.unresumable.add("sess-earlier") + room.claude.connecting = connecting = Hold() + sending = asyncio.create_task(room.send("hi", session_id="sess-earlier")) + + async with connecting: + stopping = asyncio.create_task(room.adapter.cleanup_all()) + + with pytest.raises(asyncio.CancelledError): + await sending + await stopping + + assert room.reported_failures == [] + assert room.claude.resumed == ["sess-earlier"] + assert [session.alive for session in room.claude.sessions] == [False] + + async def test_a_restarted_agent_serves_band_tools_again( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room() + room.claude.script([room.model_reply("back again")]) + await room.adapter.cleanup_all() + + await room.restart() + await room.send("hi") - assert room.chat == ["back again"] + assert room.chat == ["back again"] diff --git a/tests/adapters/claude_sdk/lifecycle/test_on_cleanup.py b/tests/adapters/claude_sdk/lifecycle/test_on_cleanup.py deleted file mode 100644 index b241eac3d..000000000 --- a/tests/adapters/claude_sdk/lifecycle/test_on_cleanup.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable - -import looptime -import pytest - -from tests.adapters.claude_sdk.fakecli import Hold -from tests.adapters.claude_sdk.helpers import ClaudeRoom, said - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -@pytest.mark.parametrize( - "virtual_clock", - [ - pytest.param(False, marks=pytest.mark.looptime(False), id="real-clock"), - pytest.param(True, marks=pytest.mark.looptime, id="virtual-clock"), - ], -) -async def test_fixture_cleanup_keeps_the_clock_that_started_the_server( - monkeypatch: pytest.MonkeyPatch, - claude_room: OpenRoom, - virtual_clock: bool, -) -> None: - room = await claude_room() - room.claude.script([room.model_reply("done")]) - await room.send("hello") - loop = asyncio.get_running_loop() - assert isinstance(loop, looptime.LoopTimeEventLoop) - cleanup_all = room.adapter.cleanup_all - - async def cleanup_on_original_clock() -> None: - assert loop.looptime_on == virtual_clock - await cleanup_all() - - # Keep the real server cleanup, observing its clock during fixture teardown. - monkeypatch.setattr(room.adapter, "cleanup_all", cleanup_on_original_clock) - - -async def leave_room(room: ClaudeRoom) -> None: - await room.leave() - - -async def shut_down(room: ClaudeRoom) -> None: - await room.adapter.cleanup_all() - - -@pytest.mark.parametrize("teardown", [leave_room, shut_down]) -async def test_teardown_mid_turn_stops_the_turn_before_closing_its_session( - claude_room: OpenRoom, teardown: Callable[[ClaudeRoom], Awaitable[None]] -) -> None: - """Closing the session under a live turn would surface as a dead CLI; the - turn is cancelled first, so the room sees neither a reply nor a failure.""" - room = await claude_room() - thinking = Hold() - room.claude.script([thinking, room.model_reply("never sent")]) - - message = asyncio.create_task(room.send("long job")) - async with thinking: - await teardown(room) - - with pytest.raises(asyncio.CancelledError): - await message - [session] = room.claude.sessions - assert not session.alive - assert room.chat == [] - assert room.failures == [] - - -async def test_a_room_left_mid_turn_can_be_rejoined_with_a_fresh_session( - claude_room: OpenRoom, -) -> None: - room = await claude_room() - thinking = Hold() - room.claude.script( - [thinking, room.model_reply("never sent")], [room.model_reply("welcome back")] - ) - message = asyncio.create_task(room.send("long job")) - async with thinking: - await room.leave() - with pytest.raises(asyncio.CancelledError): - await message - - await room.send("I'm back", history=(said("Bob", "long job"),)) - - assert len(room.claude.sessions) == 2 - assert "[Bob]: long job" in room.claude.prompts[-1] - assert room.chat == ["welcome back"] - - -async def test_a_cancelled_message_never_leaks_its_turn_into_the_next( - claude_room: OpenRoom, -) -> None: - """The runtime cancelling a message cancels its detached turn and retires - that CLI process, so nothing the abandoned turn would still do reaches the - room or is read as the next message's answer; the next message resumes the - conversation in a fresh process.""" - room = await claude_room() - abandoned = Hold() - room.claude.script( - [room.model_reply("first")], - [abandoned, room.model_reply("abandoned reply")], - [room.model_reply("second")], - ) - - await room.send("quick") - message = asyncio.create_task(room.send("slow")) - async with abandoned: - message.cancel() - with pytest.raises(asyncio.CancelledError): - await message - await room.send("again") - - assert room.chat == ["first", "second"] - assert room.failures == [] - assert room.claude.resumed == [None, "sess-1"] diff --git a/tests/adapters/claude_sdk/lifecycle/test_on_message.py b/tests/adapters/claude_sdk/lifecycle/test_on_message.py index 1425974c9..dab7c6060 100644 --- a/tests/adapters/claude_sdk/lifecycle/test_on_message.py +++ b/tests/adapters/claude_sdk/lifecycle/test_on_message.py @@ -1,17 +1,14 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable import pytest from band.core.exceptions import InputNotAccepted from tests.adapters.claude_sdk.fakecli import Hold -from tests.adapters.claude_sdk.helpers import ClaudeRoom, said +from tests.adapters.claude_sdk.helpers import OpenRoom, said from tests.conftest import eager_tasks, requires_eager_tasks -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - MEMORY_FRAMING = "Your memory of this room so far" diff --git a/tests/adapters/claude_sdk/lifecycle/test_on_started.py b/tests/adapters/claude_sdk/lifecycle/test_on_started.py index 9b440a6d1..ba4bd8317 100644 --- a/tests/adapters/claude_sdk/lifecycle/test_on_started.py +++ b/tests/adapters/claude_sdk/lifecycle/test_on_started.py @@ -1,7 +1,5 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest from band.adapters.claude_sdk import ( @@ -11,11 +9,9 @@ ) from band.core.defaultmodels import ANTHROPIC_MODEL from band.runtime.tools import MAX_INLINE_IMAGE_BYTES -from tests.adapters.claude_sdk.helpers import ClaudeRoom, with_approvals +from tests.adapters.claude_sdk.helpers import OpenRoom, with_approvals from tests.baseline.decisions import ModelDecision -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - # claude_agent_sdk's stdio transport default (subprocess_cli._DEFAULT_MAX_BUFFER_SIZE). SDK_DEFAULT_BUFFER_BYTES = 1024 * 1024 diff --git a/tests/adapters/claude_sdk/lifecycle/test_parked_turn_ownership.py b/tests/adapters/claude_sdk/lifecycle/test_parked_turn_ownership.py new file mode 100644 index 000000000..adb9d40aa --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_parked_turn_ownership.py @@ -0,0 +1,42 @@ +"""A turn parked on a person keeps its room owned until the room leaves.""" + +from __future__ import annotations + +import asyncio + +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import ( + WRITE_NOTE, + WRITE_PROMPT, + OpenRoom, + with_approvals, +) + + +class TestParkedTurnOwnership: + async def test_a_parked_approval_keeps_its_turn_owned_until_the_room_leaves( + self, claude_room: OpenRoom + ) -> None: + """``on_message`` returns while the turn waits on a person; the detached + turn still holds the room, and leaving lets it settle, its approval + declined, before the CLI closes.""" + room = await claude_room(with_approvals()) + room.claude.script([WRITE_NOTE, room.model_reply("Skipped the note.")]) + + await room.send("jot a note") + await room.until_said("Approval requested") + parked = room.turn_running + closing = room.claude.closing = Hold() + leaving = asyncio.create_task(room.leave()) + async with closing: + running_at_close = room.turn_running + await leaving + + assert (parked, running_at_close) == (True, False) + assert [cli.alive for cli in room.claude.sessions] == [False] + assert room.chat == [WRITE_PROMPT, "Skipped the note."] + # The turn read its own result, so it finished rather than being cancelled. + assert room.persisted_sessions == [ + cli.session_id for cli in room.claude.sessions + ] + assert room.failures == [] diff --git a/tests/adapters/claude_sdk/lifecycle/test_rejoin_after_leave.py b/tests/adapters/claude_sdk/lifecycle/test_rejoin_after_leave.py new file mode 100644 index 000000000..ac2845a68 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_rejoin_after_leave.py @@ -0,0 +1,33 @@ +"""A room left mid-turn is served again on rejoin.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import NO_SETTLE_GRACE, OpenRoom, said + + +class TestRejoinAfterLeave: + async def test_a_room_left_mid_turn_can_be_rejoined_with_a_fresh_session( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room(NO_SETTLE_GRACE) + thinking = Hold() + room.claude.script( + [thinking, room.model_reply("never sent")], + [room.model_reply("welcome back")], + ) + message = asyncio.create_task(room.send("long job")) + async with thinking: + await room.leave() + with pytest.raises(asyncio.CancelledError): + await message + + await room.send("I'm back", history=(said("Bob", "long job"),)) + + assert len(room.claude.sessions) == 2 + assert "[Bob]: long job" in room.claude.prompts[-1] + assert room.chat == ["welcome back"] diff --git a/tests/adapters/claude_sdk/lifecycle/test_retired_cli_authority.py b/tests/adapters/claude_sdk/lifecycle/test_retired_cli_authority.py new file mode 100644 index 000000000..2bbc883f0 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_retired_cli_authority.py @@ -0,0 +1,30 @@ +"""A retired CLI's late callbacks never act for the room's next session.""" + +from __future__ import annotations + +from claude_agent_sdk.types import PermissionResultDeny, ToolPermissionContext + +from band.adapters.claude_sdk import RETIRED_SESSION_DENIAL +from tests.adapters.claude_sdk.helpers import OpenRoom, with_approvals + + +class TestRetiredCliAuthority: + async def test_a_permission_request_from_a_left_rooms_cli_is_denied( + self, claude_room: OpenRoom + ) -> None: + """A rejoined room starts counting residencies again, so only the session + object tells the old CLI's late request apart; it never reaches the room.""" + room = await claude_room(with_approvals()) + room.claude.script([room.model_reply("first")], [room.model_reply("second")]) + await room.send("hi") + await room.leave() + await room.send("back again") + old_cli, _ = room.claude.sessions + assert old_cli.options.can_use_tool is not None + + decision = await old_cli.options.can_use_tool( + "Bash", {"command": "ls"}, ToolPermissionContext(tool_use_id="late") + ) + + assert decision == PermissionResultDeny(message=RETIRED_SESSION_DENIAL) + assert room.chat == ["first", "second"] diff --git a/tests/adapters/claude_sdk/lifecycle/test_shutdown_budget.py b/tests/adapters/claude_sdk/lifecycle/test_shutdown_budget.py new file mode 100644 index 000000000..4b775d91a --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_shutdown_budget.py @@ -0,0 +1,73 @@ +"""Adapter shutdown closes rooms before the Band MCP server, all within one +reporting budget, and leaves anything unfinished for a retry.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.adapters.claude_sdk import ClaudeSDKAdapterConfig +from band.core.exceptions import CleanupIncompleteError +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, OpenRoom + + +class TestShutdownBudget: + @pytest.mark.looptime + async def test_shutdown_keeps_mcp_for_open_rooms( + self, claude_room: OpenRoom + ) -> None: + """A CLI that is slow to close makes shutdown report incomplete cleanup; + the Band MCP server it dials stays up until a retry confirms the room + closed, and nothing is closed twice.""" + room = await claude_room(ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET)) + room.claude.script([room.model_reply("hi")]) + await room.send("hi") + backend = room.mcp_backend + closing = room.claude.closing = Hold() + + with pytest.raises(CleanupIncompleteError): + await room.adapter.cleanup_all() + [cli] = room.claude.sessions + still_serving = (backend.is_running, cli.close_calls) + closing.released.set() + await room.adapter.cleanup_all() + + assert still_serving == (True, 1) + assert (backend.is_running, cli.alive) == (False, False) + + @pytest.mark.looptime + async def test_shutdown_steps_share_one_budget( + self, claude_room: OpenRoom, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Closing the room and then the Band MCP server each fit the budget on + their own but not together, so the budget runs out during the server's + close, which a retry then finishes.""" + room = await claude_room(ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET)) + room.claude.script([room.model_reply("hi")]) + await room.send("hi") + backend = room.mcp_backend + step_s = REPORTING_BUDGET.transition_timeout_s * 0.7 + closing = room.claude.closing = Hold() + mcp = room.adapter._mcp + close_mcp = mcp.close + + # The server's real shutdown is quick; only its delay is staged here. + async def slow_mcp_close(*, final: bool) -> None: + await asyncio.sleep(step_s) + await close_mcp(final=final) + + with monkeypatch.context() as patch: + patch.setattr(mcp, "close", slow_mcp_close) + shutdown = asyncio.create_task(room.adapter.cleanup_all()) + async with closing: + await asyncio.sleep(step_s) + with pytest.raises(CleanupIncompleteError, match="MCP"): + await shutdown + [cli] = room.claude.sessions + at_budget = (cli.alive, backend.is_running) + await room.adapter.cleanup_all() + + assert at_budget == (False, True) + assert backend.is_running is False diff --git a/tests/adapters/claude_sdk/lifecycle/test_teardown_mid_turn.py b/tests/adapters/claude_sdk/lifecycle/test_teardown_mid_turn.py new file mode 100644 index 000000000..5561bdd3e --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_teardown_mid_turn.py @@ -0,0 +1,43 @@ +"""Closing a room under a live turn cancels the turn before its session.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest + +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import ( + NO_SETTLE_GRACE, + ClaudeRoom, + OpenRoom, + left, + shut_down, +) + + +class TestTeardownMidTurn: + @pytest.mark.parametrize("teardown", [left, shut_down]) + async def test_teardown_mid_turn_stops_the_turn_before_closing_its_session( + self, + claude_room: OpenRoom, + teardown: Callable[[ClaudeRoom], Awaitable[None]], + ) -> None: + """Closing the session under a live turn would surface as a dead CLI; a + turn past its settle grace is cancelled first, so the room sees neither a + reply nor a failure.""" + room = await claude_room(NO_SETTLE_GRACE) + thinking = Hold() + room.claude.script([thinking, room.model_reply("never sent")]) + + message = asyncio.create_task(room.send("long job")) + async with thinking: + await teardown(room) + + with pytest.raises(asyncio.CancelledError): + await message + [session] = room.claude.sessions + assert not session.alive + assert room.chat == [] + assert room.failures == [] diff --git a/tests/adapters/claude_sdk/lifecycleprofile.py b/tests/adapters/claude_sdk/lifecycleprofile.py new file mode 100644 index 000000000..2fa12e584 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycleprofile.py @@ -0,0 +1,79 @@ +"""The Claude SDK adapter's bridge into the common Room lifecycle suite.""" + +from __future__ import annotations + +import pytest + +from band.adapters.claude_sdk import ( + ClaudeApprovalOptions, + ClaudeSDKAdapter, + ClaudeSDKAdapterConfig, +) +from band.core.protocols import TurnResultAlreadyReported +from band.core.retention import RetainedRoom +from band.integrations.claude_sdk.session_manager import ClaudeRoomSession +from tests.adapters.claude_sdk.process import WorkspacePeer, launch_children +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import ( + ExpectedFailure, + LifecycleBridge, + LifecycleProfile, + RetentionHost, + pause_at, +) +from tests.adapters.roompeer import STARTUP_FAILURE + + +class ClaudePeer(WorkspacePeer): + """Inject a held start or a held close at the real transport.""" + + starting: Hold | None = None + closing: Hold | None = None + + async def connect(self) -> None: + await super().connect() + await pause_at(self.starting) + + async def close(self) -> None: + await pause_at(self.closing) + await super().close() + + +def install(host: RetentionHost, monkeypatch: pytest.MonkeyPatch) -> LifecycleBridge: + adapter = ClaudeSDKAdapter( + ClaudeSDKAdapterConfig( + approvals=ClaudeApprovalOptions( + mode="auto_accept", text_notifications=False + ) + ), + workspace_for_room=host.workspace, + ) + + def hosted(peer: ClaudePeer) -> None: + peer.arguments = host.peer_arguments + host.spawned(peer, peer.workspace) + + launch_children(monkeypatch, ClaudePeer, hosted) + + def session(room_id: str) -> ClaudeRoomSession | None: + manager = adapter._session_manager + return manager.session(room_id) if manager is not None else None + + def residency(room_id: str) -> RetainedRoom | None: + return room.retained if (room := session(room_id)) is not None else None + + def native_id(room_id: str) -> str | None: + return room.session_id if (room := session(room_id)) is not None else None + + return LifecycleBridge(adapter=adapter, residency=residency, native_id=native_id) + + +PROFILES = [ + LifecycleProfile( + name="claude", + install=install, + lost_turn=TurnResultAlreadyReported, + # The SDK raises a refused initialize as a bare Exception. + failed_start=ExpectedFailure(Exception, STARTUP_FAILURE), + ) +] diff --git a/tests/adapters/claude_sdk/process.py b/tests/adapters/claude_sdk/process.py index ebdffd8eb..001a995b0 100644 --- a/tests/adapters/claude_sdk/process.py +++ b/tests/adapters/claude_sdk/process.py @@ -3,11 +3,17 @@ from __future__ import annotations import sys +from collections.abc import Callable +from typing import TypeVar +import pytest +from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from band.integrations.claude_sdk import transport from band.runtime.tools import BAND_MCP_SERVER_NAME -from tests.paths import REPO_ROOT +from tests.adapters.roompeer import CLEANUP_FAILURE +from tests.paths import ROOM_PEER PROBE = """ import json @@ -25,10 +31,13 @@ class WorkspaceProcess(SubprocessCLITransport): """Exercise SDK spawning, pipes and cleanup without a Claude installation.""" refuse_close = False + # Closes refused before one succeeds; ``refuse_close`` refuses every one. + refused_closes = 0 async def close(self) -> None: - if self.refuse_close: - raise RuntimeError("subprocess cleanup failed") + if self.refuse_close or self.refused_closes: + self.refused_closes = max(self.refused_closes - 1, 0) + raise RuntimeError(CLEANUP_FAILURE) await super().close() async def _check_claude_version(self) -> None: @@ -37,6 +46,12 @@ async def _check_claude_version(self) -> None: def _build_command(self) -> list[str]: return [sys.executable, "-u", "-c", PROBE, self._options.env["PROBE_MARKER"]] + @property + def workspace(self) -> str | None: + """The directory the SDK starts this child in, if the options set one.""" + cwd = self._options.cwd + return str(cwd) if cwd else None + @property def exited(self) -> bool: return self._child.returncode is not None @@ -56,9 +71,33 @@ def _build_command(self) -> list[str]: command = [ sys.executable, "-u", - str(REPO_ROOT / "tests/adapters/roompeer.py"), + str(ROOM_PEER), *self.arguments, ] if server := self._options.mcp_servers.get(BAND_MCP_SERVER_NAME): command.extend(["--band-url", str(server["url"])]) return command + + +Child = TypeVar("Child", bound=SubprocessCLITransport) + + +def launch_children( + monkeypatch: pytest.MonkeyPatch, + child_type: type[Child], + on_launch: Callable[[Child], None] | None = None, +) -> list[Child]: + """Start every CLI the SDK launches as a real ``child_type`` process; + ``on_launch`` prepares each one before it starts. Returns the children, + in launch order, as they are launched.""" + children: list[Child] = [] + + def launch(*, prompt: str, options: ClaudeAgentOptions) -> Child: + child = child_type(prompt=prompt, options=options) + if on_launch is not None: + on_launch(child) + children.append(child) + return child + + monkeypatch.setattr(transport, "SubprocessCLITransport", launch) + return children diff --git a/tests/adapters/claude_sdk/test_cli_options.py b/tests/adapters/claude_sdk/test_cli_options.py index af93b3e91..258f26cb0 100644 --- a/tests/adapters/claude_sdk/test_cli_options.py +++ b/tests/adapters/claude_sdk/test_cli_options.py @@ -3,7 +3,6 @@ from __future__ import annotations import sys -from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any @@ -24,11 +23,13 @@ ) from band.core.types import Emit from band.integrations.claude_sdk.transport import create_transport -from tests.adapters.claude_sdk.helpers import SEND_MESSAGE_MCP_NAME, ClaudeRoom +from tests.adapters.claude_sdk.helpers import ( + SEND_MESSAGE_MCP_NAME, + ClaudeRoom, + OpenRoom, +) from tests.paths import host_absolute_path -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - def test_owned_transport_preserves_sdk_permission_routing() -> None: async def allow( diff --git a/tests/adapters/claude_sdk/test_config.py b/tests/adapters/claude_sdk/test_config.py index 75ccf4698..a93c024d5 100644 --- a/tests/adapters/claude_sdk/test_config.py +++ b/tests/adapters/claude_sdk/test_config.py @@ -3,7 +3,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any, get_args @@ -19,11 +18,9 @@ ClaudeSDKAdapterConfig, ) from tests.adapters.claude_sdk.fakecli import Hold -from tests.adapters.claude_sdk.helpers import WRITE_NOTE, ClaudeRoom +from tests.adapters.claude_sdk.helpers import WRITE_NOTE, OpenRoom from tests.baseline.decisions import ModelDecision -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - ADMIN = {"id": "admin-1", "name": "Admin"} LIST_FILES = ModelDecision.call("Bash", command="ls") diff --git a/tests/adapters/claude_sdk/test_host_options.py b/tests/adapters/claude_sdk/test_host_options.py index 4122949b0..e6f11e6af 100644 --- a/tests/adapters/claude_sdk/test_host_options.py +++ b/tests/adapters/claude_sdk/test_host_options.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable import pytest @@ -13,9 +12,7 @@ ClaudePermissionMode, ClaudeSDKAdapterConfig, ) -from tests.adapters.claude_sdk.helpers import WRITE_NOTE, ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] +from tests.adapters.claude_sdk.helpers import WRITE_NOTE, OpenRoom async def test_dont_ask_denies_an_unlisted_native_tool_without_asking_the_room( diff --git a/tests/adapters/claude_sdk/test_prompt_framing.py b/tests/adapters/claude_sdk/test_prompt_framing.py new file mode 100644 index 000000000..304b3b2b7 --- /dev/null +++ b/tests/adapters/claude_sdk/test_prompt_framing.py @@ -0,0 +1,28 @@ +"""Room updates the runtime hands a message reach Claude as system lines +ahead of the message itself.""" + +from __future__ import annotations + +from band.runtime.prompts import system_updates +from tests.adapters.claude_sdk.helpers import OpenRoom + +JOINED = "Alice joined the room" +CONTACTED = "Carol is now a contact" + + +class TestPromptFraming: + async def test_participant_and_contact_updates_precede_the_message( + self, claude_room: OpenRoom + ) -> None: + room = await claude_room() + room.claude.script([room.model_reply("hi all")]) + + await room.send( + "hello", + participants_msg=JOINED, + contacts_msg=CONTACTED, + ) + + participants, contacts, message = room.claude.prompts[-1].split("\n\n") + assert [participants, contacts] == system_updates(JOINED, CONTACTED) + assert message.endswith("hello") diff --git a/tests/adapters/claude_sdk/test_send_message_dedup.py b/tests/adapters/claude_sdk/test_send_message_dedup.py index afdf65579..1525b09ae 100644 --- a/tests/adapters/claude_sdk/test_send_message_dedup.py +++ b/tests/adapters/claude_sdk/test_send_message_dedup.py @@ -3,12 +3,8 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - from band.adapters.claude_sdk import ClaudeSDKAdapterConfig -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] +from tests.adapters.claude_sdk.helpers import OpenRoom async def test_a_repeated_reply_reaches_its_room_once_even_after_the_turn_ends( diff --git a/tests/adapters/claude_sdk/test_session_persistence.py b/tests/adapters/claude_sdk/test_session_persistence.py index 8cc73a951..a3a8e0735 100644 --- a/tests/adapters/claude_sdk/test_session_persistence.py +++ b/tests/adapters/claude_sdk/test_session_persistence.py @@ -1,79 +1,82 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable +import re import pytest from claude_agent_sdk import CLIConnectionError from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -async def test_a_room_resumes_the_session_its_history_recorded( - claude_room: OpenRoom, -) -> None: - """Bootstrap resumes the recorded session; the id is re-recorded once, and - only a bootstrap ever asks to resume.""" - room = await claude_room() - room.claude.script([room.model_reply("welcome back")], [room.model_reply("ok")]) - - await room.send("hello again", session_id="sess-from-history") - await room.send("next", session_id="sess-should-not-use") - - assert room.claude.resumed == ["sess-from-history"] - assert room.persisted_sessions == ["sess-from-history"] - assert room.chat == ["welcome back", "ok"] - - -async def test_a_session_that_cannot_resume_falls_back_to_a_fresh_one( - claude_room: OpenRoom, -) -> None: - """A recorded session the CLI can no longer resume heals silently: a fresh - session answers, and its id replaces the stale one.""" - room = await claude_room() - room.claude.unresumable.add("sess-broken") - room.claude.script([room.model_reply("fresh start")]) - - await room.send("hello", session_id="sess-broken") - - assert room.claude.resumed == ["sess-broken", None] - [fresh] = room.claude.sessions[1:] - assert room.persisted_sessions == [fresh.session_id] - assert room.chat == ["fresh start"] - assert room.failures == [] - - -@pytest.mark.parametrize( - ("recorded_session", "attempts"), - [(None, [None]), ("sess-broken", ["sess-broken", None])], - ids=["no-session-to-resume", "fallback-also-fails"], -) -async def test_a_cli_that_will_not_start_fails_the_message_without_leaking_why( - claude_room: OpenRoom, recorded_session: str | None, attempts: list[str | None] -) -> None: - room = await claude_room() - room.claude.refuse_connect = True - - with pytest.raises(CLIConnectionError, match="exited during startup"): - await room.send("hello", session_id=recorded_session) - - assert room.claude.resumed == attempts - assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] - assert room.chat == [] - - -async def test_events_the_room_rejects_do_not_break_the_turn( - claude_room: OpenRoom, -) -> None: - """Recording the session id and narrating tool use are best-effort: with - every event rejected, the reply still lands and the turn is not failed.""" - room = await claude_room() - room.tools.send_event_error = RuntimeError("events endpoint down") - room.claude.script([room.model_reply("answered anyway")]) - - await room.send("hello") - - assert room.chat == ["answered anyway"] - assert room.persisted_sessions == [] +from tests.adapters.claude_sdk.fakecli import STARTUP_EXITED +from tests.adapters.claude_sdk.helpers import OpenRoom + + +class TestSessionPersistence: + async def test_a_room_resumes_the_session_its_history_recorded( + self, + claude_room: OpenRoom, + ) -> None: + """Bootstrap resumes the recorded session; the id is re-recorded once, and + only a bootstrap ever asks to resume.""" + room = await claude_room() + room.claude.script([room.model_reply("welcome back")], [room.model_reply("ok")]) + + await room.send("hello again", session_id="sess-from-history") + await room.send("next", session_id="sess-should-not-use") + + assert room.claude.resumed == ["sess-from-history"] + assert room.persisted_sessions == ["sess-from-history"] + assert room.chat == ["welcome back", "ok"] + + async def test_a_session_that_cannot_resume_falls_back_to_a_fresh_one( + self, + claude_room: OpenRoom, + ) -> None: + """A recorded session the CLI can no longer resume heals silently: a fresh + session answers, and its id replaces the stale one.""" + room = await claude_room() + room.claude.unresumable.add("sess-broken") + room.claude.script([room.model_reply("fresh start")]) + + await room.send("hello", session_id="sess-broken") + + assert room.claude.resumed == ["sess-broken", None] + [fresh] = room.claude.sessions[1:] + assert room.persisted_sessions == [fresh.session_id] + assert room.chat == ["fresh start"] + assert room.failures == [] + + @pytest.mark.parametrize( + ("recorded_session", "attempts"), + [(None, [None]), ("sess-broken", ["sess-broken", None])], + ids=["no-session-to-resume", "fallback-also-fails"], + ) + async def test_a_cli_that_will_not_start_fails_the_message_without_leaking_why( + self, + claude_room: OpenRoom, + recorded_session: str | None, + attempts: list[str | None], + ) -> None: + room = await claude_room() + room.claude.refuse_connect = True + + with pytest.raises(CLIConnectionError, match=re.escape(STARTUP_EXITED)): + await room.send("hello", session_id=recorded_session) + + assert room.claude.resumed == attempts + assert room.failures == [GENERIC_PROVIDER_FAILURE_MESSAGE] + assert room.chat == [] + + async def test_events_the_room_rejects_do_not_break_the_turn( + self, + claude_room: OpenRoom, + ) -> None: + """Recording the session id and narrating tool use are best-effort: with + every event rejected, the reply still lands and the turn is not failed.""" + room = await claude_room() + room.tools.send_event_error = RuntimeError("events endpoint down") + room.claude.script([room.model_reply("answered anyway")]) + + await room.send("hello") + + assert room.chat == ["answered anyway"] + assert room.persisted_sessions == [] diff --git a/tests/adapters/claude_sdk/test_unfinished_leave.py b/tests/adapters/claude_sdk/test_unfinished_leave.py new file mode 100644 index 000000000..f0bc7774b --- /dev/null +++ b/tests/adapters/claude_sdk/test_unfinished_leave.py @@ -0,0 +1,49 @@ +"""A leave whose caller stops waiting still forgets the room once its owned +cleanup finishes, so a rejoined room may repeat an earlier reply.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.claude_sdk import ClaudeSDKAdapterConfig +from band.core.exceptions import CleanupIncompleteError +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, ClaudeRoom, OpenRoom +from tests.adapters.hold import Hold +from tests.adapters.waiting import until + + +async def caller_cancelled(room: ClaudeRoom, closing: Hold) -> None: + leaving = asyncio.create_task(room.leave()) + async with closing: + leaving.cancel() + await asyncio.wait({leaving}) + + +async def report_timed_out(room: ClaudeRoom, closing: Hold) -> None: + with pytest.raises(CleanupIncompleteError): + await room.leave() + async with closing: + pass + + +class TestUnfinishedLeave: + @pytest.mark.looptime + @pytest.mark.parametrize("stop_waiting", [caller_cancelled, report_timed_out]) + async def test_an_unfinished_leave_forgets_what_the_room_said( + self, + claude_room: OpenRoom, + stop_waiting: Callable[[ClaudeRoom, Hold], Awaitable[None]], + ) -> None: + room = await claude_room(ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET)) + room.claude.script([room.model_reply("hello")], [room.model_reply("hello")]) + await room.send("say hello") + closing = room.claude.closing = Hold() + + await stop_waiting(room, closing) + await until(lambda: room.disposed, failure="the room's cleanup never finished") + await room.send("say hello again") + + assert room.chat == ["hello", "hello"] diff --git a/tests/adapters/claude_sdk/test_workspaces.py b/tests/adapters/claude_sdk/test_workspaces.py index 46025db41..27ac1f47f 100644 --- a/tests/adapters/claude_sdk/test_workspaces.py +++ b/tests/adapters/claude_sdk/test_workspaces.py @@ -3,254 +3,250 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +import re from pathlib import Path import pytest from claude_agent_sdk import CLIConnectionError from band.adapters.claude_sdk import ClaudeSDKAdapter, ClaudeSDKAdapterConfig -from band.core.exceptions import InputNotAccepted +from band.core.exceptions import RoomClosingError from band.workspaces import create_room_workspace_resolver -from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold -from tests.adapters.claude_sdk.helpers import ClaudeRoom - -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - - -async def test_default_rooms_have_distinct_workspaces( - claude_room: OpenRoom, - claude: FakeClaude, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.chdir(tmp_path) - first = await claude_room() - second = first.beside("room-2") - claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) - await first.send("hi") - await second.send("hi") - assert claude.session_workspaces == [ - str(tmp_path / ".band-workspaces" / "room-1"), - str(tmp_path / ".band-workspaces" / "room-2"), - ] - - -async def test_explicit_cwd_preserves_shared_directory( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - first = await claude_room(ClaudeSDKAdapterConfig(cwd=tmp_path)) - second = first.beside("room-2") - claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) - await first.send("hi") - await second.send("hi") - assert claude.session_workspaces == [str(tmp_path), str(tmp_path)] - - -def test_cwd_and_resolver_are_mutually_exclusive(tmp_path: Path) -> None: - with pytest.raises(ValueError, match="either cwd or workspace_for_room"): - ClaudeSDKAdapter( - ClaudeSDKAdapterConfig(cwd=tmp_path), - workspace_for_room=create_room_workspace_resolver(tmp_path), - ) - - -async def test_cancelled_turn_keeps_its_workspace_until_leave( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - path = tmp_path / "original" - room = await claude_room(workspace_for_room=lambda _: str(path)) - abandoned = Hold() - claude.script( - [room.model_reply("First.")], - [abandoned, room.model_reply("Never sent.")], - [room.model_reply("Resumed.")], - [room.model_reply("Rejoined.")], - ) - await room.send("hi") - marker = path / "notes.txt" - marker.write_text("keep me") - path = tmp_path / "changed" - message = room.send_in_background("long job") - async with abandoned: - message.cancel() - with pytest.raises(asyncio.CancelledError): - await message - await room.send("again") - assert claude.session_workspaces == [str(marker.parent), str(marker.parent)] - assert claude.resumed == [None, "sess-1"] - assert [session.alive for session in claude.sessions] == [False, True] - - await room.leave() - await room.send("I'm back") - assert claude.session_workspaces[-1] == str(path) - assert claude.resumed[-1] is None - assert room.chat == ["First.", "Resumed.", "Rejoined."] - assert room.failures == [] - assert marker.read_text() == "keep me" - - -async def test_collision_is_rejected_until_owner_leaves( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - first = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) - second = first.beside("room-2") - claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) - await first.send("hi") - with pytest.raises(ValueError, match="both"): +from tests.adapters.claude_sdk.fakecli import CLEANUP_FAILED, FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import OpenRoom +from tests.adapters.lifecycle.kit import workspace_still_claimed + + +class TestRoomWorkspaces: + async def test_default_rooms_have_distinct_workspaces( + self, + claude_room: OpenRoom, + claude: FakeClaude, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.chdir(tmp_path) + first = await claude_room() + second = first.beside("room-2") + claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) + await first.send("hi") await second.send("hi") - assert second.chat == [] - assert len(second.failures) == 1 - await first.leave() - await second.send("again") - assert second.chat == ["Second."] - assert [session.alive for session in claude.sessions] == [False, True] - - -async def test_cancelled_startup_keeps_claim_and_reuses_client( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) - hold = claude.connecting = Hold() - opening = room.send_in_background("hi") - async with hold: - opening.cancel() - with pytest.raises(asyncio.CancelledError): - await opening - with pytest.raises(ValueError, match="both"): - await room.beside("room-2").send("hi") - claude.script([room.model_reply("Connected.")]) - await room.send("again") - assert len(claude.sessions) == 1 - assert claude.sessions[0].alive - assert room.chat == ["Connected."] - assert room.failures == [] - + assert claude.session_workspaces == [ + str(tmp_path / ".band-workspaces" / "room-1"), + str(tmp_path / ".band-workspaces" / "room-2"), + ] + + async def test_explicit_cwd_preserves_shared_directory( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + first = await claude_room(ClaudeSDKAdapterConfig(cwd=tmp_path)) + second = first.beside("room-2") + claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) + await first.send("hi") + await second.send("hi") + assert claude.session_workspaces == [str(tmp_path), str(tmp_path)] + + def test_cwd_and_resolver_are_mutually_exclusive(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="either cwd or workspace_for_room"): + ClaudeSDKAdapter( + ClaudeSDKAdapterConfig(cwd=tmp_path), + workspace_for_room=create_room_workspace_resolver(tmp_path), + ) + + async def test_cancelled_turn_keeps_its_workspace_until_leave( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + path = tmp_path / "original" + room = await claude_room(workspace_for_room=lambda _: str(path)) + abandoned = Hold() + claude.script( + [room.model_reply("First.")], + [abandoned, room.model_reply("Never sent.")], + [room.model_reply("Resumed.")], + [room.model_reply("Rejoined.")], + ) + await room.send("hi") + marker = path / "notes.txt" + marker.write_text("keep me") + path = tmp_path / "changed" + message = room.send_in_background("long job") + async with abandoned: + message.cancel() + with pytest.raises(asyncio.CancelledError): + await message + await room.send("again") + assert claude.session_workspaces == [str(marker.parent), str(marker.parent)] + assert claude.resumed == [None, "sess-1"] + assert [session.alive for session in claude.sessions] == [False, True] -async def test_failed_startup_closes_transport_but_keeps_membership_claim( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) - second = room.beside("room-2") - claude.refuse_connect = True - try: - with pytest.raises(CLIConnectionError, match="startup"): - await room.send("hi") - assert not claude.sessions[0].alive - assert len(room.failures) == 1 - with pytest.raises(ValueError, match="both"): - await second.send("hi") await room.leave() - finally: - claude.refuse_connect = False - claude.script([second.model_reply("Connected.")]) - await second.send("again") - assert second.chat == ["Connected."] - assert claude.sessions[-1].alive - - -async def test_failed_cleanup_blocks_reuse_and_can_be_retried( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) - second = room.beside("room-2") - claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) - await room.send("hi") - claude.refuse_close = True - try: - with pytest.raises(RuntimeError, match="cleanup failed"): - await room.leave() - with pytest.raises(ValueError, match="both"): + await room.send("I'm back") + assert claude.session_workspaces[-1] == str(path) + assert claude.resumed[-1] is None + assert room.chat == ["First.", "Resumed.", "Rejoined."] + assert room.failures == [] + assert marker.read_text() == "keep me" + + async def test_collision_is_rejected_until_owner_leaves( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + first = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) + second = first.beside("room-2") + claude.script([first.model_reply("First.")], [second.model_reply("Second.")]) + await first.send("hi") + with workspace_still_claimed(): await second.send("hi") - with pytest.raises(InputNotAccepted, match="closing"): - await room.send("again") - assert len(claude.sessions) == 1 - assert claude.sessions[0].alive - assert room.chat == ["First."] assert second.chat == [] - finally: - claude.refuse_close = False - await room.leave() - await second.send("again") - assert second.chat == ["Second."] - assert [session.alive for session in claude.sessions] == [False, True] - - -async def test_cancelled_cleanup_finishes_before_claim_transfer( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) - second = room.beside("room-2") - claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) - await room.send("hi") - hold = claude.closing = Hold() - leaving = asyncio.create_task(room.leave()) - async with hold: - leaving.cancel() - with pytest.raises(asyncio.CancelledError): - await leaving - await second.send("hi") - assert second.chat == ["Second."] - assert [session.alive for session in claude.sessions] == [False, True] - - -async def test_stop_attempts_all_rooms_and_retries_failed_cleanup( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room( - workspace_for_room=create_room_workspace_resolver(tmp_path) - ) - second = room.beside("room-2") - claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) - await room.send("hi") - await second.send("hi") - claude.sessions[0].refuse_close = True - try: - with pytest.raises(ExceptionGroup, match="cleanup failed"): + assert len(second.failures) == 1 + await first.leave() + await second.send("again") + assert second.chat == ["Second."] + assert [session.alive for session in claude.sessions] == [False, True] + + async def test_cancelled_startup_keeps_claim_and_is_recovered_before_reuse( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + """The abandoned connect keeps the workspace claim and finishes on its own; + the next message closes it and starts its own CLI instead of adopting one + bound to a turn that no longer exists.""" + room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) + hold = claude.connecting = Hold() + opening = room.send_in_background("hi") + async with hold: + opening.cancel() + with pytest.raises(asyncio.CancelledError): + await opening + with workspace_still_claimed(): + await room.beside("room-2").send("hi") + claude.script([room.model_reply("Connected.")]) + await room.send("again") + assert [session.alive for session in claude.sessions] == [False, True] + assert len(set(claude.session_workspaces)) == 1 + assert room.chat == ["Connected."] + assert room.failures == [] + + async def test_failed_startup_closes_transport_but_keeps_membership_claim( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) + second = room.beside("room-2") + claude.refuse_connect = True + try: + with pytest.raises(CLIConnectionError, match="startup"): + await room.send("hi") + assert not claude.sessions[0].alive + assert len(room.failures) == 1 + with workspace_still_claimed(): + await second.send("hi") + await room.leave() + finally: + claude.refuse_connect = False + claude.script([second.model_reply("Connected.")]) + await second.send("again") + assert second.chat == ["Connected."] + assert claude.sessions[-1].alive + + async def test_failed_cleanup_blocks_reuse_and_can_be_retried( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) + second = room.beside("room-2") + claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) + await room.send("hi") + claude.refuse_close = True + try: + with pytest.raises(RuntimeError, match=re.escape(CLEANUP_FAILED)): + await room.leave() + with workspace_still_claimed(): + await second.send("hi") + with pytest.raises(RoomClosingError): + await room.send("again") + assert len(claude.sessions) == 1 + assert claude.sessions[0].alive + assert room.chat == ["First."] + assert second.chat == [] + finally: + claude.refuse_close = False + await room.leave() + await second.send("again") + assert second.chat == ["Second."] + assert [session.alive for session in claude.sessions] == [False, True] + + async def test_cancelled_cleanup_finishes_before_claim_transfer( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room(workspace_for_room=lambda _: str(tmp_path / "shared")) + second = room.beside("room-2") + claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) + await room.send("hi") + hold = claude.closing = Hold() + leaving = asyncio.create_task(room.leave()) + async with hold: + leaving.cancel() + with pytest.raises(asyncio.CancelledError): + await leaving + await second.send("hi") + assert second.chat == ["Second."] + assert [session.alive for session in claude.sessions] == [False, True] + + async def test_stop_attempts_all_rooms_and_retries_failed_cleanup( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room( + workspace_for_room=create_room_workspace_resolver(tmp_path) + ) + second = room.beside("room-2") + claude.script([room.model_reply("First.")], [second.model_reply("Second.")]) + await room.send("hi") + await second.send("hi") + claude.sessions[0].refuse_close = True + try: + with pytest.raises(ExceptionGroup) as failed: + await room.adapter.cleanup_all() + assert failed.group_contains(RuntimeError, match=re.escape(CLEANUP_FAILED)) + assert [session.alive for session in claude.sessions] == [True, False] + finally: + claude.sessions[0].refuse_close = False await room.adapter.cleanup_all() - assert [session.alive for session in claude.sessions] == [True, False] - finally: - claude.sessions[0].refuse_close = False - await room.adapter.cleanup_all() - assert all(not session.alive for session in claude.sessions) - - -async def test_adapter_restart_preserves_workspace_files( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room( - workspace_for_room=create_room_workspace_resolver(tmp_path) - ) - claude.script([room.model_reply("Before.")], [room.model_reply("After.")]) - await room.send("hi") - marker = Path(claude.session_workspaces[0]) / "notes.txt" - marker.write_text("keep me") - await room.adapter.cleanup_all() - await room.adapter.on_started("Test Agent", "An agent under test") - await room.send("hi again") - assert claude.session_workspaces == [str(marker.parent), str(marker.parent)] - assert marker.read_text() == "keep me" - + assert all(not session.alive for session in claude.sessions) -async def test_restart_cannot_replace_a_manager_with_failed_cleanup( - claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path -) -> None: - room = await claude_room( - workspace_for_room=create_room_workspace_resolver(tmp_path) - ) - claude.script([room.model_reply("Before.")], [room.model_reply("After.")]) - await room.send("hi") - claude.refuse_close = True - try: - with pytest.raises(ExceptionGroup, match="cleanup failed"): - await room.adapter.cleanup_all() - with pytest.raises(ExceptionGroup, match="cleanup failed"): - await room.adapter.on_started("Test Agent", "An agent under test") - assert len(claude.sessions) == 1 - assert claude.sessions[0].alive - finally: - claude.refuse_close = False - await room.adapter.on_started("Test Agent", "An agent under test") - await room.send("hi again") - assert [session.alive for session in claude.sessions] == [False, True] - assert len(set(claude.session_workspaces)) == 1 + async def test_adapter_restart_preserves_workspace_files( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room( + workspace_for_room=create_room_workspace_resolver(tmp_path) + ) + claude.script([room.model_reply("Before.")], [room.model_reply("After.")]) + await room.send("hi") + marker = Path(claude.session_workspaces[0]) / "notes.txt" + marker.write_text("keep me") + await room.adapter.cleanup_all() + await room.restart() + await room.send("hi again") + assert claude.session_workspaces == [str(marker.parent), str(marker.parent)] + assert marker.read_text() == "keep me" + + async def test_restart_cannot_replace_a_manager_with_failed_cleanup( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + room = await claude_room( + workspace_for_room=create_room_workspace_resolver(tmp_path) + ) + claude.script([room.model_reply("Before.")], [room.model_reply("After.")]) + await room.send("hi") + claude.refuse_close = True + try: + with pytest.raises(ExceptionGroup) as failed: + await room.adapter.cleanup_all() + assert failed.group_contains(RuntimeError, match=re.escape(CLEANUP_FAILED)) + with pytest.raises(ExceptionGroup) as failed: + await room.restart() + assert failed.group_contains(RuntimeError, match=re.escape(CLEANUP_FAILED)) + assert len(claude.sessions) == 1 + assert claude.sessions[0].alive + finally: + claude.refuse_close = False + await room.restart() + await room.send("hi again") + assert [session.alive for session in claude.sessions] == [False, True] + assert len(set(claude.session_workspaces)) == 1 diff --git a/tests/adapters/claude_sdk/tools/test_custom_tools.py b/tests/adapters/claude_sdk/tools/test_custom_tools.py index 59423431d..1e6dfdd3e 100644 --- a/tests/adapters/claude_sdk/tools/test_custom_tools.py +++ b/tests/adapters/claude_sdk/tools/test_custom_tools.py @@ -1,7 +1,5 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - import pytest from pydantic import BaseModel, Field @@ -11,12 +9,10 @@ from band.runtime.tools import TurnEffect from tests.adapters.claude_sdk.helpers import ( MISSING_REPLY_TEXT, - ClaudeRoom, + OpenRoom, with_approvals, ) -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - class EchoInput(BaseModel): """Echo the message.""" diff --git a/tests/adapters/claude_sdk/tools/test_room_binding.py b/tests/adapters/claude_sdk/tools/test_room_binding.py index 331338556..1d6a394e5 100644 --- a/tests/adapters/claude_sdk/tools/test_room_binding.py +++ b/tests/adapters/claude_sdk/tools/test_room_binding.py @@ -3,15 +3,12 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from urllib.parse import urlsplit from band.runtime.tools import BandTool -from tests.adapters.claude_sdk.helpers import ClaudeRoom +from tests.adapters.claude_sdk.helpers import OpenRoom from tests.mcpclient import advertised_arguments, endpoint_path, mcp_session -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - async def test_rooms_dial_their_own_band_endpoint_on_one_server( claude_room: OpenRoom, diff --git a/tests/adapters/claude_sdk/tools/test_tool_names.py b/tests/adapters/claude_sdk/tools/test_tool_names.py index 163755363..7494e470e 100644 --- a/tests/adapters/claude_sdk/tools/test_tool_names.py +++ b/tests/adapters/claude_sdk/tools/test_tool_names.py @@ -8,14 +8,10 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable - from band.adapters.claude_sdk import TOOL_SEARCH, ClaudeSDKAdapter -from tests.adapters.claude_sdk.helpers import ClaudeRoom +from tests.adapters.claude_sdk.helpers import OpenRoom from tests.baseline.decisions import ModelDecision -OpenRoom = Callable[..., Awaitable[ClaudeRoom]] - def test_semantic_tool_name_strips_only_our_server_prefix() -> None: strip = ClaudeSDKAdapter._semantic_tool_name diff --git a/tests/adapters/codex/__init__.py b/tests/adapters/codex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/codex/conftest.py b/tests/adapters/codex/conftest.py new file mode 100644 index 000000000..ac4436f58 --- /dev/null +++ b/tests/adapters/codex/conftest.py @@ -0,0 +1,124 @@ +"""Fixtures for the Codex adapter tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Any + +import pytest +import pytest_asyncio + +from band.adapters.codex import CodexAdapter, CodexAdapterConfig +from band.core.retention import RoomRetentionConfig +from tests.adapters.codex.kit import ( # noqa: F401 + HELD_ROOM, + NEW_ROOM, + TRANSITION_TIMEOUT_S, + CleaningTurn, + CleaningTurnClient, + CodexApprovalRoom, + CodexRoom, + CountingCloseClient, + HeldTurnClient, + TwoRooms, + codex_room, + patch_codex_clients_by_room, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + patch_codex_client, + turn_completed, +) + + +@pytest_asyncio.fixture(loop_scope="function") +async def approval_room() -> AsyncIterator[Callable[..., Awaitable[CodexApprovalRoom]]]: + """Open a Codex room in manual approval mode; asks still waiting when the + test ends are cancelled.""" + rooms: list[CodexApprovalRoom] = [] + + async def open_room(**config: Any) -> CodexApprovalRoom: + adapter = make_codex_adapter( + FakeCodexClient(events=[]), + config=CodexAdapterConfig( + **{"approval_mode": "manual", "approval_wait_timeout_s": 5, **config} + ), + ) + await adapter.on_started("Agent", "A coding agent") + rooms.append(room := CodexApprovalRoom(adapter)) + return room + + yield open_room + for room in rooms: + for ask in room.asks: + ask.cancel() + + +@pytest_asyncio.fixture(loop_scope="function") +async def cleaning_turn() -> AsyncIterator[CleaningTurn]: + """A room whose turn was cancelled mid turn/start and is still cleaning up; + the cleanup finishes at teardown at the latest.""" + client = CleaningTurnClient() + # A leave reports its unfinished cleanup within this bound. + config = CodexAdapterConfig( + retention=RoomRetentionConfig(transition_timeout_s=TRANSITION_TIMEOUT_S) + ) + room = CodexRoom(make_codex_adapter(client, config=config), client) + sending = asyncio.create_task(room.send("work")) + await client.started.wait() + sending.cancel() + await client.cleaning.wait() + try: + yield CleaningTurn(room=room, client=client) + finally: + client.release.set() + with pytest.raises(asyncio.CancelledError): + await sending + await room.adapter.cleanup_all() + + +@pytest_asyncio.fixture(loop_scope="function") +async def two_rooms() -> AsyncIterator[TwoRooms]: + """A started adapter whose held room has a turn parked in turn/start.""" + held = HeldTurnClient( + events=[turn_completed(), turn_completed("turn-2")], hold=True + ) + new = CountingCloseClient(events=[turn_completed()]) + adapter = CodexAdapter( + config=CodexAdapterConfig( + client_close_timeout_s=None, + turn_settle_timeout_s=30, + retention=RoomRetentionConfig(transition_timeout_s=TRANSITION_TIMEOUT_S), + ) + ) + patch_codex_clients_by_room(adapter, {HELD_ROOM: held, NEW_ROOM: new}) + await adapter.on_started("Codex Agent", "A coding agent") + held_turn = asyncio.create_task(send_bootstrap(adapter, room_id=HELD_ROOM)) + await held.turn_started.wait() + rooms = TwoRooms(adapter=adapter, held=held, new=new, held_turn=held_turn) + yield rooms + await rooms.finish() + await adapter.cleanup_all() + + +@pytest_asyncio.fixture(loop_scope="function") +async def started_codex() -> AsyncIterator[Callable[..., Awaitable[CodexAdapter]]]: + """Start Codex adapters configured by ``config`` whose processes are the + fake app-servers ``clients`` in start order; each is shut down at the end.""" + adapters: list[CodexAdapter] = [] + + async def start( + client: FakeCodexClient, *replacements: FakeCodexClient, **config: Any + ) -> CodexAdapter: + adapter = CodexAdapter(CodexAdapterConfig(**config)) + patch_codex_client(adapter, client, *replacements) + await adapter.on_started("Codex Agent", "A coding agent") + adapters.append(adapter) + return adapter + + yield start + for adapter in adapters: + await adapter.cleanup_all() diff --git a/tests/adapters/codex/kit.py b/tests/adapters/codex/kit.py new file mode 100644 index 000000000..cda37d249 --- /dev/null +++ b/tests/adapters/codex/kit.py @@ -0,0 +1,782 @@ +"""Shared doubles and helpers for the Codex adapter tests.""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +import pytest_asyncio +from pydantic import BaseModel + +from band.adapters.codex import ( + ApprovalDecision, + CodexAdapter, + CodexAdapterConfig, + CodexCommand, + PendingApproval, +) +from band.client.streaming import ControlMode +from band.core.exceptions import InputNotAccepted +from band.core.retention import Residency, RetainedRoom +from band.core.types import ( + AgentInput, + HistoryProvider, + PlatformMessage, +) +from band.integrations.codex import ( + DYNAMIC_TOOL_CALL_METHOD, + TRANSPORT_CLOSED, + RpcEvent, +) +from band.integrations.codex.types import ( + CodexApprovalMethod, + CodexItemType, + CodexRequestMethod, + CodexSessionState, +) +from band.runtime.decisions import DecisionRegistry +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools +from tests.adapters.codexturns import ( + FIRST_THREAD_ID, + FakeCodexClient, + await_released_turn, + event_notification, + event_request, + final_text, + make_codex_adapter, + tool_call_request, + turn_completed, +) +from tests.adapters.dispatch import deliver_event, deliver_message +from tests.adapters.waiting import until +from tests.paths import host_absolute_path + +ROOM_ID = "room-1" +# An approval wait no test outlasts: the room's human decides first. +PATIENT_APPROVAL_S = 3600 +# Bounds a step that would otherwise hang the test if the adapter regressed. +HANG_GUARD_S = 5 +# A process close bound a held close overruns. +CLOSE_BOUND_S = 0.5 + + +def make_platform_message( + room_id: str = ROOM_ID, content: str = "hello" +) -> PlatformMessage: + return PlatformMessage( + id=str(uuid4()), + room_id=room_id, + content=content, + sender_id="user-1", + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ) + + +def agent_input( + content: str, + tools: FakeAgentTools, + *, + raw_history: list[dict[str, Any]] | None = None, + room_id: str = ROOM_ID, + is_session_bootstrap: bool = True, +) -> AgentInput: + return AgentInput( + msg=make_platform_message(room_id=room_id, content=content), + tools=tools, + history=HistoryProvider(raw=raw_history or []), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=is_session_bootstrap, + room_id=room_id, + ) + + +class ToolSchemaFakeTools(FakeAgentTools): + def get_openai_tool_schemas(self, **kwargs: Any) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": "band_send_message", + "description": "Send a message", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "mentions": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["content", "mentions"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "band_send_event", + "description": "Send an event", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "message_type": {"type": "string"}, + }, + "required": ["content", "message_type"], + }, + }, + }, + ] + + +#: Why FailingNoticeTools cannot post a notice. +NOTICE_REJECTED = "platform rejected the notice" + + +class FailingNoticeTools(ToolSchemaFakeTools): + """Room tools whose every notice the platform rejects.""" + + async def send_notice( + self, content: str, mentions: list[dict[str, str]] | None = None + ) -> Any: + raise RuntimeError(NOTICE_REJECTED) + + +# model/list as codex-cli reports it: per-model efforts, including ones newer +# than the SDK ever knew about. +LIVE_EFFORTS_MODEL_LIST: dict[str, Any] = { + "data": [ + { + "id": "gpt-6-sol", + "supportedReasoningEfforts": [ + {"reasoningEffort": "low"}, + {"reasoningEffort": "max"}, + {"reasoningEffort": "ultra"}, + ], + }, + {"id": "other", "supportedReasoningEfforts": [{"reasoningEffort": "minimal"}]}, + ] +} + + +def patch_codex_clients_by_room( + adapter: CodexAdapter, clients: dict[str, FakeCodexClient] +) -> None: + """Give each room its own fake app-server, as the real adapter does.""" + + def _build(_workspace: str) -> FakeCodexClient: + room_id = adapter._active_room.get() + assert room_id is not None + return clients[room_id] + + adapter._build_client = _build # type: ignore[method-assign] + + +def residency_of(adapter: CodexAdapter, room_id: str) -> RetainedRoom | None: + """The room's residency owner handle, or None once the room is forgotten.""" + room = adapter._room_clients.get(room_id) + return room.retained if room is not None else None + + +def thread_of(adapter: CodexAdapter, room_id: str) -> str | None: + """The Codex thread the room is on, or None while it has none.""" + return adapter._room_threads.get(room_id) + + +def wire_codex_room( + adapter: CodexAdapter, + client: FakeCodexClient, + room_id: str = ROOM_ID, +) -> None: + adapter._room_client(room_id).client = client + adapter._active_room.set(room_id) + + +async def start_room_process(adapter: CodexAdapter, room_id: str = ROOM_ID) -> None: + """Bring up ``room_id``'s Codex process through a local command's lease.""" + await deliver_message( + adapter, + make_platform_message(room_id=room_id, content=f"/{CodexCommand.MODEL}"), + ToolSchemaFakeTools(), + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=room_id, + ) + + +@dataclass(frozen=True) +class CodexTurn: + """What one driven turn left behind, as the projections tests assert on.""" + + adapter: CodexAdapter + client: FakeCodexClient + tools: FakeAgentTools + + @property + def tool_response(self) -> tuple[int | str, dict[str, Any]]: + """``(request_id, payload)`` of the first tool-call response sent back.""" + return self.client.responses[0] + + @property + def content_items(self) -> list[dict[str, Any]]: + """Content items the adapter returned for the first tool call.""" + return self.tool_response[1]["contentItems"] + + +async def run_codex_turn( + *, + events: list[RpcEvent], + tools: FakeAgentTools | None = None, + config: CodexAdapterConfig | None = None, + **adapter_kwargs: Any, +) -> CodexTurn: + """Drive one full Codex turn against ``events`` and return what it produced. + + Wraps the scaffolding a turn test otherwise repeats -- fake transport, + adapter wired to it, ``on_started``, one bootstrap ``on_message`` -- so a + test states only the events it scripts and the outcome it asserts. + """ + client = FakeCodexClient(events=events) + adapter = make_codex_adapter(client, config=config, **adapter_kwargs) + room_tools = tools if tools is not None else ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=room_tools) + return CodexTurn(adapter=adapter, client=client, tools=room_tools) + + +async def send_bootstrap( + adapter: CodexAdapter, + *, + room_id: str = ROOM_ID, + content: str = "hello", + tools: FakeAgentTools | None = None, + history: CodexSessionState | None = None, +) -> None: + """Deliver a room's session-bootstrap message to ``adapter``.""" + await deliver_message( + adapter, + make_platform_message(room_id=room_id, content=content), + tools if tools is not None else ToolSchemaFakeTools(), + history or CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=room_id, + ) + + +class CodexRoom: + """A Codex room fed messages one at a time, as Band delivers them: each + ``send`` gets its own tools and returns once the adapter hands the room + back -- its turn done, or parked on a human decision.""" + + def __init__(self, adapter: CodexAdapter, client: FakeCodexClient) -> None: + self.adapter = adapter + self.client = client + self.deliveries: list[FakeAgentTools] = [] + + @property + def chat(self) -> list[str]: + """Room messages, grouped by the delivery whose tools posted them.""" + return [ + message["content"] + for tools in self.deliveries + for message in tools.messages_sent + ] + + @property + def approval_token(self) -> str: + """The most recently advertised approval token in this room.""" + return approval_tokens_from_chat(self.chat)[-1] + + @property + def pending_approval_count(self) -> int: + return self.adapter._open_approval_count(ROOM_ID) + + @property + def events_sent(self) -> list[dict[str, Any]]: + return [event for tools in self.deliveries for event in tools.events_sent] + + @property + def turn(self) -> asyncio.Task[None]: + return self.adapter._turn_tasks[ROOM_ID].task + + async def send(self, content: str) -> None: + self.deliveries.append(tools := ToolSchemaFakeTools()) + await deliver_event( + self.adapter, + agent_input(content, tools, is_session_bootstrap=len(self.deliveries) == 1), + ) + + async def settled(self) -> None: + """Wait for a turn a human decision released to finish.""" + await await_released_turn(self.adapter, ROOM_ID) + + +def delegated_to(thread_id: str) -> RpcEvent: + """The turn's collab item naming the sub-agent thread it spawned.""" + return event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.COLLAB_AGENT_TOOL_CALL, + "id": f"spawn-{thread_id}", + "tool": "spawnAgent", + "status": "completed", + "receiverThreadIds": [thread_id], + } + }, + ) + + +def started_sub_agent(thread_id: str) -> RpcEvent: + """The item ``multi_agent_v2`` sends when the turn spawns a sub-agent.""" + return event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.SUB_AGENT_ACTIVITY, + "id": f"activity-{thread_id}", + "kind": "started", + "agentThreadId": thread_id, + } + }, + ) + + +def asked_by_sub_agent(request_id: int, thread_id: str) -> RpcEvent: + """An approval a sub-agent asks under its own thread and turn.""" + return event_request( + request_id, + CodexApprovalMethod.COMMAND_EXECUTION, + {"threadId": thread_id, "turnId": f"{thread_id}-turn", "command": "make"}, + ) + + +def asked_by_autonomous_turn() -> tuple[RpcEvent, RpcEvent]: + """A Band tool call (41) and a command approval (42) from a native turn + the room's thread started on its own, not from any Band turn.""" + autonomous = {"threadId": FIRST_THREAD_ID, "turnId": "autonomous"} + return ( + event_request( + 41, + DYNAMIC_TOOL_CALL_METHOD, + { + **autonomous, + "callId": "c-41", + "tool": BandTool.SEND_MESSAGE, + "arguments": {"content": "unasked", "mentions": ["@a"]}, + }, + ), + event_request( + 42, CodexApprovalMethod.COMMAND_EXECUTION, {**autonomous, "command": "x"} + ), + ) + + +async def refusals_of_autonomous_turn( + client: FakeCodexClient, +) -> tuple[bool, dict[str, Any]]: + """Whether the tool call (41) succeeded, and the approval (42) answer.""" + return (await answer_to(client, 41))["success"], await answer_to(client, 42) + + +#: How Band answers a Codex approval request. +DECLINED = {"decision": "decline"} +ACCEPTED = {"decision": "accept"} + +#: How a native turn no Band turn started is answered. +REFUSED_AUTONOMOUS_TURN = (False, DECLINED) + + +def answered_turn() -> list[RpcEvent]: + """A turn explicitly completed through a Band decline tool.""" + return [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + + +def parked_on_approval(*then: RpcEvent) -> tuple[RpcEvent, ...]: + """A turn that asks the room to approve one command, then plays ``then``.""" + return ( + event_request(10, CodexApprovalMethod.COMMAND_EXECUTION, {"command": "a"}), + *then, + ) + + +class CodexApprovalRoom: + """One room's manual approvals on a started Codex adapter, observed + through the room's chat: each ask runs as its own turn, and replies go + through the room's approval commands.""" + + def __init__(self, adapter: CodexAdapter) -> None: + self.adapter = adapter + self.tools = FakeAgentTools() + self.asks: list[asyncio.Task[ApprovalDecision]] = [] + + @property + def pending(self) -> DecisionRegistry[PendingApproval]: + return self.adapter._pending_approvals[ROOM_ID] + + @property + def approval_tokens(self) -> list[str]: + """The answer tokens advertised by the room's approval prompts.""" + return approval_tokens_from_chat( + [message["content"] for message in self.tools.messages_sent] + ) + + def ask( + self, approval_id: str, *, request_id: int + ) -> asyncio.Task[ApprovalDecision]: + params = {"approvalId": approval_id, "command": "npm test"} + self.asks.append( + ask := asyncio.create_task( + self.adapter._resolve_manual_approval( + tools=self.tools, + msg=make_platform_message(room_id=ROOM_ID), + room_id=ROOM_ID, + event=event_request( + request_id, CodexApprovalMethod.COMMAND_EXECUTION, params + ), + summary="npm test", + params=params, + ) + ) + ) + return ask + + @asynccontextmanager + async def prompted(self, *, count: int = 1) -> AsyncIterator[None]: + """Asks made inside the block have each been registered and their + room prompt sent by the time it exits.""" + prefix = "Approval requested" + before = sum( + message["content"].startswith(prefix) + for message in self.tools.messages_sent + ) + prompts = [self.tools.hold_message(prefix) for _ in range(count)] + yield + for prompt in prompts: + async with prompt: + pass + await self.tools.until_said(prefix, times=before + len(prompts)) + + async def reply(self, command: CodexCommand, approval_id: str = "") -> str: + """Send an approval command to the room; the room's answer.""" + await self.adapter._handle_approval_command( + tools=self.tools, + msg=make_platform_message(room_id=ROOM_ID), + room_id=ROOM_ID, + command=command, + args=approval_id, + ) + return self.tools.messages_sent[-1]["content"] + + +def requested_approval_token(tools: FakeAgentTools) -> str: + """The token the room's approval prompt asks its user to answer.""" + return approval_tokens_from_chat( + [message["content"] for message in tools.messages_sent] + )[-1] + + +def approval_tokens_from_chat(chat: list[str]) -> list[str]: + """The tokens advertised by approval commands in the chat.""" + command = re.compile(r"`/" + re.escape(CodexCommand.APPROVE) + r" ([^`]+)`") + return [ + match.group(1) + for message in chat + if (match := command.search(message)) is not None + ] + + +SKILL_ROOT = host_absolute_path("opt", "band", "skills") + + +REGISTER_SKILL_ROOT = ( + CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET, + {"extraRoots": [SKILL_ROOT]}, +) + + +class StayQuietInput(BaseModel): + """Say nothing this turn.""" + + +async def answer_to(client: FakeCodexClient, request_id: int | str) -> dict[str, Any]: + """The response the adapter sent ``request_id``, once it has.""" + await until( + lambda: request_id in client.answers, + failure=f"Request {request_id} was never answered", + ) + return client.answers[request_id] + + +class SlowTurnStartClient(FakeCodexClient): + """An app-server whose turn/start answer trails the turn's first events.""" + + async def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + retry_on_overload: bool = True, + ) -> dict[str, Any]: + result = await super().request( + method, params, retry_on_overload=retry_on_overload + ) + if method == CodexRequestMethod.TURN_START: + await self.read_by_adapter() + return result + + +class CompletesWhileRefusingClient(SlowTurnStartClient): + """An app-server that completes its turn while the adapter answers a + request no turn owns.""" + + async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: + await super().respond(request_id, result) + self.emit(turn_completed()) + await self.read_by_adapter() + + +class StopsReadingAfterTurnClient(SlowTurnStartClient): + """An app-server that asks one more thing after its turn completes, then + never reads the answer.""" + + def _release_turn_events(self, turn_id: str) -> None: + super()._release_turn_events(turn_id) + self.emit( + event_request( + 9, + DYNAMIC_TOOL_CALL_METHOD, + {"turnId": turn_id, "tool": BandTool.SEND_MESSAGE}, + ) + ) + + async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: + await asyncio.Event().wait() + + +# The notice a client queues once its Codex process has exited. +PROCESS_EXIT_REASON = "Codex process exited unexpectedly" +PROCESS_EXITED = event_notification(TRANSPORT_CLOSED, {"reason": PROCESS_EXIT_REASON}) + + +class ExitsWhileAskedClient(FakeCodexClient): + """An app-server that exits while the room decides what it asked: an + answer can no longer be written to it, as with a real exited child.""" + + async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: + raise ConnectionResetError("Connection lost") + + +class RefusalFailsClient(FakeCodexClient): + """An app-server that can no longer take an error answer.""" + + async def respond_error( + self, + request_id: int | str, + *, + code: int, + message: str, + data: Any | None = None, + ) -> None: + raise ConnectionResetError("Connection lost") + + +#: Why a CountingCloseClient refuses to close. +CLOSE_FAILURE = "Codex process close failed" + + +class CountingCloseClient(FakeCodexClient): + """Counts process starts and physical closes, optionally holding each + close open or refusing it.""" + + def __init__(self, *, events: list[RpcEvent], hold: bool = False) -> None: + super().__init__(events=events) + self.starts = 0 + self.closes = 0 + self.refuse_close = False + self.closing = asyncio.Event() + self.finish_close = asyncio.Event() + self.close_finished = asyncio.Event() + if not hold: + self.finish_close.set() + + async def initialize(self, **kwargs: Any) -> dict[str, Any]: + self.starts += 1 + return await super().initialize(**kwargs) + + async def close(self) -> None: + self.closes += 1 + self.closing.set() + await self.finish_close.wait() + if self.refuse_close: + raise RuntimeError(CLOSE_FAILURE) + await super().close() + self.close_finished.set() + + +class HeldStartClient(CountingCloseClient): + """An app-server whose initialize handshake waits until released.""" + + def __init__(self, *, events: list[RpcEvent]) -> None: + super().__init__(events=events) + self.finish_start = asyncio.Event() + + async def initialize(self, **kwargs: Any) -> dict[str, Any]: + await self.finish_start.wait() + return await super().initialize(**kwargs) + + +class HeldTurnClient(CountingCloseClient): + """An app-server whose turn/start waits until released.""" + + def __init__(self, *, events: list[RpcEvent], hold: bool = False) -> None: + super().__init__(events=events, hold=hold) + self.turn_started = asyncio.Event() + self.finish_turn = asyncio.Event() + + async def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + retry_on_overload: bool = True, + ) -> dict[str, Any]: + if method == CodexRequestMethod.TURN_START: + self.turn_started.set() + await self.finish_turn.wait() + return await super().request( + method, params, retry_on_overload=retry_on_overload + ) + + +class CleaningTurnClient(FakeCodexClient): + """An app-server whose turn/start never answers; once cancelled it runs + a cleanup step that finishes only when ``release`` is set.""" + + def __init__(self) -> None: + super().__init__() + self.started = asyncio.Event() + self.cleaning = asyncio.Event() + self.release = asyncio.Event() + self.cleanups = 0 + + async def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + retry_on_overload: bool = True, + ) -> dict[str, Any]: + if method != CodexRequestMethod.TURN_START: + return await super().request( + method, params, retry_on_overload=retry_on_overload + ) + self.started.set() + try: + await asyncio.Future[None]() + finally: + self.cleaning.set() + await self.release.wait() + self.cleanups += 1 + raise AssertionError("The cancelled turn cannot start") + + +@dataclass +class CleaningTurn: + """A room whose cancelled turn is still running its worker cleanup.""" + + room: CodexRoom + client: CleaningTurnClient + + async def stop_twice(self) -> None: + """Deliver two concurrent room STOP callbacks.""" + await asyncio.gather( + *( + self.room.adapter.on_interrupt(ROOM_ID, ControlMode.STOP) + for _ in range(2) + ) + ) + + +#: The shutdown tests' room with a turn parked in turn/start, and a room +#: that tries to start while shutdown runs. +HELD_ROOM = "room-a" +NEW_ROOM = "room-b" +TRANSITION_TIMEOUT_S = 1.0 + + +@dataclass +class TwoRooms: + """A started adapter whose held room has a turn parked in turn/start.""" + + adapter: CodexAdapter + held: HeldTurnClient + new: CountingCloseClient + held_turn: asyncio.Task[None] + + async def finish(self) -> None: + self.held.finish_turn.set() + self.held.finish_close.set() + await asyncio.gather(self.held_turn, return_exceptions=True) + + +async def refused(attempt: Awaitable[None]) -> bool: + """Whether ``attempt`` was refused as not-accepted input.""" + try: + await attempt + except InputNotAccepted: + return True + return False + + +# Adapters run their turns on the loop that started them, so setup, the test +# and teardown share the test's own loop. +@pytest_asyncio.fixture(loop_scope="function") +async def codex_room() -> AsyncIterator[Callable[..., Awaitable[CodexRoom]]]: + """Open a room on a started manual-approval Codex adapter whose server + plays ``events``; every room is cleaned up at the end.""" + rooms: list[CodexRoom] = [] + + async def open_room( + *events: RpcEvent, client: FakeCodexClient | None = None, **config: Any + ) -> CodexRoom: + client = client or FakeCodexClient(events=list(events)) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(**{"approval_mode": "manual", **config}) + ) + await adapter.on_started("Agent", "A coding agent") + rooms.append(room := CodexRoom(adapter, client)) + return room + + yield open_room + for room in rooms: + await room.adapter.on_cleanup(ROOM_ID) + + +async def disposed(residency: RetainedRoom) -> None: + """Wait until an owned disposal that outlived its caller has finished.""" + await until( + lambda: residency.snapshot.state == Residency.CLOSED, + failure="The room's disposal never finished", + ) diff --git a/tests/adapters/codex/lifecycle/__init__.py b/tests/adapters/codex/lifecycle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/codex/lifecycle/conftest.py b/tests/adapters/codex/lifecycle/conftest.py new file mode 100644 index 000000000..b21e7d5bb --- /dev/null +++ b/tests/adapters/codex/lifecycle/conftest.py @@ -0,0 +1,64 @@ +"""Codex-only lifecycle tests on the common suite's host, started on the Codex +profile alone.""" + +from __future__ import annotations + +import dataclasses +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from functools import partial +from pathlib import Path +from typing import Any + +import pytest +import pytest_asyncio + +from tests.adapters.codex.lifecycleprofile import PROFILES, install +from tests.adapters.lifecycle.kit import RetentionHost, hosted + + +@asynccontextmanager +async def codex_host( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, **settings: Any +) -> AsyncIterator[RetentionHost]: + """A host on the Codex profile whose adapter config takes ``settings``.""" + (codex,) = PROFILES + profile = dataclasses.replace(codex, install=partial(install, **settings)) + async with hosted(profile, tmp_path, monkeypatch) as host: + yield host + + +@pytest_asyncio.fixture(loop_scope="function") +async def host( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + async with codex_host(tmp_path, monkeypatch) as host: + yield host + + +# A settle grace short enough that a leave cancels a held start promptly. +BRIEF_SETTLE_GRACE_S = 0.01 + + +@pytest_asyncio.fixture(loop_scope="function") +async def brief_grace_host( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + async with codex_host( + tmp_path, monkeypatch, turn_settle_timeout_s=BRIEF_SETTLE_GRACE_S + ) as host: + yield host + + +# Ample for a real peer to close, while a held close always overruns it. +PEER_CLOSE_BOUND_S = 2.0 + + +@pytest_asyncio.fixture(loop_scope="function") +async def close_bound_host( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + async with codex_host( + tmp_path, monkeypatch, client_close_timeout_s=PEER_CLOSE_BOUND_S + ) as host: + yield host diff --git a/tests/adapters/codex/lifecycle/test_leave_ownership.py b/tests/adapters/codex/lifecycle/test_leave_ownership.py new file mode 100644 index 000000000..a8a3d8206 --- /dev/null +++ b/tests/adapters/codex/lifecycle/test_leave_ownership.py @@ -0,0 +1,126 @@ +"""Codex room lifecycle: leave ownership.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import InputNotAccepted, RoomClosingError +from band.testing import failure_reports +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import RetentionHost, workspace_still_claimed +from tests.adapters.roompeer import ( + CLEANUP_FAILURE, + PeerCommand, + memo_instruction, + read_reply, + write_instruction, +) +from tests.framework_conformance.turnprobes import DispatchingFakeTools + + +class TestLifecycleLeave: + """A Codex room's leave owns its process until the close is confirmed.""" + + async def test_a_failed_leave_is_final_until_retried_then_rejoin_starts_fresh( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + assert await host.send("room-a", memo_instruction("kept")) == ["Noted."] + left_thread = host.native_id("room-a") + host.children[0].refuse_close = True + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await host.leave("room-a") + host.children[0].refuse_close = False + with pytest.raises(RoomClosingError): + await host.send("room-a", PeerCommand.RECALL) + assert host.native_id("room-a") == left_thread + await host.leave("room-a") + assert host.native_id("room-a") is None + assert await host.send("room-a", PeerCommand.RECALL) == ["Recalled: None"] + assert host.native_id("room-a") not in (None, left_thread) + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.exited == [True, False] + assert host.overlaps == 0 + + async def test_close_timeout_retains_ownership_until_retry( + self, + close_bound_host: RetentionHost, + ) -> None: + host = close_bound_host + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + barrier = host.children[0].closing = Hold() + with pytest.raises(TimeoutError): + await host.leave("room-a") + await barrier.arrived() + assert host.exited == [False] + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + with pytest.raises(RoomClosingError): + await host.send("room-a", write_instruction("changed")) + assert host.exited == [False] + barrier.released.set() + await host.leave("room-a") + assert host.exited == [True] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + + async def test_leaving_during_startup_closes_the_half_started_process( + self, + brief_grace_host: RetentionHost, + ) -> None: + host = brief_grace_host + starting = host.next_start = Hold() + opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) + await starting.arrived() + + await host.leave("room-a") + + with pytest.raises(asyncio.CancelledError): + await opening + assert host.exited == [True] + assert host.native_id("room-a") is None + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + + async def test_a_turn_starting_its_process_at_leave_is_refused_not_failed( + self, + host: RetentionHost, + ) -> None: + tools = DispatchingFakeTools(room_id="room-a") + host.hold_startup.touch() + sending = asyncio.create_task( + host.deliver("room-a", write_instruction("alpha"), tools) + ) + await host.start_reached("room-a") + leaving = asyncio.create_task(host.leave("room-a")) + await asyncio.sleep(0) + residency = host.bridge.residency("room-a") + assert residency is not None and residency.closing + + host.hold_startup.unlink() # the start finishes inside the settle grace + await leaving + + with pytest.raises(InputNotAccepted): + await sending + assert failure_reports(tools) == [] + + async def test_a_message_after_an_abandoned_leave_finished_keeps_no_claim( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + await closing.arrived() + leaving.cancel() + with pytest.raises(asyncio.CancelledError): + await leaving + closing.released.set() + await host.forgotten("room-a") + + assert await host.send("room-a", PeerCommand.RECALL) == ["Recalled: None"] + await host.leave("room-a") + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] diff --git a/tests/adapters/codex/lifecycle/test_native_conversation_continuity.py b/tests/adapters/codex/lifecycle/test_native_conversation_continuity.py new file mode 100644 index 000000000..9e502a218 --- /dev/null +++ b/tests/adapters/codex/lifecycle/test_native_conversation_continuity.py @@ -0,0 +1,119 @@ +"""Codex room lifecycle: native conversation continuity.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapter, + CodexCommand, + CodexContinuityError, + CodexSubcommand, +) +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex import CodexJsonRpcError +from tests.adapters.lifecycle.kit import RetentionHost +from tests.adapters.roompeer import ( + CODEX_HOME, + NO_ROLLOUT, + PeerCommand, + memo_instruction, + rollout_path, +) + + +async def lose_process( + host: RetentionHost, + room_id: str, + loss: PeerCommand = PeerCommand.LOSE_TRANSPORT, +) -> str: + """Lose the room's process mid-turn and return the thread it was on.""" + thread = host.native_id(room_id) + assert thread is not None + with pytest.raises(TurnResultAlreadyReported): + await host.send(room_id, loss) + await host.settled(room_id) + return thread + + +class TestNativeConversationContinuity: + """A replacement process continues the room native conversation or refuses to run.""" + + @pytest.mark.parametrize( + "loss", [PeerCommand.LOSE_TRANSPORT, PeerCommand.EXIT_PROCESS] + ) + async def test_a_replacement_process_continues_the_same_native_conversation( + self, host: RetentionHost, loss: PeerCommand + ) -> None: + """The memo lives only in the thread's private context, so recalling it + on a new process proves the native conversation itself continued.""" + assert await host.send("room-a", memo_instruction("private-7")) == ["Noted."] + thread = await lose_process(host, "room-a", loss) + + assert await host.send("room-a", PeerCommand.RECALL) == ["Recalled: private-7"] + assert host.native_id("room-a") == thread + assert host.exited == [True, False] + assert host.overlaps == 0 + + async def test_a_refused_restore_keeps_the_conversation_and_starts_no_other( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", memo_instruction("private-7")) == ["Noted."] + thread = await lose_process(host, "room-a") + saved = rollout_path(host.root / CODEX_HOME, thread) + kept = saved.read_text() + saved.unlink() + + for _ in range(2): + with pytest.raises(CodexJsonRpcError, match=NO_ROLLOUT): + await host.send("room-a", PeerCommand.RECALL) + await host.settled("room-a") + assert host.native_id("room-a") == thread + assert not any(saved.parent.iterdir()) + + saved.write_text(kept) + assert await host.send("room-a", PeerCommand.RECALL) == ["Recalled: private-7"] + assert host.overlaps == 0 + + async def test_archiving_a_thread_codex_refuses_to_restore_starts_a_new_one( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", memo_instruction("private-7")) == ["Noted."] + thread = await lose_process(host, "room-a") + rollout_path(host.root / CODEX_HOME, thread).unlink() + with pytest.raises(CodexJsonRpcError, match=NO_ROLLOUT): + await host.send("room-a", PeerCommand.RECALL) + await host.settled("room-a") + + await host.send("room-a", f"/{CodexCommand.THREAD} {CodexSubcommand.ARCHIVE}") + + assert await host.send("room-a", memo_instruction("fresh-1")) == ["Noted."] + assert host.native_id("room-a") not in (None, thread) + + async def test_restored_threads_refuse_tools_they_were_not_started_with( + self, + host: RetentionHost, + ) -> None: + assert isinstance(host.adapter, CodexAdapter) + started_with = await host.send("room-a", PeerCommand.TOOLS) + thread = await lose_process(host, "room-a") + original = host.adapter.config + host.adapter.config = original.model_copy( + update={ + "additional_dynamic_tools": [ + {"name": "late_tool", "description": "", "inputSchema": {}} + ] + } + ) + + with pytest.raises(CodexContinuityError, match="late_tool"): + await host.send("room-a", PeerCommand.TOOLS) + assert host.native_id("room-a") == thread + + host.adapter.config = original + assert await host.send("room-a", PeerCommand.TOOLS) == started_with + archive = f"/{CodexCommand.THREAD} {CodexSubcommand.ARCHIVE}" + await host.send("room-a", archive) + assert host.native_id("room-a") is None diff --git a/tests/adapters/codex/lifecycleprofile.py b/tests/adapters/codex/lifecycleprofile.py new file mode 100644 index 000000000..34ca58b5e --- /dev/null +++ b/tests/adapters/codex/lifecycleprofile.py @@ -0,0 +1,99 @@ +"""The Codex adapter's bridge into the common Room lifecycle suite.""" + +from __future__ import annotations + +import sys +from collections.abc import Mapping, Sequence +from functools import partial +from typing import Any + +import pytest + +from band.adapters.codex import CodexAdapter, CodexAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex import CodexJsonRpcError, CodexStdioClient +from tests.adapters.codex.kit import residency_of, thread_of +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import ( + ExpectedFailure, + LifecycleBridge, + LifecycleProfile, + RetentionHost, + pause_at, +) +from tests.adapters.roompeer import CLEANUP_FAILURE, CODEX_HOME, STARTUP_FAILURE +from tests.paths import ROOM_PEER + + +class CodexPeer(CodexStdioClient): + """Inject a held start or a failed or held close at the real transport.""" + + refuse_close = False + starting: Hold | None = None + closing: Hold | None = None + + async def initialize(self, **kwargs: Any) -> dict[str, Any]: + await pause_at(self.starting) + return await super().initialize(**kwargs) + + async def _close_process(self) -> None: + await pause_at(self.closing) + if self.refuse_close: + raise RuntimeError(CLEANUP_FAILURE) + await super()._close_process() + + @property + def exited(self) -> bool: + return self._proc is not None and self._proc.returncode is not None + + +def install( + host: RetentionHost, + monkeypatch: pytest.MonkeyPatch, + **settings: Any, +) -> LifecycleBridge: + """``settings`` override the production config's fields.""" + adapter = CodexAdapter( + CodexAdapterConfig( + model="peer", + workspace_for_room=host.workspace, + codex_command=( + sys.executable, + "-u", + str(ROOM_PEER), + *host.peer_arguments, + "--home", + str(host.root / CODEX_HOME), + "--stay", + ), + **settings, + ) + ) + + def child( + *, + command: Sequence[str] | None, + cwd: str | None, + env: Mapping[str, str] | None, + ) -> CodexPeer: + peer = CodexPeer(command=command, cwd=cwd, env=env) + host.spawned(peer, cwd) + return peer + + monkeypatch.setattr("band.adapters.codex.CodexStdioClient", child) + + return LifecycleBridge( + adapter=adapter, + residency=partial(residency_of, adapter), + native_id=partial(thread_of, adapter), + ) + + +PROFILES = [ + LifecycleProfile( + name="codex", + install=install, + lost_turn=TurnResultAlreadyReported, + failed_start=ExpectedFailure(CodexJsonRpcError, STARTUP_FAILURE), + ) +] diff --git a/tests/adapters/codex/test_accept_for_session.py b/tests/adapters/codex/test_accept_for_session.py new file mode 100644 index 000000000..336bce4f6 --- /dev/null +++ b/tests/adapters/codex/test_accept_for_session.py @@ -0,0 +1,53 @@ +"""Codex adapter: accept for session.""" + +from __future__ import annotations + +from collections import OrderedDict + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestAcceptForSession: + @pytest.mark.asyncio + async def test_session_auto_approval_sends_accept_for_session(self) -> None: + """Session auto-approved requests send 'acceptForSession' to Codex.""" + events = [ + event_request( + 20, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "npm install"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="manual") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + # Pre-seed session approval for the exact command + adapter._session_approved[ROOM_ID] = OrderedDict( + [("commandExecution:npm install", None)] + ) + + await send_bootstrap(adapter, content="install deps", tools=tools) + + # Decision should be acceptForSession + responses = fake_client.responses + decisions = [r.get("decision") for _, r in responses] + assert "acceptForSession" in decisions diff --git a/tests/adapters/codex/test_adapter_config.py b/tests/adapters/codex/test_adapter_config.py new file mode 100644 index 000000000..0e980b844 --- /dev/null +++ b/tests/adapters/codex/test_adapter_config.py @@ -0,0 +1,30 @@ +"""Codex adapter: configuration defaults and rejected settings.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from band.adapters.codex import CodexAdapterConfig + + +class TestAdapterConfig: + def test_config_defaults_are_low_noise_and_manual_approval( + self, assert_no_leaked_adapter_config_env: None + ) -> None: + config = CodexAdapterConfig() + assert config.emit_turn_task_markers is False + assert config.approval_mode == "manual" + + @pytest.mark.parametrize( + "field", ["fallback_send_agent_text", "assistant_text_mode"] + ) + def test_retired_delivery_config_is_rejected(self, field: str) -> None: + with pytest.raises(ValidationError, match="Extra inputs"): + CodexAdapterConfig.model_validate({field: True}) + + def test_a_negative_settle_timeout_is_rejected(self) -> None: + """A negative grace would expire before a leftover native request is + answered.""" + with pytest.raises(ValidationError, match="greater than or equal to 0"): + CodexAdapterConfig(turn_settle_timeout_s=-1) diff --git a/tests/adapters/codex/test_adapter_shutdown.py b/tests/adapters/codex/test_adapter_shutdown.py new file mode 100644 index 000000000..30154ca5c --- /dev/null +++ b/tests/adapters/codex/test_adapter_shutdown.py @@ -0,0 +1,130 @@ +"""Codex adapter: shutdown seals admission at once, owns its one cleanup, and +stays closed until an explicit restart confirms it.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from band.integrations.codex.types import CodexRequestMethod +from tests.adapters.codex.kit import ( + CLOSE_FAILURE, + HELD_ROOM, + NEW_ROOM, + TwoRooms, + refused, + send_bootstrap, + start_room_process, +) + + +def failed_close() -> pytest.RaisesGroup[RuntimeError]: + """Expect shutdown to raise the held room's refused close, and only that.""" + return pytest.RaisesGroup( + pytest.RaisesExc(RuntimeError, match=re.escape(CLOSE_FAILURE)) + ) + + +class TestAdapterShutdown: + async def test_shutdown_seals_every_room_before_settling_held_work( + self, two_rooms: TwoRooms + ) -> None: + stopping = asyncio.create_task(two_rooms.adapter.cleanup_all()) + await asyncio.sleep(0) + + assert await refused(send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM)) + assert await refused(start_room_process(two_rooms.adapter, HELD_ROOM)) + assert two_rooms.new.starts == 0 + await two_rooms.finish() + await stopping + assert (two_rooms.held.starts, two_rooms.held.closes) == (1, 1) + + async def test_successful_shutdown_refuses_turns_until_restarted( + self, two_rooms: TwoRooms + ) -> None: + await two_rooms.finish() + await two_rooms.adapter.cleanup_all() + + assert await refused(send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM)) + assert await refused(start_room_process(two_rooms.adapter, HELD_ROOM)) + await two_rooms.adapter.on_started("Codex Agent", "Restarted") + await send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM) + assert two_rooms.new.params_of(CodexRequestMethod.TURN_START) + + @pytest.mark.looptime + async def test_settlement_counts_against_the_one_shutdown_deadline( + self, two_rooms: TwoRooms + ) -> None: + with pytest.raises(CleanupIncompleteError): + await two_rooms.adapter.cleanup_all() + with pytest.raises(CleanupIncompleteError): + await two_rooms.adapter.on_started("Codex Agent", "Too early") + + assert await refused(send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM)) + assert (two_rooms.held.starts, two_rooms.held.closes) == (1, 0) + + @pytest.mark.looptime + async def test_concurrent_and_retried_callers_join_one_close( + self, two_rooms: TwoRooms + ) -> None: + two_rooms.held.finish_turn.set() + with pytest.raises(CleanupIncompleteError): + await two_rooms.adapter.cleanup_all() + waiters = [ + asyncio.create_task(two_rooms.adapter.cleanup_all()) for _ in range(2) + ] + await asyncio.sleep(0) + two_rooms.held.finish_close.set() + + await asyncio.gather(*waiters) + assert two_rooms.held.closes == 1 + + async def test_cancelled_caller_leaves_the_shutdown_owned( + self, two_rooms: TwoRooms + ) -> None: + two_rooms.held.finish_turn.set() + stopping = asyncio.create_task(two_rooms.adapter.cleanup_all()) + await two_rooms.held.closing.wait() + stopping.cancel() + with pytest.raises(asyncio.CancelledError): + await stopping + + assert await refused(send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM)) + two_rooms.held.finish_close.set() + await two_rooms.adapter.on_started("Codex Agent", "Restarted") + assert two_rooms.held.closes == 1 + + async def test_failed_close_keeps_the_process_until_a_retry_closes_it( + self, two_rooms: TwoRooms + ) -> None: + await two_rooms.finish() + two_rooms.held.refuse_close = True + with failed_close(): + await two_rooms.adapter.cleanup_all() + with failed_close(): + await two_rooms.adapter.on_started("Codex Agent", "Close still fails") + assert await refused(send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM)) + assert (two_rooms.held.starts, two_rooms.held.closes) == (1, 2) + + two_rooms.held.refuse_close = False + await two_rooms.adapter.on_started("Codex Agent", "Restarted") + assert (two_rooms.held.starts, two_rooms.held.closes) == (1, 3) + await send_bootstrap(two_rooms.adapter, room_id=HELD_ROOM) + assert two_rooms.held.starts == 2 + + async def test_a_leaving_room_does_not_hold_up_another_room( + self, two_rooms: TwoRooms + ) -> None: + two_rooms.held.finish_turn.set() + await two_rooms.held_turn + leaving = asyncio.create_task(two_rooms.adapter.on_cleanup(HELD_ROOM)) + await two_rooms.held.closing.wait() + + await send_bootstrap(two_rooms.adapter, room_id=NEW_ROOM) + assert two_rooms.new.params_of(CodexRequestMethod.TURN_START) + assert not leaving.done() + two_rooms.held.finish_close.set() + await leaving diff --git a/tests/adapters/codex/test_approval_audit_recording.py b/tests/adapters/codex/test_approval_audit_recording.py new file mode 100644 index 000000000..f4143a149 --- /dev/null +++ b/tests/adapters/codex/test_approval_audit_recording.py @@ -0,0 +1,29 @@ +"""Codex adapter: approval audit recording.""" + +from __future__ import annotations + +from band.adapters.codex import ( + CodexAdapter, + CodexAdapterConfig, +) +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ROOM_ID + + +class TestApprovalAuditRecording: + """_record_approval_audit API surface.""" + + def test_record_approval_audit_returns_entry(self) -> None: + """_record_approval_audit returns the entry it appended.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + entry = adapter._record_approval_audit( + room_id=ROOM_ID, + request_id="req-1", + method=CodexApprovalMethod.COMMAND_EXECUTION, + decision="accept", + decided_by="tester", + summary="command: ls", + ) + assert entry.request_id == "req-1" + assert entry.decision == "accept" + assert adapter._approval_audit[ROOM_ID][-1] is entry diff --git a/tests/adapters/codex/test_approval_from_a_sequential_room.py b/tests/adapters/codex/test_approval_from_a_sequential_room.py new file mode 100644 index 000000000..6d2a8d096 --- /dev/null +++ b/tests/adapters/codex/test_approval_from_a_sequential_room.py @@ -0,0 +1,170 @@ +"""Codex adapter: approval from a sequential room.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import CodexCommand +from band.client.streaming import ControlMode +from band.core.exceptions import InputNotAccepted +from band.integrations.codex.types import CodexApprovalMethod, CodexRequestMethod +from tests.adapters.codex.kit import ( + ACCEPTED, + DECLINED, + PATIENT_APPROVAL_S, + ROOM_ID, + CodexRoom, + ToolSchemaFakeTools, + make_platform_message, + parked_on_approval, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + turn_completed, +) + + +class TestApprovalFromASequentialRoom: + """Band hands a room its messages one at a time, so the reply resolving an + approval is delivered only after the asking message's on_message returns. + Approval waits are an hour long: a regression that waits on the human + hangs until pytest's timeout instead of passing by luck.""" + + @pytest.mark.asyncio + async def test_the_reply_delivered_after_the_asking_message_resolves_it( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + *parked_on_approval(turn_completed()), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run tests") + control_tools = ToolSchemaFakeTools() + assert await room.adapter.try_handle_control( + make_platform_message( + room_id=ROOM_ID, + content=f"/{CodexCommand.APPROVE} {room.approval_token}", + ), + control_tools, + ) + await room.settled() + + assert control_tools.turn.complete + assert room.client.responses == [(10, ACCEPTED)] + + @pytest.mark.asyncio + async def test_a_request_while_a_turn_awaits_a_human_is_turned_away( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + *parked_on_approval(turn_completed()), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run tests") + with pytest.raises(InputNotAccepted): + await room.send("and lint too") + + assert not room.deliveries[-1].turn.complete + methods = room.client.request_methods + assert methods.count(CodexRequestMethod.TURN_START) == 1 + + @pytest.mark.asyncio + async def test_interrupt_reaches_a_turn_parked_on_a_human( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """ExecutionContext.interrupt()/stop_room() only cancel the cycle task + that invoked on_message -- once that returns early because the turn + released the room, only on_interrupt still reaches the parked turn. + + on_interrupt declines the pending approval and then cancels the turn + as a backstop; which of the two finishes it is an asyncio-scheduling + detail, so this asserts only that the turn stops running.""" + room = await codex_room( + *parked_on_approval(turn_completed()), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run tests") + turn = room.turn + assert not turn.done() + + await room.adapter.on_interrupt(ROOM_ID, ControlMode.INTERRUPT) + + assert turn.done() + assert room.pending_approval_count == 0 + + @pytest.mark.asyncio + async def test_cleanup_declines_a_parked_turn_instead_of_waiting( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """A turn waiting on a human holds the room's RPC lock; cleanup must + decline its approval (and any it raises afterwards) rather than block + for the whole approval wait.""" + room = await codex_room( + *parked_on_approval( + event_request( + 11, CodexApprovalMethod.COMMAND_EXECUTION, {"command": "b"} + ), + turn_completed(), + ), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run tests") + turn = room.turn + await room.adapter.on_cleanup(ROOM_ID) + await turn + + assert room.client.responses == [ + (10, DECLINED), + (11, DECLINED), + ] + assert sum(m.startswith("Approval requested") for m in room.chat) == 1 + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_cleanup_is_bounded_by_a_released_turn_still_working( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """Once released, the runtime can't cancel the turn; cleanup waits out + the settle timeout, then cancels Codex work that never ends.""" + room = await codex_room( + client=FakeCodexClient(events=list(parked_on_approval())), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run tests") + await room.send(f"/{CodexCommand.APPROVE} {room.approval_token}") + turn = room.turn + await room.adapter.on_cleanup(ROOM_ID) + + assert turn.cancelled() + assert room.client.closed + + @pytest.mark.asyncio + async def test_an_approval_codex_names_is_resolved_by_its_displayed_token( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + event_request( + 7, + CodexApprovalMethod.COMMAND_EXECUTION, + {"approvalId": "ap-1", "command": ["git", "push"]}, + ), + turn_completed(), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + + await room.send("run protected command") + token = room.approval_token + assert token != "ap-1" + await room.send(f"/{CodexCommand.APPROVE} {token}") + await room.settled() + + assert room.client.responses == [(7, ACCEPTED)] + assert f"Approval `{token}` resolved as `accept`." in room.chat diff --git a/tests/adapters/codex/test_approval_notification_failures.py b/tests/adapters/codex/test_approval_notification_failures.py new file mode 100644 index 000000000..2384c214c --- /dev/null +++ b/tests/adapters/codex/test_approval_notification_failures.py @@ -0,0 +1,126 @@ +"""Codex adapter: an approval is still answered when the room cannot be told about it.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex.types import CodexApprovalMethod +from band.testing import ( + reported_failures, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + FailingNoticeTools, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestApprovalNotificationFailures: + @pytest.mark.asyncio + async def test_approval_request_auto_decline(self) -> None: + events = [ + event_request( + 7, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "rm -rf tmp"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert fake_client.responses + response_id, payload = fake_client.responses[0] + assert response_id == 7 + assert payload["decision"] == "decline" + assert len(tools.messages_sent) == 1 + assert "Approval requested" in tools.messages_sent[0]["content"] + assert "rm -rf tmp" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_auto_approval_responds_even_if_notification_fails(self) -> None: + events = [ + event_request( + 7, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "rm -rf tmp"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + approval_mode="auto_decline", approval_text_notifications=True + ), + ) + tools = FailingNoticeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert fake_client.responses + response_id, payload = fake_client.responses[0] + assert response_id == 7 + assert payload["decision"] == "decline" + + @pytest.mark.asyncio + async def test_manual_approval_responds_with_decline_if_notification_fails( + self, + ) -> None: + events = [ + event_request( + 7, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "rm -rf tmp"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="manual") + ) + tools = FailingNoticeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert fake_client.responses + response_id, payload = fake_client.responses[0] + assert response_id == 7 + assert payload["decision"] == "decline" + assert adapter._open_approval_count(ROOM_ID) == 0 + + # The Band-delivery hiccup that caused this auto-decline must itself + # be reported -- otherwise it's indistinguishable from a genuine + # human decision, with no signal at all that anything went wrong. + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + + # The human sender was never actually notified, so the audit trail + # must not credit/blame them for this decision -- it was forced by + # the delivery failure, same as every other forced-decline path. + audit_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "approval_resolution" + ] + assert len(audit_events) == 1 + assert audit_events[0]["metadata"]["codex_decided_by"] == "system_fallback" diff --git a/tests/adapters/codex/test_approval_retirement.py b/tests/adapters/codex/test_approval_retirement.py new file mode 100644 index 000000000..6226c35f2 --- /dev/null +++ b/tests/adapters/codex/test_approval_retirement.py @@ -0,0 +1,178 @@ +"""A room leave retires answers and session approval policy together.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import CodexAdapter, CodexCommand +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ( + ACCEPTED, + PATIENT_APPROVAL_S, + ROOM_ID, + ToolSchemaFakeTools, + make_platform_message, + requested_approval_token, + residency_of, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + await_released_turn, + event_request, + turn_completed, +) + + +class TestApprovalRetirement: + @pytest.mark.parametrize( + "approval_id", [None, "reused"], ids=["request-id", "approval-id"] + ) + @pytest.mark.parametrize( + "restart", [False, True], ids=["leave-rejoin", "stop-restart"] + ) + async def test_a_reused_native_id_does_not_reuse_the_left_rooms_answer_token( + self, + started_codex: Callable[..., Awaitable[CodexAdapter]], + approval_id: str | None, + restart: bool, + ) -> None: + params = {"command": "npm test"} + if approval_id is not None: + params["approvalId"] = approval_id + old, new = ( + FakeCodexClient( + events=[ + event_request(10, CodexApprovalMethod.COMMAND_EXECUTION, params), + turn_completed(), + ] + ) + for _ in range(2) + ) + adapter = await started_codex( + old, new, approval_mode="manual", approval_wait_timeout_s=PATIENT_APPROVAL_S + ) + old_tools, new_tools = ToolSchemaFakeTools(), ToolSchemaFakeTools() + await send_bootstrap(adapter, tools=old_tools) + old_token = requested_approval_token(old_tools) + if restart: + await adapter.cleanup_all() + await adapter.on_started("Agent", "A coding agent") + else: + await adapter.on_cleanup(ROOM_ID) + await send_bootstrap(adapter, tools=new_tools) + new_token = requested_approval_token(new_tools) + late_answer = ToolSchemaFakeTools() + await adapter.try_handle_control( + make_platform_message(content=f"/{CodexCommand.APPROVE} {old_token}"), + late_answer, + ) + + assert late_answer.messages_sent[-1]["content"] == ( + f"Unknown approval id `{old_token}`. Pending: {new_token}" + ) + assert new.responses == [] + await adapter.try_handle_control( + make_platform_message(content=f"/{CodexCommand.APPROVE} {new_token}"), + ToolSchemaFakeTools(), + ) + await await_released_turn(adapter, ROOM_ID) + assert new.responses == [(10, ACCEPTED)] + + async def test_a_held_session_approval_notice_cannot_authorize_a_rejoined_room( + self, + started_codex: Callable[..., Awaitable[CodexAdapter]], + ) -> None: + old = FakeCodexClient( + events=[ + event_request( + 10, + CodexApprovalMethod.COMMAND_EXECUTION, + {"approvalId": "old", "command": "npm test"}, + ), + turn_completed(), + ] + ) + other = FakeCodexClient( + events=[ + event_request( + 10, + CodexApprovalMethod.COMMAND_EXECUTION, + {"approvalId": "other", "command": "npm test"}, + ), + turn_completed(), + ] + ) + new = FakeCodexClient( + events=[ + event_request( + 10, + CodexApprovalMethod.COMMAND_EXECUTION, + {"approvalId": "new", "command": "npm test"}, + ), + turn_completed(), + ] + ) + adapter = await started_codex( + old, + other, + new, + approval_mode="manual", + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + old_tools = ToolSchemaFakeTools() + await send_bootstrap(adapter, tools=old_tools) + old_token = requested_approval_token(old_tools) + old_residency = residency_of(adapter, ROOM_ID) + assert old_residency is not None + other_tools = ToolSchemaFakeTools() + await send_bootstrap(adapter, room_id="other-room", tools=other_tools) + other_token = requested_approval_token(other_tools) + control_tools = ToolSchemaFakeTools() + acknowledgment = control_tools.hold_message("resolved") + approving = asyncio.create_task( + adapter.try_handle_control( + make_platform_message( + content=f"/{CodexCommand.APPROVE_SESSION} {old_token}" + ), + control_tools, + ) + ) + + async with acknowledgment: + await await_released_turn(adapter, ROOM_ID) + await adapter.on_cleanup(ROOM_ID) + rejoined_tools = ToolSchemaFakeTools() + await send_bootstrap(adapter, tools=rejoined_tools) + rejoined_token = requested_approval_token(rejoined_tools) + new_residency = residency_of(adapter, ROOM_ID) + assert new_residency is not None + assert new_residency is not old_residency + assert new_residency.generation == old_residency.generation + await adapter.try_handle_control( + make_platform_message(content=f"/{CodexCommand.APPROVE} {old_token}"), + ToolSchemaFakeTools(), + ) + + assert await approving + assert old.responses == [(10, {"decision": "acceptForSession"})] + assert new.responses == other.responses == [] + await adapter.try_handle_control( + make_platform_message( + room_id="other-room", content=f"/{CodexCommand.APPROVE} {other_token}" + ), + ToolSchemaFakeTools(), + ) + await await_released_turn(adapter, "other-room") + await adapter.try_handle_control( + make_platform_message(content=f"/{CodexCommand.APPROVE} {rejoined_token}"), + ToolSchemaFakeTools(), + ) + await await_released_turn(adapter, ROOM_ID) + + assert new.responses == other.responses == [(10, ACCEPTED)] + assert old.closed + assert not new.closed and not other.closed diff --git a/tests/adapters/codex/test_audit_cap.py b/tests/adapters/codex/test_audit_cap.py new file mode 100644 index 000000000..6efc6f129 --- /dev/null +++ b/tests/adapters/codex/test_audit_cap.py @@ -0,0 +1,69 @@ +"""Codex adapter: audit cap.""" + +from __future__ import annotations + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ROOM_ID +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, +) + + +class TestAuditCap: + def test_audit_trail_capped_at_limit(self) -> None: + """Approval audit trail is capped at max_approval_audit_per_room.""" + adapter = make_codex_adapter( + FakeCodexClient(), + config=CodexAdapterConfig(max_approval_audit_per_room=5), + ) + for i in range(10): + adapter._record_approval_audit( + room_id=ROOM_ID, + request_id=str(i), + method=CodexApprovalMethod.COMMAND_EXECUTION, + decision="accept", + decided_by="test", + ) + audit = adapter._approval_audit[ROOM_ID] + assert len(audit) == 5 + # Should keep the most recent entries + assert audit[0].request_id == "5" + assert audit[-1].request_id == "9" + + def test_session_approved_capped_at_limit(self) -> None: + """Session approvals evict LRU when max_session_approved_per_room is hit.""" + adapter = make_codex_adapter( + FakeCodexClient(), + config=CodexAdapterConfig(max_session_approved_per_room=3), + ) + for i in range(5): + adapter._record_session_approval(ROOM_ID, f"commandExecution:cmd{i}") + room = adapter._session_approved[ROOM_ID] + assert list(room.keys()) == [ + "commandExecution:cmd2", + "commandExecution:cmd3", + "commandExecution:cmd4", + ] + + def test_session_approval_reinsert_moves_to_end(self) -> None: + """Re-approving an existing key moves it to the most-recent slot.""" + adapter = make_codex_adapter( + FakeCodexClient(), + config=CodexAdapterConfig(max_session_approved_per_room=3), + ) + adapter._record_session_approval(ROOM_ID, "commandExecution:a") + adapter._record_session_approval(ROOM_ID, "commandExecution:b") + adapter._record_session_approval(ROOM_ID, "commandExecution:c") + # Re-approve the oldest — it should move to the end. + adapter._record_session_approval(ROOM_ID, "commandExecution:a") + adapter._record_session_approval(ROOM_ID, "commandExecution:d") + room = adapter._session_approved[ROOM_ID] + # "b" is the oldest after re-approving "a", so it's the one evicted. + assert "commandExecution:b" not in room + assert list(room.keys()) == [ + "commandExecution:c", + "commandExecution:a", + "commandExecution:d", + ] diff --git a/tests/adapters/codex/test_cleanup.py b/tests/adapters/codex/test_cleanup.py new file mode 100644 index 000000000..a089636d8 --- /dev/null +++ b/tests/adapters/codex/test_cleanup.py @@ -0,0 +1,41 @@ +"""Codex adapter: leaving a room forgets its thread's token usage.""" + +from __future__ import annotations + +import pytest + +from tests.adapters.codex.kit import ROOM_ID, send_bootstrap +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestCleanup: + @pytest.mark.asyncio + async def test_on_cleanup_removes_per_room_token_usage(self) -> None: + # A leaked entry has no room-visible effect; it only grows the map. + events = [ + event_notification( + "thread/tokenUsage/updated", + { + "usage": { + "inputTokens": 500, + "outputTokens": 100, + "totalTokens": 600, + } + }, + ), + turn_completed(), + ] + adapter = make_codex_adapter(FakeCodexClient(events=events)) + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter) + thread_id = adapter._room_threads[ROOM_ID] + assert thread_id in adapter._token_usage + + await adapter.on_cleanup(ROOM_ID) + + assert thread_id not in adapter._token_usage diff --git a/tests/adapters/codex/test_codex_types.py b/tests/adapters/codex/test_codex_types.py new file mode 100644 index 000000000..3c27def8b --- /dev/null +++ b/tests/adapters/codex/test_codex_types.py @@ -0,0 +1,211 @@ +"""Codex adapter: codex types.""" + +from __future__ import annotations + +from band.adapters.codex import ( + _MESSAGE_ITEM_TYPES, + _REQUESTED_TOOL_ITEM_TYPES, + _ROUTING_ITEM_TYPES, + _THOUGHT_ITEM_TYPES, + _TOOL_ITEM_TYPES, + CodexAdapter, + CodexAdapterConfig, +) +from band.integrations.codex.types import ( + CodexApprovalMethod, + CodexItemType, + CodexTokenUsage, + build_agent_failure, + parse_plan_steps, +) + + +class TestCodexTypes: + def test_build_agent_failure_known_type(self) -> None: + + error_obj = { + "message": "Context overflow", + "codexErrorInfo": { + "type": "ContextWindowExceeded", + "code": "ctx_exceeded", + "retryable": False, + }, + } + failure = build_agent_failure(error_obj, thread_id="t1", turn_id="turn-1") + assert failure.provider == "codex" + assert "context overflow" in failure.message.lower() + assert failure.code == "ContextWindowExceeded" + assert failure.detail["codex_thread_id"] == "t1" + assert failure.detail["codex_turn_id"] == "turn-1" + + def test_build_agent_failure_unknown_type(self) -> None: + + error_obj = { + "message": "Something weird happened", + "codexErrorInfo": {"type": "UnknownError"}, + } + failure = build_agent_failure(error_obj) + assert failure.message == "Something weird happened" + assert failure.code == "UnknownError" + + def test_parse_plan_steps(self) -> None: + + params = { + "plan": { + "steps": [ + {"text": "Step 1", "status": "completed"}, + {"text": "Step 2", "status": "inProgress"}, + {"text": "Step 3", "status": "pending"}, + ] + } + } + steps = parse_plan_steps(params) + assert len(steps) == 3 + assert steps[0].step == "Step 1" + assert steps[0].status == "completed" + assert steps[2].status == "pending" + + def test_parse_plan_steps_string_entries(self) -> None: + + params = {"plan": {"steps": ["Read code", "Fix bug"]}} + steps = parse_plan_steps(params) + assert len(steps) == 2 + assert steps[0].step == "Read code" + assert steps[0].status == "pending" + + def test_codex_token_usage_update(self) -> None: + + usage = CodexTokenUsage() + usage.update( + { + "usage": { + "inputTokens": 1000, + "outputTokens": 500, + "reasoningTokens": 200, + "totalTokens": 1700, + } + } + ) + assert usage.input_tokens == 1000 + assert usage.output_tokens == 500 + assert usage.reasoning_tokens == 200 + assert usage.total_tokens == 1700 + meta = usage.to_metadata() + assert meta["codex_input_tokens"] == 1000 + assert "1,700" in usage.format_summary() + + def test_codex_token_usage_update_current_schema(self) -> None: + """The current app-server schema nests cumulative counters under + ``tokenUsage.total`` and names reasoning ``reasoningOutputTokens``.""" + + usage = CodexTokenUsage() + usage.update( + { + "threadId": "t-1", + "turnId": "turn-1", + "tokenUsage": { + "total": { + "totalTokens": 14822, + "inputTokens": 14725, + "cachedInputTokens": 2432, + "outputTokens": 97, + "reasoningOutputTokens": 59, + }, + "last": { + "totalTokens": 14822, + "inputTokens": 14725, + "outputTokens": 97, + "reasoningOutputTokens": 59, + }, + "modelContextWindow": 258400, + }, + } + ) + assert usage.input_tokens == 14725 + assert usage.output_tokens == 97 + assert usage.reasoning_tokens == 59 + assert usage.total_tokens == 14822 + + def test_config_new_flags_default_false(self) -> None: + """All new config flags default to False.""" + config = CodexAdapterConfig() + assert config.stream_reasoning_events is False + assert config.stream_plan_events is False + assert config.stream_commentary_events is False + assert config.emit_diff_events is False + assert config.emit_token_usage_events is False + assert config.emit_turn_lifecycle_events is False + + def test_session_approval_key_full_command_by_default(self) -> None: + """Session approval key includes full command string by default.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key( + CodexApprovalMethod.COMMAND_EXECUTION, {"command": "npm test"} + ) + assert key == "commandExecution:npm test" + + def test_session_approval_key_binary_granularity(self) -> None: + """Session approval key includes only binary when granularity is 'binary'.""" + adapter = CodexAdapter( + config=CodexAdapterConfig(session_approval_granularity="binary") + ) + key = adapter._session_approval_key( + CodexApprovalMethod.COMMAND_EXECUTION, {"command": "npm test"} + ) + assert key == "commandExecution:npm" + + def test_session_approval_key_empty_for_missing_command(self) -> None: + """Session approval key returns empty when command is missing (no wildcard).""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key(CodexApprovalMethod.COMMAND_EXECUTION, {}) + assert key == "" + + def test_session_approval_key_empty_for_file_changes_without_paths(self) -> None: + """A fileChange request without paths has no session key, so + /approve-session can't approve every future change.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key( + "item/fileChange/requestApproval", {"reason": "update"} + ) + assert key == "" + + def test_codex_item_type_fully_classified(self) -> None: + """Every ``CodexItemType`` lands in exactly one of the adapter's + buckets: tool-like, requested tool, thought-like, the skipped + messages, or read only for routing. + + A new item type added to the enum without also updating one of these + sets currently falls through to a silent ``logger.debug`` — no room + event, no test failure. This test is the guard: it fails loudly the + moment the partition stops being exhaustive. + """ + + buckets = ( + _TOOL_ITEM_TYPES, + _REQUESTED_TOOL_ITEM_TYPES, + _THOUGHT_ITEM_TYPES, + _MESSAGE_ITEM_TYPES, + _ROUTING_ITEM_TYPES, + ) + + assert set().union(*buckets) == set(CodexItemType) + assert sum(len(bucket) for bucket in buckets) == len(CodexItemType) + + def test_token_usage_update_handles_zero_values(self) -> None: + """CodexTokenUsage.update() correctly handles explicit zero values.""" + + usage = CodexTokenUsage() + usage.update( + { + "usage": { + "inputTokens": 0, + "outputTokens": 100, + "reasoningTokens": 0, + "totalTokens": 100, + } + } + ) + assert usage.input_tokens == 0 + assert usage.output_tokens == 100 + assert usage.reasoning_tokens == 0 + assert usage.total_tokens == 100 diff --git a/tests/adapters/codex/test_config_env_sourcing.py b/tests/adapters/codex/test_config_env_sourcing.py new file mode 100644 index 000000000..841519932 --- /dev/null +++ b/tests/adapters/codex/test_config_env_sourcing.py @@ -0,0 +1,65 @@ +"""Codex adapter: config env sourcing.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig + + +class TestConfigEnvSourcing: + """Aliased fields source from CODEX_* env names only, never bare vars.""" + + @pytest.fixture(autouse=True) + def clean_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + for var in ( + "EMIT_TURN_TASK_MARKERS", + "CODEX_TURN_TASK_MARKERS", + "CODEX_EMIT_TURN_TASK_MARKERS", + "CODEX_COMMAND", + "CODEX_CODEX_COMMAND", + ): + monkeypatch.delenv(var, raising=False) + + def test_bare_env_var_never_populates_turn_task_markers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("EMIT_TURN_TASK_MARKERS", "true") + + assert CodexAdapterConfig().emit_turn_task_markers is False + + def test_legacy_env_name_populates_turn_task_markers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CODEX_TURN_TASK_MARKERS", "true") + + assert CodexAdapterConfig().emit_turn_task_markers is True + + def test_prefixed_field_name_env_populates_turn_task_markers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CODEX_EMIT_TURN_TASK_MARKERS", "true") + + assert CodexAdapterConfig().emit_turn_task_markers is True + + def test_codex_ws_url_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CODEX_WS_URL", "ws://elsewhere:9999") + + assert CodexAdapterConfig().codex_ws_url == "ws://elsewhere:9999" + + def test_codex_command_env_splits_shell_string( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """CODEX_COMMAND (the established name), not the doubly-prefixed default.""" + monkeypatch.setenv("CODEX_COMMAND", "custom-codex --args") + + assert CodexAdapterConfig().codex_command == ("custom-codex", "--args") + + def test_codex_command_kwarg_wins_over_env( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CODEX_COMMAND", "ignored --value") + + config = CodexAdapterConfig(codex_command=("explicit", "--kwarg")) + + assert config.codex_command == ("explicit", "--kwarg") diff --git a/tests/adapters/codex/test_context_compaction.py b/tests/adapters/codex/test_context_compaction.py new file mode 100644 index 000000000..9edfdc45c --- /dev/null +++ b/tests/adapters/codex/test_context_compaction.py @@ -0,0 +1,70 @@ +"""Codex adapter: context compaction.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestContextCompaction: + @pytest.mark.asyncio + async def test_context_compaction_event_emitted(self) -> None: + """context/compacted events are forwarded as task events.""" + events = [ + event_notification( + "context/compacted", + {"threadId": "thr-1", "turnId": "turn-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + compaction_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "context_compaction" + ] + assert len(compaction_events) == 1 + assert compaction_events[0]["metadata"]["codex_thread_id"] == "thr-1" + + @pytest.mark.asyncio + async def test_context_compaction_ignored_when_disabled(self) -> None: + """Compaction events are not emitted when emit_turn_lifecycle_events=False.""" + events = [ + event_notification( + "context/compacted", + {"threadId": "thr-1", "turnId": "turn-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=False) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + compaction_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "context_compaction" + ] + assert compaction_events == [] diff --git a/tests/adapters/codex/test_custom_tool_effect.py b/tests/adapters/codex/test_custom_tool_effect.py new file mode 100644 index 000000000..c1555066f --- /dev/null +++ b/tests/adapters/codex/test_custom_tool_effect.py @@ -0,0 +1,71 @@ +"""Codex adapter: custom tool effect.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from band.runtime.custom_tools import declares_turn_effect +from band.runtime.tools import TurnEffect +from tests.adapters.codex.kit import ( + StayQuietInput, + run_codex_turn, +) +from tests.adapters.codexturns import ( + final_text, + tool_call_request, + turn_completed, +) +from tests.framework_conformance.turnprobes import undeclared + + +class TestCustomToolEffect: + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("declare", "chat"), + [ + pytest.param( + declares_turn_effect(TurnEffect.DECLINE), [], id="declared-silence" + ), + pytest.param(undeclared, [], id="undeclared"), + ], + ) + async def test_only_a_declared_tool_settles_the_reply( + self, declare: Callable[..., Any], chat: list[str] + ) -> None: + async def stay_quiet(args: StayQuietInput) -> str: + return "quiet" + + turn = await run_codex_turn( + events=[ + tool_call_request(1, "stayquiet"), + final_text("Nothing to add."), + turn_completed(), + ], + additional_tools=[(StayQuietInput, declare(stay_quiet))], + ) + + assert turn.tools.chat == chat + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("declare", "complete"), + [ + pytest.param(declares_turn_effect(TurnEffect.ACT), True, id="declared"), + pytest.param(undeclared, False, id="undeclared-observes"), + ], + ) + async def test_the_tool_records_its_effect_on_the_turn( + self, declare: Callable[..., Any], complete: bool + ) -> None: + async def stay_quiet(args: StayQuietInput) -> str: + return "quiet" + + turn = await run_codex_turn( + events=[tool_call_request(1, "stayquiet"), turn_completed()], + additional_tools=[(StayQuietInput, declare(stay_quiet))], + ) + + assert turn.tools.turn.complete is complete diff --git a/tests/adapters/codex/test_custom_tools.py b/tests/adapters/codex/test_custom_tools.py new file mode 100644 index 000000000..85a83dfa0 --- /dev/null +++ b/tests/adapters/codex/test_custom_tools.py @@ -0,0 +1,100 @@ +"""Codex adapter: custom tools are offered to Codex and dispatched before platform tools.""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from band.adapters.codex import ( + CodexAdapter, + CodexAdapterConfig, +) +from band.runtime.custom_tools import CustomToolDef +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestCustomTools: + @pytest.mark.asyncio + async def test_custom_tools_schemas_merged_into_dynamic_tools(self) -> None: + """Custom tool schemas appear in _build_dynamic_tools output.""" + + class WeatherInput(BaseModel): + """Get current weather for a location.""" + + city: str + + def get_weather(inp: WeatherInput) -> str: + return f"Sunny in {inp.city}" + + custom_tools: list[CustomToolDef] = [(WeatherInput, get_weather)] + adapter = CodexAdapter( + config=CodexAdapterConfig(), + additional_tools=custom_tools, + ) + + tools = ToolSchemaFakeTools() + dynamic_tools = adapter._build_dynamic_tools(tools) + + names = [t["name"] for t in dynamic_tools] + assert "weather" in names + + weather_tool = next(t for t in dynamic_tools if t["name"] == "weather") + assert weather_tool["description"] == "Get current weather for a location." + assert "inputSchema" in weather_tool + assert "city" in weather_tool["inputSchema"].get("properties", {}) + + @pytest.mark.asyncio + async def test_custom_tool_dispatched_before_platform_tools(self) -> None: + """Custom tool is invoked via execute_custom_tool, not platform tools.""" + + class CalculatorInput(BaseModel): + """Simple calculator.""" + + expression: str + + call_log: list[str] = [] + + async def calculate(inp: CalculatorInput) -> str: + call_log.append(inp.expression) + return "42" + + custom_tools: list[CustomToolDef] = [(CalculatorInput, calculate)] + events = [ + event_request( + 99, + "item/tool/call", + { + "tool": "calculator", + "arguments": {"expression": "6*7"}, + "callId": "call-99", + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), additional_tools=custom_tools + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # Custom tool was called + assert call_log == ["6*7"] + # Platform execute_tool_call was NOT called for the custom tool + assert not any(tc["tool_name"] == "calculator" for tc in tools.tool_calls) + # Response was sent back to Codex + assert fake_client.responses + _, payload = fake_client.responses[0] + assert payload["success"] is True + assert payload["contentItems"][0]["text"] == "42" diff --git a/tests/adapters/codex/test_detached_turn_outcome.py b/tests/adapters/codex/test_detached_turn_outcome.py new file mode 100644 index 000000000..ea74a71a8 --- /dev/null +++ b/tests/adapters/codex/test_detached_turn_outcome.py @@ -0,0 +1,90 @@ +"""Codex adapter: detached turn outcome.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.core.exceptions import InputNotAccepted +from band.testing import ( + MISSING_REPLY_FAILURE, + events_of_type, + failure_reports, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + CodexRoom, + parked_on_approval, +) +from tests.adapters.codexturns import ( + final_text, + turn_completed, +) + + +class TestDetachedTurnOutcome: + """A turn released to wait on a human is judged at its real end.""" + + @pytest.mark.asyncio + async def test_a_released_turn_that_ends_silently_is_reported_once( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room(*parked_on_approval(turn_completed())) + + await room.send("run tests") + await room.send(f"/approve {room.approval_token}") + await room.settled() + + turn, approval = room.deliveries + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] + assert failure_reports(approval) == [] + + @pytest.mark.asyncio + async def test_messages_during_a_released_turn_never_take_over_its_reply( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + *parked_on_approval(final_text("Tests pass."), turn_completed()) + ) + + await room.send("run tests") + with pytest.raises(InputNotAccepted): + await room.send("and lint too") + await room.send(f"/approve {room.approval_token}") + await room.settled() + + turn, busy, approval = room.deliveries + assert [e["content"] for e in events_of_type(turn, "thought")] == [ + "Codex approval request handled automatically (accept).", + "Tests pass.", + ] + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] + assert busy.chat == [] + assert not busy.turn.complete + assert turn.turn.complete and approval.turn.complete + assert failure_reports(busy) == failure_reports(approval) == [] + + @pytest.mark.asyncio + async def test_a_released_turn_ended_by_room_cleanup_is_reported_once( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room(*parked_on_approval(turn_completed())) + + await room.send("run tests") + await room.adapter.on_cleanup(ROOM_ID) + + [turn] = room.deliveries + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] + + async def test_a_released_turn_cancelled_by_room_cleanup_is_reported_once( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """With no settle grace, cleanup cancels the parked turn at once.""" + room = await codex_room(*parked_on_approval(), turn_settle_timeout_s=0) + + await room.send("run tests") + await room.adapter.on_cleanup(ROOM_ID) + + [turn] = room.deliveries + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] diff --git a/tests/adapters/codex/test_diff_byte_cap.py b/tests/adapters/codex/test_diff_byte_cap.py new file mode 100644 index 000000000..eeb83f76a --- /dev/null +++ b/tests/adapters/codex/test_diff_byte_cap.py @@ -0,0 +1,65 @@ +"""Codex adapter: diff byte cap.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + _MAX_DIFF_METADATA_BYTES, + CodexAdapterConfig, +) +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestDiffByteCap: + """``turn/diff/updated`` metadata is bounded in UTF-8 bytes, not chars.""" + + @pytest.mark.asyncio + async def test_multibyte_diff_respects_byte_budget(self) -> None: + """A diff built from 4-byte codepoints is capped to the byte budget, + not the character budget (which would be ~4× larger on the wire).""" + + # Each emoji is 4 UTF-8 bytes; use ~1.5× the byte budget worth. + emoji = "\U0001f600" + diff_chars = (_MAX_DIFF_METADATA_BYTES // 4) + 5000 + big_diff = emoji * diff_chars + assert len(big_diff.encode("utf-8")) > _MAX_DIFF_METADATA_BYTES + + events = [ + event_notification( + "turn/diff/updated", + {"diff": big_diff, "files": ["src/app.py"]}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_diff_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + diff_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_diff" + ] + assert len(diff_events) == 1 + meta = diff_events[0]["metadata"] + emitted = meta["codex_diff"] + # The emitted diff (including the truncation marker) stays within a + # small overhead of the byte budget — nowhere near 4× it. + assert len(emitted.encode("utf-8")) <= _MAX_DIFF_METADATA_BYTES + 256 + assert meta["codex_diff_truncated"] is True + assert meta["codex_diff_original_bytes"] > _MAX_DIFF_METADATA_BYTES diff --git a/tests/adapters/codex/test_diffs_and_token_usage.py b/tests/adapters/codex/test_diffs_and_token_usage.py new file mode 100644 index 000000000..0a69ed71e --- /dev/null +++ b/tests/adapters/codex/test_diffs_and_token_usage.py @@ -0,0 +1,199 @@ +"""Codex adapter: diffs and token usage.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexSessionState +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + make_platform_message, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) +from tests.adapters.dispatch import deliver_message + + +class TestDiffsAndTokenUsage: + @pytest.mark.asyncio + async def test_diff_event_forwarded(self) -> None: + """turn/diff/updated forwards as a task event when enabled.""" + events = [ + event_notification( + "turn/diff/updated", + { + "diff": "--- a/src/app.py\n+++ b/src/app.py\n@@ ...", + "files": ["src/app.py"], + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_diff_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + diff_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_diff" + ] + assert len(diff_events) == 1 + assert diff_events[0]["message_type"] == "task" + assert diff_events[0]["metadata"]["codex_files_changed"] == ["src/app.py"] + assert "src/app.py" in diff_events[0]["metadata"]["codex_diff"] + assert "1 files changed" in diff_events[0]["content"] + + @pytest.mark.asyncio + async def test_diff_event_requires_task_events_emit(self) -> None: + """Diffs are not forwarded when TASK_EVENTS is not in features.emit.""" + events = [ + event_notification( + "turn/diff/updated", + {"diff": "some diff", "files": ["f.py"]}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_diff_events=True), emit=() + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + diff_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_diff" + ] + assert diff_events == [] + + @pytest.mark.asyncio + async def test_token_usage_tracked_and_emitted(self) -> None: + """thread/tokenUsage/updated events are tracked and emitted.""" + events = [ + event_notification( + "thread/tokenUsage/updated", + { + "usage": { + "inputTokens": 15000, + "outputTokens": 3200, + "reasoningTokens": 8000, + "totalTokens": 26200, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_token_usage_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + usage_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "token_usage" + ] + assert len(usage_events) == 1 + assert usage_events[0]["metadata"]["codex_input_tokens"] == 15000 + assert usage_events[0]["metadata"]["codex_output_tokens"] == 3200 + assert usage_events[0]["metadata"]["codex_total_tokens"] == 26200 + + @pytest.mark.asyncio + async def test_token_usage_ignored_when_disabled(self) -> None: + """Token usage events are tracked internally but not emitted when disabled.""" + events = [ + event_notification( + "thread/tokenUsage/updated", + { + "usage": { + "inputTokens": 1000, + "outputTokens": 500, + "totalTokens": 1500, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_token_usage_events=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + usage_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "token_usage" + ] + assert usage_events == [] + + # But internal tracking still works + thread_id = adapter._room_threads.get(ROOM_ID) + assert thread_id is not None + usage = adapter._token_usage.get(thread_id) + assert usage is not None + assert usage.input_tokens == 1000 + + @pytest.mark.asyncio + async def test_usage_command_shows_token_usage(self) -> None: + """/usage command shows accumulated token usage.""" + events = [ + event_notification( + "thread/tokenUsage/updated", + { + "usage": { + "inputTokens": 5000, + "outputTokens": 1000, + "reasoningTokens": 2000, + "totalTokens": 8000, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # Now run /usage + await deliver_message( + adapter, + make_platform_message(content="/usage"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + usage_msgs = [ + m for m in tools.messages_sent if "token usage" in m["content"].lower() + ] + assert len(usage_msgs) >= 1 + assert "8,000" in usage_msgs[0]["content"] diff --git a/tests/adapters/codex/test_double_emit_startup_warning.py b/tests/adapters/codex/test_double_emit_startup_warning.py new file mode 100644 index 000000000..ef25f0696 --- /dev/null +++ b/tests/adapters/codex/test_double_emit_startup_warning.py @@ -0,0 +1,51 @@ +"""Codex adapter: double emit startup warning.""" + +from __future__ import annotations + +import logging + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, +) + + +class TestDoubleEmitStartupWarning: + """Enabling both turn-task channels warns operators once at startup.""" + + @pytest.mark.asyncio + async def test_warns_when_both_channels_enabled( + self, caplog: pytest.LogCaptureFixture + ) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + emit_turn_task_markers=True, emit_turn_lifecycle_events=True + ), + ) + with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): + await adapter.on_started("Agent", "A coding agent") + assert any( + "two task events per turn" in record.message for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_no_warning_when_only_one_channel_enabled( + self, caplog: pytest.LogCaptureFixture + ) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + emit_turn_task_markers=True, emit_turn_lifecycle_events=False + ), + ) + with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): + await adapter.on_started("Agent", "A coding agent") + assert not any( + "two task events per turn" in record.message for record in caplog.records + ) diff --git a/tests/adapters/codex/test_enriched_approvals.py b/tests/adapters/codex/test_enriched_approvals.py new file mode 100644 index 000000000..4d4d660e4 --- /dev/null +++ b/tests/adapters/codex/test_enriched_approvals.py @@ -0,0 +1,159 @@ +"""Codex adapter: enriched approvals.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ( + ROOM_ID, + CodexRoom, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestEnrichedApprovals: + async def test_approve_session_accepts_for_the_session_and_remembers_it( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + event_request( + 10, CodexApprovalMethod.COMMAND_EXECUTION, {"command": "npm test"} + ), + turn_completed(), + ) + + await room.send("run tests") + await room.send(f"/approve-session {room.approval_token}") + await room.settled() + + assert room.client.responses == [(10, {"decision": "acceptForSession"})] + assert "commandExecution:npm test" in room.adapter._session_approved[ROOM_ID] + assert any("session-level" in message for message in room.chat) + + @pytest.mark.asyncio + async def test_approval_audit_trail_emitted(self) -> None: + """Approval decisions emit audit trail task events.""" + events = [ + event_request( + 7, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "rm -rf tmp"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + audit_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "approval_resolution" + ] + assert len(audit_events) == 1 + assert audit_events[0]["metadata"]["codex_approval_decision"] == "decline" + assert audit_events[0]["metadata"]["codex_decided_by"] == "policy:auto_decline" + + @pytest.mark.asyncio + async def test_sandbox_command_changes_mode(self) -> None: + """The /sandbox command sets a per-room override, not mutating global config.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/sandbox read-only", tools=tools) + + # Per-room override is set, global config is unchanged + assert adapter._sandbox_overrides.get(ROOM_ID) == "read-only" + assert adapter.config.sandbox is None + assert "read-only" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_sandbox_command_is_per_room(self) -> None: + """Sandbox override in one room does not affect other rooms.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + await adapter.on_started("Agent", "A coding agent") + + adapter._sandbox_overrides[ROOM_ID] = "read-only" + + assert adapter._effective_sandbox(ROOM_ID) == "read-only" + assert adapter._effective_sandbox("room-2") is None + + @pytest.mark.asyncio + async def test_sandbox_danger_full_access_requires_confirm_flag(self) -> None: + """Escalating to danger-full-access without --confirm shows a prompt.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap( + adapter, content="/sandbox danger-full-access", tools=tools + ) + + # Override should NOT be set — confirmation was required + assert ROOM_ID not in adapter._sandbox_overrides + assert "--confirm" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_sandbox_escalation_to_danger_full_access_logs_warning( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Escalating to danger-full-access with --confirm logs a warning.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): + await send_bootstrap( + adapter, content="/sandbox danger-full-access --confirm", tools=tools + ) + + assert adapter._sandbox_overrides.get(ROOM_ID) == "danger-full-access" + assert any( + "Sandbox escalated to danger-full-access" in record.message + for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_sandbox_command_rejects_invalid_mode(self) -> None: + """The /sandbox command rejects invalid modes.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/sandbox invalid-mode", tools=tools) + + assert adapter.config.sandbox is None + assert ROOM_ID not in adapter._sandbox_overrides + assert "Invalid sandbox mode" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_permissions_command_shows_state(self) -> None: + """/permissions shows current effective permissions.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/permissions", tools=tools) + + assert "Effective permissions:" in tools.messages_sent[0]["content"] + assert "approval_mode: manual" in tools.messages_sent[0]["content"] diff --git a/tests/adapters/codex/test_execution_reporting.py b/tests/adapters/codex/test_execution_reporting.py new file mode 100644 index 000000000..51d6ba3c6 --- /dev/null +++ b/tests/adapters/codex/test_execution_reporting.py @@ -0,0 +1,216 @@ +"""Codex adapter: tool calls and results are reported as Band execution events.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import BaseModel + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.core.types import Emit +from band.runtime.custom_tools import CustomToolDef +from band.testing import ( + events_of_type, +) +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + tool_call_request, + turn_completed, +) + + +class TestExecutionReporting: + @pytest.mark.asyncio + async def test_execution_reporting_emits_tool_call_and_result_events(self) -> None: + """With emit=Emit.TOOL_CALLS, tool_call and tool_result events are emitted.""" + events = [ + event_request( + 50, + "item/tool/call", + { + "tool": "band_lookup_peers", + "arguments": {"page": 1}, + "callId": "call-50", + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "band_lookup_peers" + assert call_data["tool_call_id"] == "call-50" + + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["name"] == "band_lookup_peers" + assert result_data["tool_call_id"] == "call-50" + + @pytest.mark.asyncio + async def test_send_room_file_tool_call_event_redacts_content(self) -> None: + """band_send_room_file's raw content must never reach a tool_call + event -- report has no idea content can carry real file bytes.""" + raw_content = "raw file bytes that must never reach a tool_call event" + events = [ + tool_call_request( + 50, "band_send_room_file", {"content": raw_content, "filename": "f.txt"} + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + call_data = json.loads(events_of_type(tools, "tool_call")[0]["content"]) + assert ( + call_data["args"]["content"] + == f"<{len(raw_content.encode('utf-8'))} byte file content>" + ) + assert raw_content not in json.dumps(call_data) + + @pytest.mark.asyncio + async def test_execution_reporting_silenced_with_explicit_empty_emit(self) -> None: + """emit=() silences tool_call/tool_result events (emit otherwise defaults on).""" + events = [ + event_request( + 50, + "item/tool/call", + { + "tool": "band_lookup_peers", + "arguments": {"page": 1}, + "callId": "call-50", + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(), emit=()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_events = [ + e + for e in tools.events_sent + if e["message_type"] in {"tool_call", "tool_result"} + ] + assert tool_events == [] + + @pytest.mark.asyncio + async def test_execution_reporting_on_tool_error(self) -> None: + """Execution reporting emits tool_result with error text on failure.""" + + class FailInput(BaseModel): + """A tool that always fails.""" + + x: int + + async def fail_func(inp: FailInput) -> str: + raise RuntimeError("boom") + + custom_tools: list[CustomToolDef] = [(FailInput, fail_func)] + events = [ + event_request( + 60, + "item/tool/call", + { + "tool": "fail", + "arguments": {"x": 1}, + "callId": "call-60", + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig(), + additional_tools=custom_tools, + emit=Emit.TOOL_CALLS, + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_result_events) == 1 + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["name"] == "fail" + assert "boom" in result_data["output"] + assert result_data["tool_call_id"] == "call-60" + + # Codex response should indicate failure + _, payload = fake_client.responses[0] + assert payload["success"] is False + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tool_name", + ["band_send_event", "band_send_message"], + ) + async def test_execution_reporting_emitted_for_platform_output_tools( + self, tool_name: str + ) -> None: + """Band messaging tools are reported like any other tool — no suppression.""" + events = [ + event_request( + 70, + "item/tool/call", + { + "tool": tool_name, + "arguments": {"content": "test", "message_type": "thought"}, + "callId": "call-70", + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # The tool call itself should still execute + assert len(tools.tool_calls) == 1 + assert tools.tool_calls[0]["tool_name"] == tool_name + + # And it's reported like any other tool call + reporting_events = [ + e + for e in tools.events_sent + if e["message_type"] in {"tool_call", "tool_result"} + ] + assert [e["message_type"] for e in reporting_events] == [ + "tool_call", + "tool_result", + ] diff --git a/tests/adapters/codex/test_history_injection.py b/tests/adapters/codex/test_history_injection.py new file mode 100644 index 000000000..680785c54 --- /dev/null +++ b/tests/adapters/codex/test_history_injection.py @@ -0,0 +1,614 @@ +"""Codex adapter: history injection.""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime +from typing import Any + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, + PendingApproval, +) +from band.core.defaultmodels import OPENAI_MODEL +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex.types import ( + CodexRequestMethod, + CodexSessionState, +) +from band.runtime.decisions import DecisionRegistry +from band.runtime.tools import BandTool, ToolCallOutcome +from band.testing import ( + events_of_type, + reported_failures, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + agent_input, + make_platform_message, + send_bootstrap, + start_room_process, + wire_codex_room, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + final_text, + make_codex_adapter, + tool_call_request, + turn_completed, +) +from tests.adapters.dispatch import deliver_event, deliver_message + + +class TestHistoryInjection: + @pytest.mark.asyncio + async def test_history_injected_on_resume_failure(self) -> None: + """Resume fails, fresh thread created, first turn input contains history block.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history = [ + { + "message_type": "task", + "content": "mapping event", + "metadata": {"codex_thread_id": "thr-old"}, + }, + { + "message_type": "text", + "content": "Can you refactor the auth module?", + "sender_name": "Alice", + }, + { + "message_type": "text", + "content": "Done — split into auth_handler.py and middleware.py", + "sender_name": "CodexAgent", + }, + ] + + inp = agent_input("Now add rate limiting", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + history_items = [ + item for item in turn_input if "[Conversation History]" in item["text"] + ] + assert len(history_items) == 1 + assert "[Alice]: Can you refactor the auth module?" in history_items[0]["text"] + assert ( + "[CodexAgent]: Done — split into auth_handler.py and middleware.py" + in history_items[0]["text"] + ) + # Task events should NOT appear in history context + assert "mapping event" not in history_items[0]["text"] + + @pytest.mark.asyncio + async def test_history_not_injected_on_successful_resume(self) -> None: + """Resume succeeds, no history injection.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history = [ + { + "message_type": "task", + "content": "mapping", + "metadata": { + "codex_thread_id": "thr-existing", + "codex_room_id": ROOM_ID, + }, + }, + { + "message_type": "text", + "content": "Hello", + "sender_name": "Alice", + }, + ] + + inp = agent_input("Continue", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + assert not any("[Conversation History]" in item["text"] for item in turn_input) + + @pytest.mark.asyncio + async def test_history_not_injected_when_disabled(self) -> None: + """inject_history_on_resume_failure=False, no injection even on failure.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), + ) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig(inject_history_on_resume_failure=False), + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history = [ + { + "message_type": "text", + "content": "Hello", + "sender_name": "Alice", + }, + ] + + inp = agent_input("Continue", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + assert not any("[Conversation History]" in item["text"] for item in turn_input) + + @pytest.mark.asyncio + async def test_history_filters_non_text_messages(self) -> None: + """Only canonical text messages appear in injected context.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Not found"), + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history = [ + { + "message_type": "task", + "content": "task event", + "sender_name": "System", + "metadata": {"codex_thread_id": "thr-old", "codex_room_id": ROOM_ID}, + }, + { + "message_type": "tool_call", + "content": '{"name": "foo"}', + "sender_name": "Agent", + }, + { + "message_type": "tool_result", + "content": "result", + "sender_name": "Agent", + }, + { + "message_type": "thought", + "content": "thinking...", + "sender_name": "Agent", + }, + {"message_type": "error", "content": "oops", "sender_name": "Agent"}, + { + "message_type": "text", + "content": "Hello world", + "sender_name": "Alice", + }, + { + "message_type": "message", + "content": "Hi there", + "sender_name": "Bob", + }, + ] + + inp = agent_input("Go", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + history_items = [ + item for item in turn_input if "[Conversation History]" in item["text"] + ] + assert len(history_items) == 1 + text = history_items[0]["text"] + assert "[Alice]: Hello world" in text + # "message" is not a MessageType value; nothing on the platform + # produces it, so it does not survive replay. + assert "[Bob]: Hi there" not in text + assert "task event" not in text + assert "thinking..." not in text + assert "oops" not in text + assert "tool_call" not in text + + @pytest.mark.asyncio + async def test_history_respects_max_messages(self) -> None: + """Only last max_history_messages are injected.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Not found"), + ) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(max_history_messages=3) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history: list[dict[str, Any]] = [ + { + "message_type": "task", + "content": "mapping", + "metadata": {"codex_thread_id": "thr-old", "codex_room_id": ROOM_ID}, + }, + ] + raw_history.extend( + { + "message_type": "text", + "content": f"Message {i}", + "sender_name": "Alice", + } + for i in range(10) + ) + + inp = agent_input("Go", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + history_items = [ + item for item in turn_input if "[Conversation History]" in item["text"] + ] + assert len(history_items) == 1 + text = history_items[0]["text"] + # Only last 3 messages should be present + assert "Message 7" in text + assert "Message 8" in text + assert "Message 9" in text + assert "Message 0" not in text + assert "Message 6" not in text + + @pytest.mark.asyncio + async def test_history_cleared_after_injection(self) -> None: + """Raw history removed from memory after first turn.""" + events = [ + tool_call_request(1, BandTool.NO_REPLY), + final_text("Done."), + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Not found"), + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + raw_history = [ + { + "message_type": "text", + "content": "Hello", + "sender_name": "Alice", + }, + ] + + inp = agent_input("Go", tools, raw_history=raw_history) + await deliver_event(adapter, inp) + + # After injection, stashed data should be cleaned up + assert ROOM_ID not in adapter._raw_history_by_room + assert ROOM_ID not in adapter._needs_history_injection + + @pytest.mark.asyncio + async def test_auto_selected_model_error_propagates_without_retry(self) -> None: + """Auto-selected model errors propagate instead of trying another model.""" + fake_client = FakeCodexClient( + turn_start_error=CodexJsonRpcError( + code=-32000, + message="Model gpt-5.5 is not available for this account", + ), + turn_start_error_once=False, + model_list_result={ + "data": [ + {"id": "gpt-5.5", "hidden": False}, + {"id": "gpt-6-luna", "hidden": False}, + ] + }, + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "An agent") + + msg = make_platform_message(room_id=ROOM_ID, content="hello") + with pytest.raises(CodexJsonRpcError, match="not available"): + await deliver_message( + adapter, + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + assert fake_client.request_methods.count(CodexRequestMethod.TURN_START) == 1 + assert adapter._selected_model == "gpt-5.5" + + @pytest.mark.asyncio + async def test_model_selection_uses_first_visible_model(self) -> None: + """Auto-selection uses Codex's first visible model without fallback ordering.""" + fake_client = FakeCodexClient( + model_list_result={ + "data": [ + {"id": "gpt-5.6-sol", "hidden": False}, + {"id": OPENAI_MODEL, "hidden": False}, + ] + }, + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) + await adapter.on_started("Agent", "An agent") + await start_room_process(adapter) + + assert adapter._room_clients[ROOM_ID].selected_model == "gpt-5.6-sol" + + @pytest.mark.asyncio + async def test_explicit_model_error_propagates_without_fallback(self) -> None: + """When the user explicitly set a model, errors propagate — no silent fallback.""" + fake_client = FakeCodexClient( + turn_start_error=CodexJsonRpcError( + code=-32000, + message="Model unavailable-test-model is not available", + ), + turn_start_error_once=False, + model_list_result={ + "data": [ + {"id": "gpt-5.5", "hidden": False}, + {"id": "gpt-6-luna", "hidden": False}, + ] + }, + ) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(model="unavailable-test-model") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "An agent") + + msg = make_platform_message(room_id=ROOM_ID, content="hello") + with pytest.raises(CodexJsonRpcError, match="not available"): + await deliver_message( + adapter, + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + assert CodexRequestMethod.MODEL_LIST not in fake_client.request_methods + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert "not available" in failures[0]["message"] + + @pytest.mark.asyncio + async def test_model_selection_uses_default_when_model_list_empty(self) -> None: + """Auto-selection uses the adapter default when Codex returns no visible models.""" + fake_client = FakeCodexClient(model_list_result={"data": []}) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) + await adapter.on_started("Agent", "An agent") + await start_room_process(adapter) + + assert adapter._room_clients[ROOM_ID].selected_model == OPENAI_MODEL + + @pytest.mark.asyncio + async def test_model_selection_uses_default_when_model_list_fails(self) -> None: + """Auto-selection uses the adapter default if model discovery fails.""" + fake_client = FakeCodexClient( + model_list_error=RuntimeError("model/list unavailable") + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) + await adapter.on_started("Agent", "An agent") + await start_room_process(adapter) + + assert adapter._room_clients[ROOM_ID].selected_model == OPENAI_MODEL + + @pytest.mark.asyncio + async def test_non_model_error_propagates(self) -> None: + """Non-model-related errors propagate from turn startup.""" + fake_client = FakeCodexClient( + turn_start_error=CodexJsonRpcError( + code=-32001, message="Server overloaded" + ), + turn_start_error_once=False, + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "An agent") + + msg = make_platform_message(room_id=ROOM_ID, content="hello") + with pytest.raises(CodexJsonRpcError, match="overloaded"): + await deliver_message( + adapter, + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + @pytest.mark.asyncio + async def test_startup_config_logged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Startup emits a redacted config summary log line.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + model="gpt-5.5", sandbox="workspace-write", approval_mode="manual" + ), + ) + + with caplog.at_level("INFO", logger="band.adapters.codex"): + await adapter.on_started("TestBot", "A test agent") + + startup_logs = [ + r for r in caplog.records if "Codex adapter started" in r.message + ] + assert len(startup_logs) == 1 + log_msg = startup_logs[0].message + assert "agent=TestBot" in log_msg + assert "transport=stdio" in log_msg + assert "model=gpt-5.5" in log_msg + assert "sandbox=workspace-write" in log_msg + assert "approval_mode=manual" in log_msg + + @pytest.mark.asyncio + async def test_codex_error_emits_event_unconditionally(self) -> None: + """Non-retryable Codex errors always emit a structured error event.""" + fake_client = FakeCodexClient( + events=[ + event_notification( + "error", + {"error": {"message": "Something went wrong"}, "willRetry": False}, + ), + event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "failed"}}, + ), + ], + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "An agent") + + msg = make_platform_message(room_id=ROOM_ID, content="do something") + with pytest.raises(TurnResultAlreadyReported): + await deliver_message( + adapter, + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + error_events = events_of_type(tools, "error") + assert len(error_events) == 1 + assert "Something went wrong" in error_events[0]["content"] + + @pytest.mark.asyncio + async def test_cleanup_before_start(self) -> None: + """Calling on_cleanup on a freshly constructed adapter should not raise.""" + adapter = make_codex_adapter( + FakeCodexClient(), + config=CodexAdapterConfig(), + ) + # No on_started called — cleanup should be safe (idempotent) + await adapter.on_cleanup("room-x") + + @pytest.mark.asyncio + async def test_cleanup_clears_pending_approvals(self) -> None: + """on_cleanup should evict all pending approvals for the given room.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + await adapter.on_started("Bot", "desc") + + # Manually inject a pending approval for room-1 + loop = asyncio.get_running_loop() + fut: asyncio.Future[str] = loop.create_future() + registry: DecisionRegistry[PendingApproval] = DecisionRegistry() + registry.register_keyed( + PendingApproval( + request_id=1, + method="item/tool/call", + summary="test", + created_at=datetime.now(UTC), + future=fut, + ), + key="tok-1", + ) + adapter._pending_approvals[ROOM_ID] = registry + wire_codex_room(adapter, fake_client, ROOM_ID) + adapter._room_threads[ROOM_ID] = "thr-1" + + await adapter.on_cleanup(ROOM_ID) + + assert adapter._open_approval_count(ROOM_ID) == 0 + # The future should have been resolved (declined) + assert fut.done() + + @pytest.mark.asyncio + async def test_tool_call_validation_error_returns_friendly_message(self) -> None: + """A base-tool arg-validation failure returns a user-friendly error. + + AgentTools catches base-tool validation INSIDE execute_tool_call_structured and + returns ok=False with a friendly message (it does not raise), so the adapter + surfaces it via the ok=False path. + """ + + class ValidationErrorTools(ToolSchemaFakeTools): + async def execute_tool_call_structured( + self, tool_name: str, arguments: dict[str, Any] + ) -> ToolCallOutcome: + self.tool_calls.append({"tool_name": tool_name, "arguments": arguments}) + return ToolCallOutcome( + value="Invalid arguments for band_send_message: content: Field required", + ok=False, + error_message="content: Field required", + ) + + events = [ + tool_call_request(99, BandTool.SEND_MESSAGE), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ValidationErrorTools() + + await adapter.on_started("Bot", "desc") + await send_bootstrap(adapter, tools=tools) + + # The adapter should have responded to the tool call with success=False + error_responses = [ + (rid, payload) + for rid, payload in fake_client.responses + if payload.get("success") is False + ] + assert len(error_responses) == 1 + error_text = error_responses[0][1]["contentItems"][0]["text"] + assert "Invalid arguments for band_send_message" in error_text diff --git a/tests/adapters/codex/test_interrupted_turn_outcome.py b/tests/adapters/codex/test_interrupted_turn_outcome.py new file mode 100644 index 000000000..9316db17f --- /dev/null +++ b/tests/adapters/codex/test_interrupted_turn_outcome.py @@ -0,0 +1,20 @@ +"""Codex adapter: interrupted turn outcome.""" + +from __future__ import annotations + +import pytest + +from tests.adapters.codex.kit import run_codex_turn +from tests.adapters.codexturns import turn_completed + + +class TestInterruptedTurnOutcome: + """An interrupted turn is settled by the notice that reports it.""" + + @pytest.mark.asyncio + async def test_an_interrupted_turn_is_settled_by_its_notice(self) -> None: + """The notice answers the room, so the turn is not also a missing reply.""" + turn = await run_codex_turn(events=[turn_completed(status="interrupted")]) + + assert turn.tools.chat == ["I stopped before completing this request."] + assert turn.tools.turn.complete diff --git a/tests/adapters/codex/test_item_completed_forwarding.py b/tests/adapters/codex/test_item_completed_forwarding.py new file mode 100644 index 000000000..6068776c1 --- /dev/null +++ b/tests/adapters/codex/test_item_completed_forwarding.py @@ -0,0 +1,804 @@ +"""Codex adapter: item completed forwarding.""" + +from __future__ import annotations + +import json + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.core.types import Emit +from band.integrations.codex.types import CodexItemType +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + agent_input, + run_codex_turn, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + tool_call_request, + turn_completed, +) +from tests.adapters.dispatch import deliver_event + + +class TestItemCompletedForwarding: + """Tests for forwarding internal Codex operations as platform events.""" + + @pytest.mark.asyncio + async def test_item_completed_mcpToolCall_send_room_file_redacts_content( + self, + ) -> None: + """band_send_room_file routed through Codex's own mcpToolCall item + (a separate reporting path from item/tool/call, keyed by the bare + "tool" field before it's wrapped in the "mcp:{server}/{tool}" + display name) must also redact raw file content before it reaches a + tool_call event.""" + raw_content = "raw file bytes that must never reach a tool_call event" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "mcpToolCall", + "id": "mcp-1", + "server": "band", + "tool": "band_send_room_file", + "arguments": {"content": raw_content, "filename": "f.txt"}, + "result": {"status": "success"}, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + call_data = json.loads(events_of_type(tools, "tool_call")[0]["content"]) + assert call_data["name"] == "mcp:band/band_send_room_file" + assert ( + call_data["args"]["content"] + == f"<{len(raw_content.encode('utf-8'))} byte file content>" + ) + assert raw_content not in json.dumps(call_data) + + @pytest.mark.asyncio + async def test_item_completed_commandExecution_emits_tool_events(self) -> None: + """commandExecution item emits tool_call + tool_result with command/output.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "ls -la", + "cwd": "/workspace", + "aggregated_output": "total 42\ndrwxr-xr-x ...", + "exitCode": 0, + "status": "completed", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "exec" + assert call_data["args"]["command"] == "ls -la" + assert call_data["args"]["cwd"] == "/workspace" + assert call_data["tool_call_id"] == "cmd-1" + + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["name"] == "exec" + assert "total 42" in result_data["output"] + assert "exit_code=0" in result_data["output"] + assert result_data["tool_call_id"] == "cmd-1" + + @pytest.mark.asyncio + async def test_item_completed_fileChange_emits_tool_events(self) -> None: + """fileChange emits tool_call + tool_result with file paths.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "fileChange", + "id": "fc-1", + "changes": [ + {"path": "src/main.py"}, + {"path": "src/utils.py"}, + ], + "status": "applied", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "file_edit" + assert call_data["args"]["files"] == ["src/main.py", "src/utils.py"] + + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["output"] == "applied" + + @pytest.mark.asyncio + async def test_item_completed_fileChange_missing_changes_is_safe(self) -> None: + """fileChange without changes list should not crash and emits empty files.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "fileChange", + "id": "fc-2", + "changes": None, + "status": "applied", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + assert len(tool_call_events) == 1 + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "file_edit" + assert call_data["args"]["files"] == [] + + @pytest.mark.asyncio + async def test_item_completed_imageView_emits_tool_events(self) -> None: + """imageView emits tool_call + tool_result.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "imageView", + "id": "img-1", + "path": "/tmp/screenshot.png", + "status": "viewed", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "view_image" + assert call_data["args"]["path"] == "/tmp/screenshot.png" + + @pytest.mark.asyncio + async def test_item_completed_collabAgentToolCall_emits_tool_events(self) -> None: + """collabAgentToolCall emits tool_call + tool_result preserving empty result.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "collabAgentToolCall", + "id": "collab-1", + "tool": "delegate", + "prompt": "Review the changes", + "agents": ["Reviewer-1", "Reviewer-2"], + "result": {}, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "collab:delegate" + assert call_data["args"]["prompt"] == "Review the changes" + assert call_data["args"]["agents"] == ["Reviewer-1", "Reviewer-2"] + + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["output"] == "{}" + + @pytest.mark.asyncio + async def test_item_completed_collabAgentToolCall_non_text_list_result_preserves_data( + self, + ) -> None: + """A non-text list result is dumped as JSON, not collapsed to "completed".""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "collabAgentToolCall", + "id": "collab-2", + "tool": "delegate", + "result": [1, 2, 3], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_result_events) == 1 + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["output"] == "[1, 2, 3]" + + @pytest.mark.asyncio + async def test_item_completed_mcpToolCall_emits_tool_events(self) -> None: + """mcpToolCall emits tool_call + tool_result with server/tool name.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "mcpToolCall", + "id": "mcp-1", + "server": "filesystem", + "tool": "read_file", + "arguments": {"path": "/etc/hosts"}, + "result": {"content": "127.0.0.1 localhost"}, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_call_events) == 1 + assert len(tool_result_events) == 1 + + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "mcp:filesystem/read_file" + assert call_data["args"]["path"] == "/etc/hosts" + + result_data = json.loads(tool_result_events[0]["content"]) + assert "127.0.0.1 localhost" in result_data["output"] + + @pytest.mark.asyncio + async def test_item_completed_mcpToolCall_non_text_list_result_preserves_data( + self, + ) -> None: + """A non-text list result (e.g. an MCP image content block) is dumped as + JSON, not collapsed to the generic "completed" status. + + Unlike thought extraction, a tool-call result is + real data even when it isn't textual — ``_stringify_tool_output`` must + use its ``raw_fallback`` mode here so nothing is silently discarded. + """ + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "mcpToolCall", + "id": "mcp-2", + "server": "filesystem", + "tool": "read_image", + "arguments": {}, + "result": [ + {"type": "image", "data": "abc123", "mimeType": "image/png"} + ], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_result_events) == 1 + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["output"] != "completed" + assert "image/png" in result_data["output"] + + @pytest.mark.asyncio + async def test_a_dynamic_tool_call_is_reported_once(self) -> None: + """Codex completes the item it already requested via item/tool/call.""" + events = [ + tool_call_request(42, "band_lookup_peers"), + event_notification( + "item/completed", + { + "item": { + "type": "dynamicToolCall", + "tool": "band_lookup_peers", + "arguments": {}, + "status": "completed", + } + }, + ), + turn_completed(), + ] + + turn = await run_codex_turn( + events=events, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + + reported = [event["message_type"] for event in turn.tools.events_sent] + assert reported == ["tool_call", "tool_result"] + + @pytest.mark.asyncio + async def test_item_completed_reasoning_emits_thought(self) -> None: + """reasoning item emits thought event when emit=Emit.THOUGHTS.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-1", + "summary": [ + "Analyzing the codebase structure", + "Identified key files to modify", + ], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.THOUGHTS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thought_events = events_of_type(tools, "thought") + assert len(thought_events) == 1 + assert "Analyzing the codebase structure" in thought_events[0]["content"] + assert "Identified key files to modify" in thought_events[0]["content"] + + @pytest.mark.asyncio + async def test_item_completed_dict_summary_text_emits_thought(self) -> None: + """Reasoning summary entries shaped as {text: ...} use stringify SSOT.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-dict", + "summary": [ + {"type": "summary_text", "text": "Weighing the tradeoffs"}, + {"type": "summary_text", "text": "Choosing the safer joke"}, + ], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thought_events = events_of_type(tools, "thought") + assert len(thought_events) == 1 + assert "Weighing the tradeoffs" in thought_events[0]["content"] + assert "Choosing the safer joke" in thought_events[0]["content"] + + @pytest.mark.asyncio + async def test_item_completed_empty_reasoning_summary_skips_thought(self) -> None: + """Empty reasoning summaries must not post a '(reasoning)' placeholder.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-empty", + "summary": [], + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-blank", + "summary": ["", " "], + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-none", + "summary": None, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thought_events = events_of_type(tools, "thought") + assert thought_events == [] + + @pytest.mark.asyncio + async def test_item_completed_empty_plan_text_skips_thought(self) -> None: + """Empty plan text must not post a '(plan)' placeholder.""" + events = [ + event_notification( + "item/completed", + {"item": {"type": "plan", "id": "plan-empty", "text": ""}}, + ), + event_notification( + "item/completed", + {"item": {"type": "plan", "id": "plan-blank", "text": " "}}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thought_events = events_of_type(tools, "thought") + assert thought_events == [] + + @pytest.mark.asyncio + async def test_item_completed_skipped_when_reporting_disabled(self) -> None: + """No tool events when emit narrows to Emit.TASK_EVENTS only.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "ls", + "exitCode": 0, + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": "reasoning", + "id": "reason-1", + "summary": ["thinking"], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TASK_EVENTS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_events = [ + e + for e in tools.events_sent + if e["message_type"] in {"tool_call", "tool_result", "thought"} + ] + assert tool_events == [] + + @pytest.mark.asyncio + async def test_completed_agent_message_is_emitted_as_a_thought(self) -> None: + """Existing agentMessage behavior preserved alongside new forwarding.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "pytest", + "exitCode": 0, + "aggregated_output": "all tests passed", + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": "agentMessage", + "id": "msg-1", + "text": "All tests pass!", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # agentMessage text should still be sent as the final message + assert tools.messages_sent == [] + assert events_of_type(tools, "thought") == [] + assert not tools.turn.complete + # commandExecution should also be forwarded as tool events + tool_call_events = events_of_type(tools, "tool_call") + assert len(tool_call_events) == 1 + + @pytest.mark.asyncio + async def test_item_completed_webSearch_emits_tool_events(self) -> None: + """webSearch item emits tool_call + tool_result.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "webSearch", + "id": "ws-1", + "query": "python asyncio tutorial", + "action": {"url": "https://example.com", "title": "Tutorial"}, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + assert len(tool_call_events) == 1 + call_data = json.loads(tool_call_events[0]["content"]) + assert call_data["name"] == "web_search" + assert call_data["args"]["query"] == "python asyncio tutorial" + + @pytest.mark.asyncio + async def test_item_completed_webSearch_non_text_list_action_preserves_data( + self, + ) -> None: + """A non-text list action is dumped as JSON, not collapsed to "completed".""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "webSearch", + "id": "ws-2", + "query": "python asyncio tutorial", + "action": [{"url": "https://example.com"}], + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_result_events = events_of_type(tools, "tool_result") + assert len(tool_result_events) == 1 + result_data = json.loads(tool_result_events[0]["content"]) + assert result_data["output"] == '[{"url": "https://example.com"}]' + + @pytest.mark.asyncio + async def test_item_completed_metadata_includes_codex_ids(self) -> None: + """Forwarded events include codex_room_id, codex_thread_id, codex_turn_id.""" + events = [ + event_notification( + "item/completed", + { + "item": { + "type": "commandExecution", + "id": "cmd-1", + "command": "echo hi", + "exitCode": 0, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + tool_call_events = events_of_type(tools, "tool_call") + assert len(tool_call_events) == 1 + meta = tool_call_events[0]["metadata"] + assert meta["codex_room_id"] == ROOM_ID + assert meta["codex_thread_id"] == "thr-1" + assert meta["codex_turn_id"] == "turn-1" + + async def test_a_turn_forwards_commands_file_changes_and_reasoning(self) -> None: + tools = ToolSchemaFakeTools() + client = FakeCodexClient( + events=[ + event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.COMMAND_EXECUTION, + "id": "cmd-1", + "command": "pytest tests/", + "cwd": "/workspace", + "aggregated_output": "5 passed", + "exitCode": 0, + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.FILE_CHANGE, + "id": "fc-1", + "changes": [{"path": "src/app.py"}], + "status": "applied", + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.REASONING, + "id": "reason-1", + "summary": ["Tests pass, applying fix"], + } + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": CodexItemType.AGENT_MESSAGE, + "id": "msg-1", + "text": "Fixed the bug and all tests pass.", + } + }, + ), + turn_completed(), + ] + ) + adapter = make_codex_adapter(client, emit=Emit.TOOL_CALLS | Emit.THOUGHTS) + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await deliver_event(adapter, agent_input("fix the failing test", tools)) + + calls = [ + json.loads(event["content"]) for event in events_of_type(tools, "tool_call") + ] + assert [(call["name"], call["args"].get("command")) for call in calls] == [ + ("exec", "pytest tests/"), + ("file_edit", None), + ] + assert len(events_of_type(tools, "tool_result")) == 2 + thoughts = [event["content"] for event in events_of_type(tools, "thought")] + assert len(thoughts) == 2 + assert "Tests pass" in thoughts[0] + # The final agentMessage is a thought, never a posted reply. + assert tools.messages_sent == [] diff --git a/tests/adapters/codex/test_leave_grace.py b/tests/adapters/codex/test_leave_grace.py new file mode 100644 index 000000000..8dd5deed1 --- /dev/null +++ b/tests/adapters/codex/test_leave_grace.py @@ -0,0 +1,46 @@ +"""A leaving room's or shutting-down adapter's turn gets its settle grace +with admission closed: what it still asks is declined instead of parking on +a human, and the turn is judged at its end.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import CodexAdapter, CodexApprovalMethod +from band.testing import MISSING_REPLY_FAILURE, failure_reports +from tests.adapters.codex.kit import DECLINED, ROOM_ID, CodexRoom, parked_on_approval +from tests.adapters.codexturns import event_request, turn_completed + + +async def leave(adapter: CodexAdapter) -> None: + await adapter.on_cleanup(ROOM_ID) + + +async def shut_down(adapter: CodexAdapter) -> None: + await adapter.cleanup_all() + + +class TestLeaveGrace: + @pytest.mark.parametrize("closing", [leave, shut_down]) + async def test_an_approval_asked_while_closing_is_declined_and_the_turn_ends( + self, + codex_room: Callable[..., Awaitable[CodexRoom]], + closing: Callable[[CodexAdapter], Awaitable[None]], + ) -> None: + room = await codex_room( + *parked_on_approval( + event_request( + 11, CodexApprovalMethod.COMMAND_EXECUTION, {"command": "b"} + ), + turn_completed(), + ) + ) + await room.send("run tests") + + await closing(room.adapter) + + assert room.client.responses == [(10, DECLINED), (11, DECLINED)] + [turn] = room.deliveries + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] diff --git a/tests/adapters/codex/test_malformed_payload_tolerance.py b/tests/adapters/codex/test_malformed_payload_tolerance.py new file mode 100644 index 000000000..8b2e97119 --- /dev/null +++ b/tests/adapters/codex/test_malformed_payload_tolerance.py @@ -0,0 +1,135 @@ +"""Codex adapter: malformed payload tolerance.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.testing import reported_failures +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestMalformedPayloadTolerance: + """Adapter must survive notifications that are missing or misshapen.""" + + @pytest.mark.asyncio + async def test_error_event_with_non_dict_error_field(self) -> None: + """`error` notification where `error` is a string must not crash the turn.""" + events = [ + event_notification("error", {"error": "oops"}), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == "oops" + + @pytest.mark.asyncio + async def test_failed_turn_after_error_notification_reports_once(self) -> None: + """An `error` notification followed by a `turn/completed` with + status=failed for the same incident must report only one failure.""" + events = [ + event_notification("error", {"error": {"message": "boom"}}), + event_notification( + "turn/completed", + { + "turn": { + "id": "turn-1", + "status": "failed", + "items": [], + "error": {"message": "boom"}, + } + }, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + assert len(reported_failures(tools)) == 1 + + @pytest.mark.asyncio + async def test_failed_turn_with_falsy_scalar_error_uses_clean_fallback( + self, + ) -> None: + """A falsy, non-dict `error` (e.g. ``False``) must not become the + literal string "False" in the reported failure message.""" + events = [ + event_notification( + "turn/completed", + { + "turn": { + "id": "turn-1", + "status": "failed", + "items": [], + "error": False, + } + }, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == "Codex error: unknown" + + @pytest.mark.asyncio + async def test_turn_completed_without_items_key(self) -> None: + """turn/completed missing `items` is treated as an empty turn, not a crash.""" + events = [ + event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "completed"}}, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + + @pytest.mark.asyncio + async def test_turn_plan_updated_with_garbage_steps(self) -> None: + """Plan deltas containing non-list `steps` must be skipped, not crash.""" + events = [ + event_notification( + "turn/plan/updated", + {"plan": {"steps": "not-a-list"}}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_plan_events=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) diff --git a/tests/adapters/codex/test_manual_approval_races.py b/tests/adapters/codex/test_manual_approval_races.py new file mode 100644 index 000000000..71bf2b622 --- /dev/null +++ b/tests/adapters/codex/test_manual_approval_races.py @@ -0,0 +1,130 @@ +"""Codex adapter: manual approval races.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import ( + NO_APPROVALS_TO_RESOLVE_MESSAGE, + CodexCommand, +) +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + ROOM_ID, + CodexApprovalRoom, + requested_approval_token, + start_room_process, +) + + +class TestManualApprovalRaces: + """Races between a room reply and the approval wait's own timeout.""" + + async def test_an_old_claimed_ask_cannot_forget_a_new_ask_after_cleanup( + self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] + ) -> None: + room = await approval_room() + await start_room_process(room.adapter) + old_prompt = room.tools.hold_message("Approval requested") + old = room.ask("reused", request_id=1) + + async with old_prompt: + await room.reply(CodexCommand.APPROVE) + await room.adapter.on_cleanup(ROOM_ID) + async with room.prompted(): + new = room.ask("reused", request_id=1) + new_token = requested_approval_token(room.tools) + + assert await old == "accept" + assert not new.done() + await room.reply(CodexCommand.DECLINE, new_token) + assert await new == "decline" + + async def test_a_completed_native_request_gets_a_fresh_answer_token( + self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] + ) -> None: + room = await approval_room() + async with room.prompted(): + old = room.ask("reused", request_id=1) + old_token = requested_approval_token(room.tools) + await room.reply(CodexCommand.APPROVE, old_token) + assert await old == "accept" + async with room.prompted(): + new = room.ask("reused", request_id=1) + new_token = requested_approval_token(room.tools) + + assert await room.reply(CodexCommand.APPROVE, old_token) == ( + f"Unknown approval id `{old_token}`. Pending: {new_token}" + ) + assert not new.done() + await room.reply(CodexCommand.DECLINE, new_token) + assert await new == "decline" + + @pytest.mark.asyncio + async def test_a_reply_that_claims_while_the_prompt_send_fails_still_wins( + self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] + ) -> None: + """The approval id is visible in the task event before the prompt + send; a reply claiming it while that send fails owns the answer.""" + room = await approval_room() + failing_prompt = room.tools.hold_message( + "Approval requested", error=RuntimeError("network down") + ) + decision = room.ask("approval-xyz", request_id=1) + + async with failing_prompt: + await room.reply(CodexCommand.APPROVE) + + assert await decision == "accept" + assert events_of_type(room.tools, "error") == [] + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_a_late_reply_during_the_timeout_notice_is_not_reported_as_resolved( + self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] + ) -> None: + """A reply landing while the timeout notice is still being sent must + not be told "resolved" for a decision that already timed out.""" + room = await approval_room(approval_wait_timeout_s=300) + timeout_notice = room.tools.hold_message("timed out") + decision = room.ask("approval-xyz", request_id=1) + + async with timeout_notice: + assert await room.reply( + CodexCommand.APPROVE, requested_approval_token(room.tools) + ) == (NO_APPROVALS_TO_RESOLVE_MESSAGE) + + assert await decision == "decline" # approval_timeout_decision + + @pytest.mark.asyncio + async def test_redelivered_approvals_never_hang_evict_or_override_a_claim( + self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] + ) -> None: + """At capacity, with one approval claimed by a reply mid-resolution: + a second reply to it is told it's no longer pending; Codex re-sending + it declines at once without waiting; re-sending the open one + supersedes it (declining its asker) without evicting anything; and + both final answers stand.""" + room = await approval_room(max_pending_approvals_per_room=2) + async with room.prompted(count=2): + claimed_ask = room.ask("approval-a", request_id=1) + superseded = room.ask("approval-b", request_id=2) + token_a, token_b = room.approval_tokens + claimed = room.pending.try_claim(token_a) + assert claimed is not None + + assert await room.reply(CodexCommand.APPROVE, token_a) == ( + f"Approval `{token_a}` is no longer pending." + ) + refused = room.ask("approval-a", request_id=3) + redelivery = room.ask("approval-b", request_id=4) + + assert await refused == "decline" + assert await superseded == "decline" + assert list(room.pending) == [token_a, token_b] + await room.reply(CodexCommand.DECLINE, token_b) + claimed.payload.future.set_result("accept") + assert await redelivery == "decline" + assert await claimed_ask == "accept" diff --git a/tests/adapters/codex/test_model_after_recovery.py b/tests/adapters/codex/test_model_after_recovery.py new file mode 100644 index 000000000..1ef6980ae --- /dev/null +++ b/tests/adapters/codex/test_model_after_recovery.py @@ -0,0 +1,55 @@ +"""Codex adapter: a thread restored after a lost process keeps the room's model.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import CodexAdapter +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex.types import CodexRequestMethod +from tests.adapters.codex.kit import PROCESS_EXITED, CodexRoom, answered_turn +from tests.adapters.codexturns import FakeCodexClient + +LISTED_MODEL = "model-a" +CHOSEN_MODEL = "model-b" + + +class TestModelAfterRecovery: + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("commands", "room_model"), + [ + pytest.param((), LISTED_MODEL, id="listed-at-first-start"), + pytest.param( + (f"/model {CHOSEN_MODEL}",), CHOSEN_MODEL, id="chosen-with-model" + ), + ], + ) + async def test_a_restored_thread_keeps_the_rooms_model( + self, + started_codex: Callable[..., Awaitable[CodexAdapter]], + commands: tuple[str, ...], + room_model: str, + ) -> None: + """The model is the room's choice, not the process's: a replacement + process whose model/list leads with another model still runs the + restored thread on the model the room was using.""" + lost = FakeCodexClient( + events=[*answered_turn(), PROCESS_EXITED], + model_list_result={"data": [{"id": LISTED_MODEL, "hidden": False}]}, + ) + replacement = FakeCodexClient(events=answered_turn()) + room = CodexRoom(await started_codex(lost, replacement, model=None), lost) + + for command in commands: + await room.send(command) + await room.send("work") + with pytest.raises(TurnResultAlreadyReported): + await room.send("more work") + await room.send("work again") + + assert CodexRequestMethod.THREAD_RESUME in replacement.request_methods + (turn,) = replacement.params_of(CodexRequestMethod.TURN_START) + assert turn["model"] == room_model diff --git a/tests/adapters/codex/test_model_and_reasoning_commands.py b/tests/adapters/codex/test_model_and_reasoning_commands.py new file mode 100644 index 000000000..8cac15625 --- /dev/null +++ b/tests/adapters/codex/test_model_and_reasoning_commands.py @@ -0,0 +1,165 @@ +"""Codex adapter: /model and /reasoning choose what the next turn runs with.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex.types import ( + CodexRequestMethod, +) +from tests.adapters.codex.kit import ( + LIVE_EFFORTS_MODEL_LIST, + ROOM_ID, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + turn_completed, +) + + +class TestModelAndReasoningCommands: + @pytest.mark.asyncio + async def test_model_command_sets_override_without_starting_turn(self) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, content="/model gpt-5.5-codex", tools=tools) + + methods = fake_client.request_methods + assert CodexRequestMethod.TURN_START not in methods + assert CodexRequestMethod.THREAD_START not in methods + assert adapter._selected_model == "gpt-5.5-codex" + assert len(tools.messages_sent) == 1 + assert ( + "Model override set to `gpt-5.5-codex`" in tools.messages_sent[0]["content"] + ) + + @pytest.mark.asyncio + async def test_models_alias_lists_models_without_starting_turn(self) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, content="/models list", tools=tools) + + methods = fake_client.request_methods + assert CodexRequestMethod.TURN_START not in methods + assert CodexRequestMethod.THREAD_START not in methods + assert methods.count(CodexRequestMethod.MODEL_LIST) >= 1 + assert len(tools.messages_sent) == 1 + assert "Available models" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("effort", ["high", "max"]) + async def test_reasoning_effort_passed_in_turn_overrides(self, effort: str) -> None: + """Efforts newer than any list the SDK could hard-code reach Codex as-is.""" + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + reasoning_effort=effort, reasoning_summary="concise" + ), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="hello", tools=tools) + turn_params = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + assert turn_params["effort"] == effort + assert turn_params["summary"] == "concise" + + @pytest.mark.asyncio + async def test_reasoning_effort_omitted_when_none(self) -> None: + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="hello", tools=tools) + turn_params = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + assert "effort" not in turn_params + assert "summary" not in turn_params + + @pytest.mark.asyncio + @pytest.mark.parametrize("effort", ["high", "ultra"]) + async def test_reasoning_command_sets_effort(self, effort: str) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content=f"/reasoning {effort}", tools=tools) + room = adapter._room_clients[ROOM_ID] + assert room.reasoning_effort == effort + assert len(tools.messages_sent) == 1 + assert ( + f"Reasoning effort set to `{effort}`" in tools.messages_sent[0]["content"] + ) + + @pytest.mark.asyncio + async def test_reasoning_command_lists_efforts_the_model_supports(self) -> None: + fake_client = FakeCodexClient(model_list_result=LIVE_EFFORTS_MODEL_LIST) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(model="gpt-6-sol") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/reasoning", tools=tools) + assert len(tools.messages_sent) == 1 + assert ( + "`gpt-6-sol` supports: low, max, ultra." + in tools.messages_sent[0]["content"] + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("effort", "stored", "reply"), + [ + ("ultra", "ultra", "Reasoning effort set to `ultra`"), + ( + "hgih", + None, + ( + "`gpt-6-sol` doesn't support reasoning effort `hgih`. " + "Supported: low, max, ultra." + ), + ), + ], + ) + async def test_reasoning_command_checks_the_models_live_efforts( + self, effort: str, stored: str | None, reply: str + ) -> None: + """A typo is refused up front instead of failing every later turn.""" + fake_client = FakeCodexClient(model_list_result=LIVE_EFFORTS_MODEL_LIST) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(model="gpt-6-sol") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content=f"/reasoning {effort}", tools=tools) + assert adapter._room_clients[ROOM_ID].reasoning_effort == stored + assert len(tools.messages_sent) == 1 + assert reply in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_reasoning_command_still_answers_when_model_list_fails(self) -> None: + fake_client = FakeCodexClient( + model_list_error=RuntimeError("model/list unavailable") + ) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig(model="gpt-6-sol", reasoning_effort="high"), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/reasoning", tools=tools) + assert len(tools.messages_sent) == 1 + reply = tools.messages_sent[0]["content"] + assert "Current reasoning effort: `high`" in reply + assert "supports:" not in reply diff --git a/tests/adapters/codex/test_native_message_thoughts.py b/tests/adapters/codex/test_native_message_thoughts.py new file mode 100644 index 000000000..aaa2a0ba8 --- /dev/null +++ b/tests/adapters/codex/test_native_message_thoughts.py @@ -0,0 +1,151 @@ +"""Codex adapter: native message thoughts.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.core.types import Emit +from band.runtime.tools import BandTool +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + run_codex_turn, +) +from tests.adapters.codexturns import ( + agent_message_completed, + agent_message_delta, + agent_message_started, + tool_call_request, + turn_completed, +) + + +class TestNativeMessageThoughts: + """Native agent messages become thoughts unless a reply effect settles them.""" + + @pytest.mark.parametrize("stream", [True, False]) + @pytest.mark.parametrize("emit_thoughts", [True, False]) + @pytest.mark.parametrize("completed", ["thinking", "thinking longer", "revised"]) + async def test_completed_native_items_reconcile_streamed_thoughts( + self, + stream: bool, + emit_thoughts: bool, + completed: str, + ) -> None: + turn = await run_codex_turn( + events=[ + agent_message_started("comment", phase="commentary"), + agent_message_delta("thinking", "comment"), + agent_message_completed(completed, "comment", phase="commentary"), + agent_message_completed("final", "answer"), + turn_completed(), + ], + config=CodexAdapterConfig(stream_commentary_events=stream), + emit={Emit.THOUGHTS} if emit_thoughts else set(), + ) + expected: list[str] = [] + if emit_thoughts: + expected = [completed, "final"] + if stream: + expected = ["thinking"] + if completed != "thinking": + expected.append( + " longer" if completed == "thinking longer" else "revised" + ) + expected.append("final") + assert [e["content"] for e in events_of_type(turn.tools, "thought")] == expected + assert turn.tools.chat == [] + assert not turn.tools.turn.complete + + @pytest.mark.parametrize("effect", [BandTool.SEND_MESSAGE, BandTool.NO_REPLY]) + async def test_completed_native_text_waits_for_later_reply_effects( + self, effect: str + ) -> None: + args = ( + {"content": "tool answer", "mentions": ["@a"]} + if effect == BandTool.SEND_MESSAGE + else {} + ) + turn = await run_codex_turn( + events=[ + agent_message_completed("early closing text"), + tool_call_request(1, effect, args), + turn_completed(), + ] + ) + assert events_of_type(turn.tools, "thought") == [] + assert turn.tools.turn.replied + + @pytest.mark.parametrize("status", ["interrupted", "failed"]) + async def test_completed_native_text_is_suppressed_after_provider_failure( + self, + status: str, + ) -> None: + tools = ToolSchemaFakeTools() + if status == "failed": + with pytest.raises(TurnResultAlreadyReported): + await run_codex_turn( + tools=tools, + events=[ + agent_message_completed("not delivered"), + turn_completed(status=status), + ], + ) + else: + await run_codex_turn( + tools=tools, + events=[ + agent_message_completed("not delivered"), + turn_completed(status=status), + ], + ) + assert events_of_type(tools, "thought") == [] + + @pytest.mark.parametrize("phase", [None, "final_answer", "commentary"]) + async def test_incomplete_native_items_have_no_closing_fallback( + self, + phase: str | None, + ) -> None: + turn = await run_codex_turn( + events=[ + agent_message_started(phase=phase), + agent_message_delta("partial"), + turn_completed(), + ] + ) + assert turn.tools.chat == [] + assert events_of_type(turn.tools, "thought") == [] + assert not turn.tools.turn.complete + + async def test_failed_native_thought_delivery_does_not_settle_the_turn( + self, + ) -> None: + tools = ToolSchemaFakeTools() + tools.send_event_error = RuntimeError("telemetry unavailable") + await run_codex_turn( + tools=tools, + events=[agent_message_completed("closing narration"), turn_completed()], + ) + assert not tools.turn.complete + assert tools.chat == [] + + @pytest.mark.parametrize( + "phase, include_phase", + [(None, False), (None, True), ("final_answer", True), ("commentary", True)], + ) + async def test_completed_native_messages_without_reply_effects_remain_thoughts( + self, + phase: str | None, + include_phase: bool, + ) -> None: + completed = agent_message_completed("native", phase=phase) + if not include_phase: + completed.params["item"].pop("phase") + turn = await run_codex_turn(events=[completed, turn_completed()]) + assert [ + event["content"] for event in events_of_type(turn.tools, "thought") + ] == ["native"] + assert turn.tools.chat == [] + assert not turn.tools.turn.complete diff --git a/tests/adapters/codex/test_native_text_authority.py b/tests/adapters/codex/test_native_text_authority.py new file mode 100644 index 000000000..5f18ff3b7 --- /dev/null +++ b/tests/adapters/codex/test_native_text_authority.py @@ -0,0 +1,57 @@ +"""Codex adapter: native text authority.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.runtime.tools import BandTool +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + parked_on_approval, + run_codex_turn, +) +from tests.adapters.codexturns import ( + final_text, + tool_call_request, + turn_completed, +) + + +class TestNativeTextAuthority: + @pytest.mark.asyncio + async def test_a_tool_reply_suppresses_the_final_text(self) -> None: + turn = await run_codex_turn( + events=[ + tool_call_request( + 1, BandTool.SEND_MESSAGE, {"content": "Hi", "mentions": ["@a"]} + ), + final_text("Hi, again."), + turn_completed(), + ] + ) + + assert turn.tools.messages_sent == [] + + @pytest.mark.asyncio + async def test_a_policy_notification_never_stands_in_for_the_answer( + self, + ) -> None: + turn = await run_codex_turn( + events=[ + *parked_on_approval(final_text("Tests pass.")), + turn_completed(), + ], + config=CodexAdapterConfig( + approval_mode="auto_accept", approval_text_notifications=True + ), + ) + + assert turn.tools.chat == [ + "Approval requested (command: a). Policy decision: accept." + ] + assert not turn.tools.turn.complete + assert [e["content"] for e in events_of_type(turn.tools, "thought")] == [ + "Codex approval request handled automatically (accept).", + "Tests pass.", + ] diff --git a/tests/adapters/codex/test_native_turn_ownership.py b/tests/adapters/codex/test_native_turn_ownership.py new file mode 100644 index 000000000..e3e1bb71a --- /dev/null +++ b/tests/adapters/codex/test_native_turn_ownership.py @@ -0,0 +1,402 @@ +"""Codex adapter: native turn ownership.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from band.adapters.codex import ( + TRANSPORT_LOST_ERROR, + CodexAdapterConfig, + CodexCommand, + CodexContinuityError, +) +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex import ( + DYNAMIC_TOOL_CALL_METHOD, + TRANSPORT_CLOSED, + CodexJsonRpcError, + RpcEvent, +) +from band.integrations.codex.types import ( + CodexItemType, + CodexRequestMethod, +) +from band.runtime.tools import BandTool +from band.testing import ( + events_of_type, + reported_failures, +) +from tests.adapters.codex.kit import ( + ACCEPTED, + DECLINED, + HANG_GUARD_S, + PATIENT_APPROVAL_S, + PROCESS_EXITED, + REFUSED_AUTONOMOUS_TURN, + ROOM_ID, + CodexRoom, + CompletesWhileRefusingClient, + CountingCloseClient, + ExitsWhileAskedClient, + SlowTurnStartClient, + StopsReadingAfterTurnClient, + ToolSchemaFakeTools, + answer_to, + asked_by_autonomous_turn, + asked_by_sub_agent, + delegated_to, + parked_on_approval, + refusals_of_autonomous_turn, + residency_of, + send_bootstrap, + started_sub_agent, + thread_of, +) +from tests.adapters.codexturns import ( + FIRST_THREAD_ID, + FakeCodexClient, + event_notification, + event_request, + final_text, + make_codex_adapter, + tool_call_request, + turn_completed, +) +from tests.adapters.roompeer import NO_ROLLOUT + + +class TestNativeTurnOwnership: + """Native callbacks reach only the Band turn that started their native + turn, on the process that sent them, and a room keeps its lease through + detached decisions.""" + + @pytest.mark.asyncio + async def test_a_parked_decision_keeps_its_turn_leased_until_it_finishes( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + *parked_on_approval(turn_completed()), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + await room.send("run tests") + residency = residency_of(room.adapter, ROOM_ID) + assert residency is not None + assert (residency.snapshot.leases, residency.snapshot.accepted) == (1, 1) + + await room.send(f"/{CodexCommand.APPROVE} {room.approval_token}") + await room.settled() + assert (residency.snapshot.leases, residency.snapshot.accepted) == (0, 0) + + @pytest.mark.asyncio + async def test_native_work_no_band_turn_started_never_gets_band_tools( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + tool_call_request(1, BandTool.NO_REPLY), turn_completed() + ) + await room.send("hello") + room.client.emit(*asked_by_autonomous_turn()) + + assert await refusals_of_autonomous_turn(room.client) == REFUSED_AUTONOMOUS_TURN + assert [call["tool_name"] for call in room.deliveries[0].tool_calls] == [ + BandTool.NO_REPLY + ] + + @pytest.mark.asyncio + async def test_a_running_turn_refuses_requests_from_another_native_turn( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + *parked_on_approval(turn_completed()), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + await room.send("run tests") + room.client.emit(*asked_by_autonomous_turn()) + + assert await refusals_of_autonomous_turn(room.client) == REFUSED_AUTONOMOUS_TURN + assert room.deliveries[0].tool_calls == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "delegation", + [ + (delegated_to("thr-child"), asked_by_sub_agent(77, "thr-child")), + (asked_by_sub_agent(77, "thr-child"), delegated_to("thr-child")), + (started_sub_agent("thr-child"), asked_by_sub_agent(77, "thr-child")), + (asked_by_sub_agent(77, "thr-child"), started_sub_agent("thr-child")), + ], + ids=["named-first", "asked-first", "v2-named-first", "v2-asked-first"], + ) + async def test_a_sub_agent_the_turn_delegated_to_asks_its_room( + self, + codex_room: Callable[..., Awaitable[CodexRoom]], + delegation: tuple[RpcEvent, RpcEvent], + ) -> None: + room = await codex_room( + *delegation, + tool_call_request(1, BandTool.NO_REPLY), + turn_completed(), + approval_wait_timeout_s=PATIENT_APPROVAL_S, + ) + await room.send("delegate the cleanup") + + await room.send(f"/{CodexCommand.APPROVE} {room.approval_token}") + await room.settled() + + assert await answer_to(room.client, 77) == ACCEPTED + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "named", + [(), (delegated_to("thr-child"),)], + ids=["nothing-named", "another-named"], + ) + async def test_a_sub_agent_the_turn_never_named_is_refused_when_it_ends( + self, + codex_room: Callable[..., Awaitable[CodexRoom]], + named: tuple[RpcEvent, ...], + ) -> None: + room = await codex_room( + asked_by_sub_agent(77, "thr-elsewhere"), + *named, + tool_call_request(1, BandTool.NO_REPLY), + turn_completed(), + ) + + await room.send("work") + + assert await answer_to(room.client, 77) == DECLINED + assert room.chat == [] + + @pytest.mark.asyncio + async def test_a_late_item_is_not_charged_to_the_next_turn( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + tool_call_request(1, BandTool.NO_REPLY), + turn_completed("turn-1"), + tool_call_request(2, BandTool.NO_REPLY), + turn_completed("turn-2"), + ) + await room.send("first") + room.client.emit( + event_notification( + "item/completed", + { + "threadId": FIRST_THREAD_ID, + "turnId": "turn-1", + "item": { + "type": CodexItemType.COMMAND_EXECUTION, + "id": "late", + "command": "make", + "aggregatedOutput": "late output", + }, + }, + ) + ) + + await room.send("second") + + assert "late output" not in json.dumps(room.deliveries[-1].events_sent) + + @pytest.mark.asyncio + async def test_events_ahead_of_the_turn_start_answer_reach_their_turn( + self, + ) -> None: + client = SlowTurnStartClient( + events=[ + final_text("on time"), + event_request( + 7, + DYNAMIC_TOOL_CALL_METHOD, + {"turnId": "someone-else", "tool": BandTool.SEND_MESSAGE}, + ), + turn_completed(), + ] + ) + adapter = make_codex_adapter(client) + tools = ToolSchemaFakeTools() + await adapter.on_started("Codex Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + + assert [e["content"] for e in events_of_type(tools, "thought")] == ["on time"] + assert (await answer_to(client, 7))["success"] is False + assert tools.tool_calls == [] + + @pytest.mark.asyncio + async def test_early_events_stay_ahead_of_events_sent_while_refusing_others( + self, + ) -> None: + client = CompletesWhileRefusingClient( + events=[ + event_request( + 7, + DYNAMIC_TOOL_CALL_METHOD, + {"turnId": "someone-else", "tool": BandTool.SEND_MESSAGE}, + ), + final_text("on time"), + ] + ) + adapter = make_codex_adapter(client) + tools = ToolSchemaFakeTools() + await adapter.on_started("Codex Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + + assert [e["content"] for e in events_of_type(tools, "thought")] == ["on time"] + + @pytest.mark.asyncio + async def test_a_process_that_stops_reading_cannot_hold_a_finished_turn( + self, + ) -> None: + client = StopsReadingAfterTurnClient( + events=[turn_completed(), turn_completed("turn-2")] + ) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(turn_settle_timeout_s=0.01) + ) + await adapter.on_started("Codex Agent", "A coding agent") + + await asyncio.wait_for(send_bootstrap(adapter), timeout=HANG_GUARD_S) + await asyncio.wait_for(send_bootstrap(adapter), timeout=HANG_GUARD_S) + + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 + + @pytest.mark.asyncio + async def test_a_request_from_a_process_that_exited_is_never_put_to_the_room( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room(*parked_on_approval(PROCESS_EXITED)) + + with pytest.raises(TurnResultAlreadyReported): + await asyncio.wait_for(room.send("run tests"), timeout=HANG_GUARD_S) + + assert room.chat == [] + assert [f["message"] for f in reported_failures(room.deliveries[0])] == [ + TRANSPORT_LOST_ERROR + ] + + @pytest.mark.asyncio + async def test_requests_a_lost_process_left_waiting_are_not_answered_into_it( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room(asked_by_sub_agent(77, "thr-elsewhere"), PROCESS_EXITED) + + with pytest.raises(TurnResultAlreadyReported): + await room.send("work") + + assert room.client.responses == [] + + @pytest.mark.asyncio + async def test_a_decision_the_exited_process_cannot_read_still_reports_the_loss( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + exits = ExitsWhileAskedClient(events=list(parked_on_approval())) + room = await codex_room( + client=exits, approval_wait_timeout_s=PATIENT_APPROVAL_S + ) + await room.send("run tests") + + exits.emit(PROCESS_EXITED) + with pytest.raises(TurnResultAlreadyReported): + await asyncio.wait_for(room.settled(), timeout=HANG_GUARD_S) + + assert [f["message"] for f in reported_failures(room.deliveries[0])] == [ + TRANSPORT_LOST_ERROR + ] + + @pytest.mark.asyncio + async def test_an_answer_a_live_process_cannot_read_fails_the_turn( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + unreadable = ExitsWhileAskedClient( + events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()] + ) + room = await codex_room(client=unreadable) + + with pytest.raises(TurnResultAlreadyReported): + await room.send("hello") + + assert len(reported_failures(room.deliveries[0])) == 1 + + @pytest.mark.asyncio + async def test_a_stale_process_loss_cannot_retire_its_replacement(self) -> None: + client = CountingCloseClient( + events=[ + PROCESS_EXITED, + turn_completed("turn-2"), + turn_completed("turn-3"), + ] + ) + adapter = make_codex_adapter(client) + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter) + room = adapter._room_clients[ROOM_ID] + lost = room.generation + await send_bootstrap(adapter) + + # Releasing a process stops its reader first, so no real event can + # arrive this late; the loss is delivered as that reader would. + adapter._lose_process( + room, lost, event_notification(TRANSPORT_CLOSED, {"reason": "late"}) + ) + tools = ToolSchemaFakeTools() + await send_bootstrap(adapter, tools=tools) + + assert reported_failures(tools) == [] + # Only the lost process was replaced; its replacement kept serving. + assert (client.starts, client.closes) == (2, 1) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("restore", "error", "refusal"), + [ + ( + {"resumed_settings": {"modelProvider": "other"}}, + CodexContinuityError, + "model_provider", + ), + ( + {"resumed_thread_id": "thr-elsewhere"}, + CodexContinuityError, + "thr-elsewhere", + ), + ( + { + "resume_error": CodexJsonRpcError( + code=-32600, message=f"{NO_ROLLOUT} {FIRST_THREAD_ID}" + ) + }, + CodexJsonRpcError, + NO_ROLLOUT, + ), + ], + ids=["changed-settings", "other-thread", "no-rollout"], + ) + async def test_a_refused_restore_keeps_the_thread_and_starts_no_other( + self, restore: dict[str, Any], error: type[Exception], refusal: str + ) -> None: + client = FakeCodexClient( + events=[PROCESS_EXITED], + started_settings={"modelProvider": "openai"}, + **restore, + ) + adapter = make_codex_adapter(client) + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter) + tools = ToolSchemaFakeTools() + + with pytest.raises(error, match=refusal): + await send_bootstrap(adapter, tools=tools) + + assert thread_of(adapter, ROOM_ID) == FIRST_THREAD_ID + assert client.request_methods.count(CodexRequestMethod.THREAD_START) == 1 + assert refusal in reported_failures(tools)[0]["message"] diff --git a/tests/adapters/codex/test_network_context.py b/tests/adapters/codex/test_network_context.py new file mode 100644 index 000000000..3b7399de0 --- /dev/null +++ b/tests/adapters/codex/test_network_context.py @@ -0,0 +1,45 @@ +"""Codex adapter: network context.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import CodexRoom +from tests.adapters.codexturns import ( + event_request, + turn_completed, +) + + +class TestNetworkContext: + async def test_network_context_included_in_approval_metadata( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """networkContext from approval params is forwarded in metadata.""" + room = await codex_room( + event_request( + 10, + CodexApprovalMethod.COMMAND_EXECUTION, + { + "command": "npm install lodash", + "cwd": "/workspace", + "networkContext": {"domains": ["registry.npmjs.org"]}, + }, + ), + turn_completed(), + ) + + await room.send("install lodash") + await room.send(f"/approve {room.approval_token}") + await room.settled() + + [approval_event] = [ + e + for e in room.events_sent + if e["metadata"].get("codex_event_type") == "approval_request" + ] + assert approval_event["metadata"]["codex_network_context"] == { + "domains": ["registry.npmjs.org"] + } + assert approval_event["metadata"]["codex_command"] == "npm install lodash" diff --git a/tests/adapters/codex/test_no_reply.py b/tests/adapters/codex/test_no_reply.py new file mode 100644 index 000000000..1e0ad194f --- /dev/null +++ b/tests/adapters/codex/test_no_reply.py @@ -0,0 +1,57 @@ +"""Codex adapter: no reply.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.core.protocols import TurnResultAlreadyReported +from band.runtime.tools import BandTool +from band.testing import ( + events_of_type, + reported_failures, +) +from tests.adapters.codex.kit import ( + CodexRoom, + run_codex_turn, +) +from tests.adapters.codexturns import ( + final_text, + tool_call_request, + turn_completed, +) + + +class TestNoReply: + @pytest.mark.asyncio + async def test_no_reply_suppresses_the_closing_thought(self) -> None: + turn = await run_codex_turn( + events=[ + tool_call_request(1, BandTool.NO_REPLY, {"reason": "not for me"}), + final_text("Nothing to add."), + turn_completed(), + ] + ) + assert turn.tools.messages_sent == [] + assert reported_failures(turn.tools) == [] + + @pytest.mark.asyncio + async def test_no_reply_does_not_carry_into_the_next_turn( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + tool_call_request(1, BandTool.NO_REPLY), + turn_completed("turn-1"), + final_text("Second answer"), + turn_completed("turn-2"), + ) + + await room.send("First message") + with pytest.raises(TurnResultAlreadyReported): + await room.send("Second message") + + assert room.chat == [] + assert [ + e["content"] for e in events_of_type(room.deliveries[-1], "thought") + ] == ["Second answer"] diff --git a/tests/adapters/codex/test_per_turn_token_usage.py b/tests/adapters/codex/test_per_turn_token_usage.py new file mode 100644 index 000000000..be26e89ad --- /dev/null +++ b/tests/adapters/codex/test_per_turn_token_usage.py @@ -0,0 +1,121 @@ +"""Codex adapter: per turn token usage.""" + +from __future__ import annotations + +from band.integrations.codex.types import CodexTokenUsage + + +class TestPerTurnTokenUsage: + def test_token_usage_computes_per_turn_deltas(self) -> None: + """Per-turn deltas are computed from consecutive cumulative updates.""" + + usage = CodexTokenUsage() + + # First update: turn 1 + usage.update( + { + "usage": { + "inputTokens": 1000, + "outputTokens": 500, + "reasoningTokens": 200, + "totalTokens": 1700, + } + } + ) + assert usage.turn_input_tokens == 1000 + assert usage.turn_output_tokens == 500 + assert usage.turn_total_tokens == 1700 + + # Second update: turn 2 (cumulative increases) + usage.reset_turn_deltas() + usage.update( + { + "usage": { + "inputTokens": 2500, + "outputTokens": 900, + "reasoningTokens": 400, + "totalTokens": 3800, + } + } + ) + assert usage.turn_input_tokens == 1500 # 2500 - 1000 + assert usage.turn_output_tokens == 400 # 900 - 500 + assert usage.turn_reasoning_tokens == 200 # 400 - 200 + assert usage.turn_total_tokens == 2100 # 3800 - 1700 + + def test_token_usage_metadata_includes_turn_deltas(self) -> None: + """to_metadata() includes per-turn deltas when available.""" + + usage = CodexTokenUsage() + usage.update( + { + "usage": { + "inputTokens": 1000, + "outputTokens": 500, + "totalTokens": 1500, + } + } + ) + meta = usage.to_metadata() + assert meta["codex_turn_input_tokens"] == 1000 + assert meta["codex_turn_total_tokens"] == 1500 + + def test_token_usage_format_summary_includes_turn(self) -> None: + """format_summary() shows per-turn breakdown when deltas > 0.""" + + usage = CodexTokenUsage() + usage.update( + { + "usage": { + "inputTokens": 1000, + "outputTokens": 500, + "totalTokens": 1500, + } + } + ) + summary = usage.format_summary() + assert "turn:" in summary + assert "+1,000 in" in summary + + def test_reset_turn_deltas(self) -> None: + """reset_turn_deltas() zeroes out per-turn counters.""" + + usage = CodexTokenUsage() + usage.update( + {"usage": {"inputTokens": 1000, "outputTokens": 500, "totalTokens": 1500}} + ) + assert usage.turn_total_tokens == 1500 + usage.reset_turn_deltas() + assert usage.turn_total_tokens == 0 + assert usage.turn_input_tokens == 0 + + # Thread-level totals should be unchanged + assert usage.total_tokens == 1500 + + def test_multi_event_turn_delta_is_cumulative_from_anchor(self) -> None: + """A turn with multiple tokenUsage events reports the running turn total. + + Without the turn-start anchor, each ``update()`` would overwrite + ``turn_*`` with the per-event delta, so a turn with events at + cumulative 150 then 180 (after resetting at 100) would end the + turn reporting ``turn_input_tokens=30``. With the anchor, the + final value is ``180 - 100 = 80`` — the whole-turn rise. + """ + + usage = CodexTokenUsage() + # End of previous turn: cumulative = 100. + usage.update({"usage": {"inputTokens": 100, "outputTokens": 0}}) + # New turn starts — anchor captured at 100. + usage.reset_turn_deltas() + + # First event inside the turn. + usage.update({"usage": {"inputTokens": 150, "outputTokens": 0}}) + assert usage.turn_input_tokens == 50 # 150 - 100 + + # Second event inside the same turn — must keep growing from anchor. + usage.update({"usage": {"inputTokens": 180, "outputTokens": 0}}) + assert usage.turn_input_tokens == 80 # 180 - 100, NOT 180 - 150 + + # Third event: still anchored at 100. + usage.update({"usage": {"inputTokens": 200, "outputTokens": 0}}) + assert usage.turn_input_tokens == 100 # 200 - 100 diff --git a/tests/adapters/codex/test_plan_and_lifecycle.py b/tests/adapters/codex/test_plan_and_lifecycle.py new file mode 100644 index 000000000..19cbc530e --- /dev/null +++ b/tests/adapters/codex/test_plan_and_lifecycle.py @@ -0,0 +1,183 @@ +"""Codex adapter: plan and lifecycle.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexSessionState +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + make_platform_message, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) +from tests.adapters.dispatch import deliver_message + + +class TestPlanAndLifecycle: + @pytest.mark.asyncio + async def test_plan_steps_forwarded(self) -> None: + """turn/plan/updated forwards structured plan steps.""" + events = [ + event_notification( + "turn/plan/updated", + { + "plan": { + "steps": [ + {"text": "Read the failing test", "status": "completed"}, + {"text": "Identify root cause", "status": "inProgress"}, + {"text": "Apply fix", "status": "pending"}, + ] + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_plan_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + plan_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_plan_steps") is not None + ] + assert len(plan_events) == 1 + steps = plan_events[0]["metadata"]["codex_plan_steps"] + assert len(steps) == 3 + assert steps[0]["step"] == "Read the failing test" + assert steps[0]["status"] == "completed" + assert steps[2]["status"] == "pending" + + @pytest.mark.asyncio + async def test_plan_steps_not_forwarded_when_disabled(self) -> None: + """turn/plan/updated is ignored when stream_plan_events=False.""" + events = [ + event_notification( + "turn/plan/updated", + { + "plan": { + "steps": [ + {"text": "Step 1", "status": "pending"}, + ] + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_plan_events=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + plan_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_plan_steps") is not None + ] + assert plan_events == [] + + @pytest.mark.asyncio + async def test_turn_lifecycle_events_emitted(self) -> None: + """Enriched turn lifecycle events include duration and status.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + lifecycle_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_lifecycle" + ] + assert len(lifecycle_events) == 2 + # First event: turn started (with input summary) + assert lifecycle_events[0]["metadata"]["codex_turn_status"] == "started" + assert "codex_input_summary" in lifecycle_events[0]["metadata"] + # Second event: turn completed (with duration) + assert lifecycle_events[1]["metadata"]["codex_turn_status"] == "completed" + assert "codex_duration_s" in lifecycle_events[1]["metadata"] + + @pytest.mark.asyncio + async def test_threads_command_lists_mappings(self) -> None: + """/threads command shows room→thread mappings.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # Now run /threads + await deliver_message( + adapter, + make_platform_message(content="/threads"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + threads_msgs = [ + m + for m in tools.messages_sent + if "thread mappings" in m["content"].lower() + or "active thread" in m["content"].lower() + ] + assert len(threads_msgs) >= 1 + assert ROOM_ID in threads_msgs[0]["content"] + + @pytest.mark.asyncio + async def test_thread_archive_clears_mapping(self) -> None: + """/thread archive removes the thread mapping.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + assert ROOM_ID in adapter._room_threads + + await deliver_message( + adapter, + make_platform_message(content="/thread archive"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + assert ROOM_ID not in adapter._room_threads + assert any("archived" in m["content"].lower() for m in tools.messages_sent) diff --git a/tests/adapters/codex/test_plan_steps_robustness.py b/tests/adapters/codex/test_plan_steps_robustness.py new file mode 100644 index 000000000..b798bd4cd --- /dev/null +++ b/tests/adapters/codex/test_plan_steps_robustness.py @@ -0,0 +1,23 @@ +"""Codex adapter: plan steps robustness.""" + +from __future__ import annotations + +from band.integrations.codex.types import parse_plan_steps + + +class TestPlanStepsRobustness: + """parse_plan_steps tolerance for malformed payloads.""" + + def test_parse_plan_steps_handles_non_dict_plan(self) -> None: + """parse_plan_steps must not crash when `plan` is not a dict.""" + + assert parse_plan_steps({"plan": "not-a-dict"}) == [] + assert parse_plan_steps({"plan": ["also", "not", "a", "dict"]}) == [] + assert parse_plan_steps({"plan": None}) == [] + + def test_parse_plan_steps_reads_top_level_when_plan_absent(self) -> None: + """When there's no 'plan' key, parse_plan_steps looks at top-level steps.""" + + steps = parse_plan_steps({"steps": [{"text": "A", "status": "pending"}]}) + assert len(steps) == 1 + assert steps[0].step == "A" diff --git a/tests/adapters/codex/test_process_loss_recovery.py b/tests/adapters/codex/test_process_loss_recovery.py new file mode 100644 index 000000000..d27812544 --- /dev/null +++ b/tests/adapters/codex/test_process_loss_recovery.py @@ -0,0 +1,174 @@ +"""Codex adapter: a lost Codex process fails its turn once and the room recovers onto the same thread.""" + +from __future__ import annotations + +import logging + +import pytest + +from band.adapters.codex import TRANSPORT_LOST_ERROR, CodexAdapterConfig +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) +from band.integrations.codex import TRANSPORT_CLOSED, RpcEvent +from band.integrations.codex.types import ( + CodexRequestMethod, + CodexSessionState, +) +from band.runtime.tools import BandTool +from band.testing import ( + reported_failures, +) +from tests.adapters.codex.kit import ( + PROCESS_EXIT_REASON, + PROCESS_EXITED, + ROOM_ID, + CodexRoom, + ToolSchemaFakeTools, + send_bootstrap, + thread_of, +) +from tests.adapters.codexturns import ( + FIRST_THREAD_ID, + FakeCodexClient, + event_notification, + make_codex_adapter, + tool_call_request, + turn_completed, +) + + +class TestProcessLossRecovery: + @pytest.mark.asyncio + async def test_transport_closed_event_aborts_turn( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A transport/closed event ends the turn with a failure the room sees + and a WARNING the operator sees (the runtime logs it only at DEBUG).""" + fake_client = FakeCodexClient(events=[PROCESS_EXITED]) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + assert [f["message"] for f in reported_failures(tools)] == [ + TRANSPORT_LOST_ERROR + ] + (warning,) = [r for r in caplog.records if r.levelno == logging.WARNING] + assert PROCESS_EXIT_REASON in warning.getMessage() + + @pytest.mark.asyncio + async def test_a_lost_process_is_retired_and_its_thread_restored_exactly( + self, + ) -> None: + """Recovery closes a lost process but keeps the room's thread, usage + and settings: the next turn resumes exactly that thread on a new + process, sending only overrides resume supports, and never starts a + fresh one in its place.""" + client = FakeCodexClient( + events=[ + event_notification( + "thread/tokenUsage/updated", {"usage": {"totalTokens": 150}} + ), + PROCESS_EXITED, + tool_call_request(1, BandTool.NO_REPLY), + turn_completed("turn-2"), + ] + ) + room = CodexRoom(make_codex_adapter(client), client) + await room.adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await room.send("work") + + await room.send("more work") + await room.send("/thread info") + + assert client.closed + assert client.request_methods.count(CodexRequestMethod.THREAD_START) == 1 + (restore,) = client.params_of(CodexRequestMethod.THREAD_RESUME) + assert set(restore) == {"threadId", "cwd", "personality"} + assert restore["threadId"] == FIRST_THREAD_ID + assert f"thread_id: {FIRST_THREAD_ID}\n" in room.chat[-1] + assert "total: 150" in room.chat[-1] + + @pytest.mark.asyncio + async def test_a_thread_resumed_from_history_survives_a_lost_process( + self, + ) -> None: + """A thread Band history names was recorded in another cwd; resuming + it pins the room's workspace, so a later strict restore agrees.""" + client = FakeCodexClient( + events=[ + PROCESS_EXITED, + turn_completed("turn-2"), + ], + recorded_cwd="/where/it/first/ran", + ) + adapter = make_codex_adapter(client) + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap( + adapter, history=CodexSessionState(thread_id="thr-history") + ) + + await send_bootstrap(adapter) + + assert client.request_methods.count(CodexRequestMethod.THREAD_RESUME) == 2 + assert CodexRequestMethod.THREAD_START not in client.request_methods + assert thread_of(adapter, ROOM_ID) == "thr-history" + + @pytest.mark.asyncio + async def test_transport_closed_after_error_does_not_double_report(self) -> None: + """An "error" notification immediately followed by transport/closed for + the same incident must report the failure once, not twice -- matching + the turn/completed branch's existing failure_reported guard.""" + events = [ + event_notification( + "error", + {"error": {"message": "Something went wrong"}, "willRetry": False}, + ), + event_notification( + TRANSPORT_CLOSED, + {"reason": "Codex process exited unexpectedly"}, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + assert len(reported_failures(tools)) == 1 + + @pytest.mark.asyncio + async def test_unexpected_recv_error_reports_and_fails_turn( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """When the event stream breaks mid-turn, the adapter reports an + AgentFailure and fails the turn instead of silently degrading to a + plain chat reply with no structured signal at all. The reader logs + the failure once, naming its room.""" + + class BrokenClient(FakeCodexClient): + async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: + await self.turn_started.wait() + raise ConnectionError("transport died") + + fake_client = BrokenClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert [ + record.getMessage() for record in caplog.records if record.exc_info + ] == [f"Room {ROOM_ID}: Codex event reader failed; recovering generation 1"] diff --git a/tests/adapters/codex/test_process_residency.py b/tests/adapters/codex/test_process_residency.py new file mode 100644 index 000000000..3747c1fc4 --- /dev/null +++ b/tests/adapters/codex/test_process_residency.py @@ -0,0 +1,53 @@ +"""Codex adapter: process residency.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import PROCESS_NOT_READY_ERROR, CodexAdapterConfig +from band.core.retention import RoomRetentionConfig +from band.integrations.codex.types import CodexRequestMethod +from band.runtime.tools import BandTool +from band.testing import reported_failures +from tests.adapters.codex.kit import ( + CodexRoom, + HeldStartClient, +) +from tests.adapters.codexturns import ( + make_codex_adapter, + tool_call_request, + turn_completed, +) + + +class TestProcessResidency: + """A room's process starts within its budget.""" + + @pytest.mark.looptime + async def test_a_start_over_its_budget_fails_as_not_ready_and_the_next_starts_fresh( + self, + ) -> None: + client = HeldStartClient( + events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()] + ) + adapter = make_codex_adapter( + client, + config=CodexAdapterConfig( + retention=RoomRetentionConfig(transition_timeout_s=1) + ), + ) + await adapter.on_started("Codex Agent", "A coding agent") + room = CodexRoom(adapter, client) + + with pytest.raises(TimeoutError): + await room.send("first") + client.finish_start.set() + await room.send("second") + + assert [f["message"] for f in reported_failures(room.deliveries[0])] == [ + PROCESS_NOT_READY_ERROR + ] + # The abandoned start was cancelled before its handshake finished and + # closed; only the second message's start completed. + assert (client.starts, client.closes) == (1, 1) + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 1 diff --git a/tests/adapters/codex/test_raw_task_events.py b/tests/adapters/codex/test_raw_task_events.py new file mode 100644 index 000000000..540ccc5fe --- /dev/null +++ b/tests/adapters/codex/test_raw_task_events.py @@ -0,0 +1,121 @@ +"""Codex adapter: Codex task events are forwarded as Band task events.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestRawTaskEvents: + @pytest.mark.asyncio + async def test_forwards_raw_codex_task_events(self) -> None: + events = [ + event_notification( + "codex/event/task_started", + {"taskId": "task-1", "task": {"title": "Inspect repository"}}, + ), + event_notification( + "codex/event/task_complete", + {"taskId": "task-1", "summary": "Inspection finished"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + raw_task_events = [ + event + for event in tools.events_sent + if event["metadata"].get("codex_event_method") + in { + "codex/event/task_started", + "codex/event/task_complete", + } + ] + assert len(raw_task_events) == 2 + assert raw_task_events[0]["content"] == ( + "UUID: task-1\nTask: Inspect repository\nStatus: started" + ) + assert raw_task_events[0]["metadata"]["codex_task_id"] == "task-1" + assert raw_task_events[1]["content"] == ( + "UUID: task-1\nTask: Inspect repository\nStatus: completed\n" + "Summary: Inspection finished" + ) + assert raw_task_events[1]["metadata"]["codex_task_phase"] == "completed" + + @pytest.mark.asyncio + async def test_can_disable_synthetic_turn_task_markers(self) -> None: + events = [ + event_notification( + "codex/event/task_started", + {"taskId": "task-1", "task": {"title": "Inspect repository"}}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_task_markers=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + turn_marker_events = [ + event + for event in tools.events_sent + if "codex_turn_status" in event["metadata"] + ] + assert turn_marker_events == [] + assert any( + event["metadata"].get("codex_event_method") == "codex/event/task_started" + for event in tools.events_sent + ) + + @pytest.mark.asyncio + async def test_raw_task_event_without_explicit_task_id_does_not_emit_uuid( + self, + ) -> None: + events = [ + event_notification( + "codex/event/task_started", + {"id": "turn-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_task_markers=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + raw_task_event = next( + event + for event in tools.events_sent + if event["metadata"].get("codex_event_method") == "codex/event/task_started" + ) + assert raw_task_event["content"] == ( + "Task: Codex task lifecycle event\nStatus: started\n" + "Summary: Method: codex/event/task_started" + ) + assert "codex_task_id" not in raw_task_event["metadata"] diff --git a/tests/adapters/codex/test_read_room_file_image_passthrough.py b/tests/adapters/codex/test_read_room_file_image_passthrough.py new file mode 100644 index 000000000..fb934c9c5 --- /dev/null +++ b/tests/adapters/codex/test_read_room_file_image_passthrough.py @@ -0,0 +1,64 @@ +"""Codex adapter: read room file image passthrough.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from band.runtime.tools import ToolCallOutcome +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + run_codex_turn, +) +from tests.adapters.codexturns import ( + tool_call_request, + turn_completed, +) + + +class TestReadRoomFileImagePassthrough: + @pytest.mark.asyncio + async def test_image_result_becomes_input_image_content_item(self) -> None: + class ImageTools(ToolSchemaFakeTools): + async def execute_tool_call_structured( + self, tool_name: str, arguments: dict[str, Any] + ) -> ToolCallOutcome: + return ToolCallOutcome( + value={ + "content": [ + { + "type": "image", + "data": "ZmFrZQ==", + "mimeType": "image/png", + } + ] + }, + ok=True, + ) + + turn = await run_codex_turn( + events=[ + tool_call_request(42, "band_read_room_file", {"file_id": "f1"}), + turn_completed(), + ], + tools=ImageTools(), + ) + + response_id, response_payload = turn.tool_response + assert response_id == 42 + assert response_payload["success"] is True + assert turn.content_items == [ + {"type": "inputImage", "imageUrl": "data:image/png;base64,ZmFrZQ=="} + ] + + @pytest.mark.asyncio + async def test_non_image_result_stays_input_text(self) -> None: + turn = await run_codex_turn( + events=[ + tool_call_request(42, "band_read_room_file", {"file_id": "f1"}), + turn_completed(), + ] + ) + + assert turn.content_items[0]["type"] == "inputText" diff --git a/tests/adapters/codex/test_realtime_streaming.py b/tests/adapters/codex/test_realtime_streaming.py new file mode 100644 index 000000000..d84be728b --- /dev/null +++ b/tests/adapters/codex/test_realtime_streaming.py @@ -0,0 +1,197 @@ +"""Codex adapter: realtime streaming.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + agent_message_completed, + agent_message_delta, + agent_message_started, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestRealtimeStreaming: + @pytest.mark.asyncio + async def test_reasoning_delta_streamed_as_thought(self) -> None: + """item/reasoning/summaryTextDelta forwards as streaming thought.""" + events = [ + event_notification( + "item/reasoning/summaryTextDelta", + {"delta": "Analyzing the code...", "itemId": "item-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_reasoning_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thought_events = [ + e + for e in tools.events_sent + if e["message_type"] == "thought" and e["metadata"].get("streaming") + ] + assert len(thought_events) == 1 + assert thought_events[0]["content"] == "Analyzing the code..." + assert thought_events[0]["metadata"]["codex_item_id"] == "item-1" + + @pytest.mark.asyncio + async def test_reasoning_delta_ignored_when_disabled(self) -> None: + """Reasoning deltas are skipped when stream_reasoning_events=False.""" + events = [ + event_notification( + "item/reasoning/summaryTextDelta", + {"delta": "Thinking...", "itemId": "item-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_reasoning_events=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + streaming_events = [ + e for e in tools.events_sent if e["metadata"].get("streaming") + ] + assert streaming_events == [] + + @pytest.mark.asyncio + async def test_plan_delta_streamed_as_thought(self) -> None: + """item/plan/delta forwards as streaming thought with plan subtype.""" + events = [ + event_notification( + "item/plan/delta", + {"delta": "Step 1: Read the test", "itemId": "plan-1"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_plan_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + plan_thoughts = [ + e + for e in tools.events_sent + if e["message_type"] == "thought" and e["metadata"].get("subtype") == "plan" + ] + assert len(plan_thoughts) == 1 + assert plan_thoughts[0]["content"] == "Step 1: Read the test" + + @pytest.mark.asyncio + async def test_commentary_phase_streamed_as_thought(self) -> None: + """A started commentary item supplies the phase for streamed deltas.""" + events = [ + agent_message_started("comment", phase="commentary"), + agent_message_delta("Let me think about this...", "comment"), + agent_message_completed( + "Let me think about this...", "comment", phase="commentary" + ), + agent_message_started("answer", phase="final_answer"), + agent_message_delta("Here is the answer.", "answer"), + agent_message_completed( + "Here is the answer.", "answer", phase="final_answer" + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_commentary_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + commentary_thoughts = [ + e for e in tools.events_sent if e["metadata"].get("subtype") == "commentary" + ] + assert len(commentary_thoughts) == 1 + assert commentary_thoughts[0]["content"] == "Let me think about this..." + + assert tools.messages_sent == [] + assert [e["content"] for e in events_of_type(tools, "thought")] == [ + "Let me think about this...", + "Here is the answer.", + ] + + @pytest.mark.asyncio + async def test_streamed_commentary_is_not_replayed_on_completion( + self, + ) -> None: + """Streamed commentary is not replayed when its item completes.""" + events = [ + agent_message_started("comment", phase="commentary"), + agent_message_delta("thinking...", "comment"), + agent_message_completed("thinking...", "comment", phase="commentary"), + agent_message_started("answer", phase="final_answer"), + agent_message_delta("real answer", "answer"), + agent_message_completed("real answer", "answer", phase="final_answer"), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_commentary_events=True) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert tools.messages_sent == [] + assert [e["content"] for e in events_of_type(tools, "thought")] == [ + "thinking...", + "real answer", + ] + + @pytest.mark.asyncio + async def test_unstreamed_completed_messages_are_separate_thoughts( + self, + ) -> None: + """Unstreamed completed messages remain separate thoughts.""" + events = [ + agent_message_started("comment", phase="commentary"), + agent_message_delta("thinking...", "comment"), + agent_message_completed("thinking...", "comment", phase="commentary"), + agent_message_started("answer", phase="final_answer"), + agent_message_delta("real answer", "answer"), + agent_message_completed("real answer", "answer", phase="final_answer"), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(stream_commentary_events=False) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert tools.messages_sent == [] + assert [e["content"] for e in events_of_type(tools, "thought")] == [ + "thinking...", + "real answer", + ] diff --git a/tests/adapters/codex/test_room_process_close.py b/tests/adapters/codex/test_room_process_close.py new file mode 100644 index 000000000..37b5f5655 --- /dev/null +++ b/tests/adapters/codex/test_room_process_close.py @@ -0,0 +1,76 @@ +"""Codex adapter: leaving a room closes that room's process.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapter, + CodexAdapterConfig, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + patch_codex_clients_by_room, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + turn_completed, +) + + +class TestRoomProcessClose: + @pytest.mark.asyncio + async def test_cleanup_closes_client_when_last_room_removed(self) -> None: + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert fake_client.closed is False + await adapter.on_cleanup(ROOM_ID) + assert fake_client.closed is True + + @pytest.mark.asyncio + async def test_cleanup_idempotent(self) -> None: + """Calling on_cleanup twice for the same room should not raise.""" + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + await adapter.on_cleanup(ROOM_ID) + assert fake_client.closed is True + # Second cleanup should not raise + await adapter.on_cleanup(ROOM_ID) + + @pytest.mark.asyncio + async def test_cleanup_multi_room_closes_each_room_client(self) -> None: + """Each room owns its Codex client; cleanup closes only that room's client.""" + clients = { + ROOM_ID: FakeCodexClient(events=[turn_completed()]), + # Each room owns a separate FakeCodexClient with its own turn + # counter, so room-2's first turn is also "turn-1" -- matching + # this room's client, not a global counter. + "room-2": FakeCodexClient(events=[turn_completed()]), + } + adapter = CodexAdapter(config=CodexAdapterConfig()) + patch_codex_clients_by_room(adapter, clients) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + for room_id in (ROOM_ID, "room-2"): + await send_bootstrap(adapter, room_id=room_id, tools=tools) + + await adapter.on_cleanup(ROOM_ID) + assert clients[ROOM_ID].closed is True + assert clients["room-2"].closed is False + + await adapter.on_cleanup("room-2") + assert clients["room-2"].closed is True diff --git a/tests/adapters/codex/test_room_retirement.py b/tests/adapters/codex/test_room_retirement.py new file mode 100644 index 000000000..85e212658 --- /dev/null +++ b/tests/adapters/codex/test_room_retirement.py @@ -0,0 +1,155 @@ +"""Codex adapter: room retirement.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.exceptions import CleanupIncompleteError, InputNotAccepted +from band.core.retention import RoomRetentionConfig +from band.integrations.codex.types import CodexRequestMethod +from tests.adapters.codex.kit import ( + CLOSE_BOUND_S, + ROOM_ID, + CleaningTurn, + CountingCloseClient, + disposed, + residency_of, + send_bootstrap, +) +from tests.adapters.codexturns import ( + make_codex_adapter, + turn_completed, +) +from tests.conftest import eager_tasks, requires_eager_tasks + + +class TestRoomRetirement: + """A closing room shares one close and admits work again only once it + succeeds.""" + + @requires_eager_tasks + async def test_cleanup_readmits_and_recloses_the_room_under_an_eager_task_factory( + self, + ) -> None: + client = CountingCloseClient( + events=[turn_completed(), turn_completed("turn-2")] + ) + adapter = make_codex_adapter(client) + with eager_tasks(): + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter) + await adapter.on_cleanup(ROOM_ID) + await send_bootstrap(adapter) + await adapter.on_cleanup(ROOM_ID) + + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 + assert client.closes == 2 + + @pytest.mark.looptime + async def test_concurrent_room_cleanups_share_one_close(self) -> None: + client = CountingCloseClient(events=[turn_completed()], hold=True) + adapter = make_codex_adapter(client) + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter) + room_cleanup = asyncio.create_task(adapter.on_cleanup(ROOM_ID)) + await client.closing.wait() + every_cleanup = asyncio.create_task(adapter.cleanup_all()) + # Fake time advances only once both cleanups are waiting on the close. + await asyncio.sleep(1) + client.finish_close.set() + + await asyncio.gather(room_cleanup, every_cleanup) + assert client.closes == 1 + + async def test_cleanup_cancelled_during_close_still_readmits_the_room( + self, + ) -> None: + client = CountingCloseClient( + events=[turn_completed(), turn_completed("turn-2")], hold=True + ) + adapter = make_codex_adapter(client) + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter) + cleanup = asyncio.create_task(adapter.on_cleanup(ROOM_ID)) + await client.closing.wait() + cleanup.cancel() + with pytest.raises(asyncio.CancelledError): + await cleanup + client.finish_close.set() + await client.close_finished.wait() + + await adapter.on_cleanup(ROOM_ID) + await send_bootstrap(adapter) + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 + + @pytest.mark.looptime + async def test_a_leave_reported_unfinished_readmits_the_room_once_retried( + self, + ) -> None: + client = CountingCloseClient( + events=[turn_completed(), turn_completed("turn-2")], hold=True + ) + adapter = make_codex_adapter( + client, + config=CodexAdapterConfig( + client_close_timeout_s=None, + retention=RoomRetentionConfig(transition_timeout_s=1), + ), + ) + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter) + residency = residency_of(adapter, ROOM_ID) + assert residency is not None + with pytest.raises(CleanupIncompleteError): + await adapter.on_cleanup(ROOM_ID) + client.finish_close.set() + await disposed(residency) + + await adapter.on_cleanup(ROOM_ID) + await send_bootstrap(adapter) + + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 + + @pytest.mark.looptime + async def test_a_close_past_its_bound_names_the_room_and_bound(self) -> None: + client = CountingCloseClient(events=[turn_completed()], hold=True) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(client_close_timeout_s=CLOSE_BOUND_S) + ) + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter) + + with pytest.raises( + TimeoutError, + match=re.escape(f"room {ROOM_ID} did not close within {CLOSE_BOUND_S}s"), + ): + await adapter.on_cleanup(ROOM_ID) + client.finish_close.set() + await adapter.on_cleanup(ROOM_ID) + + async def test_stop_keeps_the_room_fenced_until_worker_cleanup_finishes( + self, cleaning_turn: CleaningTurn + ) -> None: + room, client = cleaning_turn.room, cleaning_turn.client + await cleaning_turn.stop_twice() + + with pytest.raises(CleanupIncompleteError): + await room.adapter.on_cleanup(ROOM_ID) + assert not client.closed + with pytest.raises(InputNotAccepted): + await room.send("overlap") + + async def test_concurrent_stops_share_one_worker_cleanup( + self, cleaning_turn: CleaningTurn + ) -> None: + stops = asyncio.create_task(cleaning_turn.stop_twice()) + cleaning_turn.client.release.set() + await stops + + await cleaning_turn.room.adapter.on_cleanup(ROOM_ID) + assert cleaning_turn.client.cleanups == 1 + assert cleaning_turn.client.closed diff --git a/tests/adapters/codex/test_sandbox_settings.py b/tests/adapters/codex/test_sandbox_settings.py new file mode 100644 index 000000000..3ad3ca59e --- /dev/null +++ b/tests/adapters/codex/test_sandbox_settings.py @@ -0,0 +1,78 @@ +"""Codex adapter: sandbox aliases and policies reach the thread and turn.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex.types import ( + CodexRequestMethod, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + turn_completed, +) + + +class TestSandboxSettings: + @pytest.mark.asyncio + async def test_sandbox_alias_is_normalized_for_thread_and_turn(self) -> None: + events = [turn_completed()] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(sandbox="dangerFullAccess") + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + # thread/start only accepts the sandbox field (SandboxMode enum) + assert thread_start["sandbox"] == "danger-full-access" + # turn/start uses sandboxPolicy (full SandboxPolicy tagged union) + assert turn_start["sandboxPolicy"]["type"] == "dangerFullAccess" + + @pytest.mark.asyncio + async def test_external_sandbox_alias_uses_sandbox_policy(self) -> None: + events = [turn_completed()] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(sandbox="external-sandbox") + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] + turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] + # thread/start has no sandboxPolicy field; externalSandbox is + # only representable at turn level + assert "sandbox" not in thread_start + assert "sandboxPolicy" not in thread_start + # turn/start can express the full SandboxPolicy tagged union + assert turn_start["sandboxPolicy"]["type"] == "externalSandbox" + + @pytest.mark.asyncio + async def test_sandbox_command_blocked_when_sandbox_policy_set(self) -> None: + """/sandbox is rejected when sandbox_policy is configured.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(sandbox_policy={"type": "readOnly"}) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/sandbox workspace-write", tools=tools) + + assert ROOM_ID not in adapter._sandbox_overrides + assert "Cannot override sandbox" in tools.messages_sent[0]["content"] diff --git a/tests/adapters/codex/test_self_config_tools.py b/tests/adapters/codex/test_self_config_tools.py new file mode 100644 index 000000000..4570544d4 --- /dev/null +++ b/tests/adapters/codex/test_self_config_tools.py @@ -0,0 +1,114 @@ +"""Codex adapter: the model can change its own model and reasoning through tools.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex.types import ( + CodexRequestMethod, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestSelfConfigTools: + @pytest.mark.asyncio + async def test_self_config_tools_registered_when_enabled(self) -> None: + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig(enable_self_config_tools=True), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="hello", tools=tools) + # Check that thread/start included setmodel and setreasoning dynamic tools + thread_params = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] + tool_names = [t["name"] for t in thread_params.get("dynamicTools", [])] + assert "setmodel" in tool_names + assert "setreasoning" in tool_names + + @pytest.mark.asyncio + async def test_self_config_tools_not_registered_when_disabled(self) -> None: + fake_client = FakeCodexClient(events=[turn_completed()]) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig(enable_self_config_tools=False), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="hello", tools=tools) + thread_params = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] + tool_names = [t["name"] for t in thread_params.get("dynamicTools", [])] + assert "setmodel" not in tool_names + assert "setreasoning" not in tool_names + + @pytest.mark.asyncio + async def test_setmodel_tool_changes_model(self) -> None: + events = [ + event_request( + 99, + "item/tool/call", + { + "tool": "setmodel", + "callId": "call-1", + "arguments": {"model": "o3"}, + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(enable_self_config_tools=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="switch to o3", tools=tools) + assert adapter._selected_model == "o3" + # Verify the tool response was sent back + tool_responses = [ + (rid, result) + for rid, result in fake_client.responses + if isinstance(result, dict) and "contentItems" in result + ] + assert len(tool_responses) >= 1 + result_text = tool_responses[0][1]["contentItems"][0]["text"] + assert "o3" in result_text + + @pytest.mark.asyncio + @pytest.mark.parametrize("effort", ["xhigh", "ultra"]) + async def test_setreasoning_tool_changes_effort(self, effort: str) -> None: + events = [ + event_request( + 99, + "item/tool/call", + { + "tool": "setreasoning", + "callId": "call-2", + "arguments": {"effort": effort, "summary": "detailed"}, + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(enable_self_config_tools=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="increase reasoning", tools=tools) + room = adapter._room_clients[ROOM_ID] + assert room.reasoning_effort == effort + assert room.reasoning_summary == "detailed" diff --git a/tests/adapters/codex/test_session_approval_keying.py b/tests/adapters/codex/test_session_approval_keying.py new file mode 100644 index 000000000..2759dcc52 --- /dev/null +++ b/tests/adapters/codex/test_session_approval_keying.py @@ -0,0 +1,118 @@ +"""Codex adapter: session approval keying.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapter, + CodexAdapterConfig, +) +from band.integrations.codex.types import CodexApprovalMethod, CodexSessionState +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + make_platform_message, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + await_released_turn, + event_request, + make_codex_adapter, + turn_completed, +) +from tests.adapters.dispatch import deliver_message + + +class TestSessionApprovalKeying: + """_session_approval_key behaviour across method/param shapes.""" + + def test_file_change_session_key_requires_paths(self) -> None: + """/approve-session must refuse fileChange requests with no paths.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key( + "item/fileChange/requestApproval", + {"reason": "something vague"}, + ) + assert key == "" + + def test_file_change_session_key_uses_paths_when_present(self) -> None: + """fileChange session key includes sorted path list for stable matching.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key1 = adapter._session_approval_key( + "item/fileChange/requestApproval", + {"changes": [{"path": "b.py"}, {"path": "a.py"}]}, + ) + key2 = adapter._session_approval_key( + "item/fileChange/requestApproval", + {"changes": [{"path": "a.py"}, {"path": "b.py"}]}, + ) + assert key1 == key2 + assert key1 == "fileChange:a.py|b.py" + + def test_file_change_session_key_handles_top_level_paths(self) -> None: + """fileChange session key also picks up top-level path/paths fields.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key( + "item/fileChange/requestApproval", + {"paths": ["src/foo.py", "src/bar.py"]}, + ) + assert "src/foo.py" in key + assert "src/bar.py" in key + + def test_unknown_approval_method_returns_empty_key(self) -> None: + """Session-level approval refuses unknown methods rather than bucketing them.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + assert adapter._session_approval_key("item/unknown/requestApproval", {}) == "" + + @pytest.mark.asyncio + async def test_approve_session_refused_for_fileChange_without_paths(self) -> None: + """/approve-session for a fileChange request with no paths is rejected.""" + events = [ + event_request( + 88, + "item/fileChange/requestApproval", + {"reason": "write something"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + approval_mode="manual", + approval_wait_timeout_s=0.05, + approval_timeout_decision="decline", + ), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + await await_released_turn(adapter, ROOM_ID) + + # Manual approval times out and the pending record is cleared, + # so /approve-session reports no pending approvals rather than + # storing an empty session key. + tools.messages_sent.clear() + await deliver_message( + adapter, + make_platform_message(content="/approve-session 88"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + assert any("No pending approvals" in m["content"] for m in tools.messages_sent) + assert ROOM_ID not in adapter._session_approved + + def test_session_approval_key_empty_prevents_wildcard_match(self) -> None: + """Empty session key from missing command cannot match any session set.""" + adapter = CodexAdapter(config=CodexAdapterConfig()) + key = adapter._session_approval_key(CodexApprovalMethod.COMMAND_EXECUTION, {}) + # Empty key is falsy, so `key and key in session_set` is always False + assert not key + assert not (key and key in {"commandExecution:npm"}) diff --git a/tests/adapters/codex/test_session_approval_validation.py b/tests/adapters/codex/test_session_approval_validation.py new file mode 100644 index 000000000..9af62eea6 --- /dev/null +++ b/tests/adapters/codex/test_session_approval_validation.py @@ -0,0 +1,40 @@ +"""Codex adapter: session approval validation.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from tests.adapters.codex.kit import ( + DECLINED, + ROOM_ID, + CodexRoom, +) +from tests.adapters.codexturns import ( + event_request, + turn_completed, +) + + +class TestSessionApprovalValidation: + """Guards that stop /approve-session from storing bogus session keys.""" + + async def test_approve_session_is_refused_for_a_change_with_no_paths( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + """A file change naming no paths has no signature to match, so + /approve-session is refused -- keying on the method alone would + auto-approve every future file change -- and the approval stays open + for a one-shot answer.""" + room = await codex_room( + event_request(15, "item/fileChange/requestApproval", {}), + turn_completed(), + ) + + await room.send("run") + await room.send(f"/approve-session {room.approval_token}") + await room.send(f"/decline {room.approval_token}") + await room.settled() + + assert room.client.responses == [(15, DECLINED)] + assert not room.adapter._session_approved.get(ROOM_ID) + assert sum("cannot be resolved as session-level" in m for m in room.chat) == 1 diff --git a/tests/adapters/codex/test_session_auto_approval.py b/tests/adapters/codex/test_session_auto_approval.py new file mode 100644 index 000000000..dfce0292d --- /dev/null +++ b/tests/adapters/codex/test_session_auto_approval.py @@ -0,0 +1,121 @@ +"""Codex adapter: session auto approval.""" + +from __future__ import annotations + +from collections import OrderedDict + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexApprovalMethod +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_request, + make_codex_adapter, + turn_completed, +) + + +class TestSessionAutoApproval: + @pytest.mark.asyncio + async def test_session_auto_approves_matching_command_binary(self) -> None: + """After session-level approval for npm, a new npm command is auto-approved.""" + # Two command execution requests in one turn — first will be manually + # approved, second should be auto-approved by session policy. + events = [ + event_request( + 20, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "npm install"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="manual") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + # Pre-seed the session-approved set as if /approve-session was used for "npm install" + adapter._session_approved[ROOM_ID] = OrderedDict( + [("commandExecution:npm install", None)] + ) + + await send_bootstrap(adapter, content="install deps", tools=tools) + + # The request should have been auto-approved via session policy + responses = fake_client.responses + assert any( + result.get("decision") in {"accept", "acceptForSession"} + for _, result in responses + ) + + @pytest.mark.asyncio + async def test_session_does_not_auto_approve_different_command(self) -> None: + """Session approval for 'npm install' does NOT auto-approve 'npm publish'.""" + events = [ + event_request( + 30, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "npm publish"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + # Pre-seed session approval for "npm install" only + adapter._session_approved[ROOM_ID] = OrderedDict( + [("commandExecution:npm install", None)] + ) + + await send_bootstrap(adapter, content="publish package", tools=tools) + + # npm publish should have been declined, not auto-approved + responses = fake_client.responses + assert any(result.get("decision") == "decline" for _, result in responses) + + @pytest.mark.asyncio + async def test_session_binary_granularity_approves_same_binary(self) -> None: + """With binary granularity, session approval for 'npm test' auto-approves 'npm install'.""" + events = [ + event_request( + 40, + CodexApprovalMethod.COMMAND_EXECUTION, + {"command": "npm install"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + approval_mode="manual", session_approval_granularity="binary" + ), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + # Pre-seed session approval for npm binary + adapter._session_approved[ROOM_ID] = OrderedDict( + [("commandExecution:npm", None)] + ) + + await send_bootstrap(adapter, content="install deps", tools=tools) + + # npm install should be auto-approved because binary matches + responses = fake_client.responses + assert any( + result.get("decision") in {"accept", "acceptForSession"} + for _, result in responses + ) diff --git a/tests/adapters/codex/test_skill_roots.py b/tests/adapters/codex/test_skill_roots.py new file mode 100644 index 000000000..dd697bb66 --- /dev/null +++ b/tests/adapters/codex/test_skill_roots.py @@ -0,0 +1,107 @@ +"""Codex adapter: skill roots.""" + +from __future__ import annotations + +import json + +import pytest +from pydantic import ValidationError + +from band.adapters.codex import ( + CodexAdapter, + CodexAdapterConfig, +) +from band.core.protocols import TurnResultAlreadyReported +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex.types import CodexRequestMethod +from tests.adapters.codex.kit import ( + PROCESS_EXITED, + REGISTER_SKILL_ROOT, + ROOM_ID, + SKILL_ROOT, + patch_codex_clients_by_room, + run_codex_turn, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + turn_completed, +) + + +class TestSkillRoots: + @pytest.mark.asyncio + async def test_roots_are_sent_first_after_initialize(self) -> None: + turn = await run_codex_turn( + events=[turn_completed()], + config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]), + ) + assert turn.client.requests[0] == REGISTER_SKILL_ROOT + + @pytest.mark.asyncio + async def test_no_roots_sends_nothing(self) -> None: + turn = await run_codex_turn(events=[turn_completed()]) + assert ( + CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET not in turn.client.request_methods + ) + + @pytest.mark.asyncio + async def test_every_room_process_gets_the_roots(self) -> None: + clients = { + room_id: FakeCodexClient(events=[turn_completed()]) + for room_id in (ROOM_ID, "room-2") + } + adapter = CodexAdapter(config=CodexAdapterConfig(skill_roots=[SKILL_ROOT])) + patch_codex_clients_by_room(adapter, clients) + await adapter.on_started("Codex Agent", "A coding agent") + + for room_id in clients: + await send_bootstrap(adapter, room_id=room_id) + + assert all( + client.requests[0] == REGISTER_SKILL_ROOT for client in clients.values() + ) + + @pytest.mark.asyncio + async def test_a_restarted_process_gets_the_roots_again(self) -> None: + client = FakeCodexClient( + events=[ + PROCESS_EXITED, + turn_completed("turn-2"), + ] + ) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]) + ) + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter) + await send_bootstrap(adapter) + + assert client.requests.count(REGISTER_SKILL_ROOT) == 2 + + @pytest.mark.asyncio + async def test_rejected_roots_fail_the_room_start(self) -> None: + client = FakeCodexClient( + skill_roots_error=CodexJsonRpcError(code=-32601, message="Method not found") + ) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]) + ) + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(RuntimeError, match="Codex rejected skill_roots"): + await send_bootstrap(adapter) + assert client.requests == [REGISTER_SKILL_ROOT] + + def test_relative_roots_are_refused(self) -> None: + with pytest.raises(ValidationError, match="must be absolute"): + CodexAdapterConfig(skill_roots=["relative/skills"]) + + def test_roots_are_read_from_the_environment_as_json( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("CODEX_SKILL_ROOTS", json.dumps([SKILL_ROOT])) + assert CodexAdapterConfig().skill_roots == [SKILL_ROOT] diff --git a/tests/adapters/codex/test_slash_command_coverage.py b/tests/adapters/codex/test_slash_command_coverage.py new file mode 100644 index 000000000..d0d6da77c --- /dev/null +++ b/tests/adapters/codex/test_slash_command_coverage.py @@ -0,0 +1,189 @@ +"""Codex adapter: slash command coverage.""" + +from __future__ import annotations + +import logging + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexSessionState +from band.testing import reported_failures +from tests.adapters.codex.kit import ( + NOTICE_REJECTED, + ROOM_ID, + FailingNoticeTools, + ToolSchemaFakeTools, + make_platform_message, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) +from tests.adapters.dispatch import deliver_message + + +class TestSlashCommandCoverage: + @pytest.mark.asyncio + async def test_thread_info_with_no_mapping(self) -> None: + """/thread info reports gracefully when the room has no thread yet.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/thread info", tools=tools) + + assert tools.messages_sent + assert "No thread mapped" in tools.messages_sent[-1]["content"] + + @pytest.mark.asyncio + async def test_thread_info_includes_thread_and_usage(self) -> None: + """/thread info echoes current thread id and token usage summary.""" + events = [ + event_notification( + "thread/tokenUsage/updated", + { + "usage": { + "inputTokens": 100, + "outputTokens": 50, + "totalTokens": 150, + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_token_usage_events=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + await deliver_message( + adapter, + make_platform_message(content="/thread info"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + info_msgs = [m for m in tools.messages_sent if "Thread info:" in m["content"]] + assert len(info_msgs) == 1 + content = info_msgs[0]["content"] + assert "thread_id: thr-1" in content + assert "room_id: room-1" in content + assert "150" in content + + @pytest.mark.asyncio + async def test_permissions_reflects_sandbox_override(self) -> None: + """/permissions reports the per-room sandbox override once set.""" + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="/sandbox read-only", tools=tools) + await deliver_message( + adapter, + make_platform_message(content="/permissions"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + perm_msgs = [ + m for m in tools.messages_sent if "Effective permissions:" in m["content"] + ] + assert len(perm_msgs) == 1 + assert "read-only" in perm_msgs[0]["content"] + + @pytest.mark.asyncio + async def test_local_command_reply_delivery_failure_is_not_reported( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """/help's answer failing to post is Band-side delivery, not a Codex + provider failure -- its DeliveryFailedError must be + recognized and left unreported here. The original cause still + propagates (the delivery is still marked FAILED), just never + misreported as a Codex AgentFailure.""" + + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = FailingNoticeTools() + + await adapter.on_started("Agent", "A coding agent") + with ( + caplog.at_level(logging.ERROR, logger="band.core.delivery"), + pytest.raises(RuntimeError, match=NOTICE_REJECTED), + ): + await send_bootstrap(adapter, content="/help", tools=tools) + + assert not tools.messages_sent + assert not reported_failures(tools) + assert any( + "Reply delivery failed" in record.message for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_approval_command_reply_delivery_failure_is_not_reported( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Same delivery-vs-provider-failure split as slash commands, but for + the approval-command path, which runs outside on_message's main + try/except and needs its own DeliveryFailedError handling.""" + + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = FailingNoticeTools() + + await adapter.on_started("Agent", "A coding agent") + with ( + caplog.at_level(logging.ERROR, logger="band.core.delivery"), + pytest.raises(RuntimeError, match=NOTICE_REJECTED), + ): + await send_bootstrap(adapter, content="/approvals", tools=tools) + + assert not tools.messages_sent + assert not reported_failures(tools) + assert any( + "Reply delivery failed" in record.message for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_thread_archive_clears_raw_history(self) -> None: + """/thread archive also clears raw history and injection state.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # Seed raw history and injection flag + adapter._raw_history_by_room[ROOM_ID] = [{"role": "user", "content": "hi"}] + adapter._needs_history_injection.add(ROOM_ID) + + await deliver_message( + adapter, + make_platform_message(content="/thread archive"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + + assert ROOM_ID not in adapter._raw_history_by_room + assert ROOM_ID not in adapter._needs_history_injection diff --git a/tests/adapters/codex/test_slash_command_extraction.py b/tests/adapters/codex/test_slash_command_extraction.py new file mode 100644 index 000000000..eb79540ce --- /dev/null +++ b/tests/adapters/codex/test_slash_command_extraction.py @@ -0,0 +1,64 @@ +"""Codex adapter: slash command extraction.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapter, + CodexCommand, +) + + +class TestSlashCommandExtraction: + """``_extract_local_command`` reads a command only when one leads the message.""" + + @pytest.mark.parametrize( + "content", + [ + "@team/bot Please don't /approve req-1 yet", + "@team/bot do not /approve", + "@team/bot ignore the /decline suggestion", + "@team/bot use /tmp as scratch", + ], + ) + def test_prose_mentioning_a_command_is_not_a_command(self, content: str) -> None: + """Prose that argues *against* a command must not invoke it. + + ``/approve`` resolves a pending tool-execution request, and the handler + takes the first argument token as its id — so a scan that found a slash + word anywhere in the prefix turned "don't /approve req-1 yet" into an + approval of ``req-1``. + """ + assert CodexAdapter._extract_local_command(content) is None + + @pytest.mark.parametrize( + ("content", "expected"), + [ + ("/approve req-1", (CodexCommand.APPROVE, "req-1")), + ("@owner/agent-name /approve req-1", (CodexCommand.APPROVE, "req-1")), + # Every mentioned participant contributes a token to the block. + ( + "@owner/agent-name @owner/other-bot /approve req-1", + (CodexCommand.APPROVE, "req-1"), + ), + # Unresolved mentions stay in the platform's normalized @[[uuid]] form. + ( + "@[[3029eb1d-d998-4567-bdf3-d82fc6b89a58]] /approvals", + (CodexCommand.APPROVALS, ""), + ), + ("@team/bot /approve", (CodexCommand.APPROVE, "")), + # Any whitespace separates a command from its argument, not just " ". + ("@team/bot /approve\treq-1", (CodexCommand.APPROVE, "req-1")), + ("/approve\nreq-1", (CodexCommand.APPROVE, "req-1")), + ], + ) + def test_command_behind_the_mention_block_is_recognised( + self, content: str, expected: tuple[CodexCommand, str] + ) -> None: + """The delivery mention block must never hide a real command.""" + assert CodexAdapter._extract_local_command(content) == expected + + @pytest.mark.parametrize("content", ["@team/bot /", "@team/bot /notacommand x", ""]) + def test_non_commands_are_ignored(self, content: str) -> None: + assert CodexAdapter._extract_local_command(content) is None diff --git a/tests/adapters/codex/test_status_command.py b/tests/adapters/codex/test_status_command.py new file mode 100644 index 000000000..0f77242f3 --- /dev/null +++ b/tests/adapters/codex/test_status_command.py @@ -0,0 +1,64 @@ +"""Codex adapter: /status reports the room's state without starting a turn.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex.types import ( + CodexRequestMethod, +) +from band.runtime.tools import BandTool +from tests.adapters.codex.kit import ( + ROOM_ID, + CodexRoom, + ToolSchemaFakeTools, + residency_of, + send_bootstrap, +) +from tests.adapters.codexturns import ( + LISTED_MODEL, + FakeCodexClient, + make_codex_adapter, + tool_call_request, + turn_completed, +) + + +class TestStatusCommand: + @pytest.mark.asyncio + async def test_status_command_returns_state_without_starting_turn(self) -> None: + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap( + adapter, content="@thenvoi/ar-2-darter /status", tools=tools + ) + + methods = fake_client.request_methods + assert CodexRequestMethod.TURN_START not in methods + assert CodexRequestMethod.THREAD_START not in methods + assert len(tools.messages_sent) == 1 + assert "Codex status:" in tools.messages_sent[0]["content"] + assert "thread_id: not mapped" in tools.messages_sent[0]["content"] + + @pytest.mark.asyncio + async def test_status_reports_the_room_model_without_registering_a_room( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + tool_call_request(1, BandTool.NO_REPLY), turn_completed() + ) + + await room.send("/status") + assert residency_of(room.adapter, ROOM_ID) is None + await room.send("work") + await room.send("/status") + + assert f"selected_model: {LISTED_MODEL}\n" in room.chat[-1] diff --git a/tests/adapters/codex/test_structured_error_detail_cap.py b/tests/adapters/codex/test_structured_error_detail_cap.py new file mode 100644 index 000000000..00932adf8 --- /dev/null +++ b/tests/adapters/codex/test_structured_error_detail_cap.py @@ -0,0 +1,94 @@ +"""Codex adapter: structured error detail cap.""" + +from __future__ import annotations + +from typing import Any + +from band.integrations.codex.types import ( + _MAX_ERROR_DETAIL_CHARS, + build_agent_failure, +) + + +class TestStructuredErrorDetailCap: + """``additionalDetails`` is attacker-influenceable and must be capped.""" + + def test_long_additional_details_string_is_truncated(self) -> None: + + long_detail = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": long_detail, + } + ) + detail = failure.detail["codex_additional_details"] + assert isinstance(detail, str) + assert len(detail) < len(long_detail) + assert "truncated" in detail + + def test_structured_dict_additional_details_are_preserved(self) -> None: + """Only string details are capped; dict/list payloads pass through.""" + + payload = {"hint": "refresh token", "code": 401} + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": payload, + } + ) + assert failure.detail["codex_additional_details"] == payload + + def test_empty_additional_details_is_dropped(self) -> None: + """Empty strings are not echoed into detail.""" + + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": "", + } + ) + assert failure.detail is None + + def test_oversized_dict_additional_details_is_replaced_with_marker( + self, + ) -> None: + """Large non-string payloads must not slip past the byte cap. + + A hostile upstream that embeds a megabyte of nested JSON in + ``additionalDetails`` would otherwise inflate every downstream + WebSocket frame. When the serialized form exceeds the cap we + replace the whole payload with a truncated marker string. + """ + + # Build a dict whose JSON serialization comfortably exceeds the cap. + oversized_value = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) + payload = {"nested": {"blob": oversized_value}} + + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": payload, + } + ) + detail = failure.detail["codex_additional_details"] + assert isinstance(detail, str) + assert "truncated" in detail + assert len(detail) < len(oversized_value) + + def test_unserializable_additional_details_is_dropped(self) -> None: + """Payloads that ``json.dumps`` can't handle without ``default=str`` + round-trip through ``default=str``; pathological unserializable + objects (e.g. a circular reference) must be dropped rather than + raising into the event-emission path.""" + + circular: dict[str, Any] = {} + circular["self"] = circular + + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": circular, + } + ) + assert failure.detail is None diff --git a/tests/adapters/codex/test_structured_error_mappings.py b/tests/adapters/codex/test_structured_error_mappings.py new file mode 100644 index 000000000..a75be7176 --- /dev/null +++ b/tests/adapters/codex/test_structured_error_mappings.py @@ -0,0 +1,54 @@ +"""Codex adapter: structured error mappings.""" + +from __future__ import annotations + +import pytest + +from band.integrations.codex.types import build_agent_failure + + +class TestStructuredErrorMappings: + """build_agent_failure passes codexErrorInfo through verbatim -- no + remediation/suggested-action policy; that belongs to a consumer, not + this shared shape.""" + + @pytest.mark.parametrize( + "error_type", + [ + "HttpConnectionFailed", + "SandboxError", + "Unauthorized", + "BadRequest", + "ResponseTooManyFailedAttempts", + ], + ) + def test_error_type_becomes_the_failure_code(self, error_type: str) -> None: + failure = build_agent_failure( + {"codexErrorInfo": {"type": error_type, "retryable": True}} + ) + assert failure.code == error_type + assert failure.detail["codex_is_retryable"] is True + + def test_non_dict_codex_error_info_is_tolerated(self) -> None: + + failure = build_agent_failure( + {"message": "boom", "codexErrorInfo": "not-a-dict"} + ) + assert failure.code is None + assert failure.message == "boom" + + def test_missing_codex_error_info_falls_back_to_message(self) -> None: + + failure = build_agent_failure({"message": "network down"}) + assert failure.code is None + assert failure.message == "network down" + + def test_additional_details_preserved_in_detail(self) -> None: + + failure = build_agent_failure( + { + "codexErrorInfo": {"type": "Unauthorized"}, + "additionalDetails": {"hint": "refresh token"}, + } + ) + assert failure.detail["codex_additional_details"] == {"hint": "refresh token"} diff --git a/tests/adapters/codex/test_structured_error_normalization.py b/tests/adapters/codex/test_structured_error_normalization.py new file mode 100644 index 000000000..13bdbb28f --- /dev/null +++ b/tests/adapters/codex/test_structured_error_normalization.py @@ -0,0 +1,27 @@ +"""Codex adapter: structured error normalization.""" + +from __future__ import annotations + +from typing import Any + +from band.integrations.codex.types import build_agent_failure + + +class TestStructuredErrorNormalization: + """build_agent_failure handling of non-standard inputs.""" + + def test_structured_error_with_string_error_obj(self) -> None: + """_handle_error_event normalizes string error_obj before structuring. + + The review flagged a redundant isinstance(error_obj, dict) check that + was dead code; this test asserts the normalization still works when + the original error_obj is a string rather than a dict. + """ + + # Simulate the normalization the adapter performs: convert string to + # {"message": } before passing to build_agent_failure. + error_obj: dict[str, Any] = {"message": "raw string error"} + failure = build_agent_failure(error_obj) + assert "raw string error" in failure.message + # No codexErrorInfo -> no known error type. + assert failure.code is None diff --git a/tests/adapters/codex/test_structured_errors.py b/tests/adapters/codex/test_structured_errors.py new file mode 100644 index 000000000..b46bbae1b --- /dev/null +++ b/tests/adapters/codex/test_structured_errors.py @@ -0,0 +1,160 @@ +"""Codex adapter: structured errors.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.protocols import ( + GENERIC_PROVIDER_FAILURE_MESSAGE, + TurnResultAlreadyReported, +) +from band.integrations.codex import CodexJsonRpcError, RpcEvent +from band.testing import reported_failures +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestStructuredErrors: + @pytest.mark.asyncio + async def test_structured_error_from_error_event(self) -> None: + """Error events with codexErrorInfo emit structured metadata.""" + events = [ + event_notification( + "error", + { + "error": { + "message": "Context window exceeded", + "codexErrorInfo": { + "type": "ContextWindowExceeded", + "code": "context_window_exceeded", + "retryable": False, + }, + }, + "willRetry": False, + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["code"] == "ContextWindowExceeded" + assert failures[0]["detail"]["codex_is_retryable"] is False + assert "context window" in failures[0]["message"].lower() + + @pytest.mark.asyncio + async def test_structured_error_from_failed_turn(self) -> None: + """turn/completed with status=failed and codexErrorInfo emits structured error.""" + events = [ + event_notification( + "turn/completed", + { + "turn": { + "id": "turn-1", + "status": "failed", + "error": { + "message": "Usage limit hit", + "codexErrorInfo": { + "type": "UsageLimitExceeded", + "code": "usage_limit", + "retryable": False, + }, + }, + } + }, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == "UsageLimitExceeded" + + @pytest.mark.asyncio + async def test_structured_error_from_failed_turn_with_no_error_key(self) -> None: + """turn/completed with status=failed but no "error" key at all must + still report a failure before raising, not just claim it did.""" + events = [ + event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "failed", "items": []}}, + ), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + + @pytest.mark.asyncio + async def test_generic_exception_reports_and_propagates(self) -> None: + """A bare exception outside Codex's structured-error paths (not a + CodexJsonRpcError, not a delivery/already-reported failure) must + still surface via the generic fallback and propagate.""" + fake_client = FakeCodexClient( + events=[], + turn_start_error=RuntimeError("transport hiccup"), + turn_start_error_once=False, + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Agent", "A coding agent") + with pytest.raises(RuntimeError, match="transport hiccup"): + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert "transport hiccup" not in failures[0]["message"] + + @pytest.mark.asyncio + async def test_rpc_error_from_event_loop_keeps_curated_failure(self) -> None: + """RPC errors raised while receiving events keep their provider message.""" + rpc_error = CodexJsonRpcError(code=-32000, message="model unavailable") + + class RpcErrorClient(FakeCodexClient): + async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: + await self.turn_started.wait() + raise rpc_error + + rpc_error_client = RpcErrorClient() + adapter = make_codex_adapter(rpc_error_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(CodexJsonRpcError, match="model unavailable"): + await send_bootstrap(adapter, tools=tools) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == str(rpc_error) diff --git a/tests/adapters/codex/test_thread_resumption.py b/tests/adapters/codex/test_thread_resumption.py new file mode 100644 index 000000000..e96058f70 --- /dev/null +++ b/tests/adapters/codex/test_thread_resumption.py @@ -0,0 +1,157 @@ +"""Codex adapter: thread resumption.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime + +from band.adapters.codex import CodexAdapter +from band.integrations.codex import CodexJsonRpcError, CodexRequestMethod +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + agent_input, + answered_turn, +) +from tests.adapters.codexturns import ( + FakeCodexClient, +) + + +class TestThreadResumption: + """A room resumes its Codex thread from Band history, or rebuilds context when it cannot.""" + + async def test_on_event_uses_converter_history_to_resume_thread( + self, started_codex: Callable[[FakeCodexClient], Awaitable[CodexAdapter]] + ) -> None: + tools = ToolSchemaFakeTools() + fake_client = FakeCodexClient(events=answered_turn()) + adapter = await started_codex(fake_client) + + raw_history = [ + { + "message_type": "task", + "content": "historical mapping", + "metadata": { + "codex_thread_id": "thr-history", + "codex_room_id": ROOM_ID, + "codex_created_at": datetime.now(UTC).isoformat(), + }, + } + ] + await adapter.on_event( + agent_input( + "continue work", + tools, + raw_history=raw_history, + room_id=ROOM_ID, + is_session_bootstrap=True, + ) + ) + + methods = fake_client.request_methods + assert CodexRequestMethod.THREAD_RESUME in methods + assert CodexRequestMethod.THREAD_START not in methods + assert any("Status: resumed" in event["content"] for event in tools.events_sent) + + async def test_restart_rehydrates_mapping_from_previous_task_events( + self, started_codex: Callable[[FakeCodexClient], Awaitable[CodexAdapter]] + ) -> None: + tools_first = ToolSchemaFakeTools() + fake_client_first = FakeCodexClient(events=answered_turn()) + adapter_first = await started_codex(fake_client_first) + await adapter_first.on_event( + agent_input( + "first turn", + tools_first, + room_id="room-9", + is_session_bootstrap=True, + ) + ) + + persisted_history = list(tools_first.events_sent) + + tools_second = ToolSchemaFakeTools() + fake_client_second = FakeCodexClient(events=answered_turn()) + adapter_second = await started_codex(fake_client_second) + await adapter_second.on_event( + agent_input( + "second turn after restart", + tools_second, + raw_history=persisted_history, + room_id="room-9", + is_session_bootstrap=True, + ) + ) + + methods_second = fake_client_second.request_methods + assert CodexRequestMethod.THREAD_RESUME in methods_second + assert CodexRequestMethod.THREAD_START not in methods_second + + async def test_resume_failure_injects_conversation_history( + self, started_codex: Callable[[FakeCodexClient], Awaitable[CodexAdapter]] + ) -> None: + """Full lifecycle: first session produces history, second session resume fails, + verify turn input contains history context.""" + # --- First session: produce some history --- + tools_first = ToolSchemaFakeTools() + fake_client_first = FakeCodexClient(events=answered_turn()) + adapter_first = await started_codex(fake_client_first) + await adapter_first.on_event( + agent_input( + "refactor the auth module", + tools_first, + room_id="room-hist", + is_session_bootstrap=True, + ) + ) + + # Simulate persisted history: task events + text messages + persisted_history = list(tools_first.events_sent) + persisted_history.append( + { + "message_type": "text", + "content": "refactor the auth module", + "sender_name": "Alice", + } + ) + persisted_history.append( + { + "message_type": "text", + "content": "Done — split into auth_handler.py and middleware.py", + "sender_name": "CodexAgent", + } + ) + + # --- Second session: resume fails, should inject history --- + tools_second = ToolSchemaFakeTools() + fake_client_second = FakeCodexClient( + events=answered_turn(), + resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), + ) + adapter_second = await started_codex(fake_client_second) + await adapter_second.on_event( + agent_input( + "now add rate limiting", + tools_second, + raw_history=persisted_history, + room_id="room-hist", + is_session_bootstrap=True, + ) + ) + + methods = fake_client_second.request_methods + assert CodexRequestMethod.THREAD_RESUME in methods + assert CodexRequestMethod.THREAD_START in methods + + turn_start = fake_client_second.params_of(CodexRequestMethod.TURN_START)[0] + turn_input = turn_start["input"] + history_items = [ + item for item in turn_input if "[Conversation History]" in item["text"] + ] + assert len(history_items) == 1 + assert "[Alice]: refactor the auth module" in history_items[0]["text"] + assert ( + "[CodexAgent]: Done — split into auth_handler.py and middleware.py" + in history_items[0]["text"] + ) diff --git a/tests/adapters/codex/test_token_usage_counter_monotonicity.py b/tests/adapters/codex/test_token_usage_counter_monotonicity.py new file mode 100644 index 000000000..91db7af03 --- /dev/null +++ b/tests/adapters/codex/test_token_usage_counter_monotonicity.py @@ -0,0 +1,39 @@ +"""Codex adapter: token usage counter monotonicity.""" + +from __future__ import annotations + +import logging + +import pytest + +from band.integrations.codex.types import CodexTokenUsage + + +class TestTokenUsageCounterMonotonicity: + """CodexTokenUsage protection against non-monotonic cumulative updates.""" + + def test_token_usage_warns_on_non_monotonic_counters( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A decreasing cumulative counter triggers a warning. + + After the adapter anchors a new turn (``reset_turn_deltas``), a + late event from the previous turn with a smaller cumulative must + leave the turn deltas clamped to 0 rather than going negative. + """ + + usage = CodexTokenUsage() + usage.update({"usage": {"inputTokens": 100, "outputTokens": 100}}) + # Adapter anchors the new turn at cumulative=100/100. + usage.reset_turn_deltas() + with caplog.at_level(logging.WARNING, logger="band.integrations.codex.types"): + usage.update({"usage": {"inputTokens": 50, "outputTokens": 50}}) + assert any( + "token usage counter decreased" in record.message.lower() + for record in caplog.records + ) + # Cumulative stays at 100 (monotonic); turn delta clamped to 0. + assert usage.input_tokens == 100 + assert usage.output_tokens == 100 + assert usage.turn_input_tokens == 0 + assert usage.turn_output_tokens == 0 diff --git a/tests/adapters/codex/test_token_usage_cumulative_monotonicity.py b/tests/adapters/codex/test_token_usage_cumulative_monotonicity.py new file mode 100644 index 000000000..19d44d0a3 --- /dev/null +++ b/tests/adapters/codex/test_token_usage_cumulative_monotonicity.py @@ -0,0 +1,37 @@ +"""Codex adapter: late events with smaller cumulative counters must not rewind state.""" + +from __future__ import annotations + +from band.integrations.codex.types import CodexTokenUsage + + +class TestTokenUsageCumulativeMonotonicity: + """Late events with smaller cumulative counters must not rewind state. + + Without the max-preserving guard, a late ``thread/tokenUsage/updated`` + from the previous turn can overwrite the cumulative totals with a + smaller value. The next real event of the current turn then computes + ``turn_delta = new - (rewound)`` and double-counts the gap. + """ + + def test_late_smaller_event_does_not_corrupt_next_delta(self) -> None: + + usage = CodexTokenUsage() + # End of previous turn: cumulative = 100. + usage.update({"usage": {"inputTokens": 100, "outputTokens": 0}}) + assert usage.input_tokens == 100 + + # Adapter starts a new turn. + usage.reset_turn_deltas() + + # Late event from the previous turn arrives with a smaller cumulative. + usage.update({"usage": {"inputTokens": 80, "outputTokens": 0}}) + # Cumulative must stay at 100 (monotonic), turn delta clamped to 0. + assert usage.input_tokens == 100 + assert usage.turn_input_tokens == 0 + + # First real event of the new turn: cumulative = 120. + usage.update({"usage": {"inputTokens": 120, "outputTokens": 0}}) + # Turn delta is 120 - 100 = 20, NOT 120 - 80 = 40. + assert usage.turn_input_tokens == 20 + assert usage.input_tokens == 120 diff --git a/tests/adapters/codex/test_token_usage_emission.py b/tests/adapters/codex/test_token_usage_emission.py new file mode 100644 index 000000000..924c69016 --- /dev/null +++ b/tests/adapters/codex/test_token_usage_emission.py @@ -0,0 +1,45 @@ +"""Codex adapter: token usage emission.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import CodexTokenUsage +from tests.adapters.codex.kit import ROOM_ID, ToolSchemaFakeTools +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, +) + + +class TestTokenUsageEmission: + """Emission guards for _emit_token_usage_event.""" + + @pytest.mark.asyncio + async def test_token_usage_event_skipped_when_total_is_zero(self) -> None: + """_emit_token_usage_event must not emit before any real usage arrives. + + Prior to the fix, `if not usage:` was always False (dataclass + instances are truthy) so an empty token_usage event could be emitted + even before Codex sent any thread/tokenUsage/updated notification. + """ + + fake_client = FakeCodexClient() + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + # Seed an empty CodexTokenUsage (total_tokens == 0) and call the + # emit helper directly. It should short-circuit. + adapter._token_usage["thread-x"] = CodexTokenUsage() + await adapter._emit_token_usage_event( + tools=tools, thread_id="thread-x", room_id=ROOM_ID + ) + + usage_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "token_usage" + ] + assert usage_events == [] diff --git a/tests/adapters/codex/test_transport_contract_prompt.py b/tests/adapters/codex/test_transport_contract_prompt.py new file mode 100644 index 000000000..1658608c9 --- /dev/null +++ b/tests/adapters/codex/test_transport_contract_prompt.py @@ -0,0 +1,61 @@ +"""Codex adapter: transport contract prompt.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.integrations.codex.types import ( + CodexRequestMethod, + CodexSessionState, +) +from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS +from band.runtime.tools import BandTool +from tests.adapters.codex.kit import ( + run_codex_turn, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + tool_call_request, + turn_completed, +) + + +class TestTransportContractPrompt: + """Every Codex thread receives the mandatory Band transport contract.""" + + @pytest.mark.parametrize( + "config", + [ + CodexAdapterConfig(), + CodexAdapterConfig(system_prompt="custom"), + CodexAdapterConfig(include_base_instructions=False), + ], + ) + async def test_codex_transport_contract_is_in_default_and_custom_prompts( + self, + config: CodexAdapterConfig, + ) -> None: + turn = await run_codex_turn( + events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()], + config=config, + ) + inputs = turn.client.params_of(CodexRequestMethod.TURN_START)[0]["input"] + assert inputs[0]["text"].count(COMMUNICATION_INSTRUCTIONS) == 1 + + async def test_restored_codex_thread_receives_the_mandatory_transport_contract( + self, + ) -> None: + client = FakeCodexClient( + events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()] + ) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(system_prompt="custom") + ) + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, history=CodexSessionState(thread_id="restored")) + inputs = client.params_of(CodexRequestMethod.TURN_START)[0]["input"] + assert inputs[0]["text"].count(COMMUNICATION_INSTRUCTIONS) == 1 + assert client.params_of(CodexRequestMethod.THREAD_RESUME) diff --git a/tests/adapters/codex/test_turn_delivery.py b/tests/adapters/codex/test_turn_delivery.py new file mode 100644 index 000000000..bc29fc708 --- /dev/null +++ b/tests/adapters/codex/test_turn_delivery.py @@ -0,0 +1,272 @@ +"""Codex adapter: a room message starts or resumes the thread, runs a turn and settles its reply.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex.types import ( + CodexRequestMethod, + CodexSessionState, +) +from band.runtime.tools import ToolCallOutcome +from band.testing import ( + events_of_type, +) +from tests.adapters.codex.kit import ( + ROOM_ID, + ToolSchemaFakeTools, + make_platform_message, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + agent_message_completed, + event_notification, + event_request, + make_codex_adapter, + tool_call_request, + turn_completed, +) +from tests.adapters.dispatch import deliver_message + + +class TestTurnDelivery: + @pytest.mark.asyncio + async def test_bootstrap_starts_thread_without_relaying_native_deltas(self) -> None: + events = [ + event_notification( + "item/agentMessage/delta", + {"itemId": "msg-1", "delta": "harness-ok"}, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert CodexRequestMethod.THREAD_START in fake_client.request_methods + thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] + assert "dynamicTools" in thread_start + dynamic_names = [t["name"] for t in thread_start["dynamicTools"]] + assert "band_send_message" in dynamic_names + assert "band_send_event" in dynamic_names + + assert tools.messages_sent == [] + assert not tools.turn.complete + + @pytest.mark.asyncio + async def test_assistant_text_as_thought_posts_no_reply(self) -> None: + """Completed native text is narration, without mentions or settlement.""" + events = [ + agent_message_completed("(Waiting on the review.)", "msg-1"), + turn_completed(), + ] + adapter = make_codex_adapter( + FakeCodexClient(events=events), + config=CodexAdapterConfig(), + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert tools.messages_sent == [] + thoughts = [e for e in tools.events_sent if e["message_type"] == "thought"] + assert [e["content"] for e in thoughts] == ["(Waiting on the review.)"] + assert not tools.turn.complete + + @pytest.mark.asyncio + async def test_system_prompt_retry_after_turn_start_failure(self) -> None: + """System instructions stay pending until turn/start succeeds.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient( + events=events, + turn_start_error=CodexJsonRpcError( + code=-32000, + message="Model not available", + ), + turn_start_error_once=True, + ) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(model="gpt-5.5") + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + + with pytest.raises(CodexJsonRpcError, match="not available"): + await send_bootstrap(adapter, content="first try", tools=tools) + assert ROOM_ID not in adapter._prompt_injected_rooms + + await deliver_message( + adapter, + make_platform_message(room_id=ROOM_ID, content="second try"), + tools, + CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=ROOM_ID, + ) + assert ROOM_ID in adapter._prompt_injected_rooms + + turn_inputs = [ + params["input"] + for params in fake_client.params_of(CodexRequestMethod.TURN_START) + ] + assert len(turn_inputs) == 2 + for turn_input in turn_inputs: + assert any( + item.get("text", "").startswith("[System Instructions]\n") + for item in turn_input + ) + + @pytest.mark.asyncio + async def test_tool_call_request_is_dispatched_and_responded(self) -> None: + events = [ + tool_call_request(42, "band_lookup_peers", {"page": 1, "page_size": 10}), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert len(tools.tool_calls) == 1 + assert tools.tool_calls[0]["tool_name"] == "band_lookup_peers" + assert fake_client.responses + response_id, response_payload = fake_client.responses[0] + assert response_id == 42 + assert response_payload["success"] is True + + @pytest.mark.asyncio + async def test_failed_reply_leaves_native_text_as_unsettled_thought( + self, + ) -> None: + """Fallback agent text should still be delivered when send_message fails. + + The failure is a non-raising ok=False (bad args / API error) — the case the + plain execute_tool_call would misread as success and wrongly suppress. + """ + + class SendMessageFailureTools(ToolSchemaFakeTools): + async def execute_tool_call_structured( + self, tool_name: str, arguments: dict[str, Any] + ) -> ToolCallOutcome: + self.tool_calls.append({"tool_name": tool_name, "arguments": arguments}) + if tool_name == "band_send_message": + return ToolCallOutcome( + value="Error executing band_send_message: send failed", + ok=False, + error_message="send failed", + ) + return await super().execute_tool_call_structured(tool_name, arguments) + + events = [ + event_request( + 77, + "item/tool/call", + { + "tool": "band_send_message", + "arguments": {"content": "hi"}, + "callId": "call-77", + }, + ), + event_notification( + "item/completed", + { + "item": { + "type": "agentMessage", + "id": "msg-1", + "text": "fallback final text", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = SendMessageFailureTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + assert tools.messages_sent == [] + assert not tools.turn.complete + assert [e["content"] for e in events_of_type(tools, "thought")] == [ + "fallback final text" + ] + assert len(fake_client.responses) == 1 + _, payload = fake_client.responses[0] + assert payload["success"] is False + + @pytest.mark.asyncio + async def test_resume_failure_falls_back_to_thread_start(self) -> None: + events = [turn_completed()] + fake_client = FakeCodexClient( + events=events, + resume_error=CodexJsonRpcError(code=-32002, message="Not found"), + ) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap( + adapter, + tools=tools, + history=CodexSessionState(thread_id="thr-old", room_id=ROOM_ID), + ) + + methods = fake_client.request_methods + assert CodexRequestMethod.THREAD_RESUME in methods + assert CodexRequestMethod.THREAD_START in methods + + @pytest.mark.asyncio + async def test_item_completed_text_overrides_accumulated_deltas(self) -> None: + """item/completed text is authoritative and should replace any accumulated deltas.""" + events = [ + event_notification( + "item/agentMessage/delta", + {"itemId": "msg-1", "delta": "partial "}, + ), + event_notification( + "item/agentMessage/delta", + {"itemId": "msg-1", "delta": "garbled"}, + ), + event_notification( + "item/completed", + { + "item": { + "type": "agentMessage", + "id": "msg-1", + "text": "authoritative final text", + } + }, + ), + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + await send_bootstrap(adapter, tools=tools) + + # The authoritative text from item/completed should be used, not the deltas. + assert tools.messages_sent == [] + assert [e["content"] for e in events_of_type(tools, "thought")] == [ + "authoritative final text" + ] diff --git a/tests/adapters/codex/test_turn_lifecycle_events.py b/tests/adapters/codex/test_turn_lifecycle_events.py new file mode 100644 index 000000000..24de54873 --- /dev/null +++ b/tests/adapters/codex/test_turn_lifecycle_events.py @@ -0,0 +1,77 @@ +"""Codex adapter: a turn's start and terminal outcome are emitted as lifecycle events.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.testing import events_of_type +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + event_notification, + make_codex_adapter, + turn_completed, +) + + +class TestTurnLifecycleEvents: + @pytest.mark.asyncio + async def test_turn_started_lifecycle_event_emitted(self) -> None: + """Turn started lifecycle event includes input summary.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + await send_bootstrap(adapter, content="fix the login bug", tools=tools) + + started_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_lifecycle" + and e["metadata"].get("codex_turn_status") == "started" + ] + assert len(started_events) == 1 + assert ( + started_events[0]["metadata"]["codex_input_summary"] == "fix the login bug" + ) + + @pytest.mark.asyncio + async def test_failed_turn_emits_terminal_lifecycle_event(self) -> None: + """A reported failed turn still closes the lifecycle event pair.""" + events = [ + event_notification( + "turn/completed", + {"turn": {"id": "turn-1", "status": "failed", "items": []}}, + ) + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + emit_turn_lifecycle_events=True, + ), + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + lifecycle_events = [ + event + for event in events_of_type(tools, "task") + if event["metadata"].get("codex_event_type") == "turn_lifecycle" + ] + assert [ + event["metadata"]["codex_turn_status"] for event in lifecycle_events + ] == ["started", "failed"] diff --git a/tests/adapters/codex/test_turn_lifecycle_events_disabled.py b/tests/adapters/codex/test_turn_lifecycle_events_disabled.py new file mode 100644 index 000000000..33154f076 --- /dev/null +++ b/tests/adapters/codex/test_turn_lifecycle_events_disabled.py @@ -0,0 +1,41 @@ +"""Codex adapter: turn lifecycle events disabled.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import CodexAdapterConfig +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, + turn_completed, +) + + +class TestTurnLifecycleEventsDisabled: + @pytest.mark.asyncio + async def test_no_lifecycle_events_when_disabled(self) -> None: + """With emit_turn_lifecycle_events=False, neither started nor completed + lifecycle task events are emitted.""" + events = [ + turn_completed(), + ] + fake_client = FakeCodexClient(events=events) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=False) + ) + tools = ToolSchemaFakeTools() + await adapter.on_started("Agent", "A coding agent") + + await send_bootstrap(adapter, tools=tools) + + lifecycle_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_lifecycle" + ] + assert lifecycle_events == [] diff --git a/tests/adapters/codex/test_turn_timeout.py b/tests/adapters/codex/test_turn_timeout.py new file mode 100644 index 000000000..15e152d72 --- /dev/null +++ b/tests/adapters/codex/test_turn_timeout.py @@ -0,0 +1,114 @@ +"""Codex adapter: a turn past its deadline is interrupted and reported.""" + +from __future__ import annotations + +import pytest + +from band.adapters.codex import ( + CodexAdapterConfig, +) +from band.core.protocols import FAILURE_CODE_TIMEOUT, TurnResultAlreadyReported +from band.integrations.codex.types import ( + CodexRequestMethod, +) +from band.testing import ( + reported_failures, +) +from tests.adapters.codex.kit import ( + ToolSchemaFakeTools, + send_bootstrap, +) +from tests.adapters.codexturns import ( + FakeCodexClient, + make_codex_adapter, +) + + +class TestTurnTimeout: + @pytest.mark.asyncio + async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: + """When recv_event times out, the adapter sends turn/interrupt, reports + the failure, and fails the turn so its delivery is marked FAILED -- + same as every sibling adapter's own turn-timeout handling.""" + # No events means FakeCodexClient raises asyncio.TimeoutError immediately. + fake_client = FakeCodexClient(events=[]) + adapter = make_codex_adapter( + fake_client, config=CodexAdapterConfig(turn_timeout_s=0.01) + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + # Adapter should have sent turn/interrupt with both identifiers. + assert fake_client.params_of(CodexRequestMethod.TURN_INTERRUPT) == [ + {"threadId": "thr-1", "turnId": "turn-1"} + ] + + # The turn fails the platform's turn, so no separate "I stopped..." + # chat reply goes out alongside the structured failure event. + assert not tools.messages_sent + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert failures[0]["code"] == "timeout" + + @pytest.mark.asyncio + async def test_turn_timeout_emits_failed_lifecycle_event(self) -> None: + """A timed-out turn still gets a failed-status lifecycle event, same + as the transport/closed and turn/completed(status=failed) paths -- + the timeout branch's TurnResultAlreadyReported raise happens from + inside its own except clause, so it can't rely on the sibling + `except TurnResultAlreadyReported` handler to emit it.""" + fake_client = FakeCodexClient(events=[]) + adapter = make_codex_adapter( + fake_client, + config=CodexAdapterConfig( + turn_timeout_s=0.01, + emit_turn_lifecycle_events=True, + ), + ) + tools = ToolSchemaFakeTools() + + await adapter.on_started("Codex Agent", "A coding agent") + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + lifecycle_events = [ + e + for e in tools.events_sent + if e["metadata"].get("codex_event_type") == "turn_lifecycle" + ] + assert len(lifecycle_events) == 2 + assert lifecycle_events[0]["metadata"]["codex_turn_status"] == "started" + assert lifecycle_events[1]["metadata"]["codex_turn_status"] == "failed" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "interrupt_error", + [None, ConnectionResetError("Codex process is gone")], + ids=["interrupted", "interrupt-failed"], + ) + async def test_a_timed_out_turn_is_reported_once_and_the_room_serves_on( + self, interrupt_error: Exception | None + ) -> None: + """Whether or not Codex takes the interrupt, the timeout is the turn's + one failure and the room's next message still reaches Codex.""" + client = FakeCodexClient(events=[], interrupt_error=interrupt_error) + adapter = make_codex_adapter( + client, config=CodexAdapterConfig(turn_timeout_s=0.01) + ) + deliveries = [ToolSchemaFakeTools(), ToolSchemaFakeTools()] + await adapter.on_started("Codex Agent", "A coding agent") + + for tools in deliveries: + with pytest.raises(TurnResultAlreadyReported): + await send_bootstrap(adapter, tools=tools) + + assert [ + [failure["code"] for failure in reported_failures(tools)] + for tools in deliveries + ] == [[FAILURE_CODE_TIMEOUT], [FAILURE_CODE_TIMEOUT]] + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 diff --git a/tests/adapters/codex/test_unhandled_server_request.py b/tests/adapters/codex/test_unhandled_server_request.py new file mode 100644 index 000000000..6c40d6c72 --- /dev/null +++ b/tests/adapters/codex/test_unhandled_server_request.py @@ -0,0 +1,104 @@ +"""Codex adapter: a server request Band does not handle is refused, never left waiting.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from band.integrations.codex import RpcEvent +from band.integrations.codex.types import CodexRequestMethod +from band.runtime.tools import BandTool +from tests.adapters.codex.kit import CodexRoom, RefusalFailsClient +from tests.adapters.codexturns import ( + FIRST_THREAD_ID, + event_request, + tool_call_request, + turn_completed, +) +from tests.adapters.waiting import until + +# JSON-RPC's standard code for a method the receiver does not implement. +METHOD_NOT_FOUND = -32601 +UNKNOWN_METHOD = "item/unknown/request" + + +def unknown_request(request_id: int, **params: Any) -> RpcEvent: + return event_request(request_id, UNKNOWN_METHOD, params) + + +def from_autonomous_turn(request_id: int) -> RpcEvent: + """An unknown request from a native turn no Band turn started.""" + return unknown_request(request_id, threadId=FIRST_THREAD_ID, turnId="autonomous") + + +def failed_refusals(caplog: pytest.LogCaptureFixture) -> list[str]: + """Warnings logged with the error that kept a refusal from the process.""" + return [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and record.exc_info is not None + ] + + +class TestUnhandledServerRequest: + @pytest.mark.asyncio + async def test_a_turn_refuses_an_unknown_request_and_still_finishes( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + unknown_request(5), + tool_call_request(1, BandTool.NO_REPLY), + turn_completed(), + ) + + await room.send("work") + + assert room.client.error_codes == {5: METHOD_NOT_FOUND} + assert [call["tool_name"] for call in room.deliveries[0].tool_calls] == [ + BandTool.NO_REPLY + ] + + @pytest.mark.asyncio + async def test_an_unknown_request_outside_any_turn_is_refused( + self, codex_room: Callable[..., Awaitable[CodexRoom]] + ) -> None: + room = await codex_room( + tool_call_request(1, BandTool.NO_REPLY), turn_completed() + ) + await room.send("work") + + room.client.emit(from_autonomous_turn(6)) + + await until( + lambda: room.client.error_codes == {6: METHOD_NOT_FOUND}, + failure="The unknown request was never refused", + ) + + @pytest.mark.asyncio + async def test_a_refusal_the_process_cannot_take_is_logged_and_the_room_serves_on( + self, + codex_room: Callable[..., Awaitable[CodexRoom]], + caplog: pytest.LogCaptureFixture, + ) -> None: + client = RefusalFailsClient( + events=[ + tool_call_request(1, BandTool.NO_REPLY), + turn_completed(), + tool_call_request(2, BandTool.NO_REPLY), + turn_completed("turn-2"), + ] + ) + room = await codex_room(client=client) + await room.send("work") + + client.emit(from_autonomous_turn(6)) + await until( + lambda: any(UNKNOWN_METHOD in log for log in failed_refusals(caplog)), + failure="The failed refusal was never logged", + ) + await room.send("more work") + + assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 diff --git a/tests/adapters/codexturns.py b/tests/adapters/codexturns.py index c484bb6e5..8974badda 100644 --- a/tests/adapters/codexturns.py +++ b/tests/adapters/codexturns.py @@ -14,7 +14,8 @@ from typing import Any from band.adapters.codex import CodexAdapter, CodexAdapterConfig -from band.integrations.codex import CodexRequestMethod, RpcEvent +from band.integrations.codex import TRANSPORT_CLOSED, CodexRequestMethod, RpcEvent +from tests.adapters.waiting import until class RecordedRequests: @@ -47,8 +48,21 @@ async def await_released_turn( await asyncio.wait_for(asyncio.shield(turn.task), timeout=timeout_s) +#: The model FakeCodexClient's model/list offers. +LISTED_MODEL = "gpt-5.5" +#: The id of the first thread a FakeCodexClient starts. +FIRST_THREAD_ID = "thr-1" + + class FakeCodexClient(RecordedRequests): - """Minimal fake transport client for adapter tests.""" + """Minimal fake transport client for adapter tests. + + Like a real app-server, it emits a turn's scripted events only once that + turn starts: each ``turn/start`` releases the events up to and including + the next ``turn/completed`` or transport close, and a scripted event that + names no turn is stamped with the started turn's id. Once released + events run out, the turn keeps working until something ends it. + """ def __init__( self, @@ -57,9 +71,14 @@ def __init__( resume_error: Exception | None = None, turn_start_error: Exception | None = None, turn_start_error_once: bool = True, + interrupt_error: Exception | None = None, model_list_result: dict[str, Any] | None = None, model_list_error: Exception | None = None, skill_roots_error: Exception | None = None, + started_settings: dict[str, Any] | None = None, + resumed_settings: dict[str, Any] | None = None, + resumed_thread_id: str | None = None, + recorded_cwd: str | None = None, ) -> None: self.connected = False self.initialized = False @@ -71,11 +90,21 @@ def __init__( self._resume_error = resume_error self._turn_start_error = turn_start_error self._turn_start_error_once = turn_start_error_once + self._interrupt_error = interrupt_error self._model_list_result = model_list_result self._model_list_error = model_list_error self._skill_roots_error = skill_roots_error + # What thread/start and thread/resume report the thread runs with. + self._started_settings = started_settings or {} + self._resumed_settings = resumed_settings or self._started_settings + self._resumed_thread_id = resumed_thread_id + # The cwd a resumed thread reports when resume names none. + self._recorded_cwd = recorded_cwd self._thread_counter = 0 self._turn_counter = 0 + self._released: deque[RpcEvent] = deque() + self._emitted = asyncio.Event() + self.turn_started = asyncio.Event() async def connect(self) -> None: self.connected = True @@ -108,17 +137,27 @@ async def request( raise self._model_list_error if self._model_list_result is not None: return self._model_list_result - return {"data": [{"id": "gpt-5.5", "hidden": False}]} + return {"data": [{"id": LISTED_MODEL, "hidden": False}]} case CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET: if self._skill_roots_error is not None: raise self._skill_roots_error case CodexRequestMethod.THREAD_RESUME: if self._resume_error is not None: raise self._resume_error - return {"thread": {"id": payload.get("threadId", "thr-resumed")}} + thread_id = self._resumed_thread_id or payload.get("threadId") + cwd = payload.get("cwd") or self._recorded_cwd + return { + "thread": {"id": thread_id}, + **({"cwd": cwd} if cwd else {}), + **self._resumed_settings, + } case CodexRequestMethod.THREAD_START: self._thread_counter += 1 - return {"thread": {"id": f"thr-{self._thread_counter}"}} + thread_id = f"thr-{self._thread_counter}" + return {"thread": {"id": thread_id}, **self._started_settings} + case CodexRequestMethod.TURN_INTERRUPT: + if self._interrupt_error is not None: + raise self._interrupt_error case CodexRequestMethod.TURN_START: if self._turn_start_error is not None: err = self._turn_start_error @@ -126,6 +165,8 @@ async def request( self._turn_start_error = None raise err self._turn_counter += 1 + self._release_turn_events(f"turn-{self._turn_counter}") + self.turn_started.set() return { "turn": { "id": f"turn-{self._turn_counter}", @@ -137,10 +178,45 @@ async def request( return {} + def _release_turn_events(self, turn_id: str) -> None: + while self._events: + event = self._events.popleft() + self._released.append(_attributed(event, turn_id)) + if event.method in {"turn/completed", TRANSPORT_CLOSED}: + break + self._emitted.set() + + def emit(self, *events: RpcEvent) -> None: + """Send events now, outside any scripted turn.""" + self._released.extend(events) + self._emitted.set() + + async def read_by_adapter(self) -> None: + """Wait until the adapter's reader has taken every event sent so far. + + The reader routes an event in the same step that takes it, so this + also means every taken event has been routed. + """ + await until( + lambda: not self._released, + failure="The adapter never read the events sent to it", + ) + + @property + def answers(self) -> dict[int | str, dict[str, Any]]: + """Each answered request's result, by request id.""" + return dict(self.responses) + + @property + def error_codes(self) -> dict[int | str, int]: + """Each request answered with an error, by request id.""" + return {request_id: code for request_id, code, _ in self.response_errors} + async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - if not self._events: - raise TimeoutError - return self._events.popleft() + while not self._released: + self._emitted.clear() + await asyncio.wait_for(self._emitted.wait(), timeout=timeout_s) + return self._released.popleft() async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: self.responses.append((request_id, result)) @@ -159,9 +235,30 @@ async def close(self) -> None: self.closed = True -def patch_codex_client(adapter: CodexAdapter, client: FakeCodexClient) -> None: - def _build(_config: CodexAdapterConfig) -> FakeCodexClient: - return client +def _attributed(event: RpcEvent, turn_id: str) -> RpcEvent: + """``event`` naming ``turn_id`` when it names no turn of its own.""" + params = event.params if isinstance(event.params, dict) else {} + if "turnId" in params or "turn" in params or event.method == TRANSPORT_CLOSED: + return event + params = {**params, "turnId": turn_id} + return RpcEvent( + kind=event.kind, + method=event.method, + params=params, + id=event.id, + raw={**event.raw, "params": params}, + ) + + +def patch_codex_client( + adapter: CodexAdapter, client: FakeCodexClient, *replacements: FakeCodexClient +) -> None: + """Serve ``client`` as the first Codex process and each of ``replacements`` + to the next process started after a loss; the last one keeps serving.""" + processes = deque([client, *replacements]) + + def _build(_workspace: str) -> FakeCodexClient: + return processes.popleft() if len(processes) > 1 else processes[0] adapter._build_client = _build # type: ignore[method-assign] @@ -214,8 +311,7 @@ def agent_message_started( return event_notification( "item/started", { - "threadId": "thr-1", - "turnId": "turn-1", + "threadId": FIRST_THREAD_ID, "startedAtMs": 1, "item": {"type": "agentMessage", "id": item_id, "text": "", "phase": phase}, }, @@ -225,7 +321,7 @@ def agent_message_started( def agent_message_delta(text: str, item_id: str = "m") -> RpcEvent: return event_notification( "item/agentMessage/delta", - {"threadId": "thr-1", "turnId": "turn-1", "itemId": item_id, "delta": text}, + {"threadId": FIRST_THREAD_ID, "itemId": item_id, "delta": text}, ) @@ -235,8 +331,7 @@ def agent_message_completed( return event_notification( "item/completed", { - "threadId": "thr-1", - "turnId": "turn-1", + "threadId": FIRST_THREAD_ID, "completedAtMs": 2, "item": { "type": "agentMessage", diff --git a/tests/adapters/cursor_acp/__init__.py b/tests/adapters/cursor_acp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/cursor_acp/conftest.py b/tests/adapters/cursor_acp/conftest.py new file mode 100644 index 000000000..842524979 --- /dev/null +++ b/tests/adapters/cursor_acp/conftest.py @@ -0,0 +1,91 @@ +"""Fixtures for the Cursor ACP adapter tests.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path +from typing import Any + +import pytest +import pytest_asyncio + +from band.adapters.cursor_acp import ( + CursorACPAdapter, + CursorACPAdapterConfig, +) +from band.core.types import Capability +from band.runtime.custom_tools import CustomToolDef +from band.runtime.tools.types import BandTool +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + cursor_custom_tools, + echo, +) +from tests.adapters.cursor_acp.process import CursorProcess +from tests.adapters.lifecycle.kit import hosted +from tests.baseline.adapter import Adapter +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import pair_in_process +from tests.integrations.acp.lifecycleprofile import PROFILES + + +@pytest_asyncio.fixture(loop_scope="function") +async def cursor_process( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[CursorProcess]: + profile = next( + profile for profile in PROFILES if profile.name == Adapter.CURSOR_ACP + ) + async with hosted(profile, tmp_path, monkeypatch) as host: + host.shared = False + yield CursorProcess(host) + + +# The adapter's ACP runtime lives on the loop that started it, so setup, the +# test and teardown share the test's own loop. +@pytest_asyncio.fixture(loop_scope="function") +async def cursor_room() -> AsyncIterator[Callable[..., Awaitable[CursorRoom]]]: + adapters: list[CursorACPAdapter] = [] + + async def open_room( + agent: FakeACPAgent, + *, + capabilities: set[Capability] | None = None, + additional_tools: list[CustomToolDef] | None = None, + **config: Any, + ) -> CursorRoom: + adapter = CursorACPAdapter( + CursorACPAdapterConfig(**config), + capabilities=capabilities, + additional_tools=additional_tools, + ) + pair_in_process(adapter, agent) + await adapter.on_started("Cursor", "Cursor agent under test") + adapters.append(adapter) + return CursorRoom(adapter, agent) + + yield open_room + for adapter in adapters: + await adapter.stop() + + +@pytest.fixture +def cursor_approval_room( + cursor_room: Callable[..., Awaitable[CursorRoom]], +) -> Callable[[str, str], Awaitable[CursorRoom]]: + async def open_room(title: str, approval_mode: str) -> CursorRoom: + return await cursor_room( + FakeACPAgent() + .will_ask_permission(title=title) + .will_call_mcp_tool( + "reply", + BandTool.SEND_MESSAGE, + arguments={"content": "finished", "mentions": ["@user-1"]}, + ), + approval_mode=approval_mode, + capabilities={Capability.MEMORY}, + additional_tools=cursor_custom_tools(echo), + decision_timeout_s=0.1, + ) + + return open_room diff --git a/tests/adapters/cursor_acp/kit.py b/tests/adapters/cursor_acp/kit.py new file mode 100644 index 000000000..8ee2ebc70 --- /dev/null +++ b/tests/adapters/cursor_acp/kit.py @@ -0,0 +1,220 @@ +"""Doubles and helpers for the Cursor ACP adapter tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, cast +from uuid import uuid4 + +from acp.schema import NewSessionResponse +from pydantic import BaseModel, create_model + +from band.adapters.cursor_acp import ( + CursorACPAdapter, + CursorACPAdapterConfig, + CursorTurn, +) +from band.core.protocols import AgentToolsProtocol +from band.core.types import AgentInput, HistoryProvider, PlatformMessage +from band.runtime.custom_tools import CustomToolDef +from band.runtime.tools.registry import BAND_MCP_SERVER_NAME, mcp_tool_spelling +from band.runtime.tools.types import BandTool +from band.testing import FakeAgentTools +from tests.integrations.acp.acp_toolkit.agent import ( + FakeACPAgent, + Gate, +) +from tests.integrations.acp.acp_toolkit.harness import ( + accepted_work_finished, + pending_decisions, +) + + +class DecisionTools(FakeAgentTools): + """Room tools that enforce the real mention contract (unlike a hand-rolled + fake, this raises on a mention-less send -- see FakeAgentTools.send_notice) + and signal once a pending decision is visible in the room. + + Decision prompts and replies are the adapter's own posts, so they go + through ``send_notice``. + """ + + def __init__(self) -> None: + super().__init__() + self.prompt_sent = asyncio.Event() + + async def send_notice( + self, content: str, mentions: list[str] | list[dict[str, str]] | None = None + ) -> object: + result = await super().send_notice(content, mentions) + self.prompt_sent.set() + return result + + @property + def messages(self) -> list[str]: + return [cast(str, sent["content"]) for sent in self.messages_sent] + + +class FailingDecisionTools(DecisionTools): + def __init__(self, *, fail_after: int = 0) -> None: + super().__init__() + self._fail_after = fail_after + + async def send_notice( + self, content: str, mentions: list[str] | list[dict[str, str]] | None = None + ) -> object: + if len(self.messages_sent) >= self._fail_after: + raise RuntimeError("room delivery failed") + return await super().send_notice(content, mentions) + + +def cursor_turn( + adapter: CursorACPAdapter, + room_id: str, + tools: AgentToolsProtocol, + requester_id: str | None, + session_id: str | None = None, +) -> CursorTurn: + return CursorTurn( + room=adapter._room(room_id), + tools=tools, + requester_id=requester_id, + release=asyncio.get_running_loop().create_future(), + session_id=session_id, + ) + + +def room_message( + content: str, *, room_id: str = "room-1", sender_id: str = "user-1" +) -> PlatformMessage: + return PlatformMessage( + id=str(uuid4()), + room_id=room_id, + content=content, + sender_id=sender_id, + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ) + + +def said(tools: FakeAgentTools) -> list[str]: + return [cast(str, sent["content"]) for sent in tools.messages_sent] + + +def permission_requests(tools: FakeAgentTools) -> list[str]: + return [message for message in said(tools) if "needs permission" in message] + + +class CursorRoom: + """A Cursor room whose ``agent acp`` peer is a scripted in-process ACP + agent: each message goes through ``on_event`` with its own tools, as the + runtime delivers it, so every turn is judged.""" + + def __init__(self, adapter: CursorACPAdapter, agent: FakeACPAgent) -> None: + self.adapter = adapter + self.agent = agent + self._bootstrapped = False + + async def send(self, content: str) -> FakeAgentTools: + """Deliver ``content``; return the tools its turn posted through.""" + tools = FakeAgentTools(room_id="room-1") + bootstrap, self._bootstrapped = not self._bootstrapped, True + await self.adapter.on_event( + AgentInput( + msg=room_message(content), + tools=tools, + history=HistoryProvider(raw=[]), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=bootstrap, + room_id="room-1", + ) + ) + return tools + + async def turns_finished(self) -> None: + """Wait for every turn still running after its message returned; a + disposed room already joined its turns.""" + if (room := self.adapter._rooms.get("room-1")) is not None: + await accepted_work_finished(room) + + +def pending_tokens(adapter: CursorACPAdapter) -> list[str]: + """Every open decision token, across the adapter's rooms.""" + return [token for room in pending_decisions(adapter).values() for token in room] + + +def activate(adapter: CursorACPAdapter, turn: CursorTurn) -> CursorTurn: + """Accept ``turn`` and let it take its room, as a running turn does.""" + adapter._accept(turn) + adapter._take_room(turn) + return turn + + +async def resident(open_room: Callable[..., Awaitable[CursorRoom]]) -> CursorACPAdapter: + """A Cursor adapter whose ``room-1`` has a live residency, so a turn + activated there speaks for it.""" + room = await open_room( + FakeACPAgent().will_call_mcp_tool( + "reply", + BandTool.SEND_MESSAGE, + arguments={"content": "ready", "mentions": ["@user-1"]}, + ) + ) + await room.send("start") + await room.turns_finished() + return room.adapter + + +def cursor_in( + workspace_root: Path, config: CursorACPAdapterConfig | None = None +) -> CursorACPAdapter: + """A Cursor adapter whose room workspaces live under ``workspace_root``.""" + return CursorACPAdapter( + config, workspace_for_room=lambda room_id: str(workspace_root / room_id) + ) + + +class EchoInput(BaseModel): + text: str + + +def echo(arguments: EchoInput) -> str: + return arguments.text + + +def custom_reply() -> str: + return "custom reply" + + +def cursor_custom_tools( + echo_handler: Callable[[EchoInput], str], +) -> list[CustomToolDef]: + return [ + (EchoInput, echo_handler), + ( + create_model( + f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, BandTool.SEND_MESSAGE)}Input" + ), + custom_reply, + ), + ] + + +class SetupGatedAgent(FakeACPAgent): + def __init__(self) -> None: + super().__init__() + self.setup = Gate() + + async def new_session( + self, cwd: str, mcp_servers: Any = None, **kwargs: Any + ) -> NewSessionResponse: + self.setup.received.set() + await self.setup.release.wait() + return await super().new_session(cwd, mcp_servers, **kwargs) diff --git a/tests/adapters/cursor_acp/process.py b/tests/adapters/cursor_acp/process.py new file mode 100644 index 000000000..d91ac5927 --- /dev/null +++ b/tests/adapters/cursor_acp/process.py @@ -0,0 +1,106 @@ +"""Drive and observe Cursor decisions through its real subprocess profile.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import cast + +from band.adapters.cursor_acp import CursorACPAdapter +from band.integrations.acp.cursor import ROOM_COMMAND, CursorCommandWord +from tests.adapters.lifecycle.kit import RetentionHost +from tests.adapters.waiting import UNTIL_TIMEOUT_S, until +from tests.framework_conformance.turnprobes import DispatchingFakeTools +from tests.integrations.acp.acp_toolkit.harness import ( + TranscriptTools, + accepted_work_finished, + decision_token, +) +from tests.integrations.acp.acp_toolkit.lifecycle import LifecycleChild +from tests.integrations.acp.acp_toolkit.workspacepeer import ( + CURSOR_ALLOW_OPTION, + CURSOR_JOURNAL, + CURSOR_QUESTION_ID, + CursorChoice, + CursorPeerEvent, + CursorPeerRecord, +) +from tests.integrations.acp.roomownership.kit import questions + + +class RecoveryClose(StrEnum): + SETTLED = "settled-recovery" + REFUSED_JOIN = "refused-close-joined-by-successor" + + +class CursorProcessTools(DispatchingFakeTools, TranscriptTools): + """The peer's MCP tools post through the same transcript as its decisions.""" + + def __init__(self, room_id: str) -> None: + super().__init__() + self.room_id = room_id + + +def continue_question(token: str) -> str: + return ( + f"{ROOM_COMMAND} {CursorCommandWord.ANSWER} {token} " + f"{CURSOR_QUESTION_ID}={CursorChoice.CONTINUE}" + ) + + +def allow_write(token: str) -> str: + return f"{ROOM_COMMAND} {CursorCommandWord.SELECT} {token} {CURSOR_ALLOW_OPTION}" + + +async def asked_question(tools: CursorProcessTools) -> str: + await until( + lambda: bool(questions(tools.reply)), failure="Cursor never asked a question" + ) + [prompt] = questions(tools.reply) + return decision_token(prompt) + + +@dataclass +class CursorProcess: + host: RetentionHost + + @property + def adapter(self) -> CursorACPAdapter: + return cast(CursorACPAdapter, self.host.adapter) + + def workspace(self, room: str = "room-a") -> Path: + holder = self.adapter._rooms.get(room) + workspace = ( + holder.workspace + if holder is not None and holder.workspace is not None + else self.host.workspace(room) + ) + return Path(workspace) + + async def send(self, content: str, room: str = "room-a") -> CursorProcessTools: + tools = CursorProcessTools(room) + async with asyncio.timeout(UNTIL_TIMEOUT_S): + await self.host.deliver(room, content, tools) + return tools + + async def finished(self, room: str = "room-a") -> None: + async with asyncio.timeout(UNTIL_TIMEOUT_S): + await accepted_work_finished(self.adapter._rooms[room]) + + async def kill_child(self, number: int) -> None: + child = cast(LifecycleChild, self.host.children[number]) + child.process.kill() + async with asyncio.timeout(UNTIL_TIMEOUT_S): + await child.process.wait() + + def observed(self, event: CursorPeerEvent, room: str = "room-a") -> list[str]: + path = self.workspace(room) / CURSOR_JOURNAL + if not path.exists(): + return [] + records = [ + CursorPeerRecord.model_validate_json(line) + for line in path.read_text().splitlines() + ] + return [record.value for record in records if record.event == event] diff --git a/tests/adapters/cursor_acp/test_band_tool_approval.py b/tests/adapters/cursor_acp/test_band_tool_approval.py new file mode 100644 index 000000000..cc07e2d03 --- /dev/null +++ b/tests/adapters/cursor_acp/test_band_tool_approval.py @@ -0,0 +1,209 @@ +"""Which tool permissions reach the room under each Cursor approval policy.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest +from acp.schema import ( + AllowedOutcome, + PermissionOption, + ToolCallUpdate, +) + +from band.core.types import Capability +from band.integrations.acp.permissions import ALLOW_ALWAYS_KIND, ALLOW_ONCE_KIND +from band.runtime.tools.registry import BAND_MCP_SERVER_NAME +from band.runtime.tools.types import BandTool +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + permission_requests, +) +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent + + +class TestBandToolApproval: + @pytest.mark.asyncio + @pytest.mark.parametrize("approval_mode", ["manual", "auto_accept", "auto_decline"]) + @pytest.mark.parametrize( + "title", + [ + pytest.param("band-band_store_memory: band_store_memory", id="memory"), + pytest.param( + "band-band_send_message: band_send_message", + id="reply-with-custom-name-collision", + ), + pytest.param("band-echo: echo", id="custom-tool"), + ], + ) + async def test_registered_band_tools_are_approved_without_asking_the_room( + self, + cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], + approval_mode: str, + title: str, + ) -> None: + room = await cursor_approval_room(title, approval_mode) + tools = await room.send("run the tool") + await room.turns_finished() + + assert room.agent.approved is True + assert permission_requests(tools) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("approval_mode", "approved", "room_requests"), + [ + ("manual", False, 1), + ("auto_accept", True, 0), + ("auto_decline", False, 0), + ], + ) + @pytest.mark.parametrize( + "title", + [ + pytest.param( + "other-band_send_message: band_send_message", id="other-server" + ), + pytest.param( + "band-band_store_memory: band_send_message", id="mismatched-identity" + ), + pytest.param("shell", id="native-tool"), + pytest.param("shell: shell", id="native-display-title"), + pytest.param("band-unknown: unknown", id="unregistered-tool"), + ], + ) + async def test_other_tools_follow_cursor_approval_policy( + self, + cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], + approval_mode: str, + title: str, + approved: bool, + room_requests: int, + ) -> None: + room = await cursor_approval_room(title, approval_mode) + tools = await room.send("run the tool") + await room.turns_finished() + + assert room.agent.approved is approved + assert len(permission_requests(tools)) == room_requests + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("approval_mode", "persistent", "approved", "room_requests"), + [ + ("manual", False, True, 0), + ("auto_accept", False, True, 0), + ("auto_decline", False, True, 0), + ("manual", True, False, 1), + ("auto_accept", True, True, 0), + ("auto_decline", True, False, 0), + ], + ) + async def test_raw_input_band_permission_obeys_grant_duration( + self, + cursor_room: Callable[..., Awaitable[CursorRoom]], + approval_mode: str, + persistent: bool, + approved: bool, + room_requests: int, + ) -> None: + agent = FakeACPAgent() + + @agent.on_prompt + async def ask_band_permission(peer: FakeACPAgent, session_id: str) -> None: + response = await peer.ask_permission( + session_id, + ToolCallUpdate( + toolCallId="reply", + title="band-band_store_memory: band_store_memory" + if persistent + else "Reply to room", + rawInput=None + if persistent + else { + "providerIdentifier": BAND_MCP_SERVER_NAME, + "toolName": BandTool.STORE_MEMORY, + "args": {}, + }, + ), + [ + PermissionOption( + optionId="grant", + name="Allow", + kind=ALLOW_ALWAYS_KIND if persistent else ALLOW_ONCE_KIND, + ) + ], + ) + peer.approved = isinstance(response.outcome, AllowedOutcome) + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": "finished", "mentions": ["@user-1"]}, + ) + + room = await cursor_room( + agent, + approval_mode=approval_mode, + capabilities={Capability.MEMORY}, + decision_timeout_s=0.1, + ) + tools = await room.send("reply") + await room.turns_finished() + + assert agent.approved is approved + assert len(permission_requests(tools)) == room_requests + + @pytest.mark.asyncio + @pytest.mark.parametrize("approval_mode", ["manual", "auto_decline"]) + async def test_registered_native_title_uses_configured_permission_policy( + self, + cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], + approval_mode: str, + ) -> None: + room = await cursor_approval_room("echo", approval_mode) + tools = await room.send("run native echo") + await room.turns_finished() + assert room.agent.approved is False + assert len(permission_requests(tools)) == ( + 1 if approval_mode == "manual" else 0 + ) + + @pytest.mark.asyncio + async def test_foreign_mcp_identity_cannot_use_a_band_display_title( + self, + cursor_room: Callable[..., Awaitable[CursorRoom]], + ) -> None: + agent = FakeACPAgent() + + @agent.on_prompt + async def ask_foreign_permission(peer: FakeACPAgent, session_id: str) -> None: + response = await peer.ask_permission( + session_id, + ToolCallUpdate( + toolCallId="foreign", + title="band-band_send_message: band_send_message", + rawInput={ + "providerIdentifier": "foreign", + "toolName": BandTool.SEND_MESSAGE, + "args": {}, + }, + ), + [ + PermissionOption( + optionId="once", name="Allow once", kind=ALLOW_ONCE_KIND + ) + ], + ) + peer.approved = isinstance(response.outcome, AllowedOutcome) + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": "finished", "mentions": ["@user-1"]}, + ) + + room = await cursor_room(agent, approval_mode="auto_decline") + await room.send("run") + await room.turns_finished() + assert agent.approved is False diff --git a/tests/adapters/cursor_acp/test_config.py b/tests/adapters/cursor_acp/test_config.py new file mode 100644 index 000000000..8989e471f --- /dev/null +++ b/tests/adapters/cursor_acp/test_config.py @@ -0,0 +1,70 @@ +"""Cursor ACP adapter config validation.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.adapters.cursor_acp import ( + CursorACPAdapter, + CursorACPAdapterConfig, +) + + +class TestCursorACPAdapterConfig: + @pytest.mark.parametrize( + ("settings", "error"), + [ + ({"api_key": "a", "auth_token": "b"}, "either api_key or auth_token"), + ({"command": ()}, "requires a command"), + ( + {"auth_method": "api_key"}, + "auth_method\n Input should be 'cursor_login'", + ), + ( + {"decision_timeout_s": 0.0}, + "decision_timeout_s\n Input should be greater than 0", + ), + ( + {"max_pending_decisions": 0}, + "max_pending_decisions\n Input should be greater than 0", + ), + ( + {"decision_timeout_s": 300.0, "turn_timeout_s": 300.0}, + "decision_timeout_s must be less than turn_timeout_s", + ), + ( + {"decision_timeout_s": 900.0}, + "decision_timeout_s must be less than turn_timeout_s", + ), + ], + ids=[ + "ambiguous-auth", + "empty-command", + "foreign-auth-method", + "non-positive-decision-timeout", + "non-positive-max-pending", + "decision-timeout-equals-turn-timeout", + "decision-timeout-reaching-the-default-turn-timeout", + ], + ) + def test_rejects_invalid_settings( + self, settings: dict[str, object], error: str + ) -> None: + with pytest.raises(ValueError, match=error): + CursorACPAdapterConfig.model_validate(settings) + + def test_authorized_senders_load_from_any_list_of_ids(self) -> None: + config = CursorACPAdapterConfig.model_validate( + {"decision_authorized_senders": ["owner", "owner", "admin"]} + ) + + assert config.decision_authorized_senders == frozenset({"owner", "admin"}) + + def test_cwd_and_a_workspace_resolver_are_exclusive(self, tmp_path: Path) -> None: + with pytest.raises(ValueError, match="either cwd or workspace_for_room"): + CursorACPAdapter( + CursorACPAdapterConfig(cwd=str(tmp_path)), + workspace_for_room=lambda room_id: str(tmp_path / room_id), + ) diff --git a/tests/adapters/cursor_acp/test_control_messages.py b/tests/adapters/cursor_acp/test_control_messages.py new file mode 100644 index 000000000..640abb187 --- /dev/null +++ b/tests/adapters/cursor_acp/test_control_messages.py @@ -0,0 +1,198 @@ +"""The ``/cursor`` room control commands.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import cast + +import pytest +from acp.schema import PermissionOption + +from band.adapters.cursor_acp import ( + CursorACPAdapter, + CursorACPAdapterConfig, +) +from band.core.types import PlatformMessage +from band.integrations.acp.client_adapter import ACPPermissionRequest +from band.integrations.acp.cursor import ROOM_COMMAND, CursorCommandWord +from band.integrations.acp.types import ACPToolCall +from tests.adapters.cursor_acp.kit import ( + DecisionTools, + cursor_turn, + pending_tokens, + room_message, +) + + +class TestCursorACPAdapterControlMessages: + @pytest.mark.asyncio + async def test_bare_cursor_lists_pending_decisions(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, SimpleNamespace(content="/cursor", sender_id="user-1") + ), + tools, + "room-1", + ) + + assert handled is True + assert tools.messages[-1] == "Pending Cursor decisions: none" + + @pytest.mark.asyncio + async def test_cursor_decisions_lists_pending_decisions(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace(content="/cursor decisions", sender_id="user-1"), + ), + tools, + "room-1", + ) + + assert handled is True + assert tools.messages[-1] == "Pending Cursor decisions: none" + + @pytest.mark.asyncio + async def test_a_two_word_command_shows_usage(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace(content="/cursor accept", sender_id="user-1"), + ), + tools, + "room-1", + ) + + assert handled is True + assert tools.messages[-1] == ( + "Use `/cursor decisions` to list pending Cursor decisions." + ) + + @pytest.mark.asyncio + async def test_a_structurally_invalid_decision_command_is_rejected(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + pending = asyncio.create_task( + adapter._resolve_plan( + cursor_turn(adapter, "room-1", tools, "user-1", "session-1"), + {"plan": "Plan"}, + ) + ) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, + # "select" is a permission verb, not a plan verb. + SimpleNamespace( + content=f"/cursor select {token} allow-once", sender_id="user-1" + ), + ), + tools, + "room-1", + ) + + assert handled is True + assert tools.messages[-1] == ( + f"That command is not valid for Cursor plan decision `{token}`." + ) + assert not pending.done() + adapter._cancel_all_decisions() + await pending + + @pytest.mark.asyncio + async def test_manual_permission_denies_silently_with_no_matching_active_turn( + self, + ) -> None: + """No active turn for this room/session means there is no room to + relay the decision to; it must deny without sending anything.""" + adapter = CursorACPAdapter() + request = ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall("call-1", "shell", {}), + options=( + PermissionOption( + optionId="allow-once", name="Allow", kind="allow_once" + ), + ), + ) + + result = await adapter._resolve_cursor_permission(request) + + assert result is None + + @pytest.mark.asyncio + async def test_exceeding_max_pending_decisions_evicts_the_rooms_oldest( + self, + ) -> None: + adapter = CursorACPAdapter(CursorACPAdapterConfig(max_pending_decisions=1)) + first_tools = DecisionTools() + second_tools = DecisionTools() + + first = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn(adapter, "room-1", first_tools, "user-1", "session-1"), + prompt="Plan {token}", + ) + ) + await first_tools.prompt_sent.wait() + second = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn( + adapter, "room-1", second_tools, "user-1", "session-1" + ), + prompt="Plan {token}", + ) + ) + await second_tools.prompt_sent.wait() + + assert await first is None + assert len(pending_tokens(adapter)) == 1 + adapter._cancel_all_decisions() + await second + + @pytest.mark.asyncio + async def test_a_full_room_never_evicts_another_rooms_decision(self) -> None: + adapter = CursorACPAdapter(CursorACPAdapterConfig(max_pending_decisions=1)) + rooms = ("room-1", "room-2") + tools = {room: DecisionTools() for room in rooms} + waits = [ + asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn( + adapter, room, tools[room], "user-1", f"session-{room}" + ), + prompt="Plan {token}", + ) + ) + for room in rooms + ] + for room in rooms: + await tools[room].prompt_sent.wait() + [first_prompt] = tools["room-1"].messages + token = first_prompt.removeprefix("Plan ") + + await adapter._handle_control_message( + room_message(f"{ROOM_COMMAND} {CursorCommandWord.ACCEPT} {token}"), + tools["room-1"], + "room-1", + ) + + assert await waits[0] == {"outcome": {"outcome": "accepted"}} + adapter._cancel_all_decisions() + assert await waits[1] is None diff --git a/tests/adapters/cursor_acp/test_decisions.py b/tests/adapters/cursor_acp/test_decisions.py new file mode 100644 index 000000000..dbcbe772b --- /dev/null +++ b/tests/adapters/cursor_acp/test_decisions.py @@ -0,0 +1,751 @@ +"""Room-driven permission, question and plan decisions.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from types import SimpleNamespace +from typing import cast + +import pytest +from acp.schema import PermissionOption + +from band.adapters.cursor_acp import ( + DECISION_UNAUTHORIZED_MESSAGE, + CursorACPAdapter, + CursorACPAdapterConfig, +) +from band.client.streaming import ControlMode +from band.core.types import PlatformMessage +from band.integrations.acp.client_adapter import ACPPermissionRequest +from band.integrations.acp.cursor import PLAN_REQUESTED_TEMPLATE +from band.integrations.acp.permissions import ALLOW_ONCE_KIND +from band.integrations.acp.types import ACPToolCall +from band.runtime.tools.registry import BAND_MCP_SERVER_NAME +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + DecisionTools, + FailingDecisionTools, + activate, + cursor_turn, + pending_tokens, + permission_requests, + resident, + room_message, + said, +) +from tests.integrations.acp.acp_toolkit.agent import ( + FakeACPAgent, + allow_or_reject, +) +from tests.integrations.acp.acp_toolkit.harness import ( + decision_token, + pending_decisions, +) + + +class TestCursorACPAdapterDecisions: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "inactive", + ["absent", "other-room", "other-session", "interrupted", "cleaned-up"], + ) + @pytest.mark.parametrize("approval_mode", ["manual", "auto_accept", "auto_decline"]) + async def test_band_permission_requires_a_matching_live_turn( + self, inactive: str, approval_mode: str + ) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter(CursorACPAdapterConfig(approval_mode=approval_mode)) + if inactive != "absent": + activate( + adapter, + cursor_turn( + adapter, + "other-room" if inactive == "other-room" else "room-1", + tools, + "user-1", + "other-session" if inactive == "other-session" else "session-1", + ), + ) + if inactive == "interrupted": + await adapter.on_interrupt("room-1", ControlMode.STOP) + if inactive == "cleaned-up": + await adapter.on_cleanup("room-1") + request = ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall( + "reply", + adapter._canonical_tool_name( + "band-band_send_message: band_send_message" + ), + {}, + mcp_server=BAND_MCP_SERVER_NAME, + ), + options=( + PermissionOption( + optionId="once", name="Allow once", kind=ALLOW_ONCE_KIND + ), + ), + ) + + assert await adapter._resolve_cursor_permission(request) is None + assert tools.messages == [] + + @pytest.mark.asyncio + async def test_manual_question_requires_a_valid_room_answer(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + turn = cursor_turn(adapter, "room-1", tools, "user-1", "session-1") + activate(adapter, turn) + + pending = asyncio.create_task( + adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "mode", + "prompt": "Choose mode", + "options": [ + {"id": "agent", "label": "Agent"}, + {"id": "plan", "label": "Plan"}, + ], + } + ] + }, + ) + ) + await tools.prompt_sent.wait() + assert "agent=Agent" in tools.messages[0] + assert "plan=Plan" in tools.messages[0] + token = pending_tokens(adapter)[0] + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor answer {token} mode=plan", sender_id="user-1" + ), + ), + tools, + "room-1", + ) + + assert handled is True + assert await pending == { + "outcome": { + "outcome": "answered", + "answers": [{"questionId": "mode", "selectedOptionIds": ["plan"]}], + } + } + + @pytest.mark.asyncio + async def test_manual_multi_question_rejects_an_unauthorized_answer_then_resolves( + self, + ) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter( + CursorACPAdapterConfig(decision_authorized_senders=frozenset({"owner"})) + ) + turn = cursor_turn(adapter, "room-1", tools, "requester", "session-1") + activate(adapter, turn) + pending = asyncio.create_task( + adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "files", + "prompt": "Choose files", + "allowMultiple": True, + "options": [ + {"id": "readme", "label": "README"}, + {"id": "config", "label": "Config"}, + ], + }, + { + "id": "mode", + "prompt": "Choose mode", + "options": [{"id": "plan", "label": "Plan"}], + }, + ] + }, + ) + ) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=(f"/cursor answer {token} files=readme,config mode=plan"), + sender_id="intruder", + ), + ), + tools, + "room-1", + ) + + assert not pending.done() + assert tools.messages[-1] == DECISION_UNAUTHORIZED_MESSAGE + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=(f"/cursor answer {token} files=readme,config mode=plan"), + sender_id="owner", + ), + ), + tools, + "room-1", + ) + + assert await pending == { + "outcome": { + "outcome": "answered", + "answers": [ + { + "questionId": "files", + "selectedOptionIds": ["readme", "config"], + }, + {"questionId": "mode", "selectedOptionIds": ["plan"]}, + ], + } + } + + @pytest.mark.asyncio + async def test_a_prompt_less_question_is_visible_and_answerable(self) -> None: + """Regression: a question with no string ``prompt`` was silently + absent from the summary shown to the user while still being + required by _answer_result, making a complete answer impossible to + construct. It must fall back to a displayable label (its id).""" + tools = DecisionTools() + adapter = CursorACPAdapter() + turn = cursor_turn(adapter, "room-1", tools, "user-1", "session-1") + activate(adapter, turn) + + pending = asyncio.create_task( + adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "confirm", + "options": [ + {"id": "yes", "label": "Yes"}, + {"id": "no", "label": "No"}, + ], + } + ] + }, + ) + ) + await tools.prompt_sent.wait() + assert "confirm" in tools.messages[0] + token = pending_tokens(adapter)[0] + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor answer {token} confirm=yes", sender_id="user-1" + ), + ), + tools, + "room-1", + ) + + assert await pending == { + "outcome": { + "outcome": "answered", + "answers": [{"questionId": "confirm", "selectedOptionIds": ["yes"]}], + } + } + + @pytest.mark.asyncio + async def test_a_question_with_no_answerable_options_cancels_the_whole_exchange( + self, + ) -> None: + """Regression: a question with an empty/unparseable options list used + to silently vanish from the projected choices, so the exchange could + be marked 'answered' without Cursor's required question ever being + answered. It must cancel outright instead.""" + adapter = CursorACPAdapter() + turn = cursor_turn(adapter, "room-1", DecisionTools(), "user-1", "session-1") + + result = await adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "q1", + "prompt": "Pick one", + "options": [{"id": "a", "label": "A"}], + }, + {"id": "q2", "prompt": "Malformed", "options": []}, + ] + }, + ) + + assert result == {"outcome": {"outcome": "cancelled"}} + + @pytest.mark.asyncio + async def test_a_duplicate_question_id_keeps_the_first_occurrence(self) -> None: + """Regression: two questions sharing an id used to silently collapse + to whichever was processed last, discarding the earlier question's + options with no signal.""" + adapter = CursorACPAdapter(CursorACPAdapterConfig(question_mode="auto_first")) + turn = cursor_turn(adapter, "room-1", DecisionTools(), "user-1", "session-1") + + result = await adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "q1", + "prompt": "First q1", + "options": [{"id": "a", "label": "A"}], + }, + { + "id": "q1", + "prompt": "Second q1", + "options": [{"id": "b", "label": "B"}], + }, + ] + }, + ) + + assert result == { + "outcome": { + "outcome": "answered", + "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], + } + } + + def test_duplicate_question_answer_is_rejected(self) -> None: + result = CursorACPAdapter._answer_result( + ["mode=agent", "mode=plan"], + {"mode": ("agent", "plan")}, + frozenset(), + ) + + assert not isinstance(result, dict) + + @pytest.mark.asyncio + async def test_auto_question_uses_the_first_advertised_option(self) -> None: + adapter = CursorACPAdapter(CursorACPAdapterConfig(question_mode="auto_first")) + turn = cursor_turn(adapter, "room-1", DecisionTools(), "user-1", "session-1") + + result = await adapter._resolve_question( + turn, + { + "questions": [ + { + "id": "mode", + "prompt": "Choose mode", + "options": [ + {"id": "agent", "label": "Agent"}, + {"id": "plan", "label": "Plan"}, + ], + } + ] + }, + ) + + assert cast(dict[str, object], cast(dict[str, object], result)["outcome"])[ + "answers" + ] == [{"questionId": "mode", "selectedOptionIds": ["agent"]}] + + @pytest.mark.asyncio + async def test_manual_permission_can_be_denied_from_the_room( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + tools = DecisionTools() + adapter = await resident(cursor_room) + activate(adapter, cursor_turn(adapter, "room-1", tools, "user-1", "session-1")) + request = ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall("call-1", "shell", {}), + options=( + PermissionOption( + optionId="allow-once", name="Allow", kind="allow_once" + ), + ), + ) + pending = asyncio.create_task(adapter._resolve_cursor_permission(request)) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace(content=f"/cursor deny {token}", sender_id="user-1"), + ), + tools, + "room-1", + ) + + assert await pending is None + + @pytest.mark.asyncio + async def test_manual_permission_can_select_an_advertised_option( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + tools = DecisionTools() + adapter = await resident(cursor_room) + activate(adapter, cursor_turn(adapter, "room-1", tools, "user-1", "session-1")) + request = ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall("call-1", "shell", {}), + options=( + PermissionOption( + optionId="allow-once", name="Allow", kind="allow_once" + ), + ), + ) + pending = asyncio.create_task(adapter._resolve_cursor_permission(request)) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor select {token} allow-once", sender_id="user-1" + ), + ), + tools, + "room-1", + ) + + assert await pending == "allow-once" + + @pytest.mark.asyncio + async def test_an_always_grant_settles_a_parallel_repeat_without_asking( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + tools = DecisionTools() + adapter = await resident(cursor_room) + activate(adapter, cursor_turn(adapter, "room-1", tools, "user-1", "session-1")) + + def request(call_id: str) -> ACPPermissionRequest: + return ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall(call_id, "`echo x >> out.txt`", {}), + options=( + PermissionOption( + optionId="allow-once", name="Allow once", kind="allow_once" + ), + PermissionOption( + optionId="allow-always", + name="Allow always", + kind="allow_always", + ), + ), + ) + + first = asyncio.create_task(adapter._resolve_cursor_permission(request("a"))) + repeat = asyncio.create_task(adapter._resolve_cursor_permission(request("b"))) + await tools.prompt_sent.wait() + [token] = pending_tokens(adapter) + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor select {token} allow-always", sender_id="user-1" + ), + ), + tools, + "room-1", + ) + + assert await asyncio.gather(first, repeat) == ["allow-always"] * 2 + assert len(permission_requests(tools)) == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("plan_mode", "outcome"), + [("auto_accept", "accepted"), ("auto_decline", "rejected")], + ) + async def test_automatic_plan_policy_returns_its_outcome( + self, plan_mode: str, outcome: str + ) -> None: + adapter = CursorACPAdapter( + CursorACPAdapterConfig.model_validate({"plan_mode": plan_mode}) + ) + + result = await adapter._resolve_plan( + cursor_turn(adapter, "room-1", DecisionTools(), "user-1", "session-1"), + {"plan": "Plan"}, + ) + + assert result == {"outcome": {"outcome": outcome}} + + @pytest.mark.asyncio + async def test_manual_plan_accepts_a_room_command(self) -> None: + tools = DecisionTools() + adapter = CursorACPAdapter() + pending = asyncio.create_task( + adapter._resolve_plan( + cursor_turn(adapter, "room-1", tools, "user-1", "session-1"), + {"plan": "Plan"}, + ) + ) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + assert tools.messages == [ + PLAN_REQUESTED_TEMPLATE.format(plan="Plan", token=token) + ] + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace(content=f"/cursor accept {token}", sender_id="user-1"), + ), + tools, + "room-1", + ) + + assert await pending == {"outcome": {"outcome": "accepted"}} + + @pytest.mark.asyncio + async def test_automatic_permission_policies_use_offered_options(self) -> None: + request = ACPPermissionRequest( + room_id="room-1", + session_id="session-1", + tool_call=ACPToolCall("call-1", "shell", {}), + options=( + PermissionOption( + optionId="allow-once", name="Allow", kind="allow_once" + ), + ), + ) + + accepted = CursorACPAdapter(CursorACPAdapterConfig(approval_mode="auto_accept")) + declined = CursorACPAdapter( + CursorACPAdapterConfig(approval_mode="auto_decline") + ) + + assert await accepted._resolve_cursor_permission(request) == "allow-once" + assert await declined._resolve_cursor_permission(request) is None + + @pytest.mark.asyncio + async def test_decision_delivery_failure_cleans_up_the_pending_token(self) -> None: + adapter = CursorACPAdapter() + + result = await adapter._wait_for_decision( + kind="plan", + turn=cursor_turn( + adapter, "room-1", FailingDecisionTools(), "user-1", "session-1" + ), + prompt="Plan {token}", + ) + + assert result is None + assert len(pending_tokens(adapter)) == 0 + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_timeout_notice_failure_still_cancels_the_decision(self) -> None: + adapter = CursorACPAdapter() + + result = await adapter._wait_for_decision( + kind="plan", + turn=cursor_turn( + adapter, + "room-1", + FailingDecisionTools(fail_after=1), + "user-1", + "session-1", + ), + prompt="Plan {token}", + ) + + assert result is None + assert len(pending_tokens(adapter)) == 0 + + @pytest.mark.asyncio + async def test_a_reply_that_claims_while_the_prompt_send_fails_still_wins( + self, + ) -> None: + """`/cursor decisions` lists a token before its prompt lands; a reply + claiming it while that send fails owns the answer.""" + tools = DecisionTools() + failing_prompt = tools.hold_message("Plan ", error=RuntimeError("network down")) + adapter = CursorACPAdapter() + pending = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn(adapter, "room-1", tools, "user-1", "session-1"), + prompt="Plan {token}", + ) + ) + + async with failing_prompt: + token = pending_tokens(adapter)[0] + await adapter._handle_control_message( + room_message(f"/cursor accept {token}"), tools, "room-1" + ) + + assert await pending == {"outcome": {"outcome": "accepted"}} + assert tools.messages == [f"Cursor plan decision `{token}` resolved."] + + @pytest.mark.asyncio + @pytest.mark.looptime + async def test_a_late_reply_during_the_timeout_notice_is_not_reported_as_resolved( + self, + ) -> None: + """A room reply for a token whose timeout notice is still being sent + must be told "not pending" -- the timeout already cancelled it.""" + tools = DecisionTools() + timeout_notice = tools.hold_message("timed out") + adapter = CursorACPAdapter() + pending = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn(adapter, "room-1", tools, "user-1", "session-1"), + prompt="Plan {token}", + ) + ) + await tools.prompt_sent.wait() + [token] = pending_tokens(adapter) + + async with timeout_notice: + await adapter._handle_control_message( + room_message(f"/cursor accept {token}"), tools, "room-1" + ) + assert tools.messages[-1] == f"Cursor decision `{token}` is not pending." + + assert await pending is None + + @pytest.mark.asyncio + async def test_a_reply_for_an_already_claimed_token_is_told_not_pending( + self, + ) -> None: + """The second claim-guard gap the shared registry closes: nothing in + the original code stopped _handle_control_message from resolving a + token something else (a timeout, in production) had already claimed + a moment earlier -- only the timeout-notice-send window above was + guarded. Simulate that by claiming the token directly before the + room command arrives: the command must report "not pending", never + touch the future, and never say "resolved".""" + tools = DecisionTools() + adapter = CursorACPAdapter() + turn = cursor_turn(adapter, "room-1", tools, "user-1", "session-1") + + pending_task = asyncio.create_task( + adapter._wait_for_decision(kind="plan", turn=turn, prompt="Plan {token}") + ) + await tools.prompt_sent.wait() + token = pending_tokens(adapter)[0] + + claimed = adapter._pending_decisions["room-1"].try_claim(token) + assert claimed is not None + + handled = await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace(content=f"/cursor accept {token}", sender_id="user-1"), + ), + tools, + "room-1", + ) + + assert handled is True + assert tools.messages[-1] == f"Cursor decision `{token}` is not pending." + assert not claimed.payload.future.done() + + claimed.payload.future.set_result(None) + assert await pending_task is None + + @pytest.mark.asyncio + async def test_room_cleanup_cancels_only_its_pending_decision(self) -> None: + first_tools = DecisionTools() + second_tools = DecisionTools() + adapter = CursorACPAdapter() + first = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn(adapter, "room-1", first_tools, "user-1", "session-1"), + prompt="Plan {token}", + ) + ) + second = asyncio.create_task( + adapter._wait_for_decision( + kind="plan", + turn=cursor_turn( + adapter, "room-2", second_tools, "user-2", "session-2" + ), + prompt="Plan {token}", + ) + ) + await asyncio.gather( + first_tools.prompt_sent.wait(), second_tools.prompt_sent.wait() + ) + tokens = {room: token for room, [token] in pending_decisions(adapter).items()} + + await adapter.on_cleanup("room-1") + + assert await first is None + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor accept {tokens['room-1']}", sender_id="user-1" + ), + ), + first_tools, + "room-1", + ) + assert first_tools.messages[-1] == ( + f"Cursor decision `{tokens['room-1']}` is not pending." + ) + + await adapter._handle_control_message( + cast( + PlatformMessage, + SimpleNamespace( + content=f"/cursor accept {tokens['room-2']}", sender_id="user-2" + ), + ), + second_tools, + "room-2", + ) + + assert await second == {"outcome": {"outcome": "accepted"}} + + @pytest.mark.asyncio + async def test_a_room_reply_resolves_the_decision_it_was_sent_for( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + """on_message must return once a decision opens, not after the whole + turn -- otherwise the very reply meant to resolve it would queue + behind the still-open turn and every manual decision would time + out. The room delivers both messages sequentially, as + ExecutionContext does, to a real ACP turn.""" + room = await cursor_room( + FakeACPAgent() + .will_ask_permission(title="shell", options=allow_or_reject("allow-once")) + .will_call_mcp_tool( + "reply", + "band_send_message", + arguments={"content": "ran it", "mentions": ["@alice"]}, + ) + ) + + turn = await room.send("run it") + [prompt] = said(turn) + reply = await room.send(f"/cursor select {decision_token(prompt)} allow-once") + await turn.until_said("ran it") + + assert room.agent.approved is True + assert said(reply) == [ + f"Cursor permission decision `{decision_token(prompt)}` resolved." + ] diff --git a/tests/adapters/cursor_acp/test_detached_turn.py b/tests/adapters/cursor_acp/test_detached_turn.py new file mode 100644 index 000000000..a906b65a3 --- /dev/null +++ b/tests/adapters/cursor_acp/test_detached_turn.py @@ -0,0 +1,126 @@ +"""Judging a turn that detached on a pending decision.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +import pytest + +from band.client.streaming import ControlMode +from band.testing import MISSING_REPLY_FAILURE, failure_reports +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + said, +) +from tests.integrations.acp.acp_toolkit.agent import ( + FakeACPAgent, + allow_or_reject, +) +from tests.integrations.acp.acp_toolkit.harness import decision_token + + +class TestCursorACPAdapterDetachedTurn: + """A turn parked on a decision releases its message early, so it is + judged at its real end instead of when ``on_event`` returns.""" + + @pytest.mark.asyncio + async def test_a_detached_turn_that_ends_with_nothing_is_reported_after_release( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + room = await cursor_room( + FakeACPAgent().will_ask_permission( + title="shell", options=allow_or_reject("allow-once") + ) + ) + + # on_event returning normally is what keeps the delivery PROCESSED. + turn = await room.send("run it") + [prompt] = said(turn) + assert failure_reports(turn) == [] + + await room.send(f"/cursor deny {decision_token(prompt)}") + await room.turns_finished() + + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] + + @pytest.mark.asyncio + async def test_a_decision_reply_settles_on_its_own_tools( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + """A decision reply is a whole turn of its own: its notice settles + that message's tools, and neither it nor the decision prompt stands + in for the parked turn's answer, which still relays there.""" + room = await cursor_room( + FakeACPAgent() + .will_ask_permission(title="shell", options=allow_or_reject("allow-once")) + .will_call_mcp_tool( + "reply", + "band_send_message", + arguments={"content": "ran it", "mentions": ["@alice"]}, + ) + ) + turn = await room.send("run it") + [prompt] = said(turn) + + reply = await room.send(f"/cursor select {decision_token(prompt)} allow-once") + await room.turns_finished() + + assert reply.turn.complete + assert not reply.turn.replied + assert failure_reports(reply) == [] + assert said(turn) == [prompt, "ran it"] + assert failure_reports(turn) == [] + + @pytest.mark.asyncio + async def test_a_turn_woken_by_cleanup_is_judged_at_its_end( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + """Cleanup cancels the parked permission and the agent ends its turn + within the settle grace, without answering the room.""" + room = await cursor_room( + FakeACPAgent().will_ask_permission( + title="shell", options=allow_or_reject("allow-once") + ) + ) + turn = await room.send("run it") + + await room.adapter.on_cleanup("room-1") + + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] + + @pytest.mark.asyncio + async def test_an_interrupted_detached_turn_is_not_reported( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + """A room /stop ends the parked turn; the adapter settled it.""" + room = await cursor_room( + FakeACPAgent().will_ask_permission( + title="shell", options=allow_or_reject("allow-once") + ) + ) + turn = await room.send("run it") + + await room.adapter.on_interrupt("room-1", ControlMode.STOP) + await room.turns_finished() + + assert failure_reports(turn) == [] + + @pytest.mark.asyncio + async def test_a_detached_turn_cancelled_by_cleanup_is_reported_once( + self, cursor_room: Callable[..., Awaitable[CursorRoom]] + ) -> None: + """With no settle grace, cleanup cancels the parked turn at once; its + message was already handed back, so the turn reports it.""" + room = await cursor_room( + FakeACPAgent().will_ask_permission( + title="shell", options=allow_or_reject("allow-once") + ), + turn_settle_timeout_s=0, + ) + turn = await room.send("run it") + [prompt] = said(turn) + + await room.adapter.on_cleanup("room-1") + + assert said(turn) == [prompt] + assert failure_reports(turn) == [MISSING_REPLY_FAILURE] diff --git a/tests/adapters/cursor_acp/test_launch.py b/tests/adapters/cursor_acp/test_launch.py new file mode 100644 index 000000000..e48646307 --- /dev/null +++ b/tests/adapters/cursor_acp/test_launch.py @@ -0,0 +1,66 @@ +"""How the Cursor ACP adapter launches ``agent acp``.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.adapters.cursor_acp import ( + DEFAULT_CURSOR_ACP_COMMAND, + CursorACPAdapter, + CursorACPAdapterConfig, +) +from tests.adapters.cursor_acp.kit import cursor_in +from tests.integrations.acp.acp_toolkit.harness import launch_for + + +class TestCursorACPAdapterLaunch: + @pytest.mark.asyncio + async def test_launches_agent_acp_without_authenticate( + self, tmp_path: Path + ) -> None: + adapter = cursor_in(tmp_path) + + launch = await launch_for(adapter) + + assert launch.command == DEFAULT_CURSOR_ACP_COMMAND + assert launch.auth_method is None + + @pytest.mark.parametrize( + ("config", "env"), + [ + pytest.param( + CursorACPAdapterConfig(api_key="key"), + {"CURSOR_API_KEY": "key"}, + id="api-key", + ), + pytest.param( + CursorACPAdapterConfig(auth_token="token", env={"OTHER": "x"}), + {"OTHER": "x", "CURSOR_AUTH_TOKEN": "token"}, + id="auth-token-merged-into-env", + ), + pytest.param( + CursorACPAdapterConfig( + api_key="shortcut", env={"CURSOR_API_KEY": "environment"} + ), + {"CURSOR_API_KEY": "environment"}, + id="env-wins-over-api-key", + ), + ], + ) + @pytest.mark.asyncio + async def test_credentials_reach_the_agent_environment( + self, config: CursorACPAdapterConfig, env: dict[str, str], tmp_path: Path + ) -> None: + launch = await launch_for(cursor_in(tmp_path, config)) + + assert launch.env == env + + @pytest.mark.asyncio + async def test_cwd_becomes_a_room_workspace_root(self, tmp_path: Path) -> None: + adapter = CursorACPAdapter(CursorACPAdapterConfig(cwd=str(tmp_path))) + + launch = await launch_for(adapter, "room-a") + + assert launch.cwd == str(tmp_path / "room-a") diff --git a/tests/adapters/cursor_acp/test_project_workflow.py b/tests/adapters/cursor_acp/test_project_workflow.py new file mode 100644 index 000000000..f89b9d315 --- /dev/null +++ b/tests/adapters/cursor_acp/test_project_workflow.py @@ -0,0 +1,122 @@ +"""A full project workflow across human gates and rejoin.""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Awaitable, Callable +from pathlib import Path + +import pytest +from acp.helpers import start_tool_call, update_tool_call + +from band.core.types import Capability +from band.runtime.tools.registry import BAND_MCP_SERVER_NAME, mcp_tool_spelling +from band.runtime.tools.types import BandTool +from band.testing import failure_reports +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + EchoInput, + cursor_custom_tools, + permission_requests, + said, +) +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import decision_token +from tests.mcpclient import STORE_MEMORY_ARGS + + +class TestProjectWorkflow: + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("approval_mode", "first_edit", "second_edit"), + [ + ("manual", False, True), + ("auto_accept", True, True), + ("auto_decline", False, False), + ], + ) + async def test_project_workflow_keeps_band_tools_available_across_human_gates_and_rejoin( + self, + cursor_room: Callable[..., Awaitable[CursorRoom]], + tmp_path: Path, + approval_mode: str, + first_edit: bool, + second_edit: bool, + ) -> None: + project = tmp_path / "project.txt" + project.write_text("original") + audit = tmp_path / "audit.txt" + marker = "project workflow completed" + + def record_work(arguments: EchoInput) -> str: + with audit.open("a") as stream: + stream.write(arguments.text + "\n") + return arguments.text + + async def edit_project(agent: FakeACPAgent, session_id: str) -> None: + await agent.emit(session_id, start_tool_call("native-edit", "shell")) + process = await asyncio.create_subprocess_exec( + sys.executable, + "-c", + "from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])", + str(project), + "repaired", + ) + if await process.wait() != 0: + raise RuntimeError("project edit failed") + await agent.emit( + session_id, + update_tool_call("native-edit", raw_output="project repaired"), + ) + + agent = ( + FakeACPAgent() + .will_ask_permission(title="band-echo: echo") + .will_call_mcp_tool( + "custom-work", + "echo", + arguments={"text": marker}, + requires_approval=True, + ) + ) + agent.will_ask_permission(title="shell").will_execute_if_approved(edit_project) + for tool, arguments in [ + (BandTool.STORE_MEMORY, {**STORE_MEMORY_ARGS, "content": marker}), + (BandTool.SEND_MESSAGE, {"content": marker, "mentions": ["@user-1"]}), + ]: + agent.will_ask_permission( + title=f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, tool)}: {tool}" + ).will_call_mcp_tool( + tool, tool, arguments=arguments, requires_approval=True + ) + agent.will_say("I already posted the result.") + room = await cursor_room( + agent, + approval_mode=approval_mode, + capabilities={Capability.MEMORY}, + additional_tools=cursor_custom_tools(record_work), + ) + + for attempt, edited in enumerate((first_edit, second_edit)): + project.write_text("original") + turn = await room.send("repair the project and record the result") + if approval_mode == "manual": + assert project.read_text() == "original" + [request] = permission_requests(turn) + assert "shell" in request + command = "deny" if attempt == 0 else "select" + option = "" if attempt == 0 else " allow-1" + decision = await room.send( + f"/cursor {command} {decision_token(request)}{option}" + ) + assert failure_reports(decision) == [] + await room.turns_finished() + + assert project.read_text() == ("repaired" if edited else "original") + assert audit.read_text().splitlines() == [marker] * (attempt + 1) + assert turn.memory_contents == [marker] + assert said(turn) == [*permission_requests(turn), marker] + assert turn.turn.replied + assert failure_reports(turn) == [] + await room.adapter.on_cleanup("room-1") diff --git a/tests/adapters/cursor_acp/test_session_setup_interrupt.py b/tests/adapters/cursor_acp/test_session_setup_interrupt.py new file mode 100644 index 000000000..9ed40a582 --- /dev/null +++ b/tests/adapters/cursor_acp/test_session_setup_interrupt.py @@ -0,0 +1,35 @@ +"""Interrupting a room while its Cursor session is still being set up.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest + +from band.client.streaming import ControlMode +from tests.adapters.cursor_acp.kit import ( + CursorRoom, + SetupGatedAgent, +) + + +class TestSessionSetupInterrupt: + @pytest.mark.asyncio + async def test_interrupt_during_session_setup_prevents_prompt( + self, + cursor_room: Callable[..., Awaitable[CursorRoom]], + ) -> None: + agent = SetupGatedAgent() + room = await cursor_room(agent) + sending = asyncio.create_task(room.send("start")) + try: + await agent.setup.received.wait() + await room.adapter.on_interrupt("room-1", ControlMode.STOP) + agent.setup.release.set() + await asyncio.gather(sending, return_exceptions=True) + await room.turns_finished() + finally: + agent.setup.release.set() + await asyncio.gather(sending, return_exceptions=True) + assert agent.prompts == [] diff --git a/tests/adapters/cursor_acp/test_subprocess_lifecycle.py b/tests/adapters/cursor_acp/test_subprocess_lifecycle.py new file mode 100644 index 000000000..c66c2a76a --- /dev/null +++ b/tests/adapters/cursor_acp/test_subprocess_lifecycle.py @@ -0,0 +1,175 @@ +"""Cursor decision and setup ownership through a real spawned ACP process.""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from typing import cast + +import pytest + +from band.client.streaming import ControlMode +from band.core.retention import Residency +from band.integrations.acp.cursor import DECISION_NOT_PENDING_TEMPLATE +from band.testing import failure_reports +from tests.adapters.cursor_acp.kit import permission_requests +from tests.adapters.cursor_acp.process import ( + CursorProcess, + RecoveryClose, + allow_write, + asked_question, + continue_question, +) +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import workspace_still_claimed +from tests.adapters.roompeer import ( + CLEANUP_FAILURE, + WORKSPACE_NOTE, + PeerCommand, + read_reply, + write_instruction, +) +from tests.adapters.waiting import until +from tests.integrations.acp.acp_toolkit.harness import decision_token +from tests.integrations.acp.acp_toolkit.lifecycle import LifecycleChild +from tests.integrations.acp.acp_toolkit.workspacepeer import ( + CURSOR_SETUP_HOLD, + CURSOR_SETUP_READY, + CursorChoice, + CursorPeerEvent, + cursor_workflow, +) + + +class TestSubprocessLifecycle: + @pytest.mark.parametrize("cleanup", list(RecoveryClose)) + async def test_a_parked_decision_loses_authority_with_its_process( + self, cursor_process: CursorProcess, cleanup: RecoveryClose + ) -> None: + process = cursor_process + process.host.shared = True + old = await process.send(cursor_workflow("obsolete")) + old_token = await asked_question(old) + process.host.shared = False + other = await process.send(write_instruction("independent"), room="room-b") + assert other.chat == ["independent"] + assert not (process.workspace() / WORKSPACE_NOTE).exists() + + retained = process.host.bridge.residency("room-a") + assert retained is not None + child = cast(LifecycleChild, process.host.children[0]) + if cleanup == RecoveryClose.REFUSED_JOIN: + child.closing = Hold() + child.refuse_close = True + await process.kill_child(0) + await process.finished() + if cleanup == RecoveryClose.REFUSED_JOIN: + assert child.closing is not None + await child.closing.arrived() + joining = asyncio.create_task(process.send(cursor_workflow("premature"))) + await until( + lambda: retained.snapshot.accepted == 1, + failure="The successor never joined the held recovery close", + ) + child.closing.released.set() + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await joining + assert retained.snapshot.state == Residency.CLEANUP_PENDING + assert retained.snapshot.pending_cleanup + assert process.host.exited == [True, False] + process.host.shared = True + try: + with workspace_still_claimed(): + await process.send(write_instruction("transfer"), room="room-c") + finally: + process.host.shared = False + child.refuse_close = False + else: + await process.host.settled("room-a") + successor = await process.send(cursor_workflow("approved")) + token = await asked_question(successor) + late = await process.send(continue_question(old_token)) + + assert late.chat == [DECISION_NOT_PENDING_TEMPLATE.format(token=old_token)] + assert token != old_token + assert process.host.exited == [True, False, False] + assert len(failure_reports(old)) == 1 + assert process.observed(CursorPeerEvent.QUESTION_ANSWERED) == [] + assert process.observed(CursorPeerEvent.FILE_WRITTEN) == [] + + await process.send(continue_question(token)) + await until( + lambda: bool(permission_requests(successor)), + failure="Cursor never requested write permission", + ) + [permission] = permission_requests(successor) + assert not (process.workspace() / WORKSPACE_NOTE).exists() + await process.send(allow_write(decision_token(permission))) + await process.finished() + + assert process.observed(CursorPeerEvent.QUESTION_ANSWERED) == [ + CursorChoice.CONTINUE + ] + assert process.observed(CursorPeerEvent.FILE_WRITTEN) == ["approved"] + assert (process.workspace() / WORKSPACE_NOTE).read_text() == "approved" + assert successor.chat[-1] == "approved" + assert successor.reply.errors == [] + assert all("approved" in plan for plan in successor.reply.plans) + assert any("completed" in plan for plan in successor.reply.plans) + assert other.reply.plans == [] + assert await process.host.send("room-b", PeerCommand.READ) == [ + read_reply("independent") + ] + assert process.host.overlaps == 0 + if cleanup == RecoveryClose.REFUSED_JOIN: + assert child.exited + assert child.process.stdout is not None + assert child.process.stdout.at_eof() + # The refused exit leaves a test-owned context whose dead pipe + # may report connection loss again after confirmed stdout EOF. + with suppress(ConnectionError): + await child.close() + assert child.exited + assert child.process.stdin.is_closing() + + async def test_interrupting_an_allocated_session_never_prompts_and_finally_reaps_it( + self, cursor_process: CursorProcess + ) -> None: + process = cursor_process + process.host.shared = True + workspace = process.workspace() + workspace.mkdir(parents=True) + hold = workspace / CURSOR_SETUP_HOLD + hold.touch() + opening = asyncio.create_task(process.send(write_instruction("cancelled"))) + try: + await until( + (workspace / CURSOR_SETUP_READY).exists, + failure="The peer never allocated its held session", + ) + assert len(process.observed(CursorPeerEvent.SESSION_CREATED)) == 1 + await process.adapter.on_interrupt("room-a", ControlMode.STOP) + finally: + hold.unlink(missing_ok=True) + with pytest.raises(asyncio.CancelledError): + await opening + await process.finished() + + assert process.observed(CursorPeerEvent.PROMPT_STARTED) == [] + assert not (workspace / WORKSPACE_NOTE).exists() + healthy = await process.send(write_instruction("healthy")) + await process.finished() + assert healthy.chat == ["healthy"] + assert healthy.reply.errors == [] + [prompt] = process.observed(CursorPeerEvent.PROMPT_STARTED) + assert write_instruction("healthy") in prompt + assert process.host.overlaps == 0 + + await process.host.leave("room-a") + assert all(process.host.exited) + assert process.host.bridge.residency("room-a") is None + assert await process.host.send("room-b", PeerCommand.READ) == [ + read_reply("healthy") + ] + await process.host.leave("room-b") + assert all(process.host.exited) diff --git a/tests/adapters/hold.py b/tests/adapters/hold.py new file mode 100644 index 000000000..cec010471 --- /dev/null +++ b/tests/adapters/hold.py @@ -0,0 +1,42 @@ +"""A barrier a test parks a provider step at, shared by every provider's fakes.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from types import TracebackType + +from tests.adapters.waiting import UNTIL_TIMEOUT_S + + +@dataclass +class Hold: + """Parks the turn at this step: ``async with hold`` waits for the turn to + reach it and lets the turn continue on exit.""" + + reached: asyncio.Event = field(default_factory=asyncio.Event) + released: asyncio.Event = field(default_factory=asyncio.Event) + + async def __aenter__(self) -> None: + await self.arrived() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.released.set() + + async def arrived(self) -> None: + """Wait for the turn to reach this step, failing if it never does.""" + try: + async with asyncio.timeout(UNTIL_TIMEOUT_S): + await self.reached.wait() + except TimeoutError: + raise AssertionError("the turn never reached its hold") from None + + async def wait(self) -> None: + """Signal the step is reached and park until the test releases it.""" + self.reached.set() + await self.released.wait() diff --git a/tests/adapters/lifecycle/__init__.py b/tests/adapters/lifecycle/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/lifecycle/conftest.py b/tests/adapters/lifecycle/conftest.py new file mode 100644 index 000000000..5fdd70c9d --- /dev/null +++ b/tests/adapters/lifecycle/conftest.py @@ -0,0 +1,41 @@ +"""Run the common Room lifecycle suite against every registered provider.""" + +from __future__ import annotations + +import importlib +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest +import pytest_asyncio + +from tests.adapters.lifecycle.kit import ( + PROFILE_MODULE, + LifecycleProfile, + RetentionHost, + hosted, +) +from tests.paths import REPO_ROOT + + +def registered_profiles() -> dict[str, LifecycleProfile]: + modules = sorted((REPO_ROOT / "tests").rglob(PROFILE_MODULE)) + profiles = ( + profile + for path in modules + for profile in importlib.import_module( + ".".join(path.relative_to(REPO_ROOT).with_suffix("").parts) + ).PROFILES + ) + return {profile.name: profile for profile in profiles} + + +PROFILES = registered_profiles() + + +@pytest_asyncio.fixture(params=list(PROFILES), loop_scope="function") +async def host( + request: pytest.FixtureRequest, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + async with hosted(PROFILES[request.param], tmp_path, monkeypatch) as host: + yield host diff --git a/tests/adapters/lifecycle/kit.py b/tests/adapters/lifecycle/kit.py new file mode 100644 index 000000000..0ff80621c --- /dev/null +++ b/tests/adapters/lifecycle/kit.py @@ -0,0 +1,289 @@ +"""The common Room lifecycle suite's host and the contract each provider's +bridge fulfils. + +A provider joins the suite from its own tests with a ``lifecycleprofile.py`` +module exposing ``PROFILES``; the suite discovers it, so no provider edits +these shared files. +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import AsyncIterator, Callable, Iterator +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Protocol + +import pytest + +from band.core.exceptions import InputNotAccepted +from band.core.protocols import FrameworkAdapter +from band.core.retention import Residency, RetainedRoom +from band.core.types import AgentInput, HistoryProvider, MessageType, PlatformMessage +from band.testing.fake_tools import events_of_type +from tests.adapters.hold import Hold +from tests.adapters.roompeer import ( + FAIL_STARTUP_ARG, + HOLD_STARTUP_ARG, + STARTING_MARKER, +) +from tests.adapters.waiting import UNTIL_TIMEOUT_S, until +from tests.framework_conformance.turnprobes import DispatchingFakeTools + +IN_FLIGHT = frozenset({Residency.ACQUIRING, Residency.SUSPENDING}) + +# Modules that register a provider's profiles, each under its own tests. +PROFILE_MODULE = "lifecycleprofile.py" + +# The distinctive start of the refusal band.workspaces raises for a workspace +# another room still holds; the config-time "not both" error must not match. +WORKSPACE_CLAIMED = "workspace_for_room assigned" + + +async def pause_at(hold: Hold | None) -> None: + """Stop at ``hold``, when set, until the test releases it.""" + if hold is not None: + await hold.wait() + + +@contextmanager +def workspace_still_claimed() -> Iterator[None]: + """Expect the refusal of a workspace another room still holds.""" + with pytest.raises(ValueError, match=re.escape(WORKSPACE_CLAIMED)): + yield + + +@dataclass(frozen=True) +class ExpectedFailure: + """The exact error a provider reports: its type and message.""" + + error: type[BaseException] + message: str + + +@contextmanager +def raises_exactly(expected: ExpectedFailure) -> Iterator[None]: + """Expect exactly ``expected``'s type, not a subclass, carrying its message.""" + with pytest.raises(expected.error, match=re.escape(expected.message)) as raised: + yield + assert raised.type is expected.error, raised.value + + +class LifecyclePeer(Protocol): + """A provider's real child process with a held start or a failed or held + close injected at its transport. + + ``refuse_close`` fails a close attempt and leaves the process running. + Tests allow the close before the room retries it: a provider may retry by + escalating past the refused seam, as ACP kills the process it still holds. + """ + + refuse_close: bool + starting: Hold | None + closing: Hold | None + + @property + def exited(self) -> bool: ... + + async def close(self) -> None: ... + + +class LifecycleAdapter(FrameworkAdapter, Protocol): + async def cleanup_all(self) -> None: ... + + +@dataclass(frozen=True) +class LifecycleBridge: + """A started provider adapter and the projections the suite asserts on.""" + + adapter: LifecycleAdapter + # The room's retained residency, None once the room has none. + residency: Callable[[str], RetainedRoom | None] + # The room's provider conversation id, None once it has none. + native_id: Callable[[str], str | None] + + +@dataclass(frozen=True) +class LifecycleProfile: + """One provider configuration the common suite runs against. + + ``install`` builds the adapter on its production config with retention + disabled, routing every child it starts through ``RetentionHost.spawned``. + ``lost_turn`` is the error a turn whose transport is lost fails with, and + ``failed_start`` what a turn whose start the peer refused reports, even + when that failed start's close is refused too. + """ + + name: str + install: Callable[[RetentionHost, pytest.MonkeyPatch], LifecycleBridge] + lost_turn: type[BaseException] + failed_start: ExpectedFailure + + +@dataclass +class RetentionHost: + profile: LifecycleProfile + root: Path + fail_startup: Path + hold_startup: Path + shared: bool = True + children: list[LifecyclePeer] = field(default_factory=list) + child_cwds: list[str | None] = field(default_factory=list) + # Children started while an earlier child in the same workspace still ran. + overlaps: int = 0 + next_start: Hold | None = None + bridge: LifecycleBridge = field(init=False) + + @property + def peer_arguments(self) -> tuple[str, ...]: + """Flags that let ``fail_startup``/``hold_startup`` steer the peer.""" + return ( + FAIL_STARTUP_ARG, + str(self.fail_startup), + HOLD_STARTUP_ARG, + str(self.hold_startup), + ) + + @property + def adapter(self) -> LifecycleAdapter: + return self.bridge.adapter + + @property + def lost_turn(self) -> type[BaseException]: + return self.profile.lost_turn + + @property + def failed_start(self) -> ExpectedFailure: + return self.profile.failed_start + + def workspace(self, room_id: str) -> str: + return str(self.root / ("shared" if self.shared else room_id)) + + def spawned(self, child: LifecyclePeer, cwd: str | None) -> None: + """Record a started child, handing it the next start hold.""" + child.starting, self.next_start = self.next_start, None + self.overlaps += any( + not earlier.exited and earlier_cwd == cwd + for earlier, earlier_cwd in zip(self.children, self.child_cwds, strict=True) + ) + self.children.append(child) + self.child_cwds.append(cwd) + + async def send(self, room_id: str, content: str) -> list[str]: + tools = DispatchingFakeTools(room_id=room_id) + await self.deliver(room_id, content, tools) + return [message["content"] for message in tools.messages_sent] + + async def deliver( + self, room_id: str, content: str, tools: DispatchingFakeTools + ) -> None: + await self.adapter.on_event( + AgentInput( + msg=PlatformMessage( + id=content, + room_id=room_id, + content=content, + sender_id="user", + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ), + tools=tools, + history=HistoryProvider(raw=[]), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=room_id, + ) + ) + + async def leave(self, room_id: str) -> None: + await self.adapter.on_cleanup(room_id) + + async def assert_refuses_input( + self, + room_id: str, + content: str, + *, + error: type[InputNotAccepted] = InputNotAccepted, + ) -> None: + """Refused input never posts a reply or a user-visible failure.""" + tools = DispatchingFakeTools(room_id=room_id) + try: + async with asyncio.timeout(UNTIL_TIMEOUT_S): + with pytest.raises(error): + await self.deliver(room_id, content, tools) + except TimeoutError: + pytest.fail("Closing room queued input instead of refusing it") + tools.assert_no_messages_sent() + assert events_of_type(tools, MessageType.ERROR) == [] + + async def shutdown(self) -> None: + await self.adapter.cleanup_all() + + def state(self, room_id: str) -> Residency | None: + residency = self.bridge.residency(room_id) + return residency.snapshot.state if residency is not None else None + + async def forgotten(self, room_id: str) -> None: + """Wait until the adapter no longer holds the room, its disposal done.""" + await until( + lambda: self.bridge.residency(room_id) is None, + failure=f"Room {room_id} was never forgotten", + ) + + async def settled(self, room_id: str) -> None: + """Wait out the room's in-flight process transition, such as recovery.""" + await until( + lambda: self.state(room_id) not in IN_FLIGHT, + failure=f"Room {room_id} never settled", + ) + + async def start_reached(self, room_id: str) -> None: + """Wait until the room's peer has begun starting in its workspace.""" + marker = Path(self.workspace(room_id)) / STARTING_MARKER + await until( + marker.exists, failure=f"Room {room_id}'s peer never began starting" + ) + + def native_id(self, room_id: str) -> str | None: + return self.bridge.native_id(room_id) + + @property + def exited(self) -> list[bool]: + return [child.exited for child in self.children] + + +@asynccontextmanager +async def hosted( + profile: LifecycleProfile, root: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[RetentionHost]: + """A started ``profile`` adapter whose children are released and closed on exit.""" + host = RetentionHost( + profile=profile, + root=root, + fail_startup=root / "fail-startup", + hold_startup=root / "hold-startup", + ) + host.bridge = profile.install(host, monkeypatch) + await host.adapter.on_started("Peer", "A subprocess under test") + try: + yield host + finally: + host.fail_startup.unlink(missing_ok=True) + host.hold_startup.unlink(missing_ok=True) + for child in host.children: + child.refuse_close = False + for barrier in (child.starting, child.closing): + if barrier is not None: + barrier.released.set() + try: + await host.adapter.cleanup_all() + finally: + for child in host.children: + await child.close() diff --git a/tests/adapters/lifecycle/runtime.py b/tests/adapters/lifecycle/runtime.py new file mode 100644 index 000000000..937d9197e --- /dev/null +++ b/tests/adapters/lifecycle/runtime.py @@ -0,0 +1,38 @@ +"""A posting HTTP peer for lifecycle tests through the real host scheduler.""" + +from __future__ import annotations + +import json + +import httpx + +from band.core.types import MessageType +from tests.runtime.helpers import ALICE, LifecyclePlatform, page + + +class ReplyingPlatform(LifecyclePlatform): + def __init__(self) -> None: + super().__init__() + self.replies: list[str] = [] + self.errors: list[str] = [] + + def answer(self, request: httpx.Request) -> httpx.Response: + resource = request.url.path.rsplit("/", 1)[-1] + if resource == "participants": + return page([{**ALICE, "role": "member", "status": "active"}]) + if request.method == "POST" and resource in {"messages", "events"}: + payload = json.loads(request.content) + if resource == "messages": + self.replies.append(payload["message"]["content"]) + result = {"id": "reply", "recipients": [], "success": True} + else: + event = payload["event"] + if event["message_type"] == MessageType.ERROR: + self.errors.append(event["content"]) + result = { + "id": "event", + "message_type": event["message_type"], + "success": True, + } + return httpx.Response(200, json={"data": result}) + return super().answer(request) diff --git a/tests/adapters/lifecycle/test_closing_admission.py b/tests/adapters/lifecycle/test_closing_admission.py new file mode 100644 index 000000000..cbb79b0d2 --- /dev/null +++ b/tests/adapters/lifecycle/test_closing_admission.py @@ -0,0 +1,147 @@ +"""Closing rooms refuse new work throughout interrupted teardown and restart.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from band.core.exceptions import InputNotAccepted, RoomClosingError +from band.core.types import MessageType +from band.testing.fake_tools import events_of_type +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import RetentionHost, workspace_still_claimed +from tests.adapters.roompeer import ( + CLEANUP_FAILURE, + WORKSPACE_NOTE, + PeerCommand, + read_reply, + write_instruction, +) +from tests.adapters.waiting import until +from tests.framework_conformance.turnprobes import DispatchingFakeTools + + +class TestClosingAdmission: + """The adapter's admission fence lasts until its concrete child is disposed.""" + + async def test_held_close_refuses_work_while_another_room_keeps_serving( + self, host: RetentionHost + ) -> None: + host.shared = False + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + native = host.native_id("room-a") + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + async with closing: + await host.assert_refuses_input( + "room-a", write_instruction("changed"), error=RoomClosingError + ) + assert await host.send("room-b", PeerCommand.READ) == [read_reply("bravo")] + assert host.native_id("room-a") == native + assert host.exited == [False, False] + await leaving + assert host.native_id("room-a") is None + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert host.native_id("room-a") is not None + assert host.exited == [True, False, False] + assert host.overlaps == 0 + + async def test_cancelled_and_timed_out_cleanup_keep_admission_closed_until_disposal( + self, host: RetentionHost + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + native = host.native_id("room-a") + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + async with closing: + leaving.cancel() + with pytest.raises(asyncio.CancelledError): + await leaving + with pytest.raises(TimeoutError): + async with asyncio.timeout(0): + await host.leave("room-a") + await host.assert_refuses_input( + "room-a", write_instruction("changed"), error=RoomClosingError + ) + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.exited == [False] + assert host.native_id("room-a") == native + await host.forgotten("room-a") + assert host.exited == [True] + assert await host.send("room-b", PeerCommand.READ) == [read_reply("alpha")] + assert host.overlaps == 0 + + async def test_leave_during_startup_fences_competing_input_before_close( + self, host: RetentionHost + ) -> None: + tools = DispatchingFakeTools(room_id="room-a") + starting = host.next_start = Hold() + opening = asyncio.create_task( + host.deliver("room-a", write_instruction("never written"), tools) + ) + await starting.arrived() + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + residency = host.bridge.residency("room-a") + assert residency is not None + await until(lambda: residency.closing, failure="Leave never fenced the room") + await host.assert_refuses_input("room-a", write_instruction("competing")) + starting.released.set() + async with closing: + await host.assert_refuses_input("room-a", PeerCommand.READ) + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.exited == [False] + await leaving + with pytest.raises((asyncio.CancelledError, InputNotAccepted)): + await opening + tools.assert_no_messages_sent() + assert events_of_type(tools, MessageType.ERROR) == [] + assert host.exited == [True] + assert not (Path(host.workspace("room-a")) / WORKSPACE_NOTE).exists() + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + assert host.overlaps == 0 + + async def test_restart_retries_failed_stop_before_readmitting_any_room( + self, host: RetentionHost + ) -> None: + host.shared = False + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + native = host.native_id("room-a") + child = host.children[0] + child.refuse_close = True + with pytest.raises(ExceptionGroup) as failed: + await host.shutdown() + assert failed.group_contains(RuntimeError, match=CLEANUP_FAILURE, depth=None) + child.refuse_close = False + closing = child.closing = Hold() + restarting = asyncio.create_task(host.adapter.on_started("Peer", "Restart")) + async with closing: + await host.assert_refuses_input("room-a", write_instruction("changed")) + await host.assert_refuses_input("room-b", write_instruction("bravo")) + assert host.native_id("room-a") == native + assert host.exited == [False] + await restarting + assert host.exited == [True] + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + assert host.overlaps == 0 + + async def test_stopping_refuses_a_new_room_before_claiming_its_workspace( + self, host: RetentionHost + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + closing = host.children[0].closing = Hold() + stopping = asyncio.create_task(host.shutdown()) + async with closing: + await host.assert_refuses_input("room-b", write_instruction("bravo")) + assert host.exited == [False] + await stopping + assert host.exited == [True] + await host.adapter.on_started("Peer", "Restart") + assert await host.send("room-b", PeerCommand.READ) == [read_reply("alpha")] + assert host.overlaps == 0 diff --git a/tests/adapters/lifecycle/test_process_loss.py b/tests/adapters/lifecycle/test_process_loss.py new file mode 100644 index 000000000..7142307ca --- /dev/null +++ b/tests/adapters/lifecycle/test_process_loss.py @@ -0,0 +1,61 @@ +"""Room workspace lifecycle: process loss.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.core.retention import Residency +from tests.adapters.lifecycle.kit import RetentionHost, workspace_still_claimed +from tests.adapters.roompeer import PeerCommand, read_reply, write_instruction + + +class TestProcessLoss: + """A lost process is reaped while the room keeps its workspace.""" + + @pytest.mark.parametrize( + "loss", [PeerCommand.LOSE_TRANSPORT, PeerCommand.EXIT_PROCESS] + ) + async def test_transport_loss_reaps_child_and_preserves_workspace_until_leave( + self, + host: RetentionHost, + loss: PeerCommand, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + original = Path(host.workspace("room-a")) + host.root = host.root / "changed" + with pytest.raises(host.lost_turn): + await host.send("room-a", loss) + await host.settled("room-a") + assert host.exited == [True] + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert host.exited == [True, False] + + new_directory = Path(host.workspace("room-a")) + new_directory.mkdir(parents=True) + (new_directory / "notes.txt").write_text("bravo") + await host.leave("room-a") + assert await host.send("room-a", PeerCommand.READ) == [read_reply("bravo")] + assert (original / "notes.txt").read_text() == "alpha" + + async def test_transport_loss_with_failed_cleanup_can_recover_safely( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + host.children[0].refuse_close = True + with pytest.raises(host.lost_turn): + await host.send("room-a", PeerCommand.LOSE_TRANSPORT) + await host.settled("room-a") + assert host.state("room-a") == Residency.CLEANUP_PENDING + + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.state("room-a") == Residency.CLEANUP_PENDING + assert (len(host.children), host.overlaps) == (1, 0) + + host.children[0].refuse_close = False + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert host.exited == [True, False] + assert host.overlaps == 0 diff --git a/tests/adapters/lifecycle/test_room_independence.py b/tests/adapters/lifecycle/test_room_independence.py new file mode 100644 index 000000000..4c2a83cd7 --- /dev/null +++ b/tests/adapters/lifecycle/test_room_independence.py @@ -0,0 +1,31 @@ +"""Room workspace lifecycle: room independence.""" + +from __future__ import annotations + +import asyncio + +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import RetentionHost +from tests.adapters.roompeer import PeerCommand, read_reply, write_instruction + + +class TestRoomIndependence: + """One room starting or closing never blocks another room.""" + + async def test_rooms_progress_while_another_room_starts_or_closes( + self, + host: RetentionHost, + ) -> None: + host.shared = False + starting = host.next_start = Hold() + opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) + async with starting: + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + assert await opening == ["alpha"] + + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + async with closing: + assert await host.send("room-b", PeerCommand.READ) == [read_reply("bravo")] + await leaving + assert host.exited == [True, False] diff --git a/tests/adapters/lifecycle/test_room_ownership.py b/tests/adapters/lifecycle/test_room_ownership.py new file mode 100644 index 000000000..80e6e29d5 --- /dev/null +++ b/tests/adapters/lifecycle/test_room_ownership.py @@ -0,0 +1,191 @@ +"""Room workspace lifecycle: room ownership.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from band.core.exceptions import InputNotAccepted, RoomClosingError +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import ( + RetentionHost, + raises_exactly, + workspace_still_claimed, +) +from tests.adapters.roompeer import ( + CLEANUP_FAILURE, + WORKSPACE_NOTE, + PeerCommand, + read_reply, + write_instruction, +) +from tests.adapters.waiting import spin + + +class TestRoomOwnership: + """A room keeps its child and workspace until cleanup provably completes.""" + + async def test_completed_turns_retain_children_until_explicit_cleanup( + self, + host: RetentionHost, + ) -> None: + host.shared = False + for room in ("room-a", "room-b", "room-c"): + assert await host.send(room, write_instruction(room)) == [room] + assert host.exited == [False, False, False] + assert await host.send("room-a", PeerCommand.READ) == [read_reply("room-a")] + assert host.exited == [False, False, False] + await host.shutdown() + assert host.exited == [True, True, True] + + async def test_failed_cleanup_retains_ownership_and_blocks_replacement( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + host.children[0].refuse_close = True + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await host.leave("room-a") + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + with pytest.raises(RoomClosingError): + await host.send("room-a", write_instruction("changed")) + assert host.exited == [False] + assert (Path(host.workspace("room-a")) / WORKSPACE_NOTE).read_text() == "alpha" + host.children[0].refuse_close = False + await host.leave("room-a") + assert host.exited == [True] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + assert host.exited == [True, False] + + async def test_failed_startup_keeps_membership_claim_until_leave( + self, + host: RetentionHost, + ) -> None: + host.fail_startup.touch() + with raises_exactly(host.failed_start): + await host.send("room-a", write_instruction("alpha")) + await host.settled("room-a") + assert host.exited == [True] + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + await host.leave("room-a") + host.fail_startup.unlink() + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + + async def test_failed_room_cleanup_can_recover_in_the_same_workspace( + self, + host: RetentionHost, + ) -> None: + """A retried leave frees the room to rejoin its workspace.""" + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + host.children[0].refuse_close = True + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await host.leave("room-a") + host.children[0].refuse_close = False + await host.leave("room-a") + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert host.exited == [True, False] + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + + async def test_cancelled_startup_keeps_ownership_and_can_recover( + self, + host: RetentionHost, + ) -> None: + host.hold_startup.touch() + opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) + await host.start_reached("room-a") + opening.cancel() + with pytest.raises(asyncio.CancelledError): + await opening + host.hold_startup.unlink() + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + assert host.exited.count(False) == 1 + assert host.overlaps == 0 + await host.leave("room-a") + assert all(host.exited) + + async def test_cancelled_cleanup_can_be_completed_before_transfer( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + barrier = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave("room-a")) + async with barrier: + leaving.cancel() + with pytest.raises(asyncio.CancelledError): + await leaving + assert host.exited == [False] + await host.leave("room-a") + assert host.exited == [True] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + + async def test_concurrent_leaves_join_one_held_close( + self, + host: RetentionHost, + ) -> None: + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + barrier = host.children[0].closing = Hold() + first = asyncio.create_task(host.leave("room-a")) + await barrier.arrived() + second = asyncio.create_task(host.leave("room-a")) + await spin() + assert not second.done() + with pytest.raises(RoomClosingError): + await host.send("room-a", write_instruction("changed")) + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.exited == [False] + barrier.released.set() + await asyncio.gather(first, second) + assert host.exited == [True] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + assert host.overlaps == 0 + + async def test_shutdown_attempts_every_room_and_retains_failed_children( + self, + host: RetentionHost, + ) -> None: + host.shared = False + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] + host.children[0].refuse_close = True + with pytest.raises(ExceptionGroup) as failed: + await host.shutdown() + assert failed.group_contains(RuntimeError, match=CLEANUP_FAILURE, depth=None) + assert host.exited == [False, True] + with pytest.raises(InputNotAccepted): + await host.send("room-a", PeerCommand.READ) + assert len(host.children) == 2 + host.children[0].refuse_close = False + await host.adapter.on_started("Peer", "Restarting after failed cleanup") + assert host.exited == [True, True] + assert await host.send("room-a", PeerCommand.READ) == [read_reply("alpha")] + assert host.overlaps == 0 + + async def test_failed_startup_cleanup_blocks_replacement_until_retry( + self, + host: RetentionHost, + ) -> None: + host.fail_startup.touch() + host.hold_startup.touch() + opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) + await host.start_reached("room-a") + host.children[0].refuse_close = True + host.hold_startup.unlink() + with raises_exactly(host.failed_start): + await opening + with workspace_still_claimed(): + await host.send("room-b", write_instruction("bravo")) + assert host.exited == [False] + host.fail_startup.unlink() + host.children[0].refuse_close = False + assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] + assert host.exited == [True, False] + assert host.overlaps == 0 diff --git a/tests/adapters/lifecycle/test_runtime_closing.py b/tests/adapters/lifecycle/test_runtime_closing.py new file mode 100644 index 000000000..aa849c96d --- /dev/null +++ b/tests/adapters/lifecycle/test_runtime_closing.py @@ -0,0 +1,68 @@ +"""Closing-room refusal preserves a real host delivery until rejoin.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.platform.link import BandLink +from band.runtime.types import SessionConfig +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import RetentionHost +from tests.adapters.lifecycle.runtime import ReplyingPlatform +from tests.adapters.roompeer import write_instruction +from tests.conftest import make_message_event +from tests.runtime.helpers import AGENT_ID, ROOM_ID, rest_client_over, run_through + + +class TestRuntimeClosing: + @pytest.mark.parametrize("delivery", ["live", "backlog"]) + async def test_refused_delivery_is_replayed_once_after_confirmed_close( + self, host: RetentionHost, delivery: str + ) -> None: + assert await host.send(ROOM_ID, write_instruction("before")) == ["before"] + closing = host.children[0].closing = Hold() + leaving = asyncio.create_task(host.leave(ROOM_ID)) + platform = ReplyingPlatform() + content = write_instruction("after") + message_id = "waiting-for-close" + platform.add_message(message_id, content=content) + async with rest_client_over(platform.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + context = run_through( + host.adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + try: + async with closing: + if delivery == "live": + await context._process_event( + make_message_event( + room_id=ROOM_ID, + msg_id=message_id, + content=content, + sender_id="user-1", + ) + ) + else: + await context._reconcile() + assert platform.marked("processed") == [] + assert platform.marked("failed") == [] + assert platform.replies == platform.errors == [] + assert host.exited == [False] + await leaving + await context._reconcile() + await context._reconcile() + assert platform.marked("processed") == [message_id] + assert platform.marked("failed") == [] + assert platform.replies == ["after"] + assert platform.errors == [] + assert host.exited == [True, False] + assert host.overlaps == 0 + finally: + await context.stop() diff --git a/tests/adapters/roompeer.py b/tests/adapters/roompeer.py index 8700b5e46..1ec2e0f73 100644 --- a/tests/adapters/roompeer.py +++ b/tests/adapters/roompeer.py @@ -10,6 +10,7 @@ import sys import threading import time +import uuid from enum import StrEnum from pathlib import Path from typing import Any @@ -20,18 +21,42 @@ from band.runtime.tools import BandTool WRITE_PREFIX = "WRITE=" +MEMO_PREFIX = "MEMO=" +PEER_PROVIDER = "peer" +# The peer's protocol with the suite: it marks its workspace when a start +# begins, refuses a failed start, and a peer's refused close says so. +STARTING_MARKER = "starting.txt" +STARTUP_FAILURE = "startup failed" +CLEANUP_FAILURE = "subprocess cleanup failed" +# How the Codex peer refuses to resume a thread it never saved. +NO_ROLLOUT = "no rollout found for thread id" +# Where the Codex peer saves each thread's conversation, unless told otherwise. +CODEX_HOME = "codex-home" +WORKSPACE_NOTE = "notes.txt" +# How often a held start checks whether its hold file is gone. +HOLD_POLL_S = 0.01 +# Flags that fail or hold a peer's start while their file exists. +FAIL_STARTUP_ARG = "--fail-startup" +HOLD_STARTUP_ARG = "--hold-startup" class PeerCommand(StrEnum): READ = "READ" LOSE_TRANSPORT = "LOSE_TRANSPORT" EXIT_PROCESS = "EXIT_PROCESS" + RECALL = "RECALL" + TOOLS = "TOOLS" def write_instruction(marker: str) -> str: return f"{WRITE_PREFIX}{marker}" +def memo_instruction(marker: str) -> str: + """Keep ``marker`` only in the thread's private context.""" + return f"{MEMO_PREFIX}{marker}" + + def emit(message: dict[str, Any]) -> None: sys.stdout.write(json.dumps(message) + "\n") sys.stdout.flush() @@ -44,8 +69,13 @@ def perform(tool_name: str, arguments: dict[str, str]) -> str: return path.read_text() +def read_reply(value: str) -> str: + """The peer's reply to a READ that found ``value``.""" + return f"Read: {value}" + + def reply_text(tool_name: str, value: str) -> str: - return f"Read: {value}" if tool_name == "Read" else value + return read_reply(value) if tool_name == "Read" else value async def reply(url: str, content: str) -> None: @@ -66,7 +96,7 @@ def file_request(content: str) -> dict[str, Any]: return json.loads(content) except json.JSONDecodeError: marker = re.search(rf"{re.escape(WRITE_PREFIX)}([a-zA-Z0-9-]+)", content) - arguments = {"file_path": "notes.txt"} + arguments = {"file_path": WORKSPACE_NOTE} if marker is not None: arguments["content"] = marker.group(1) return { @@ -81,97 +111,199 @@ def lose_transport() -> None: threading.Event().wait() -def initialize(failure: Path | None, hold: Path | None) -> bool: - Path("starting.txt").touch() - while hold is not None and hold.exists(): - time.sleep(0.01) +def begin_startup() -> None: + Path(STARTING_MARKER).touch() + + +def startup_held(hold: Path | None) -> bool: + return hold is not None and hold.exists() + + +def startup_refused(failure: Path | None) -> bool: return failure is not None and failure.exists() -def codex( - message: dict[str, Any], failure: Path | None, hold: Path | None, threads: set[str] -) -> None: - match message["method"]: - case "initialize": - failed = initialize(failure, hold) - answer = ( - {"error": {"code": -32000, "message": "startup failed"}} - if failed - else {"result": {}} - ) - emit({"id": message["id"], **answer}) - case "thread/start" | "thread/resume": - thread_id = f"peer-{os.getpid()}-{len(threads)}" - threads.add(thread_id) - emit({"id": message["id"], "result": {"thread": {"id": thread_id}}}) - case "turn/start": - if message["params"]["threadId"] not in threads: - emit( - { - "id": message["id"], - "error": {"code": -32000, "message": "unknown thread"}, - } +def initialize(failure: Path | None, hold: Path | None) -> bool: + """Start, waiting while ``hold`` exists; whether ``failure`` refuses it.""" + begin_startup() + while startup_held(hold): + time.sleep(HOLD_POLL_S) + return startup_refused(failure) + + +def rollout_path(home: Path, thread_id: str) -> Path: + """Where the Codex peer saves ``thread_id`` under ``home``.""" + return home / f"{thread_id}.json" + + +class AppServer: + """A Codex app-server whose threads outlive the process, like rollouts. + + A thread is saved under ``home`` from its first turn on: its tools and a + private memo that no Band message ever carries. Resuming restores both + and ignores tools sent with the request, as Codex does; resuming a + thread that was never saved fails with Codex's own refusal. + """ + + def __init__(self, home: Path, failure: Path | None, hold: Path | None) -> None: + self.home = home + self.failure = failure + self.hold = hold + self.loaded: dict[str, dict[str, Any]] = {} + self.turns = 0 + + def saved(self, thread_id: str) -> Path: + return rollout_path(self.home, thread_id) + + def save(self, thread_id: str) -> None: + self.home.mkdir(parents=True, exist_ok=True) + self.saved(thread_id).write_text(json.dumps(self.loaded[thread_id])) + + def answer(self, message: dict[str, Any], thread_id: str) -> None: + thread = self.loaded[thread_id] + emit( + { + "id": message["id"], + "result": { + "thread": {"id": thread_id}, + "modelProvider": PEER_PROVIDER, + "cwd": message["params"].get("cwd") or os.getcwd(), + "tools": thread["tools"], + }, + } + ) + + def handle(self, message: dict[str, Any]) -> None: + match message["method"]: + case "initialize": + failed = initialize(self.failure, self.hold) + answer = ( + {"error": {"code": -32000, "message": STARTUP_FAILURE}} + if failed + else {"result": {}} ) - return - emit({"id": message["id"], "result": {"turn": {"id": "peer-turn"}}}) - content = "\n".join( - item["text"] - for item in message["params"]["input"] - if item["type"] == "text" - ) - if PeerCommand.EXIT_PROCESS in content: - os._exit(0) - if PeerCommand.LOSE_TRANSPORT in content: - lose_transport() - request = file_request(content) - result = perform(request["tool_name"], request["input"]) + emit({"id": message["id"], **answer}) + case "thread/start": + thread_id = f"peer-thread-{uuid.uuid4().hex[:12]}" + tools = [tool["name"] for tool in message["params"]["dynamicTools"]] + self.loaded[thread_id] = {"tools": tools, "memo": None} + self.answer(message, thread_id) + case "thread/resume": + thread_id = message["params"]["threadId"] + if not self.saved(thread_id).exists(): + emit( + { + "id": message["id"], + "error": { + "code": -32600, + "message": f"{NO_ROLLOUT} {thread_id}", + }, + } + ) + return + self.loaded[thread_id] = json.loads(self.saved(thread_id).read_text()) + self.answer(message, thread_id) + case "turn/start": + self.turn(message) + case _: + if "id" in message: + emit({"id": message["id"], "result": {}}) + + def turn(self, message: dict[str, Any]) -> None: + thread_id = message["params"]["threadId"] + if thread_id not in self.loaded: emit( { - "id": "peer-reply", - "method": "item/tool/call", - "params": { - "tool": BandTool.SEND_MESSAGE, - "arguments": { - "content": reply_text(request["tool_name"], result), - "mentions": ["@alice"], - }, - }, + "id": message["id"], + "error": {"code": -32000, "message": "unknown thread"}, } ) - response = json.loads(sys.stdin.readline()) - if not response.get("result", {}).get("success"): - raise RuntimeError("The Band reply tool failed") - emit( - { - "method": "turn/completed", - "params": { - "turn": { - "id": "peer-turn", - "status": "completed", - "items": [], - "error": None, - } + return + self.turns += 1 + turn_id = f"{thread_id}-turn-{os.getpid()}-{self.turns}" + emit({"id": message["id"], "result": {"turn": {"id": turn_id}}}) + self.save(thread_id) + content = "\n".join( + item["text"] + for item in message["params"]["input"] + if item["type"] == "text" + ) + if PeerCommand.EXIT_PROCESS in content: + os._exit(0) + if PeerCommand.LOSE_TRANSPORT in content: + lose_transport() + reply = self.reply_to(content, thread_id) + self.call_tool( + thread_id, + turn_id, + BandTool.SEND_MESSAGE, + {"content": reply, "mentions": ["@alice"]}, + ) + emit( + { + "method": "turn/completed", + "params": { + "threadId": thread_id, + "turn": { + "id": turn_id, + "status": "completed", + "items": [], + "error": None, }, - } - ) - case _: - if "id" in message: - emit({"id": message["id"], "result": {}}) + }, + } + ) + + def reply_to(self, content: str, thread_id: str) -> str: + thread = self.loaded[thread_id] + if (memo := re.search(rf"{MEMO_PREFIX}([a-zA-Z0-9-]+)", content)) is not None: + thread["memo"] = memo.group(1) + self.save(thread_id) + return "Noted." + if PeerCommand.RECALL in content: + return f"Recalled: {thread['memo']}" + if PeerCommand.TOOLS in content: + return f"Tools: {sorted(thread['tools'])}" + request = file_request(content) + return reply_text( + request["tool_name"], perform(request["tool_name"], request["input"]) + ) + + def call_tool( + self, thread_id: str, turn_id: str, tool: str, arguments: dict[str, Any] + ) -> None: + emit( + { + "id": f"call-{turn_id}", + "method": "item/tool/call", + "params": { + "threadId": thread_id, + "turnId": turn_id, + "callId": f"call-{turn_id}", + "tool": tool, + "arguments": arguments, + }, + } + ) + response = json.loads(sys.stdin.readline()) + if not response.get("result", {}).get("success"): + raise RuntimeError(f"The Band tool {tool} failed: {response}") def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("--fail-startup", type=Path) - parser.add_argument("--hold-startup", type=Path) + parser.add_argument(FAIL_STARTUP_ARG, type=Path) + parser.add_argument(HOLD_STARTUP_ARG, type=Path) parser.add_argument("--stay", action="store_true") parser.add_argument("--band-url") + parser.add_argument("--home", type=Path, default=Path(CODEX_HOME)) options = parser.parse_args() tool_name = "" - threads: set[str] = set() + server = AppServer(options.home, options.fail_startup, options.hold_startup) for line in sys.stdin: message = json.loads(line) if "method" in message: - codex(message, options.fail_startup, options.hold_startup, threads) + server.handle(message) continue match message["type"]: case "control_request": @@ -179,7 +311,7 @@ def main() -> None: options.fail_startup, options.hold_startup ) answer = ( - {"subtype": "error", "error": "startup failed"} + {"subtype": "error", "error": STARTUP_FAILURE} if failed else {"subtype": "success", "response": {}} ) diff --git a/tests/adapters/test_codex_adapter.py b/tests/adapters/test_codex_adapter.py deleted file mode 100644 index 29cee634e..000000000 --- a/tests/adapters/test_codex_adapter.py +++ /dev/null @@ -1,7288 +0,0 @@ -"""Tests for CodexAdapter.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from collections import OrderedDict -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import asynccontextmanager -from dataclasses import dataclass -from datetime import UTC, datetime -from typing import Any -from uuid import uuid4 - -import pytest -import pytest_asyncio -from pydantic import BaseModel, ValidationError - -from band.adapters.codex import ( - _MAX_DIFF_METADATA_BYTES, - _MESSAGE_ITEM_TYPES, - _REQUESTED_TOOL_ITEM_TYPES, - _THOUGHT_ITEM_TYPES, - _TOOL_ITEM_TYPES, - NO_APPROVALS_TO_RESOLVE_MESSAGE, - ApprovalDecision, - CodexAdapter, - CodexAdapterConfig, - CodexCommand, - PendingApproval, -) -from band.client.streaming import ControlMode -from band.core.defaultmodels import OPENAI_MODEL -from band.core.exceptions import CleanupIncompleteError, InputNotAccepted -from band.core.protocols import ( - GENERIC_PROVIDER_FAILURE_MESSAGE, - TurnResultAlreadyReported, -) -from band.core.types import ( - AgentInput, - Emit, - HistoryProvider, - PlatformMessage, -) -from band.integrations.codex import CodexJsonRpcError, RpcEvent -from band.integrations.codex.types import ( - _MAX_ERROR_DETAIL_CHARS, - CodexItemType, - CodexRequestMethod, - CodexSessionState, - CodexTokenUsage, - build_agent_failure, - parse_plan_steps, -) -from band.runtime.custom_tools import CustomToolDef, declares_turn_effect -from band.runtime.decisions import DecisionRegistry -from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS -from band.runtime.tools import BandTool, ToolCallOutcome, TurnEffect -from band.testing import ( - MISSING_REPLY_FAILURE, - FakeAgentTools, - events_of_type, - failure_reports, - reported_failures, -) -from tests.adapters.codexturns import ( - FakeCodexClient, - agent_message_completed, - agent_message_delta, - agent_message_started, - await_released_turn, - event_notification, - event_request, - final_text, - make_codex_adapter, - tool_call_request, - turn_completed, -) -from tests.adapters.dispatch import deliver_event, deliver_message -from tests.conftest import eager_tasks, requires_eager_tasks -from tests.framework_conformance.turnprobes import undeclared -from tests.paths import host_absolute_path - - -def make_platform_message( - room_id: str = "room-1", content: str = "hello" -) -> PlatformMessage: - return PlatformMessage( - id=str(uuid4()), - room_id=room_id, - content=content, - sender_id="user-1", - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ) - - -class ToolSchemaFakeTools(FakeAgentTools): - def get_openai_tool_schemas(self, **kwargs: Any) -> list[dict[str, Any]]: - return [ - { - "type": "function", - "function": { - "name": "band_send_message", - "description": "Send a message", - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string"}, - "mentions": { - "type": "array", - "items": {"type": "string"}, - }, - }, - "required": ["content", "mentions"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "band_send_event", - "description": "Send an event", - "parameters": { - "type": "object", - "properties": { - "content": {"type": "string"}, - "message_type": {"type": "string"}, - }, - "required": ["content", "message_type"], - }, - }, - }, - ] - - -# model/list as codex-cli reports it: per-model efforts, including ones newer -# than the SDK ever knew about. -_LIVE_EFFORTS_MODEL_LIST: dict[str, Any] = { - "data": [ - { - "id": "gpt-6-sol", - "supportedReasoningEfforts": [ - {"reasoningEffort": "low"}, - {"reasoningEffort": "max"}, - {"reasoningEffort": "ultra"}, - ], - }, - {"id": "other", "supportedReasoningEfforts": [{"reasoningEffort": "minimal"}]}, - ] -} - - -def patch_codex_clients_by_room( - adapter: CodexAdapter, clients: dict[str, FakeCodexClient] -) -> None: - """Give each room its own fake app-server, as the real adapter does.""" - - def _build(_config: CodexAdapterConfig) -> FakeCodexClient: - room_id = adapter._active_room.get() - assert room_id is not None - return clients[room_id] - - adapter._build_client = _build # type: ignore[method-assign] - - -def wire_codex_room( - adapter: CodexAdapter, - client: FakeCodexClient, - room_id: str = "room-1", - *, - initialized: bool = True, -) -> None: - adapter._room_client(room_id) - adapter._active_room.set(room_id) - adapter._client = client # type: ignore[assignment] - if initialized: - adapter._initialized = True - - -@dataclass(frozen=True) -class CodexTurn: - """What one driven turn left behind, as the projections tests assert on.""" - - adapter: CodexAdapter - client: FakeCodexClient - tools: FakeAgentTools - - @property - def tool_response(self) -> tuple[int | str, dict[str, Any]]: - """``(request_id, payload)`` of the first tool-call response sent back.""" - return self.client.responses[0] - - @property - def content_items(self) -> list[dict[str, Any]]: - """Content items the adapter returned for the first tool call.""" - return self.tool_response[1]["contentItems"] - - -async def run_codex_turn( - *, - events: list[RpcEvent], - tools: FakeAgentTools | None = None, - config: CodexAdapterConfig | None = None, - **adapter_kwargs: Any, -) -> CodexTurn: - """Drive one full Codex turn against ``events`` and return what it produced. - - Wraps the scaffolding a turn test otherwise repeats -- fake transport, - adapter wired to it, ``on_started``, one bootstrap ``on_message`` -- so a - test states only the events it scripts and the outcome it asserts. - """ - client = FakeCodexClient(events=events) - adapter = make_codex_adapter(client, config=config, **adapter_kwargs) - room_tools = tools if tools is not None else ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await send_bootstrap(adapter, tools=room_tools) - return CodexTurn(adapter=adapter, client=client, tools=room_tools) - - -ROOM_ID = "room-1" - - -async def send_bootstrap( - adapter: CodexAdapter, - *, - room_id: str = ROOM_ID, - tools: FakeAgentTools | None = None, -) -> None: - """Deliver a room's session-bootstrap message to ``adapter``.""" - await deliver_message( - adapter, - make_platform_message(room_id=room_id), - tools if tools is not None else ToolSchemaFakeTools(), - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id=room_id, - ) - - -class CodexRoom: - """A Codex room fed messages one at a time, as Band delivers them: each - ``send`` gets its own tools and returns once the adapter hands the room - back -- its turn done, or parked on a human decision.""" - - def __init__(self, adapter: CodexAdapter, client: FakeCodexClient) -> None: - self.adapter = adapter - self.client = client - self.deliveries: list[FakeAgentTools] = [] - - @property - def chat(self) -> list[str]: - """Room messages, grouped by the delivery whose tools posted them.""" - return [ - message["content"] - for tools in self.deliveries - for message in tools.messages_sent - ] - - @property - def events_sent(self) -> list[dict[str, Any]]: - return [event for tools in self.deliveries for event in tools.events_sent] - - @property - def turn(self) -> asyncio.Task[None]: - return self.adapter._turn_tasks[ROOM_ID].task - - async def send(self, content: str) -> None: - self.deliveries.append(tools := ToolSchemaFakeTools()) - await deliver_event( - self.adapter, - AgentInput( - msg=make_platform_message(room_id=ROOM_ID, content=content), - tools=tools, - history=HistoryProvider(raw=[]), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=len(self.deliveries) == 1, - room_id=ROOM_ID, - ), - ) - - async def settled(self) -> None: - """Wait for a turn a human decision released to finish.""" - await await_released_turn(self.adapter, ROOM_ID) - - -# Adapters run their turns on the loop that started them, so setup, the test -# and teardown share the test's own loop. -@pytest_asyncio.fixture(loop_scope="function") -async def codex_room() -> AsyncIterator[Callable[..., Awaitable[CodexRoom]]]: - """Open a room on a started manual-approval Codex adapter whose server - plays ``events``; every room is cleaned up at the end.""" - rooms: list[CodexRoom] = [] - - async def open_room( - *events: RpcEvent, client: FakeCodexClient | None = None, **config: Any - ) -> CodexRoom: - client = client or FakeCodexClient(events=list(events)) - adapter = make_codex_adapter( - client, config=CodexAdapterConfig(**{"approval_mode": "manual", **config}) - ) - await adapter.on_started("Agent", "A coding agent") - rooms.append(room := CodexRoom(adapter, client)) - return room - - yield open_room - for room in rooms: - await room.adapter.on_cleanup(ROOM_ID) - - -class TestCodexAdapter: - def test_config_defaults_are_low_noise_and_manual_approval( - self, assert_no_leaked_adapter_config_env: None - ) -> None: - config = CodexAdapterConfig() - assert config.emit_turn_task_markers is False - assert config.approval_mode == "manual" - - @pytest.mark.asyncio - async def test_bootstrap_starts_thread_without_relaying_native_deltas(self) -> None: - events = [ - event_notification( - "item/agentMessage/delta", - {"itemId": "msg-1", "delta": "harness-ok"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert CodexRequestMethod.THREAD_START in fake_client.request_methods - thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] - assert "dynamicTools" in thread_start - dynamic_names = [t["name"] for t in thread_start["dynamicTools"]] - assert "band_send_message" in dynamic_names - assert "band_send_event" in dynamic_names - - assert tools.messages_sent == [] - assert not tools.turn.complete - - @pytest.mark.asyncio - async def test_assistant_text_as_thought_posts_no_reply(self) -> None: - """Completed native text is narration, without mentions or settlement.""" - events = [ - agent_message_completed("(Waiting on the review.)", "msg-1"), - turn_completed(), - ] - adapter = make_codex_adapter( - FakeCodexClient(events=events), - config=CodexAdapterConfig(), - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.messages_sent == [] - thoughts = [e for e in tools.events_sent if e["message_type"] == "thought"] - assert [e["content"] for e in thoughts] == ["(Waiting on the review.)"] - assert not tools.turn.complete - - @pytest.mark.parametrize( - "field", ["fallback_send_agent_text", "assistant_text_mode"] - ) - def test_retired_delivery_config_is_rejected(self, field: str) -> None: - with pytest.raises(ValidationError, match="Extra inputs"): - CodexAdapterConfig.model_validate({field: True}) - - @pytest.mark.asyncio - async def test_system_prompt_retry_after_turn_start_failure(self) -> None: - """System instructions stay pending until turn/start succeeds.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - turn_start_error=CodexJsonRpcError( - code=-32000, - message="Model not available", - ), - turn_start_error_once=True, - ) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(model="gpt-5.5") - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - with pytest.raises(CodexJsonRpcError, match="not available"): - await deliver_message( - adapter, - make_platform_message(room_id="room-1", content="first try"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert "room-1" not in adapter._prompt_injected_rooms - - await deliver_message( - adapter, - make_platform_message(room_id="room-1", content="second try"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - assert "room-1" in adapter._prompt_injected_rooms - - turn_inputs = [ - params["input"] - for params in fake_client.params_of(CodexRequestMethod.TURN_START) - ] - assert len(turn_inputs) == 2 - for turn_input in turn_inputs: - assert any( - item.get("text", "").startswith("[System Instructions]\n") - for item in turn_input - ) - - @pytest.mark.asyncio - async def test_tool_call_request_is_dispatched_and_responded(self) -> None: - events = [ - tool_call_request(42, "band_lookup_peers", {"page": 1, "page_size": 10}), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert len(tools.tool_calls) == 1 - assert tools.tool_calls[0]["tool_name"] == "band_lookup_peers" - assert fake_client.responses - response_id, response_payload = fake_client.responses[0] - assert response_id == 42 - assert response_payload["success"] is True - - @pytest.mark.asyncio - async def test_failed_reply_leaves_native_text_as_unsettled_thought( - self, - ) -> None: - """Fallback agent text should still be delivered when send_message fails. - - The failure is a non-raising ok=False (bad args / API error) — the case the - plain execute_tool_call would misread as success and wrongly suppress. - """ - - class SendMessageFailureTools(ToolSchemaFakeTools): - async def execute_tool_call_structured( - self, tool_name: str, arguments: dict[str, Any] - ) -> ToolCallOutcome: - self.tool_calls.append({"tool_name": tool_name, "arguments": arguments}) - if tool_name == "band_send_message": - return ToolCallOutcome( - value="Error executing band_send_message: send failed", - ok=False, - error_message="send failed", - ) - return await super().execute_tool_call_structured(tool_name, arguments) - - events = [ - event_request( - 77, - "item/tool/call", - { - "tool": "band_send_message", - "arguments": {"content": "hi"}, - "callId": "call-77", - }, - ), - event_notification( - "item/completed", - { - "item": { - "type": "agentMessage", - "id": "msg-1", - "text": "fallback final text", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = SendMessageFailureTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.messages_sent == [] - assert not tools.turn.complete - assert [e["content"] for e in events_of_type(tools, "thought")] == [ - "fallback final text" - ] - assert len(fake_client.responses) == 1 - _, payload = fake_client.responses[0] - assert payload["success"] is False - - @pytest.mark.asyncio - async def test_resume_failure_falls_back_to_thread_start(self) -> None: - events = [turn_completed()] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Not found"), - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(thread_id="thr-old", room_id="room-1"), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - methods = fake_client.request_methods - assert CodexRequestMethod.THREAD_RESUME in methods - assert CodexRequestMethod.THREAD_START in methods - - @pytest.mark.asyncio - async def test_approval_request_auto_decline(self) -> None: - events = [ - event_request( - 7, - "item/commandExecution/requestApproval", - {"command": "rm -rf tmp"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert fake_client.responses - response_id, payload = fake_client.responses[0] - assert response_id == 7 - assert payload["decision"] == "decline" - assert len(tools.messages_sent) == 1 - assert "Approval requested" in tools.messages_sent[0]["content"] - assert "rm -rf tmp" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_auto_approval_responds_even_if_notification_fails(self) -> None: - class FailingNotifyTools(ToolSchemaFakeTools): - async def send_notice( - self, content: str, mentions: list[dict[str, str]] | None = None - ) -> Any: - raise RuntimeError("notification failed") - - events = [ - event_request( - 7, - "item/commandExecution/requestApproval", - {"command": "rm -rf tmp"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - approval_mode="auto_decline", approval_text_notifications=True - ), - ) - tools = FailingNotifyTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert fake_client.responses - response_id, payload = fake_client.responses[0] - assert response_id == 7 - assert payload["decision"] == "decline" - - @pytest.mark.asyncio - async def test_manual_approval_responds_with_decline_if_notification_fails( - self, - ) -> None: - class FailingNotifyTools(ToolSchemaFakeTools): - async def send_notice( - self, content: str, mentions: list[dict[str, str]] | None = None - ) -> Any: - raise RuntimeError("notification failed") - - events = [ - event_request( - 7, - "item/commandExecution/requestApproval", - {"command": "rm -rf tmp"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="manual") - ) - tools = FailingNotifyTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert fake_client.responses - response_id, payload = fake_client.responses[0] - assert response_id == 7 - assert payload["decision"] == "decline" - assert "room-1" not in adapter._pending_approvals - - # The Band-delivery hiccup that caused this auto-decline must itself - # be reported -- otherwise it's indistinguishable from a genuine - # human decision, with no signal at all that anything went wrong. - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - - # The human sender was never actually notified, so the audit trail - # must not credit/blame them for this decision -- it was forced by - # the delivery failure, same as every other forced-decline path. - audit_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "approval_resolution" - ] - assert len(audit_events) == 1 - assert audit_events[0]["metadata"]["codex_decided_by"] == "system_fallback" - - @pytest.mark.asyncio - async def test_cleanup_closes_client_when_last_room_removed(self) -> None: - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(room_id="room-1"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert fake_client.closed is False - await adapter.on_cleanup("room-1") - assert fake_client.closed is True - - @pytest.mark.asyncio - async def test_cleanup_idempotent(self) -> None: - """Calling on_cleanup twice for the same room should not raise.""" - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(room_id="room-1"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - await adapter.on_cleanup("room-1") - assert fake_client.closed is True - # Second cleanup should not raise - await adapter.on_cleanup("room-1") - - @pytest.mark.asyncio - async def test_cleanup_multi_room_closes_each_room_client(self) -> None: - """Each room owns its Codex client; cleanup closes only that room's client.""" - clients = { - "room-1": FakeCodexClient(events=[turn_completed()]), - # Each room owns a separate FakeCodexClient with its own turn - # counter, so room-2's first turn is also "turn-1" -- matching - # this room's client, not a global counter. - "room-2": FakeCodexClient(events=[turn_completed()]), - } - adapter = CodexAdapter(config=CodexAdapterConfig()) - patch_codex_clients_by_room(adapter, clients) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - for room_id in ("room-1", "room-2"): - await deliver_message( - adapter, - make_platform_message(room_id=room_id), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id=room_id, - ) - - await adapter.on_cleanup("room-1") - assert clients["room-1"].closed is True - assert clients["room-2"].closed is False - - await adapter.on_cleanup("room-2") - assert clients["room-2"].closed is True - - @pytest.mark.asyncio - async def test_forwards_raw_codex_task_events(self) -> None: - events = [ - event_notification( - "codex/event/task_started", - {"taskId": "task-1", "task": {"title": "Inspect repository"}}, - ), - event_notification( - "codex/event/task_complete", - {"taskId": "task-1", "summary": "Inspection finished"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - raw_task_events = [ - event - for event in tools.events_sent - if event["metadata"].get("codex_event_method") - in { - "codex/event/task_started", - "codex/event/task_complete", - } - ] - assert len(raw_task_events) == 2 - assert raw_task_events[0]["content"] == ( - "UUID: task-1\nTask: Inspect repository\nStatus: started" - ) - assert raw_task_events[0]["metadata"]["codex_task_id"] == "task-1" - assert raw_task_events[1]["content"] == ( - "UUID: task-1\nTask: Inspect repository\nStatus: completed\n" - "Summary: Inspection finished" - ) - assert raw_task_events[1]["metadata"]["codex_task_phase"] == "completed" - - @pytest.mark.asyncio - async def test_can_disable_synthetic_turn_task_markers(self) -> None: - events = [ - event_notification( - "codex/event/task_started", - {"taskId": "task-1", "task": {"title": "Inspect repository"}}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_task_markers=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - turn_marker_events = [ - event - for event in tools.events_sent - if "codex_turn_status" in event["metadata"] - ] - assert turn_marker_events == [] - assert any( - event["metadata"].get("codex_event_method") == "codex/event/task_started" - for event in tools.events_sent - ) - - @pytest.mark.asyncio - async def test_raw_task_event_without_explicit_task_id_does_not_emit_uuid( - self, - ) -> None: - events = [ - event_notification( - "codex/event/task_started", - {"id": "turn-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_task_markers=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - raw_task_event = next( - event - for event in tools.events_sent - if event["metadata"].get("codex_event_method") == "codex/event/task_started" - ) - assert raw_task_event["content"] == ( - "Task: Codex task lifecycle event\nStatus: started\n" - "Summary: Method: codex/event/task_started" - ) - assert "codex_task_id" not in raw_task_event["metadata"] - - @pytest.mark.asyncio - async def test_status_command_returns_state_without_starting_turn(self) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="@thenvoi/ar-2-darter /status"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - methods = fake_client.request_methods - assert CodexRequestMethod.TURN_START not in methods - assert CodexRequestMethod.THREAD_START not in methods - assert len(tools.messages_sent) == 1 - assert "Codex status:" in tools.messages_sent[0]["content"] - assert "thread_id: not mapped" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_model_command_sets_override_without_starting_turn(self) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/model gpt-5.5-codex"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - methods = fake_client.request_methods - assert CodexRequestMethod.TURN_START not in methods - assert CodexRequestMethod.THREAD_START not in methods - assert adapter._selected_model == "gpt-5.5-codex" - assert len(tools.messages_sent) == 1 - assert ( - "Model override set to `gpt-5.5-codex`" in tools.messages_sent[0]["content"] - ) - - @pytest.mark.asyncio - async def test_models_alias_lists_models_without_starting_turn(self) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/models list"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - methods = fake_client.request_methods - assert CodexRequestMethod.TURN_START not in methods - assert CodexRequestMethod.THREAD_START not in methods - assert methods.count(CodexRequestMethod.MODEL_LIST) >= 1 - assert len(tools.messages_sent) == 1 - assert "Available models" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - @pytest.mark.parametrize("effort", ["high", "max"]) - async def test_reasoning_effort_passed_in_turn_overrides(self, effort: str) -> None: - """Efforts newer than any list the SDK could hard-code reach Codex as-is.""" - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - reasoning_effort=effort, reasoning_summary="concise" - ), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="hello"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - turn_params = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - assert turn_params["effort"] == effort - assert turn_params["summary"] == "concise" - - @pytest.mark.asyncio - async def test_reasoning_effort_omitted_when_none(self) -> None: - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="hello"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - turn_params = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - assert "effort" not in turn_params - assert "summary" not in turn_params - - @pytest.mark.asyncio - @pytest.mark.parametrize("effort", ["high", "ultra"]) - async def test_reasoning_command_sets_effort(self, effort: str) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content=f"/reasoning {effort}"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - room = adapter._room_clients["room-1"] - assert room.reasoning_effort == effort - assert len(tools.messages_sent) == 1 - assert ( - f"Reasoning effort set to `{effort}`" in tools.messages_sent[0]["content"] - ) - - @pytest.mark.asyncio - async def test_reasoning_command_lists_efforts_the_model_supports(self) -> None: - fake_client = FakeCodexClient(model_list_result=_LIVE_EFFORTS_MODEL_LIST) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(model="gpt-6-sol") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/reasoning"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert len(tools.messages_sent) == 1 - assert ( - "`gpt-6-sol` supports: low, max, ultra." - in tools.messages_sent[0]["content"] - ) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("effort", "stored", "reply"), - [ - ("ultra", "ultra", "Reasoning effort set to `ultra`"), - ( - "hgih", - None, - ( - "`gpt-6-sol` doesn't support reasoning effort `hgih`. " - "Supported: low, max, ultra." - ), - ), - ], - ) - async def test_reasoning_command_checks_the_models_live_efforts( - self, effort: str, stored: str | None, reply: str - ) -> None: - """A typo is refused up front instead of failing every later turn.""" - fake_client = FakeCodexClient(model_list_result=_LIVE_EFFORTS_MODEL_LIST) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(model="gpt-6-sol") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content=f"/reasoning {effort}"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert adapter._room_clients["room-1"].reasoning_effort == stored - assert len(tools.messages_sent) == 1 - assert reply in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_reasoning_command_still_answers_when_model_list_fails(self) -> None: - fake_client = FakeCodexClient( - model_list_error=RuntimeError("model/list unavailable") - ) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(model="gpt-6-sol", reasoning_effort="high"), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/reasoning"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert len(tools.messages_sent) == 1 - reply = tools.messages_sent[0]["content"] - assert "Current reasoning effort: `high`" in reply - assert "supports:" not in reply - - @pytest.mark.asyncio - async def test_self_config_tools_registered_when_enabled(self) -> None: - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(enable_self_config_tools=True), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="hello"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - # Check that thread/start included setmodel and setreasoning dynamic tools - thread_params = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] - tool_names = [t["name"] for t in thread_params.get("dynamicTools", [])] - assert "setmodel" in tool_names - assert "setreasoning" in tool_names - - @pytest.mark.asyncio - async def test_self_config_tools_not_registered_when_disabled(self) -> None: - fake_client = FakeCodexClient(events=[turn_completed()]) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(enable_self_config_tools=False), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="hello"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - thread_params = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] - tool_names = [t["name"] for t in thread_params.get("dynamicTools", [])] - assert "setmodel" not in tool_names - assert "setreasoning" not in tool_names - - @pytest.mark.asyncio - async def test_setmodel_tool_changes_model(self) -> None: - events = [ - event_request( - 99, - "item/tool/call", - { - "tool": "setmodel", - "callId": "call-1", - "arguments": {"model": "o3"}, - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(enable_self_config_tools=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="switch to o3"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert adapter._selected_model == "o3" - # Verify the tool response was sent back - tool_responses = [ - (rid, result) - for rid, result in fake_client.responses - if isinstance(result, dict) and "contentItems" in result - ] - assert len(tool_responses) >= 1 - result_text = tool_responses[0][1]["contentItems"][0]["text"] - assert "o3" in result_text - - @pytest.mark.asyncio - @pytest.mark.parametrize("effort", ["xhigh", "ultra"]) - async def test_setreasoning_tool_changes_effort(self, effort: str) -> None: - events = [ - event_request( - 99, - "item/tool/call", - { - "tool": "setreasoning", - "callId": "call-2", - "arguments": {"effort": effort, "summary": "detailed"}, - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(enable_self_config_tools=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="increase reasoning"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - room = adapter._room_clients["room-1"] - assert room.reasoning_effort == effort - assert room.reasoning_summary == "detailed" - - @pytest.mark.asyncio - async def test_sandbox_alias_is_normalized_for_thread_and_turn(self) -> None: - events = [turn_completed()] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(sandbox="dangerFullAccess") - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - # thread/start only accepts the sandbox field (SandboxMode enum) - assert thread_start["sandbox"] == "danger-full-access" - # turn/start uses sandboxPolicy (full SandboxPolicy tagged union) - assert turn_start["sandboxPolicy"]["type"] == "dangerFullAccess" - - @pytest.mark.asyncio - async def test_external_sandbox_alias_uses_sandbox_policy(self) -> None: - events = [turn_completed()] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(sandbox="external-sandbox") - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thread_start = fake_client.params_of(CodexRequestMethod.THREAD_START)[0] - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - # thread/start has no sandboxPolicy field; externalSandbox is - # only representable at turn level - assert "sandbox" not in thread_start - assert "sandboxPolicy" not in thread_start - # turn/start can express the full SandboxPolicy tagged union - assert turn_start["sandboxPolicy"]["type"] == "externalSandbox" - - @pytest.mark.asyncio - async def test_transport_closed_event_aborts_turn( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """A transport/closed event ends the turn with a failure the room sees - and a WARNING the operator sees (the runtime logs it only at DEBUG).""" - events = [ - event_notification( - "transport/closed", - {"reason": "Codex process exited unexpectedly"}, - ) - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Adapter should report a failure mentioning the disconnect. - failures = reported_failures(tools) - assert any("transport closed" in f["message"].lower() for f in failures) - (warning,) = [r for r in caplog.records if r.levelno == logging.WARNING] - assert "transport closed" in warning.getMessage() - - @pytest.mark.asyncio - async def test_transport_closed_resets_client_state(self) -> None: - """After transport/closed, _client and _initialized should be reset - so the next message rebuilds the client via _ensure_client_ready().""" - events = [ - event_notification( - "transport/closed", - {"reason": "Codex process exited unexpectedly"}, - ) - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # After transport/closed, client state should be reset - assert adapter._client is None - assert adapter._initialized is False - - @pytest.mark.asyncio - async def test_transport_closed_clears_per_room_state(self) -> None: - """After transport/closed, per-room state (thread_id, raw_history, - pending approvals) must be cleared so the next turn does a fresh - thread/start instead of reusing a cached thread_id from the dead - session.""" - events = [ - event_notification( - "transport/closed", - {"reason": "Codex process exited unexpectedly"}, - ) - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Codex Agent", "A coding agent") - - # Pre-populate per-room state to simulate an active session. - adapter._room_threads["room-1"] = "old-thread-id" - adapter._raw_history_by_room["room-1"] = [{"role": "user", "content": "hi"}] - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - # Per-room state should be cleared so next turn starts fresh. - assert "room-1" not in adapter._room_threads - assert "room-1" not in adapter._raw_history_by_room - - @pytest.mark.asyncio - async def test_transport_closed_drains_token_usage_for_dead_threads( - self, - ) -> None: - """Token-usage entries keyed by dead thread ids must be dropped on - transport/closed; otherwise they leak past on_cleanup because the - thread id is no longer reachable through ``_room_threads``. - """ - - events = [ - event_notification( - "transport/closed", - {"reason": "Codex process exited unexpectedly"}, - ) - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Codex Agent", "A coding agent") - - # Pre-populate per-room state + token usage to simulate an active - # session with recorded usage. - adapter._room_threads["room-1"] = "old-thread-id" - adapter._token_usage["old-thread-id"] = CodexTokenUsage( - input_tokens=100, total_tokens=150 - ) - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - # Dead thread's usage entry must be gone even without a matching - # on_cleanup (the room id can no longer look up the thread id). - assert "old-thread-id" not in adapter._token_usage - - @pytest.mark.asyncio - async def test_transport_closed_after_error_does_not_double_report(self) -> None: - """An "error" notification immediately followed by transport/closed for - the same incident must report the failure once, not twice -- matching - the turn/completed branch's existing failure_reported guard.""" - events = [ - event_notification( - "error", - {"error": {"message": "Something went wrong"}, "willRetry": False}, - ), - event_notification( - "transport/closed", - {"reason": "Codex process exited unexpectedly"}, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Codex Agent", "A coding agent") - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert len(reported_failures(tools)) == 1 - - @pytest.mark.asyncio - async def test_turn_timeout_sends_interrupt_and_clean_error(self) -> None: - """When recv_event times out, the adapter sends turn/interrupt, reports - the failure, and fails the turn so its delivery is marked FAILED -- - same as every sibling adapter's own turn-timeout handling.""" - # No events means FakeCodexClient raises asyncio.TimeoutError immediately. - fake_client = FakeCodexClient(events=[]) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(turn_timeout_s=0.01) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Adapter should have sent turn/interrupt with both identifiers. - assert fake_client.params_of(CodexRequestMethod.TURN_INTERRUPT) == [ - {"threadId": "thr-1", "turnId": "turn-1"} - ] - - # The turn fails the platform's turn, so no separate "I stopped..." - # chat reply goes out alongside the structured failure event. - assert not tools.messages_sent - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - assert failures[0]["code"] == "timeout" - - @pytest.mark.asyncio - async def test_turn_timeout_emits_failed_lifecycle_event(self) -> None: - """A timed-out turn still gets a failed-status lifecycle event, same - as the transport/closed and turn/completed(status=failed) paths -- - the timeout branch's TurnResultAlreadyReported raise happens from - inside its own except clause, so it can't rely on the sibling - `except TurnResultAlreadyReported` handler to emit it.""" - fake_client = FakeCodexClient(events=[]) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - turn_timeout_s=0.01, - emit_turn_lifecycle_events=True, - ), - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - lifecycle_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_lifecycle" - ] - assert len(lifecycle_events) == 2 - assert lifecycle_events[0]["metadata"]["codex_turn_status"] == "started" - assert lifecycle_events[1]["metadata"]["codex_turn_status"] == "failed" - - @pytest.mark.asyncio - async def test_item_completed_text_overrides_accumulated_deltas(self) -> None: - """item/completed text is authoritative and should replace any accumulated deltas.""" - events = [ - event_notification( - "item/agentMessage/delta", - {"itemId": "msg-1", "delta": "partial "}, - ), - event_notification( - "item/agentMessage/delta", - {"itemId": "msg-1", "delta": "garbled"}, - ), - event_notification( - "item/completed", - { - "item": { - "type": "agentMessage", - "id": "msg-1", - "text": "authoritative final text", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # The authoritative text from item/completed should be used, not the deltas. - assert tools.messages_sent == [] - assert [e["content"] for e in events_of_type(tools, "thought")] == [ - "authoritative final text" - ] - - @pytest.mark.asyncio - async def test_custom_tools_schemas_merged_into_dynamic_tools(self) -> None: - """Custom tool schemas appear in _build_dynamic_tools output.""" - - class WeatherInput(BaseModel): - """Get current weather for a location.""" - - city: str - - def get_weather(inp: WeatherInput) -> str: - return f"Sunny in {inp.city}" - - custom_tools: list[CustomToolDef] = [(WeatherInput, get_weather)] - adapter = CodexAdapter( - config=CodexAdapterConfig(), - additional_tools=custom_tools, - ) - - tools = ToolSchemaFakeTools() - dynamic_tools = adapter._build_dynamic_tools(tools) - - names = [t["name"] for t in dynamic_tools] - assert "weather" in names - - weather_tool = next(t for t in dynamic_tools if t["name"] == "weather") - assert weather_tool["description"] == "Get current weather for a location." - assert "inputSchema" in weather_tool - assert "city" in weather_tool["inputSchema"].get("properties", {}) - - @pytest.mark.asyncio - async def test_custom_tool_dispatched_before_platform_tools(self) -> None: - """Custom tool is invoked via execute_custom_tool, not platform tools.""" - - class CalculatorInput(BaseModel): - """Simple calculator.""" - - expression: str - - call_log: list[str] = [] - - async def calculate(inp: CalculatorInput) -> str: - call_log.append(inp.expression) - return "42" - - custom_tools: list[CustomToolDef] = [(CalculatorInput, calculate)] - events = [ - event_request( - 99, - "item/tool/call", - { - "tool": "calculator", - "arguments": {"expression": "6*7"}, - "callId": "call-99", - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), additional_tools=custom_tools - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Custom tool was called - assert call_log == ["6*7"] - # Platform execute_tool_call was NOT called for the custom tool - assert not any(tc["tool_name"] == "calculator" for tc in tools.tool_calls) - # Response was sent back to Codex - assert fake_client.responses - _, payload = fake_client.responses[0] - assert payload["success"] is True - assert payload["contentItems"][0]["text"] == "42" - - @pytest.mark.asyncio - async def test_execution_reporting_emits_tool_call_and_result_events(self) -> None: - """With emit=Emit.TOOL_CALLS, tool_call and tool_result events are emitted.""" - events = [ - event_request( - 50, - "item/tool/call", - { - "tool": "band_lookup_peers", - "arguments": {"page": 1}, - "callId": "call-50", - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "band_lookup_peers" - assert call_data["tool_call_id"] == "call-50" - - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["name"] == "band_lookup_peers" - assert result_data["tool_call_id"] == "call-50" - - @pytest.mark.asyncio - async def test_send_room_file_tool_call_event_redacts_content(self) -> None: - """band_send_room_file's raw content must never reach a tool_call - event -- report has no idea content can carry real file bytes.""" - raw_content = "raw file bytes that must never reach a tool_call event" - events = [ - tool_call_request( - 50, "band_send_room_file", {"content": raw_content, "filename": "f.txt"} - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - call_data = json.loads(events_of_type(tools, "tool_call")[0]["content"]) - assert ( - call_data["args"]["content"] - == f"<{len(raw_content.encode('utf-8'))} byte file content>" - ) - assert raw_content not in json.dumps(call_data) - - @pytest.mark.asyncio - async def test_execution_reporting_silenced_with_explicit_empty_emit(self) -> None: - """emit=() silences tool_call/tool_result events (emit otherwise defaults on).""" - events = [ - event_request( - 50, - "item/tool/call", - { - "tool": "band_lookup_peers", - "arguments": {"page": 1}, - "callId": "call-50", - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(), emit=()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_events = [ - e - for e in tools.events_sent - if e["message_type"] in {"tool_call", "tool_result"} - ] - assert tool_events == [] - - @pytest.mark.asyncio - async def test_execution_reporting_on_tool_error(self) -> None: - """Execution reporting emits tool_result with error text on failure.""" - - class FailInput(BaseModel): - """A tool that always fails.""" - - x: int - - async def fail_func(inp: FailInput) -> str: - raise RuntimeError("boom") - - custom_tools: list[CustomToolDef] = [(FailInput, fail_func)] - events = [ - event_request( - 60, - "item/tool/call", - { - "tool": "fail", - "arguments": {"x": 1}, - "callId": "call-60", - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(), - additional_tools=custom_tools, - emit=Emit.TOOL_CALLS, - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_result_events) == 1 - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["name"] == "fail" - assert "boom" in result_data["output"] - assert result_data["tool_call_id"] == "call-60" - - # Codex response should indicate failure - _, payload = fake_client.responses[0] - assert payload["success"] is False - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "tool_name", - ["band_send_event", "band_send_message"], - ) - async def test_execution_reporting_emitted_for_platform_output_tools( - self, tool_name: str - ) -> None: - """Band messaging tools are reported like any other tool — no suppression.""" - events = [ - event_request( - 70, - "item/tool/call", - { - "tool": tool_name, - "arguments": {"content": "test", "message_type": "thought"}, - "callId": "call-70", - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # The tool call itself should still execute - assert len(tools.tool_calls) == 1 - assert tools.tool_calls[0]["tool_name"] == tool_name - - # And it's reported like any other tool call - reporting_events = [ - e - for e in tools.events_sent - if e["message_type"] in {"tool_call", "tool_result"} - ] - assert [e["message_type"] for e in reporting_events] == [ - "tool_call", - "tool_result", - ] - - -class TestItemCompletedForwarding: - """Tests for forwarding internal Codex operations as platform events.""" - - @pytest.mark.asyncio - async def test_item_completed_mcpToolCall_send_room_file_redacts_content( - self, - ) -> None: - """band_send_room_file routed through Codex's own mcpToolCall item - (a separate reporting path from item/tool/call, keyed by the bare - "tool" field before it's wrapped in the "mcp:{server}/{tool}" - display name) must also redact raw file content before it reaches a - tool_call event.""" - raw_content = "raw file bytes that must never reach a tool_call event" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "mcpToolCall", - "id": "mcp-1", - "server": "band", - "tool": "band_send_room_file", - "arguments": {"content": raw_content, "filename": "f.txt"}, - "result": {"status": "success"}, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - call_data = json.loads(events_of_type(tools, "tool_call")[0]["content"]) - assert call_data["name"] == "mcp:band/band_send_room_file" - assert ( - call_data["args"]["content"] - == f"<{len(raw_content.encode('utf-8'))} byte file content>" - ) - assert raw_content not in json.dumps(call_data) - - @pytest.mark.asyncio - async def test_item_completed_commandExecution_emits_tool_events(self) -> None: - """commandExecution item emits tool_call + tool_result with command/output.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "commandExecution", - "id": "cmd-1", - "command": "ls -la", - "cwd": "/workspace", - "aggregated_output": "total 42\ndrwxr-xr-x ...", - "exitCode": 0, - "status": "completed", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "exec" - assert call_data["args"]["command"] == "ls -la" - assert call_data["args"]["cwd"] == "/workspace" - assert call_data["tool_call_id"] == "cmd-1" - - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["name"] == "exec" - assert "total 42" in result_data["output"] - assert "exit_code=0" in result_data["output"] - assert result_data["tool_call_id"] == "cmd-1" - - @pytest.mark.asyncio - async def test_item_completed_fileChange_emits_tool_events(self) -> None: - """fileChange emits tool_call + tool_result with file paths.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "fileChange", - "id": "fc-1", - "changes": [ - {"path": "src/main.py"}, - {"path": "src/utils.py"}, - ], - "status": "applied", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "file_edit" - assert call_data["args"]["files"] == ["src/main.py", "src/utils.py"] - - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["output"] == "applied" - - @pytest.mark.asyncio - async def test_item_completed_fileChange_missing_changes_is_safe(self) -> None: - """fileChange without changes list should not crash and emits empty files.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "fileChange", - "id": "fc-2", - "changes": None, - "status": "applied", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - assert len(tool_call_events) == 1 - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "file_edit" - assert call_data["args"]["files"] == [] - - @pytest.mark.asyncio - async def test_item_completed_imageView_emits_tool_events(self) -> None: - """imageView emits tool_call + tool_result.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "imageView", - "id": "img-1", - "path": "/tmp/screenshot.png", - "status": "viewed", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "view_image" - assert call_data["args"]["path"] == "/tmp/screenshot.png" - - @pytest.mark.asyncio - async def test_item_completed_collabAgentToolCall_emits_tool_events(self) -> None: - """collabAgentToolCall emits tool_call + tool_result preserving empty result.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "collabAgentToolCall", - "id": "collab-1", - "tool": "delegate", - "prompt": "Review the changes", - "agents": ["Reviewer-1", "Reviewer-2"], - "result": {}, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "collab:delegate" - assert call_data["args"]["prompt"] == "Review the changes" - assert call_data["args"]["agents"] == ["Reviewer-1", "Reviewer-2"] - - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["output"] == "{}" - - @pytest.mark.asyncio - async def test_item_completed_collabAgentToolCall_non_text_list_result_preserves_data( - self, - ) -> None: - """A non-text list result is dumped as JSON, not collapsed to "completed".""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "collabAgentToolCall", - "id": "collab-2", - "tool": "delegate", - "result": [1, 2, 3], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_result_events) == 1 - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["output"] == "[1, 2, 3]" - - @pytest.mark.asyncio - async def test_item_completed_mcpToolCall_emits_tool_events(self) -> None: - """mcpToolCall emits tool_call + tool_result with server/tool name.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "mcpToolCall", - "id": "mcp-1", - "server": "filesystem", - "tool": "read_file", - "arguments": {"path": "/etc/hosts"}, - "result": {"content": "127.0.0.1 localhost"}, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_call_events) == 1 - assert len(tool_result_events) == 1 - - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "mcp:filesystem/read_file" - assert call_data["args"]["path"] == "/etc/hosts" - - result_data = json.loads(tool_result_events[0]["content"]) - assert "127.0.0.1 localhost" in result_data["output"] - - @pytest.mark.asyncio - async def test_item_completed_mcpToolCall_non_text_list_result_preserves_data( - self, - ) -> None: - """A non-text list result (e.g. an MCP image content block) is dumped as - JSON, not collapsed to the generic "completed" status. - - Unlike thought extraction, a tool-call result is - real data even when it isn't textual — ``_stringify_tool_output`` must - use its ``raw_fallback`` mode here so nothing is silently discarded. - """ - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "mcpToolCall", - "id": "mcp-2", - "server": "filesystem", - "tool": "read_image", - "arguments": {}, - "result": [ - {"type": "image", "data": "abc123", "mimeType": "image/png"} - ], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_result_events) == 1 - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["output"] != "completed" - assert "image/png" in result_data["output"] - - @pytest.mark.asyncio - async def test_a_dynamic_tool_call_is_reported_once(self) -> None: - """Codex completes the item it already requested via item/tool/call.""" - events = [ - tool_call_request(42, "band_lookup_peers"), - event_notification( - "item/completed", - { - "item": { - "type": "dynamicToolCall", - "tool": "band_lookup_peers", - "arguments": {}, - "status": "completed", - } - }, - ), - turn_completed(), - ] - - turn = await run_codex_turn( - events=events, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - - reported = [event["message_type"] for event in turn.tools.events_sent] - assert reported == ["tool_call", "tool_result"] - - @pytest.mark.asyncio - async def test_item_completed_reasoning_emits_thought(self) -> None: - """reasoning item emits thought event when emit=Emit.THOUGHTS.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-1", - "summary": [ - "Analyzing the codebase structure", - "Identified key files to modify", - ], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.THOUGHTS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thought_events = events_of_type(tools, "thought") - assert len(thought_events) == 1 - assert "Analyzing the codebase structure" in thought_events[0]["content"] - assert "Identified key files to modify" in thought_events[0]["content"] - - @pytest.mark.asyncio - async def test_item_completed_dict_summary_text_emits_thought(self) -> None: - """Reasoning summary entries shaped as {text: ...} use stringify SSOT.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-dict", - "summary": [ - {"type": "summary_text", "text": "Weighing the tradeoffs"}, - {"type": "summary_text", "text": "Choosing the safer joke"}, - ], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thought_events = events_of_type(tools, "thought") - assert len(thought_events) == 1 - assert "Weighing the tradeoffs" in thought_events[0]["content"] - assert "Choosing the safer joke" in thought_events[0]["content"] - - @pytest.mark.asyncio - async def test_item_completed_empty_reasoning_summary_skips_thought(self) -> None: - """Empty reasoning summaries must not post a '(reasoning)' placeholder.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-empty", - "summary": [], - } - }, - ), - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-blank", - "summary": ["", " "], - } - }, - ), - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-none", - "summary": None, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thought_events = events_of_type(tools, "thought") - assert thought_events == [] - - @pytest.mark.asyncio - async def test_item_completed_empty_plan_text_skips_thought(self) -> None: - """Empty plan text must not post a '(plan)' placeholder.""" - events = [ - event_notification( - "item/completed", - {"item": {"type": "plan", "id": "plan-empty", "text": ""}}, - ), - event_notification( - "item/completed", - {"item": {"type": "plan", "id": "plan-blank", "text": " "}}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit={Emit.THOUGHTS} - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thought_events = events_of_type(tools, "thought") - assert thought_events == [] - - @pytest.mark.asyncio - async def test_item_completed_skipped_when_reporting_disabled(self) -> None: - """No tool events when emit narrows to Emit.TASK_EVENTS only.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "commandExecution", - "id": "cmd-1", - "command": "ls", - "exitCode": 0, - } - }, - ), - event_notification( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-1", - "summary": ["thinking"], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TASK_EVENTS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_events = [ - e - for e in tools.events_sent - if e["message_type"] in {"tool_call", "tool_result", "thought"} - ] - assert tool_events == [] - - @pytest.mark.asyncio - async def test_completed_agent_message_is_emitted_as_a_thought(self) -> None: - """Existing agentMessage behavior preserved alongside new forwarding.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "commandExecution", - "id": "cmd-1", - "command": "pytest", - "exitCode": 0, - "aggregated_output": "all tests passed", - } - }, - ), - event_notification( - "item/completed", - { - "item": { - "type": "agentMessage", - "id": "msg-1", - "text": "All tests pass!", - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # agentMessage text should still be sent as the final message - assert tools.messages_sent == [] - assert events_of_type(tools, "thought") == [] - assert not tools.turn.complete - # commandExecution should also be forwarded as tool events - tool_call_events = events_of_type(tools, "tool_call") - assert len(tool_call_events) == 1 - - @pytest.mark.asyncio - async def test_item_completed_webSearch_emits_tool_events(self) -> None: - """webSearch item emits tool_call + tool_result.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "webSearch", - "id": "ws-1", - "query": "python asyncio tutorial", - "action": {"url": "https://example.com", "title": "Tutorial"}, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - assert len(tool_call_events) == 1 - call_data = json.loads(tool_call_events[0]["content"]) - assert call_data["name"] == "web_search" - assert call_data["args"]["query"] == "python asyncio tutorial" - - @pytest.mark.asyncio - async def test_item_completed_webSearch_non_text_list_action_preserves_data( - self, - ) -> None: - """A non-text list action is dumped as JSON, not collapsed to "completed".""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "webSearch", - "id": "ws-2", - "query": "python asyncio tutorial", - "action": [{"url": "https://example.com"}], - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_result_events = events_of_type(tools, "tool_result") - assert len(tool_result_events) == 1 - result_data = json.loads(tool_result_events[0]["content"]) - assert result_data["output"] == '[{"url": "https://example.com"}]' - - @pytest.mark.asyncio - async def test_item_completed_metadata_includes_codex_ids(self) -> None: - """Forwarded events include codex_room_id, codex_thread_id, codex_turn_id.""" - events = [ - event_notification( - "item/completed", - { - "item": { - "type": "commandExecution", - "id": "cmd-1", - "command": "echo hi", - "exitCode": 0, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - tool_call_events = events_of_type(tools, "tool_call") - assert len(tool_call_events) == 1 - meta = tool_call_events[0]["metadata"] - assert meta["codex_room_id"] == "room-1" - assert meta["codex_thread_id"] == "thr-1" - assert meta["codex_turn_id"] == "turn-1" - - -class TestHistoryInjection: - @pytest.mark.asyncio - async def test_history_injected_on_resume_failure(self) -> None: - """Resume fails, fresh thread created, first turn input contains history block.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history = [ - { - "message_type": "task", - "content": "mapping event", - "metadata": {"codex_thread_id": "thr-old"}, - }, - { - "message_type": "text", - "content": "Can you refactor the auth module?", - "sender_name": "Alice", - }, - { - "message_type": "text", - "content": "Done — split into auth_handler.py and middleware.py", - "sender_name": "CodexAgent", - }, - ] - - inp = AgentInput( - msg=make_platform_message( - room_id="room-1", content="Now add rate limiting" - ), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - history_items = [ - item for item in turn_input if "[Conversation History]" in item["text"] - ] - assert len(history_items) == 1 - assert "[Alice]: Can you refactor the auth module?" in history_items[0]["text"] - assert ( - "[CodexAgent]: Done — split into auth_handler.py and middleware.py" - in history_items[0]["text"] - ) - # Task events should NOT appear in history context - assert "mapping event" not in history_items[0]["text"] - - @pytest.mark.asyncio - async def test_history_not_injected_on_successful_resume(self) -> None: - """Resume succeeds, no history injection.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history = [ - { - "message_type": "task", - "content": "mapping", - "metadata": { - "codex_thread_id": "thr-existing", - "codex_room_id": "room-1", - }, - }, - { - "message_type": "text", - "content": "Hello", - "sender_name": "Alice", - }, - ] - - inp = AgentInput( - msg=make_platform_message(room_id="room-1", content="Continue"), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - assert not any("[Conversation History]" in item["text"] for item in turn_input) - - @pytest.mark.asyncio - async def test_history_not_injected_when_disabled(self) -> None: - """inject_history_on_resume_failure=False, no injection even on failure.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), - ) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(inject_history_on_resume_failure=False), - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history = [ - { - "message_type": "text", - "content": "Hello", - "sender_name": "Alice", - }, - ] - - inp = AgentInput( - msg=make_platform_message(room_id="room-1", content="Continue"), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - assert not any("[Conversation History]" in item["text"] for item in turn_input) - - @pytest.mark.asyncio - async def test_history_filters_non_text_messages(self) -> None: - """Only canonical text messages appear in injected context.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Not found"), - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history = [ - { - "message_type": "task", - "content": "task event", - "sender_name": "System", - "metadata": {"codex_thread_id": "thr-old", "codex_room_id": "room-1"}, - }, - { - "message_type": "tool_call", - "content": '{"name": "foo"}', - "sender_name": "Agent", - }, - { - "message_type": "tool_result", - "content": "result", - "sender_name": "Agent", - }, - { - "message_type": "thought", - "content": "thinking...", - "sender_name": "Agent", - }, - {"message_type": "error", "content": "oops", "sender_name": "Agent"}, - { - "message_type": "text", - "content": "Hello world", - "sender_name": "Alice", - }, - { - "message_type": "message", - "content": "Hi there", - "sender_name": "Bob", - }, - ] - - inp = AgentInput( - msg=make_platform_message(room_id="room-1", content="Go"), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - history_items = [ - item for item in turn_input if "[Conversation History]" in item["text"] - ] - assert len(history_items) == 1 - text = history_items[0]["text"] - assert "[Alice]: Hello world" in text - # "message" is not a MessageType value; nothing on the platform - # produces it, so it does not survive replay. - assert "[Bob]: Hi there" not in text - assert "task event" not in text - assert "thinking..." not in text - assert "oops" not in text - assert "tool_call" not in text - - @pytest.mark.asyncio - async def test_history_respects_max_messages(self) -> None: - """Only last max_history_messages are injected.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Not found"), - ) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(max_history_messages=3) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history: list[dict[str, Any]] = [ - { - "message_type": "task", - "content": "mapping", - "metadata": {"codex_thread_id": "thr-old", "codex_room_id": "room-1"}, - }, - ] - raw_history.extend( - { - "message_type": "text", - "content": f"Message {i}", - "sender_name": "Alice", - } - for i in range(10) - ) - - inp = AgentInput( - msg=make_platform_message(room_id="room-1", content="Go"), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - turn_start = fake_client.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - history_items = [ - item for item in turn_input if "[Conversation History]" in item["text"] - ] - assert len(history_items) == 1 - text = history_items[0]["text"] - # Only last 3 messages should be present - assert "Message 7" in text - assert "Message 8" in text - assert "Message 9" in text - assert "Message 0" not in text - assert "Message 6" not in text - - @pytest.mark.asyncio - async def test_history_cleared_after_injection(self) -> None: - """Raw history removed from memory after first turn.""" - events = [ - tool_call_request(1, BandTool.NO_REPLY), - final_text("Done."), - turn_completed(), - ] - fake_client = FakeCodexClient( - events=events, - resume_error=CodexJsonRpcError(code=-32002, message="Not found"), - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Codex Agent", "A coding agent") - - raw_history = [ - { - "message_type": "text", - "content": "Hello", - "sender_name": "Alice", - }, - ] - - inp = AgentInput( - msg=make_platform_message(room_id="room-1", content="Go"), - tools=tools, - history=HistoryProvider(raw=raw_history), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_event(adapter, inp) - - # After injection, stashed data should be cleaned up - assert "room-1" not in adapter._raw_history_by_room - assert "room-1" not in adapter._needs_history_injection - - @pytest.mark.asyncio - async def test_auto_selected_model_error_propagates_without_retry(self) -> None: - """Auto-selected model errors propagate instead of trying another model.""" - fake_client = FakeCodexClient( - turn_start_error=CodexJsonRpcError( - code=-32000, - message="Model gpt-5.5 is not available for this account", - ), - turn_start_error_once=False, - model_list_result={ - "data": [ - {"id": "gpt-5.5", "hidden": False}, - {"id": "gpt-6-luna", "hidden": False}, - ] - }, - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "An agent") - - msg = make_platform_message(room_id="room-1", content="hello") - with pytest.raises(CodexJsonRpcError, match="not available"): - await deliver_message( - adapter, - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - assert fake_client.request_methods.count(CodexRequestMethod.TURN_START) == 1 - assert adapter._selected_model == "gpt-5.5" - - @pytest.mark.asyncio - async def test_model_selection_uses_first_visible_model(self) -> None: - """Auto-selection uses Codex's first visible model without fallback ordering.""" - fake_client = FakeCodexClient( - model_list_result={ - "data": [ - {"id": "gpt-5.6-sol", "hidden": False}, - {"id": OPENAI_MODEL, "hidden": False}, - ] - }, - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) - await adapter.on_started("Agent", "An agent") - adapter._room_client("room-1") - adapter._active_room.set("room-1") - await adapter._ensure_client_ready() - - assert adapter._selected_model == "gpt-5.6-sol" - - @pytest.mark.asyncio - async def test_explicit_model_error_propagates_without_fallback(self) -> None: - """When the user explicitly set a model, errors propagate — no silent fallback.""" - fake_client = FakeCodexClient( - turn_start_error=CodexJsonRpcError( - code=-32000, - message="Model unavailable-test-model is not available", - ), - turn_start_error_once=False, - model_list_result={ - "data": [ - {"id": "gpt-5.5", "hidden": False}, - {"id": "gpt-6-luna", "hidden": False}, - ] - }, - ) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(model="unavailable-test-model") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "An agent") - - msg = make_platform_message(room_id="room-1", content="hello") - with pytest.raises(CodexJsonRpcError, match="not available"): - await deliver_message( - adapter, - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - assert CodexRequestMethod.MODEL_LIST not in fake_client.request_methods - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - assert "not available" in failures[0]["message"] - - @pytest.mark.asyncio - async def test_model_selection_uses_default_when_model_list_empty(self) -> None: - """Auto-selection uses the adapter default when Codex returns no visible models.""" - fake_client = FakeCodexClient(model_list_result={"data": []}) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) - await adapter.on_started("Agent", "An agent") - adapter._room_client("room-1") - adapter._active_room.set("room-1") - await adapter._ensure_client_ready() - - assert adapter._selected_model == OPENAI_MODEL - - @pytest.mark.asyncio - async def test_model_selection_uses_default_when_model_list_fails(self) -> None: - """Auto-selection uses the adapter default if model discovery fails.""" - fake_client = FakeCodexClient( - model_list_error=RuntimeError("model/list unavailable") - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) - await adapter.on_started("Agent", "An agent") - adapter._room_client("room-1") - adapter._active_room.set("room-1") - await adapter._ensure_client_ready() - - assert adapter._selected_model == OPENAI_MODEL - - @pytest.mark.asyncio - async def test_non_model_error_propagates(self) -> None: - """Non-model-related errors propagate from turn startup.""" - fake_client = FakeCodexClient( - turn_start_error=CodexJsonRpcError( - code=-32001, message="Server overloaded" - ), - turn_start_error_once=False, - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig(model=None)) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "An agent") - - msg = make_platform_message(room_id="room-1", content="hello") - with pytest.raises(CodexJsonRpcError, match="overloaded"): - await deliver_message( - adapter, - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - @pytest.mark.asyncio - async def test_startup_config_logged( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Startup emits a redacted config summary log line.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - model="gpt-5.5", sandbox="workspace-write", approval_mode="manual" - ), - ) - - with caplog.at_level("INFO", logger="band.adapters.codex"): - await adapter.on_started("TestBot", "A test agent") - - startup_logs = [ - r for r in caplog.records if "Codex adapter started" in r.message - ] - assert len(startup_logs) == 1 - log_msg = startup_logs[0].message - assert "agent=TestBot" in log_msg - assert "transport=stdio" in log_msg - assert "model=gpt-5.5" in log_msg - assert "sandbox=workspace-write" in log_msg - assert "approval_mode=manual" in log_msg - - @pytest.mark.asyncio - async def test_codex_error_emits_event_unconditionally(self) -> None: - """Non-retryable Codex errors always emit a structured error event.""" - fake_client = FakeCodexClient( - events=[ - event_notification( - "error", - {"error": {"message": "Something went wrong"}, "willRetry": False}, - ), - event_notification( - "turn/completed", - {"turn": {"id": "turn-1", "status": "failed"}}, - ), - ], - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "An agent") - - msg = make_platform_message(room_id="room-1", content="do something") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - error_events = events_of_type(tools, "error") - assert len(error_events) == 1 - assert "Something went wrong" in error_events[0]["content"] - - @pytest.mark.asyncio - async def test_cleanup_before_start(self) -> None: - """Calling on_cleanup on a freshly constructed adapter should not raise.""" - adapter = make_codex_adapter( - FakeCodexClient(), - config=CodexAdapterConfig(), - ) - # No on_started called — cleanup should be safe (idempotent) - await adapter.on_cleanup("room-x") - - @pytest.mark.asyncio - async def test_cleanup_clears_pending_approvals(self) -> None: - """on_cleanup should evict all pending approvals for the given room.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - await adapter.on_started("Bot", "desc") - - # Manually inject a pending approval for room-1 - loop = asyncio.get_running_loop() - fut: asyncio.Future[str] = loop.create_future() - registry: DecisionRegistry[PendingApproval] = DecisionRegistry() - registry.register_keyed( - PendingApproval( - request_id=1, - method="item/tool/call", - summary="test", - created_at=datetime.now(UTC), - future=fut, - ), - key="tok-1", - ) - adapter._pending_approvals["room-1"] = registry - wire_codex_room(adapter, fake_client, "room-1") - adapter._room_threads["room-1"] = "thr-1" - - await adapter.on_cleanup("room-1") - - assert "room-1" not in adapter._pending_approvals - # The future should have been resolved (declined) - assert fut.done() - - @pytest.mark.asyncio - async def test_tool_call_validation_error_returns_friendly_message(self) -> None: - """A base-tool arg-validation failure returns a user-friendly error. - - AgentTools catches base-tool validation INSIDE execute_tool_call_structured and - returns ok=False with a friendly message (it does not raise), so the adapter - surfaces it via the ok=False path. - """ - - class ValidationErrorTools(ToolSchemaFakeTools): - async def execute_tool_call_structured( - self, tool_name: str, arguments: dict[str, Any] - ) -> ToolCallOutcome: - self.tool_calls.append({"tool_name": tool_name, "arguments": arguments}) - return ToolCallOutcome( - value="Invalid arguments for band_send_message: content: Field required", - ok=False, - error_message="content: Field required", - ) - - events = [ - tool_call_request(99, BandTool.SEND_MESSAGE), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ValidationErrorTools() - - await adapter.on_started("Bot", "desc") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # The adapter should have responded to the tool call with success=False - error_responses = [ - (rid, payload) - for rid, payload in fake_client.responses - if payload.get("success") is False - ] - assert len(error_responses) == 1 - error_text = error_responses[0][1]["contentItems"][0]["text"] - assert "Invalid arguments for band_send_message" in error_text - - -# =========================================================================== -# Phase 1: Structured error reporting -# =========================================================================== - - -class TestStructuredErrors: - @pytest.mark.asyncio - async def test_structured_error_from_error_event(self) -> None: - """Error events with codexErrorInfo emit structured metadata.""" - events = [ - event_notification( - "error", - { - "error": { - "message": "Context window exceeded", - "codexErrorInfo": { - "type": "ContextWindowExceeded", - "code": "context_window_exceeded", - "retryable": False, - }, - }, - "willRetry": False, - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - assert failures[0]["code"] == "ContextWindowExceeded" - assert failures[0]["detail"]["codex_is_retryable"] is False - assert "context window" in failures[0]["message"].lower() - - @pytest.mark.asyncio - async def test_structured_error_from_failed_turn(self) -> None: - """turn/completed with status=failed and codexErrorInfo emits structured error.""" - events = [ - event_notification( - "turn/completed", - { - "turn": { - "id": "turn-1", - "status": "failed", - "error": { - "message": "Usage limit hit", - "codexErrorInfo": { - "type": "UsageLimitExceeded", - "code": "usage_limit", - "retryable": False, - }, - }, - } - }, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["code"] == "UsageLimitExceeded" - - @pytest.mark.asyncio - async def test_structured_error_from_failed_turn_with_no_error_key(self) -> None: - """turn/completed with status=failed but no "error" key at all must - still report a failure before raising, not just claim it did.""" - events = [ - event_notification( - "turn/completed", - {"turn": {"id": "turn-1", "status": "failed", "items": []}}, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - - @pytest.mark.asyncio - async def test_generic_exception_reports_and_propagates(self) -> None: - """A bare exception outside Codex's structured-error paths (not a - CodexJsonRpcError, not a delivery/already-reported failure) must - still surface via the generic fallback and propagate.""" - fake_client = FakeCodexClient( - events=[], - turn_start_error=RuntimeError("transport hiccup"), - turn_start_error_once=False, - ) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - with pytest.raises(RuntimeError, match="transport hiccup"): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE - assert "transport hiccup" not in failures[0]["message"] - - -# =========================================================================== -# Phase 1: Enriched approvals & session-level acceptance -# =========================================================================== - - -class TestEnrichedApprovals: - async def test_approve_session_accepts_for_the_session_and_remembers_it( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room( - event_request( - 10, "item/commandExecution/requestApproval", {"command": "npm test"} - ), - turn_completed(), - ) - - await room.send("run tests") - await room.send("/approve-session req-10") - await room.settled() - - assert room.client.responses == [(10, {"decision": "acceptForSession"})] - assert "commandExecution:npm test" in room.adapter._session_approved[ROOM_ID] - assert any("session-level" in message for message in room.chat) - - @pytest.mark.asyncio - async def test_approval_audit_trail_emitted(self) -> None: - """Approval decisions emit audit trail task events.""" - events = [ - event_request( - 7, - "item/commandExecution/requestApproval", - {"command": "rm -rf tmp"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - audit_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "approval_resolution" - ] - assert len(audit_events) == 1 - assert audit_events[0]["metadata"]["codex_approval_decision"] == "decline" - assert audit_events[0]["metadata"]["codex_decided_by"] == "policy:auto_decline" - - @pytest.mark.asyncio - async def test_sandbox_command_changes_mode(self) -> None: - """The /sandbox command sets a per-room override, not mutating global config.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/sandbox read-only"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Per-room override is set, global config is unchanged - assert adapter._sandbox_overrides.get("room-1") == "read-only" - assert adapter.config.sandbox is None - assert "read-only" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_sandbox_command_is_per_room(self) -> None: - """Sandbox override in one room does not affect other rooms.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - await adapter.on_started("Agent", "A coding agent") - - adapter._sandbox_overrides["room-1"] = "read-only" - - assert adapter._effective_sandbox("room-1") == "read-only" - assert adapter._effective_sandbox("room-2") is None - - @pytest.mark.asyncio - async def test_sandbox_danger_full_access_requires_confirm_flag(self) -> None: - """Escalating to danger-full-access without --confirm shows a prompt.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/sandbox danger-full-access"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Override should NOT be set — confirmation was required - assert "room-1" not in adapter._sandbox_overrides - assert "--confirm" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_sandbox_escalation_to_danger_full_access_logs_warning( - self, - caplog: pytest.LogCaptureFixture, - ) -> None: - """Escalating to danger-full-access with --confirm logs a warning.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): - await deliver_message( - adapter, - make_platform_message(content="/sandbox danger-full-access --confirm"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert adapter._sandbox_overrides.get("room-1") == "danger-full-access" - assert any( - "Sandbox escalated to danger-full-access" in record.message - for record in caplog.records - ) - - @pytest.mark.asyncio - async def test_sandbox_command_rejects_invalid_mode(self) -> None: - """The /sandbox command rejects invalid modes.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/sandbox invalid-mode"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert adapter.config.sandbox is None - assert "room-1" not in adapter._sandbox_overrides - assert "Invalid sandbox mode" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_permissions_command_shows_state(self) -> None: - """/permissions shows current effective permissions.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/permissions"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert "Effective permissions:" in tools.messages_sent[0]["content"] - assert "approval_mode: manual" in tools.messages_sent[0]["content"] - - -# =========================================================================== -# Phase 2: Plan & task lifecycle -# =========================================================================== - - -class TestPlanAndLifecycle: - @pytest.mark.asyncio - async def test_plan_steps_forwarded(self) -> None: - """turn/plan/updated forwards structured plan steps.""" - events = [ - event_notification( - "turn/plan/updated", - { - "plan": { - "steps": [ - {"text": "Read the failing test", "status": "completed"}, - {"text": "Identify root cause", "status": "inProgress"}, - {"text": "Apply fix", "status": "pending"}, - ] - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_plan_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - plan_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_plan_steps") is not None - ] - assert len(plan_events) == 1 - steps = plan_events[0]["metadata"]["codex_plan_steps"] - assert len(steps) == 3 - assert steps[0]["step"] == "Read the failing test" - assert steps[0]["status"] == "completed" - assert steps[2]["status"] == "pending" - - @pytest.mark.asyncio - async def test_plan_steps_not_forwarded_when_disabled(self) -> None: - """turn/plan/updated is ignored when stream_plan_events=False.""" - events = [ - event_notification( - "turn/plan/updated", - { - "plan": { - "steps": [ - {"text": "Step 1", "status": "pending"}, - ] - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_plan_events=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - plan_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_plan_steps") is not None - ] - assert plan_events == [] - - @pytest.mark.asyncio - async def test_turn_lifecycle_events_emitted(self) -> None: - """Enriched turn lifecycle events include duration and status.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - lifecycle_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_lifecycle" - ] - assert len(lifecycle_events) == 2 - # First event: turn started (with input summary) - assert lifecycle_events[0]["metadata"]["codex_turn_status"] == "started" - assert "codex_input_summary" in lifecycle_events[0]["metadata"] - # Second event: turn completed (with duration) - assert lifecycle_events[1]["metadata"]["codex_turn_status"] == "completed" - assert "codex_duration_s" in lifecycle_events[1]["metadata"] - - @pytest.mark.asyncio - async def test_threads_command_lists_mappings(self) -> None: - """/threads command shows room→thread mappings.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Now run /threads - await deliver_message( - adapter, - make_platform_message(content="/threads"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - threads_msgs = [ - m - for m in tools.messages_sent - if "thread mappings" in m["content"].lower() - or "active thread" in m["content"].lower() - ] - assert len(threads_msgs) >= 1 - assert "room-1" in threads_msgs[0]["content"] - - @pytest.mark.asyncio - async def test_thread_archive_clears_mapping(self) -> None: - """/thread archive removes the thread mapping.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - assert "room-1" in adapter._room_threads - - await deliver_message( - adapter, - make_platform_message(content="/thread archive"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - assert "room-1" not in adapter._room_threads - assert any("archived" in m["content"].lower() for m in tools.messages_sent) - - -# =========================================================================== -# Phase 3: Real-time streaming -# =========================================================================== - - -class TestRealtimeStreaming: - @pytest.mark.asyncio - async def test_reasoning_delta_streamed_as_thought(self) -> None: - """item/reasoning/summaryTextDelta forwards as streaming thought.""" - events = [ - event_notification( - "item/reasoning/summaryTextDelta", - {"delta": "Analyzing the code...", "itemId": "item-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_reasoning_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - thought_events = [ - e - for e in tools.events_sent - if e["message_type"] == "thought" and e["metadata"].get("streaming") - ] - assert len(thought_events) == 1 - assert thought_events[0]["content"] == "Analyzing the code..." - assert thought_events[0]["metadata"]["codex_item_id"] == "item-1" - - @pytest.mark.asyncio - async def test_reasoning_delta_ignored_when_disabled(self) -> None: - """Reasoning deltas are skipped when stream_reasoning_events=False.""" - events = [ - event_notification( - "item/reasoning/summaryTextDelta", - {"delta": "Thinking...", "itemId": "item-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_reasoning_events=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - streaming_events = [ - e for e in tools.events_sent if e["metadata"].get("streaming") - ] - assert streaming_events == [] - - @pytest.mark.asyncio - async def test_plan_delta_streamed_as_thought(self) -> None: - """item/plan/delta forwards as streaming thought with plan subtype.""" - events = [ - event_notification( - "item/plan/delta", - {"delta": "Step 1: Read the test", "itemId": "plan-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_plan_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - plan_thoughts = [ - e - for e in tools.events_sent - if e["message_type"] == "thought" and e["metadata"].get("subtype") == "plan" - ] - assert len(plan_thoughts) == 1 - assert plan_thoughts[0]["content"] == "Step 1: Read the test" - - @pytest.mark.asyncio - async def test_commentary_phase_streamed_as_thought(self) -> None: - """A started commentary item supplies the phase for streamed deltas.""" - events = [ - agent_message_started("comment", phase="commentary"), - agent_message_delta("Let me think about this...", "comment"), - agent_message_completed( - "Let me think about this...", "comment", phase="commentary" - ), - agent_message_started("answer", phase="final_answer"), - agent_message_delta("Here is the answer.", "answer"), - agent_message_completed( - "Here is the answer.", "answer", phase="final_answer" - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_commentary_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - commentary_thoughts = [ - e for e in tools.events_sent if e["metadata"].get("subtype") == "commentary" - ] - assert len(commentary_thoughts) == 1 - assert commentary_thoughts[0]["content"] == "Let me think about this..." - - assert tools.messages_sent == [] - assert [e["content"] for e in events_of_type(tools, "thought")] == [ - "Let me think about this...", - "Here is the answer.", - ] - - @pytest.mark.asyncio - async def test_streamed_commentary_is_not_replayed_on_completion( - self, - ) -> None: - """Streamed commentary is not replayed when its item completes.""" - events = [ - agent_message_started("comment", phase="commentary"), - agent_message_delta("thinking...", "comment"), - agent_message_completed("thinking...", "comment", phase="commentary"), - agent_message_started("answer", phase="final_answer"), - agent_message_delta("real answer", "answer"), - agent_message_completed("real answer", "answer", phase="final_answer"), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_commentary_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.messages_sent == [] - assert [e["content"] for e in events_of_type(tools, "thought")] == [ - "thinking...", - "real answer", - ] - - @pytest.mark.asyncio - async def test_unstreamed_completed_messages_are_separate_thoughts( - self, - ) -> None: - """Unstreamed completed messages remain separate thoughts.""" - events = [ - agent_message_started("comment", phase="commentary"), - agent_message_delta("thinking...", "comment"), - agent_message_completed("thinking...", "comment", phase="commentary"), - agent_message_started("answer", phase="final_answer"), - agent_message_delta("real answer", "answer"), - agent_message_completed("real answer", "answer", phase="final_answer"), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_commentary_events=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.messages_sent == [] - assert [e["content"] for e in events_of_type(tools, "thought")] == [ - "thinking...", - "real answer", - ] - - -# =========================================================================== -# Phase 4: Diffs + token usage -# =========================================================================== - - -class TestDiffsAndTokenUsage: - @pytest.mark.asyncio - async def test_diff_event_forwarded(self) -> None: - """turn/diff/updated forwards as a task event when enabled.""" - events = [ - event_notification( - "turn/diff/updated", - { - "diff": "--- a/src/app.py\n+++ b/src/app.py\n@@ ...", - "files": ["src/app.py"], - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_diff_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - diff_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_diff" - ] - assert len(diff_events) == 1 - assert diff_events[0]["message_type"] == "task" - assert diff_events[0]["metadata"]["codex_files_changed"] == ["src/app.py"] - assert "src/app.py" in diff_events[0]["metadata"]["codex_diff"] - assert "1 files changed" in diff_events[0]["content"] - - @pytest.mark.asyncio - async def test_diff_event_requires_task_events_emit(self) -> None: - """Diffs are not forwarded when TASK_EVENTS is not in features.emit.""" - events = [ - event_notification( - "turn/diff/updated", - {"diff": "some diff", "files": ["f.py"]}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_diff_events=True), emit=() - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - diff_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_diff" - ] - assert diff_events == [] - - @pytest.mark.asyncio - async def test_token_usage_tracked_and_emitted(self) -> None: - """thread/tokenUsage/updated events are tracked and emitted.""" - events = [ - event_notification( - "thread/tokenUsage/updated", - { - "usage": { - "inputTokens": 15000, - "outputTokens": 3200, - "reasoningTokens": 8000, - "totalTokens": 26200, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_token_usage_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - usage_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "token_usage" - ] - assert len(usage_events) == 1 - assert usage_events[0]["metadata"]["codex_input_tokens"] == 15000 - assert usage_events[0]["metadata"]["codex_output_tokens"] == 3200 - assert usage_events[0]["metadata"]["codex_total_tokens"] == 26200 - - @pytest.mark.asyncio - async def test_token_usage_ignored_when_disabled(self) -> None: - """Token usage events are tracked internally but not emitted when disabled.""" - events = [ - event_notification( - "thread/tokenUsage/updated", - { - "usage": { - "inputTokens": 1000, - "outputTokens": 500, - "totalTokens": 1500, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_token_usage_events=False) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - usage_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "token_usage" - ] - assert usage_events == [] - - # But internal tracking still works - thread_id = adapter._room_threads.get("room-1") - assert thread_id is not None - usage = adapter._token_usage.get(thread_id) - assert usage is not None - assert usage.input_tokens == 1000 - - @pytest.mark.asyncio - async def test_usage_command_shows_token_usage(self) -> None: - """/usage command shows accumulated token usage.""" - events = [ - event_notification( - "thread/tokenUsage/updated", - { - "usage": { - "inputTokens": 5000, - "outputTokens": 1000, - "reasoningTokens": 2000, - "totalTokens": 8000, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Now run /usage - await deliver_message( - adapter, - make_platform_message(content="/usage"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - usage_msgs = [ - m for m in tools.messages_sent if "token usage" in m["content"].lower() - ] - assert len(usage_msgs) >= 1 - assert "8,000" in usage_msgs[0]["content"] - - -# =========================================================================== -# Types unit tests -# =========================================================================== - - -class TestCodexTypes: - def test_build_agent_failure_known_type(self) -> None: - - error_obj = { - "message": "Context overflow", - "codexErrorInfo": { - "type": "ContextWindowExceeded", - "code": "ctx_exceeded", - "retryable": False, - }, - } - failure = build_agent_failure(error_obj, thread_id="t1", turn_id="turn-1") - assert failure.provider == "codex" - assert "context overflow" in failure.message.lower() - assert failure.code == "ContextWindowExceeded" - assert failure.detail["codex_thread_id"] == "t1" - assert failure.detail["codex_turn_id"] == "turn-1" - - def test_build_agent_failure_unknown_type(self) -> None: - - error_obj = { - "message": "Something weird happened", - "codexErrorInfo": {"type": "UnknownError"}, - } - failure = build_agent_failure(error_obj) - assert failure.message == "Something weird happened" - assert failure.code == "UnknownError" - - def test_parse_plan_steps(self) -> None: - - params = { - "plan": { - "steps": [ - {"text": "Step 1", "status": "completed"}, - {"text": "Step 2", "status": "inProgress"}, - {"text": "Step 3", "status": "pending"}, - ] - } - } - steps = parse_plan_steps(params) - assert len(steps) == 3 - assert steps[0].step == "Step 1" - assert steps[0].status == "completed" - assert steps[2].status == "pending" - - def test_parse_plan_steps_string_entries(self) -> None: - - params = {"plan": {"steps": ["Read code", "Fix bug"]}} - steps = parse_plan_steps(params) - assert len(steps) == 2 - assert steps[0].step == "Read code" - assert steps[0].status == "pending" - - def test_codex_token_usage_update(self) -> None: - - usage = CodexTokenUsage() - usage.update( - { - "usage": { - "inputTokens": 1000, - "outputTokens": 500, - "reasoningTokens": 200, - "totalTokens": 1700, - } - } - ) - assert usage.input_tokens == 1000 - assert usage.output_tokens == 500 - assert usage.reasoning_tokens == 200 - assert usage.total_tokens == 1700 - meta = usage.to_metadata() - assert meta["codex_input_tokens"] == 1000 - assert "1,700" in usage.format_summary() - - def test_codex_token_usage_update_current_schema(self) -> None: - """The current app-server schema nests cumulative counters under - ``tokenUsage.total`` and names reasoning ``reasoningOutputTokens``.""" - - usage = CodexTokenUsage() - usage.update( - { - "threadId": "t-1", - "turnId": "turn-1", - "tokenUsage": { - "total": { - "totalTokens": 14822, - "inputTokens": 14725, - "cachedInputTokens": 2432, - "outputTokens": 97, - "reasoningOutputTokens": 59, - }, - "last": { - "totalTokens": 14822, - "inputTokens": 14725, - "outputTokens": 97, - "reasoningOutputTokens": 59, - }, - "modelContextWindow": 258400, - }, - } - ) - assert usage.input_tokens == 14725 - assert usage.output_tokens == 97 - assert usage.reasoning_tokens == 59 - assert usage.total_tokens == 14822 - - def test_config_new_flags_default_false(self) -> None: - """All new config flags default to False.""" - config = CodexAdapterConfig() - assert config.stream_reasoning_events is False - assert config.stream_plan_events is False - assert config.stream_commentary_events is False - assert config.emit_diff_events is False - assert config.emit_token_usage_events is False - assert config.emit_turn_lifecycle_events is False - - def test_session_approval_key_full_command_by_default(self) -> None: - """Session approval key includes full command string by default.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key( - "item/commandExecution/requestApproval", {"command": "npm test"} - ) - assert key == "commandExecution:npm test" - - def test_session_approval_key_binary_granularity(self) -> None: - """Session approval key includes only binary when granularity is 'binary'.""" - adapter = CodexAdapter( - config=CodexAdapterConfig(session_approval_granularity="binary") - ) - key = adapter._session_approval_key( - "item/commandExecution/requestApproval", {"command": "npm test"} - ) - assert key == "commandExecution:npm" - - def test_session_approval_key_empty_for_missing_command(self) -> None: - """Session approval key returns empty when command is missing (no wildcard).""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key("item/commandExecution/requestApproval", {}) - assert key == "" - - def test_session_approval_key_empty_for_file_changes_without_paths(self) -> None: - """Session approval refuses fileChange requests that carry no paths. - - Previously the bare method name was used, which turned a single - /approve-session into a blanket "approve every future file change" - switch. We now require a path signature. - """ - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key( - "item/fileChange/requestApproval", {"reason": "update"} - ) - assert key == "" - - def test_codex_item_type_fully_classified(self) -> None: - """Every ``CodexItemType`` lands in exactly one of the adapter's - buckets: tool-like, requested tool, thought-like, or the skipped - messages. - - A new item type added to the enum without also updating one of these - sets currently falls through to a silent ``logger.debug`` — no room - event, no test failure. This test is the guard: it fails loudly the - moment the partition stops being exhaustive. - """ - - buckets = ( - _TOOL_ITEM_TYPES, - _REQUESTED_TOOL_ITEM_TYPES, - _THOUGHT_ITEM_TYPES, - _MESSAGE_ITEM_TYPES, - ) - - assert set().union(*buckets) == set(CodexItemType) - assert sum(len(bucket) for bucket in buckets) == len(CodexItemType) - - -class TestSessionAutoApproval: - @pytest.mark.asyncio - async def test_session_auto_approves_matching_command_binary(self) -> None: - """After session-level approval for npm, a new npm command is auto-approved.""" - # Two command execution requests in one turn — first will be manually - # approved, second should be auto-approved by session policy. - events = [ - event_request( - 20, - "item/commandExecution/requestApproval", - {"command": "npm install"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="manual") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - # Pre-seed the session-approved set as if /approve-session was used for "npm install" - adapter._session_approved["room-1"] = OrderedDict( - [("commandExecution:npm install", None)] - ) - - await deliver_message( - adapter, - make_platform_message(content="install deps"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # The request should have been auto-approved via session policy - responses = fake_client.responses - assert any( - result.get("decision") in {"accept", "acceptForSession"} - for _, result in responses - ) - - @pytest.mark.asyncio - async def test_session_does_not_auto_approve_different_command(self) -> None: - """Session approval for 'npm install' does NOT auto-approve 'npm publish'.""" - events = [ - event_request( - 30, - "item/commandExecution/requestApproval", - {"command": "npm publish"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="auto_decline") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - # Pre-seed session approval for "npm install" only - adapter._session_approved["room-1"] = OrderedDict( - [("commandExecution:npm install", None)] - ) - - await deliver_message( - adapter, - make_platform_message(content="publish package"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # npm publish should have been declined, not auto-approved - responses = fake_client.responses - assert any(result.get("decision") == "decline" for _, result in responses) - - @pytest.mark.asyncio - async def test_session_binary_granularity_approves_same_binary(self) -> None: - """With binary granularity, session approval for 'npm test' auto-approves 'npm install'.""" - events = [ - event_request( - 40, - "item/commandExecution/requestApproval", - {"command": "npm install"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - approval_mode="manual", session_approval_granularity="binary" - ), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - # Pre-seed session approval for npm binary - adapter._session_approved["room-1"] = OrderedDict( - [("commandExecution:npm", None)] - ) - - await deliver_message( - adapter, - make_platform_message(content="install deps"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # npm install should be auto-approved because binary matches - responses = fake_client.responses - assert any( - result.get("decision") in {"accept", "acceptForSession"} - for _, result in responses - ) - - -class TestCleanup: - @pytest.mark.asyncio - async def test_on_cleanup_removes_per_room_token_usage(self) -> None: - """on_cleanup for a room also removes the thread's token usage.""" - events = [ - event_notification( - "thread/tokenUsage/updated", - { - "usage": { - "inputTokens": 500, - "outputTokens": 100, - "totalTokens": 600, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Verify usage was tracked - thread_id = adapter._room_threads.get("room-1") - assert thread_id is not None - assert thread_id in adapter._token_usage - - # Add a second room so cleanup doesn't close the client entirely - adapter._room_threads["room-2"] = "other-thread" - - await adapter.on_cleanup("room-1") - assert thread_id not in adapter._token_usage - - -class TestAuditCap: - def test_audit_trail_capped_at_limit(self) -> None: - """Approval audit trail is capped at max_approval_audit_per_room.""" - adapter = make_codex_adapter( - FakeCodexClient(), - config=CodexAdapterConfig(max_approval_audit_per_room=5), - ) - for i in range(10): - adapter._record_approval_audit( - room_id="room-1", - request_id=str(i), - method="item/commandExecution/requestApproval", - decision="accept", - decided_by="test", - ) - audit = adapter._approval_audit["room-1"] - assert len(audit) == 5 - # Should keep the most recent entries - assert audit[0].request_id == "5" - assert audit[-1].request_id == "9" - - def test_session_approved_capped_at_limit(self) -> None: - """Session approvals evict LRU when max_session_approved_per_room is hit.""" - adapter = make_codex_adapter( - FakeCodexClient(), - config=CodexAdapterConfig(max_session_approved_per_room=3), - ) - for i in range(5): - adapter._record_session_approval("room-1", f"commandExecution:cmd{i}") - room = adapter._session_approved["room-1"] - assert list(room.keys()) == [ - "commandExecution:cmd2", - "commandExecution:cmd3", - "commandExecution:cmd4", - ] - - def test_session_approval_reinsert_moves_to_end(self) -> None: - """Re-approving an existing key moves it to the most-recent slot.""" - adapter = make_codex_adapter( - FakeCodexClient(), - config=CodexAdapterConfig(max_session_approved_per_room=3), - ) - adapter._record_session_approval("room-1", "commandExecution:a") - adapter._record_session_approval("room-1", "commandExecution:b") - adapter._record_session_approval("room-1", "commandExecution:c") - # Re-approve the oldest — it should move to the end. - adapter._record_session_approval("room-1", "commandExecution:a") - adapter._record_session_approval("room-1", "commandExecution:d") - room = adapter._session_approved["room-1"] - # "b" is the oldest after re-approving "a", so it's the one evicted. - assert "commandExecution:b" not in room - assert list(room.keys()) == [ - "commandExecution:c", - "commandExecution:a", - "commandExecution:d", - ] - - -class TestReviewFixes: - """Tests for issues identified in PR review.""" - - @pytest.mark.asyncio - async def test_sandbox_command_blocked_when_sandbox_policy_set(self) -> None: - """/sandbox is rejected when sandbox_policy is configured.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(sandbox_policy={"type": "readOnly"}) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/sandbox workspace-write"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert "room-1" not in adapter._sandbox_overrides - assert "Cannot override sandbox" in tools.messages_sent[0]["content"] - - @pytest.mark.asyncio - async def test_thread_archive_clears_raw_history(self) -> None: - """/thread archive also clears raw history and injection state.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Seed raw history and injection flag - adapter._raw_history_by_room["room-1"] = [{"role": "user", "content": "hi"}] - adapter._needs_history_injection.add("room-1") - - await deliver_message( - adapter, - make_platform_message(content="/thread archive"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - assert "room-1" not in adapter._raw_history_by_room - assert "room-1" not in adapter._needs_history_injection - - def test_token_usage_update_handles_zero_values(self) -> None: - """CodexTokenUsage.update() correctly handles explicit zero values.""" - - usage = CodexTokenUsage() - usage.update( - { - "usage": { - "inputTokens": 0, - "outputTokens": 100, - "reasoningTokens": 0, - "totalTokens": 100, - } - } - ) - assert usage.input_tokens == 0 - assert usage.output_tokens == 100 - assert usage.reasoning_tokens == 0 - assert usage.total_tokens == 100 - - def test_session_approval_key_empty_prevents_wildcard_match(self) -> None: - """Empty session key from missing command cannot match any session set.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key("item/commandExecution/requestApproval", {}) - # Empty key is falsy, so `key and key in session_set` is always False - assert not key - assert not (key and key in {"commandExecution:npm"}) - - @pytest.mark.asyncio - async def test_unexpected_recv_error_reports_and_fails_turn(self) -> None: - """When recv_event raises a non-timeout exception, the adapter reports - an AgentFailure and fails the turn instead of silently degrading to a - plain chat reply with no structured signal at all.""" - - class BrokenClient(FakeCodexClient): - async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - raise ConnectionError("transport died") - - fake_client = BrokenClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "codex" - assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE - - @pytest.mark.asyncio - async def test_rpc_error_from_event_loop_keeps_curated_failure(self) -> None: - """RPC errors raised while receiving events keep their provider message.""" - rpc_error = CodexJsonRpcError(code=-32000, message="model unavailable") - - class RpcErrorClient(FakeCodexClient): - async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - raise rpc_error - - rpc_error_client = RpcErrorClient() - adapter = make_codex_adapter(rpc_error_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - with pytest.raises(CodexJsonRpcError, match="model unavailable"): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["message"] == str(rpc_error) - - @pytest.mark.asyncio - async def test_failed_turn_emits_terminal_lifecycle_event(self) -> None: - """A reported failed turn still closes the lifecycle event pair.""" - events = [ - event_notification( - "turn/completed", - {"turn": {"id": "turn-1", "status": "failed", "items": []}}, - ) - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - emit_turn_lifecycle_events=True, - ), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - lifecycle_events = [ - event - for event in events_of_type(tools, "task") - if event["metadata"].get("codex_event_type") == "turn_lifecycle" - ] - assert [ - event["metadata"]["codex_turn_status"] for event in lifecycle_events - ] == ["started", "failed"] - - -# =========================================================================== -# Gap fixes: acceptForSession, network_context, turn started, compaction, -# per-turn token deltas -# =========================================================================== - - -class TestAcceptForSession: - @pytest.mark.asyncio - async def test_session_auto_approval_sends_accept_for_session(self) -> None: - """Session auto-approved requests send 'acceptForSession' to Codex.""" - events = [ - event_request( - 20, - "item/commandExecution/requestApproval", - {"command": "npm install"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(approval_mode="manual") - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - # Pre-seed session approval for the exact command - adapter._session_approved["room-1"] = OrderedDict( - [("commandExecution:npm install", None)] - ) - - await deliver_message( - adapter, - make_platform_message(content="install deps"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - # Decision should be acceptForSession - responses = fake_client.responses - decisions = [r.get("decision") for _, r in responses] - assert "acceptForSession" in decisions - - -class TestNetworkContext: - async def test_network_context_included_in_approval_metadata( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - """networkContext from approval params is forwarded in metadata.""" - room = await codex_room( - event_request( - 10, - "item/commandExecution/requestApproval", - { - "command": "npm install lodash", - "cwd": "/workspace", - "networkContext": {"domains": ["registry.npmjs.org"]}, - }, - ), - turn_completed(), - ) - - await room.send("install lodash") - await room.send("/approve req-10") - await room.settled() - - [approval_event] = [ - e - for e in room.events_sent - if e["metadata"].get("codex_event_type") == "approval_request" - ] - assert approval_event["metadata"]["codex_network_context"] == { - "domains": ["registry.npmjs.org"] - } - assert approval_event["metadata"]["codex_command"] == "npm install lodash" - - -class TestTurnStartedLifecycle: - @pytest.mark.asyncio - async def test_turn_started_lifecycle_event_emitted(self) -> None: - """Turn started lifecycle event includes input summary.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="fix the login bug"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - started_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_lifecycle" - and e["metadata"].get("codex_turn_status") == "started" - ] - assert len(started_events) == 1 - assert ( - started_events[0]["metadata"]["codex_input_summary"] == "fix the login bug" - ) - - -class TestContextCompaction: - @pytest.mark.asyncio - async def test_context_compaction_event_emitted(self) -> None: - """context/compacted events are forwarded as task events.""" - events = [ - event_notification( - "context/compacted", - {"threadId": "thr-1", "turnId": "turn-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - compaction_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "context_compaction" - ] - assert len(compaction_events) == 1 - assert compaction_events[0]["metadata"]["codex_thread_id"] == "thr-1" - - @pytest.mark.asyncio - async def test_context_compaction_ignored_when_disabled(self) -> None: - """Compaction events are not emitted when emit_turn_lifecycle_events=False.""" - events = [ - event_notification( - "context/compacted", - {"threadId": "thr-1", "turnId": "turn-1"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=False) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - compaction_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "context_compaction" - ] - assert compaction_events == [] - - -class TestPerTurnTokenUsage: - def test_token_usage_computes_per_turn_deltas(self) -> None: - """Per-turn deltas are computed from consecutive cumulative updates.""" - - usage = CodexTokenUsage() - - # First update: turn 1 - usage.update( - { - "usage": { - "inputTokens": 1000, - "outputTokens": 500, - "reasoningTokens": 200, - "totalTokens": 1700, - } - } - ) - assert usage.turn_input_tokens == 1000 - assert usage.turn_output_tokens == 500 - assert usage.turn_total_tokens == 1700 - - # Second update: turn 2 (cumulative increases) - usage.reset_turn_deltas() - usage.update( - { - "usage": { - "inputTokens": 2500, - "outputTokens": 900, - "reasoningTokens": 400, - "totalTokens": 3800, - } - } - ) - assert usage.turn_input_tokens == 1500 # 2500 - 1000 - assert usage.turn_output_tokens == 400 # 900 - 500 - assert usage.turn_reasoning_tokens == 200 # 400 - 200 - assert usage.turn_total_tokens == 2100 # 3800 - 1700 - - def test_token_usage_metadata_includes_turn_deltas(self) -> None: - """to_metadata() includes per-turn deltas when available.""" - - usage = CodexTokenUsage() - usage.update( - { - "usage": { - "inputTokens": 1000, - "outputTokens": 500, - "totalTokens": 1500, - } - } - ) - meta = usage.to_metadata() - assert meta["codex_turn_input_tokens"] == 1000 - assert meta["codex_turn_total_tokens"] == 1500 - - def test_token_usage_format_summary_includes_turn(self) -> None: - """format_summary() shows per-turn breakdown when deltas > 0.""" - - usage = CodexTokenUsage() - usage.update( - { - "usage": { - "inputTokens": 1000, - "outputTokens": 500, - "totalTokens": 1500, - } - } - ) - summary = usage.format_summary() - assert "turn:" in summary - assert "+1,000 in" in summary - - def test_reset_turn_deltas(self) -> None: - """reset_turn_deltas() zeroes out per-turn counters.""" - - usage = CodexTokenUsage() - usage.update( - {"usage": {"inputTokens": 1000, "outputTokens": 500, "totalTokens": 1500}} - ) - assert usage.turn_total_tokens == 1500 - usage.reset_turn_deltas() - assert usage.turn_total_tokens == 0 - assert usage.turn_input_tokens == 0 - - # Thread-level totals should be unchanged - assert usage.total_tokens == 1500 - - def test_multi_event_turn_delta_is_cumulative_from_anchor(self) -> None: - """A turn with multiple tokenUsage events reports the running turn total. - - Without the turn-start anchor, each ``update()`` would overwrite - ``turn_*`` with the per-event delta, so a turn with events at - cumulative 150 then 180 (after resetting at 100) would end the - turn reporting ``turn_input_tokens=30``. With the anchor, the - final value is ``180 - 100 = 80`` — the whole-turn rise. - """ - - usage = CodexTokenUsage() - # End of previous turn: cumulative = 100. - usage.update({"usage": {"inputTokens": 100, "outputTokens": 0}}) - # New turn starts — anchor captured at 100. - usage.reset_turn_deltas() - - # First event inside the turn. - usage.update({"usage": {"inputTokens": 150, "outputTokens": 0}}) - assert usage.turn_input_tokens == 50 # 150 - 100 - - # Second event inside the same turn — must keep growing from anchor. - usage.update({"usage": {"inputTokens": 180, "outputTokens": 0}}) - assert usage.turn_input_tokens == 80 # 180 - 100, NOT 180 - 150 - - # Third event: still anchored at 100. - usage.update({"usage": {"inputTokens": 200, "outputTokens": 0}}) - assert usage.turn_input_tokens == 100 # 200 - 100 - - -# =========================================================================== -# Review follow-ups: fixes for bugs/issues found in code review -# =========================================================================== - - -class TestPlanStepsRobustness: - """parse_plan_steps tolerance for malformed payloads.""" - - def test_parse_plan_steps_handles_non_dict_plan(self) -> None: - """parse_plan_steps must not crash when `plan` is not a dict.""" - - assert parse_plan_steps({"plan": "not-a-dict"}) == [] - assert parse_plan_steps({"plan": ["also", "not", "a", "dict"]}) == [] - assert parse_plan_steps({"plan": None}) == [] - - def test_parse_plan_steps_reads_top_level_when_plan_absent(self) -> None: - """When there's no 'plan' key, parse_plan_steps looks at top-level steps.""" - - steps = parse_plan_steps({"steps": [{"text": "A", "status": "pending"}]}) - assert len(steps) == 1 - assert steps[0].step == "A" - - -class TestSessionApprovalValidation: - """Guards that stop /approve-session from storing bogus session keys.""" - - async def test_approve_session_is_refused_for_a_change_with_no_paths( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - """A file change naming no paths has no signature to match, so - /approve-session is refused -- keying on the method alone would - auto-approve every future file change -- and the approval stays open - for a one-shot answer.""" - room = await codex_room( - event_request(15, "item/fileChange/requestApproval", {}), - turn_completed(), - ) - - await room.send("run") - await room.send("/approve-session req-15") - await room.send("/decline req-15") - await room.settled() - - assert room.client.responses == [(15, {"decision": "decline"})] - assert not room.adapter._session_approved.get(ROOM_ID) - assert sum("cannot be resolved as session-level" in m for m in room.chat) == 1 - - -class TestTokenUsageEmission: - """Emission guards for _emit_token_usage_event.""" - - @pytest.mark.asyncio - async def test_token_usage_event_skipped_when_total_is_zero(self) -> None: - """_emit_token_usage_event must not emit before any real usage arrives. - - Prior to the fix, `if not usage:` was always False (dataclass - instances are truthy) so an empty token_usage event could be emitted - even before Codex sent any thread/tokenUsage/updated notification. - """ - - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - # Seed an empty CodexTokenUsage (total_tokens == 0) and call the - # emit helper directly. It should short-circuit. - adapter._token_usage["thread-x"] = CodexTokenUsage() - await adapter._emit_token_usage_event( - tools=tools, thread_id="thread-x", room_id="room-1" - ) - - usage_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "token_usage" - ] - assert usage_events == [] - - -class TestStructuredErrorNormalization: - """build_agent_failure handling of non-standard inputs.""" - - def test_structured_error_with_string_error_obj(self) -> None: - """_handle_error_event normalizes string error_obj before structuring. - - The review flagged a redundant isinstance(error_obj, dict) check that - was dead code; this test asserts the normalization still works when - the original error_obj is a string rather than a dict. - """ - - # Simulate the normalization the adapter performs: convert string to - # {"message": } before passing to build_agent_failure. - error_obj: dict[str, Any] = {"message": "raw string error"} - failure = build_agent_failure(error_obj) - assert "raw string error" in failure.message - # No codexErrorInfo -> no known error type. - assert failure.code is None - - -class TestSessionApprovalKeying: - """_session_approval_key behaviour across method/param shapes.""" - - def test_file_change_session_key_requires_paths(self) -> None: - """/approve-session must refuse fileChange requests with no paths.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key( - "item/fileChange/requestApproval", - {"reason": "something vague"}, - ) - assert key == "" - - def test_file_change_session_key_uses_paths_when_present(self) -> None: - """fileChange session key includes sorted path list for stable matching.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key1 = adapter._session_approval_key( - "item/fileChange/requestApproval", - {"changes": [{"path": "b.py"}, {"path": "a.py"}]}, - ) - key2 = adapter._session_approval_key( - "item/fileChange/requestApproval", - {"changes": [{"path": "a.py"}, {"path": "b.py"}]}, - ) - assert key1 == key2 - assert key1 == "fileChange:a.py|b.py" - - def test_file_change_session_key_handles_top_level_paths(self) -> None: - """fileChange session key also picks up top-level path/paths fields.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - key = adapter._session_approval_key( - "item/fileChange/requestApproval", - {"paths": ["src/foo.py", "src/bar.py"]}, - ) - assert "src/foo.py" in key - assert "src/bar.py" in key - - def test_unknown_approval_method_returns_empty_key(self) -> None: - """Session-level approval refuses unknown methods rather than bucketing them.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - assert adapter._session_approval_key("item/unknown/requestApproval", {}) == "" - - @pytest.mark.asyncio - async def test_approve_session_refused_for_fileChange_without_paths(self) -> None: - """/approve-session for a fileChange request with no paths is rejected.""" - events = [ - event_request( - 88, - "item/fileChange/requestApproval", - {"reason": "write something"}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - approval_mode="manual", - approval_wait_timeout_s=0.05, - approval_timeout_decision="decline", - ), - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await await_released_turn(adapter, "room-1") - - # Manual approval times out and the pending record is cleared, - # so /approve-session reports no pending approvals rather than - # storing an empty session key. - tools.messages_sent.clear() - await deliver_message( - adapter, - make_platform_message(content="/approve-session 88"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - assert any("No pending approvals" in m["content"] for m in tools.messages_sent) - assert "room-1" not in adapter._session_approved - - -def parked_on_approval(*then: RpcEvent) -> tuple[RpcEvent, ...]: - """A turn that asks the room to approve one command, then plays ``then``.""" - return ( - event_request(10, "item/commandExecution/requestApproval", {"command": "a"}), - *then, - ) - - -class EndlessWorkClient(FakeCodexClient): - """A Codex server that, once its scripted events run out, keeps the - turn working forever.""" - - async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - if self._events: - return self._events.popleft() - await asyncio.Event().wait() - raise AssertionError("unreachable") - - -class TestApprovalFromASequentialRoom: - """Band hands a room its messages one at a time, so the reply resolving an - approval is delivered only after the asking message's on_message returns. - Approval waits are an hour long: a regression that waits on the human - hangs until pytest's timeout instead of passing by luck.""" - - @pytest.mark.asyncio - async def test_the_reply_delivered_after_the_asking_message_resolves_it( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room( - *parked_on_approval(turn_completed()), approval_wait_timeout_s=3600 - ) - - await room.send("run tests") - control_tools = ToolSchemaFakeTools() - assert await room.adapter.try_handle_control( - make_platform_message( - room_id=ROOM_ID, content=f"/{CodexCommand.APPROVE} req-10" - ), - control_tools, - ) - await room.settled() - - assert control_tools.turn.complete - assert room.client.responses == [(10, {"decision": "accept"})] - - @pytest.mark.asyncio - async def test_a_request_while_a_turn_awaits_a_human_is_turned_away( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room( - *parked_on_approval(turn_completed()), approval_wait_timeout_s=3600 - ) - - await room.send("run tests") - with pytest.raises(InputNotAccepted): - await room.send("and lint too") - - assert not room.deliveries[-1].turn.complete - methods = room.client.request_methods - assert methods.count(CodexRequestMethod.TURN_START) == 1 - - @pytest.mark.asyncio - async def test_interrupt_reaches_a_turn_parked_on_a_human( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - """ExecutionContext.interrupt()/stop_room() only cancel the cycle task - that invoked on_message -- once that returns early because the turn - released the room, only on_interrupt still reaches the parked turn. - - on_interrupt declines the pending approval and then cancels the turn - as a backstop; which of the two finishes it is an asyncio-scheduling - detail, so this asserts only that the turn stops running.""" - room = await codex_room( - *parked_on_approval(turn_completed()), approval_wait_timeout_s=3600 - ) - - await room.send("run tests") - turn = room.turn - assert not turn.done() - - await room.adapter.on_interrupt(ROOM_ID, ControlMode.INTERRUPT) - - assert turn.done() - assert ROOM_ID not in room.adapter._pending_approvals - - @pytest.mark.asyncio - async def test_cleanup_declines_a_parked_turn_instead_of_waiting( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - """A turn waiting on a human holds the room's RPC lock; cleanup must - decline its approval (and any it raises afterwards) rather than block - for the whole approval wait.""" - room = await codex_room( - *parked_on_approval( - event_request( - 11, "item/commandExecution/requestApproval", {"command": "b"} - ), - turn_completed(), - ), - approval_wait_timeout_s=3600, - ) - - await room.send("run tests") - turn = room.turn - await room.adapter.on_cleanup(ROOM_ID) - await turn - - assert room.client.responses == [ - (10, {"decision": "decline"}), - (11, {"decision": "decline"}), - ] - assert sum(m.startswith("Approval requested") for m in room.chat) == 1 - - @pytest.mark.asyncio - @pytest.mark.looptime - async def test_cleanup_is_bounded_by_a_released_turn_still_working( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - """Once released, the runtime can't cancel the turn; cleanup waits out - the settle timeout, then cancels Codex work that never ends.""" - room = await codex_room( - client=EndlessWorkClient(events=list(parked_on_approval())), - approval_wait_timeout_s=3600, - ) - - await room.send("run tests") - await room.send(f"/{CodexCommand.APPROVE} req-10") - turn = room.turn - await room.adapter.on_cleanup(ROOM_ID) - - assert turn.cancelled() - assert room.client.closed - - -class CodexApprovalRoom: - """One room's manual approvals on a started Codex adapter, observed - through the room's chat: each ask runs as its own turn, and replies go - through the room's approval commands.""" - - def __init__(self, adapter: CodexAdapter) -> None: - self.adapter = adapter - self.tools = FakeAgentTools() - self.asks: list[asyncio.Task[ApprovalDecision]] = [] - - @property - def pending(self) -> DecisionRegistry[PendingApproval]: - return self.adapter._pending_approvals[ROOM_ID] - - def ask( - self, approval_id: str, *, request_id: int - ) -> asyncio.Task[ApprovalDecision]: - params = {"approvalId": approval_id, "command": "npm test"} - self.asks.append( - ask := asyncio.create_task( - self.adapter._resolve_manual_approval( - tools=self.tools, - msg=make_platform_message(room_id=ROOM_ID), - room_id=ROOM_ID, - event=event_request( - request_id, "item/commandExecution/requestApproval", params - ), - summary="npm test", - params=params, - ) - ) - ) - return ask - - @asynccontextmanager - async def prompted(self, *approval_ids: str) -> AsyncIterator[None]: - """Asks made inside the block have each been registered and their - room prompt sent by the time it exits.""" - prompts = [self.tools.hold_message(f"`{id_}`") for id_ in approval_ids] - yield - for prompt in prompts: - async with prompt: - pass - - async def reply(self, command: CodexCommand, approval_id: str = "") -> str: - """Send an approval command to the room; the room's answer.""" - await self.adapter._handle_approval_command( - tools=self.tools, - msg=make_platform_message(room_id=ROOM_ID), - room_id=ROOM_ID, - command=command, - args=approval_id, - ) - return self.tools.messages_sent[-1]["content"] - - -@pytest_asyncio.fixture(loop_scope="function") -async def approval_room() -> AsyncIterator[Callable[..., Awaitable[CodexApprovalRoom]]]: - """Open a Codex room in manual approval mode; asks still waiting when the - test ends are cancelled.""" - rooms: list[CodexApprovalRoom] = [] - - async def open_room(**config: Any) -> CodexApprovalRoom: - adapter = make_codex_adapter( - FakeCodexClient(events=[]), - config=CodexAdapterConfig( - **{"approval_mode": "manual", "approval_wait_timeout_s": 5, **config} - ), - ) - await adapter.on_started("Agent", "A coding agent") - rooms.append(room := CodexApprovalRoom(adapter)) - return room - - yield open_room - for room in rooms: - for ask in room.asks: - ask.cancel() - - -class TestManualApprovalRaces: - """Races between a room reply and the approval wait's own timeout.""" - - @pytest.mark.asyncio - async def test_a_reply_that_claims_while_the_prompt_send_fails_still_wins( - self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] - ) -> None: - """The approval id is visible in the task event before the prompt - send; a reply claiming it while that send fails owns the answer.""" - room = await approval_room() - failing_prompt = room.tools.hold_message( - "Approval requested", error=RuntimeError("network down") - ) - decision = room.ask("approval-xyz", request_id=1) - - async with failing_prompt: - await room.reply(CodexCommand.APPROVE) - - assert await decision == "accept" - assert events_of_type(room.tools, "error") == [] - - @pytest.mark.asyncio - @pytest.mark.looptime - async def test_a_late_reply_during_the_timeout_notice_is_not_reported_as_resolved( - self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] - ) -> None: - """A reply landing while the timeout notice is still being sent must - not be told "resolved" for a decision that already timed out.""" - room = await approval_room(approval_wait_timeout_s=300) - timeout_notice = room.tools.hold_message("timed out") - decision = room.ask("approval-xyz", request_id=1) - - async with timeout_notice: - assert await room.reply(CodexCommand.APPROVE, "approval-xyz") == ( - NO_APPROVALS_TO_RESOLVE_MESSAGE - ) - - assert await decision == "decline" # approval_timeout_decision - - @pytest.mark.asyncio - async def test_redelivered_approvals_never_hang_evict_or_override_a_claim( - self, approval_room: Callable[..., Awaitable[CodexApprovalRoom]] - ) -> None: - """At capacity, with one approval claimed by a reply mid-resolution: - a second reply to it is told it's no longer pending; Codex re-sending - it declines at once without waiting; re-sending the open one - supersedes it (declining its asker) without evicting anything; and - both final answers stand.""" - room = await approval_room(max_pending_approvals_per_room=2) - async with room.prompted("approval-a", "approval-b"): - claimed_ask = room.ask("approval-a", request_id=1) - superseded = room.ask("approval-b", request_id=2) - claimed = room.pending.try_claim("approval-a") - assert claimed is not None - - assert await room.reply(CodexCommand.APPROVE, "approval-a") == ( - "Approval `approval-a` is no longer pending." - ) - refused = room.ask("approval-a", request_id=3) - redelivery = room.ask("approval-b", request_id=4) - - assert await refused == "decline" - assert await superseded == "decline" - assert list(room.pending) == ["approval-a", "approval-b"] - await room.reply(CodexCommand.DECLINE, "approval-b") - claimed.payload.future.set_result("accept") - assert await redelivery == "decline" - assert await claimed_ask == "accept" - - -class TestTokenUsageCounterMonotonicity: - """CodexTokenUsage protection against non-monotonic cumulative updates.""" - - def test_token_usage_warns_on_non_monotonic_counters( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """A decreasing cumulative counter triggers a warning. - - After the adapter anchors a new turn (``reset_turn_deltas``), a - late event from the previous turn with a smaller cumulative must - leave the turn deltas clamped to 0 rather than going negative. - """ - - usage = CodexTokenUsage() - usage.update({"usage": {"inputTokens": 100, "outputTokens": 100}}) - # Adapter anchors the new turn at cumulative=100/100. - usage.reset_turn_deltas() - with caplog.at_level(logging.WARNING, logger="band.integrations.codex.types"): - usage.update({"usage": {"inputTokens": 50, "outputTokens": 50}}) - assert any( - "token usage counter decreased" in record.message.lower() - for record in caplog.records - ) - # Cumulative stays at 100 (monotonic); turn delta clamped to 0. - assert usage.input_tokens == 100 - assert usage.output_tokens == 100 - assert usage.turn_input_tokens == 0 - assert usage.turn_output_tokens == 0 - - -class TestApprovalAuditRecording: - """_record_approval_audit API surface.""" - - def test_record_approval_audit_returns_entry(self) -> None: - """_record_approval_audit returns the entry it appended.""" - adapter = CodexAdapter(config=CodexAdapterConfig()) - entry = adapter._record_approval_audit( - room_id="room-1", - request_id="req-1", - method="item/commandExecution/requestApproval", - decision="accept", - decided_by="tester", - summary="command: ls", - ) - assert entry.request_id == "req-1" - assert entry.decision == "accept" - assert adapter._approval_audit["room-1"][-1] is entry - - -# =========================================================================== -# Review follow-ups (review-202 round): coverage gaps surfaced during review -# =========================================================================== - - -class TestStructuredErrorMappings: - """build_agent_failure passes codexErrorInfo through verbatim -- no - remediation/suggested-action policy; that belongs to a consumer, not - this shared shape.""" - - @pytest.mark.parametrize( - "error_type", - [ - "HttpConnectionFailed", - "SandboxError", - "Unauthorized", - "BadRequest", - "ResponseTooManyFailedAttempts", - ], - ) - def test_error_type_becomes_the_failure_code(self, error_type: str) -> None: - failure = build_agent_failure( - {"codexErrorInfo": {"type": error_type, "retryable": True}} - ) - assert failure.code == error_type - assert failure.detail["codex_is_retryable"] is True - - def test_non_dict_codex_error_info_is_tolerated(self) -> None: - - failure = build_agent_failure( - {"message": "boom", "codexErrorInfo": "not-a-dict"} - ) - assert failure.code is None - assert failure.message == "boom" - - def test_missing_codex_error_info_falls_back_to_message(self) -> None: - - failure = build_agent_failure({"message": "network down"}) - assert failure.code is None - assert failure.message == "network down" - - def test_additional_details_preserved_in_detail(self) -> None: - - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": {"hint": "refresh token"}, - } - ) - assert failure.detail["codex_additional_details"] == {"hint": "refresh token"} - - -class TestSlashCommandCoverage: - @pytest.mark.asyncio - async def test_thread_info_with_no_mapping(self) -> None: - """/thread info reports gracefully when the room has no thread yet.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/thread info"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert tools.messages_sent - assert "No thread mapped" in tools.messages_sent[-1]["content"] - - @pytest.mark.asyncio - async def test_thread_info_includes_thread_and_usage(self) -> None: - """/thread info echoes current thread id and token usage summary.""" - events = [ - event_notification( - "thread/tokenUsage/updated", - { - "usage": { - "inputTokens": 100, - "outputTokens": 50, - "totalTokens": 150, - } - }, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_token_usage_events=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_message( - adapter, - make_platform_message(content="/thread info"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - info_msgs = [m for m in tools.messages_sent if "Thread info:" in m["content"]] - assert len(info_msgs) == 1 - content = info_msgs[0]["content"] - assert "thread_id: thr-1" in content - assert "room_id: room-1" in content - assert "150" in content - - @pytest.mark.asyncio - async def test_permissions_reflects_sandbox_override(self) -> None: - """/permissions reports the per-room sandbox override once set.""" - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(content="/sandbox read-only"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - await deliver_message( - adapter, - make_platform_message(content="/permissions"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id="room-1", - ) - - perm_msgs = [ - m for m in tools.messages_sent if "Effective permissions:" in m["content"] - ] - assert len(perm_msgs) == 1 - assert "read-only" in perm_msgs[0]["content"] - - @pytest.mark.asyncio - async def test_local_command_reply_delivery_failure_is_not_reported( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """/help's answer failing to post is Band-side delivery, not a Codex - provider failure -- its DeliveryFailedError must be - recognized and left unreported here. The original cause still - propagates (the delivery is still marked FAILED), just never - misreported as a Codex AgentFailure.""" - - class FailingNoticeTools(ToolSchemaFakeTools): - async def send_notice( - self, content: str, mentions: list[dict[str, str]] | None = None - ) -> Any: - raise RuntimeError("platform rejected the message") - - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = FailingNoticeTools() - - await adapter.on_started("Agent", "A coding agent") - with ( - caplog.at_level(logging.ERROR, logger="band.core.delivery"), - pytest.raises(RuntimeError, match="platform rejected the message"), - ): - await deliver_message( - adapter, - make_platform_message(content="/help"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert not tools.messages_sent - assert not reported_failures(tools) - assert any( - "Reply delivery failed" in record.message for record in caplog.records - ) - - @pytest.mark.asyncio - async def test_approval_command_reply_delivery_failure_is_not_reported( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Same delivery-vs-provider-failure split as slash commands, but for - the approval-command path, which runs outside on_message's main - try/except and needs its own DeliveryFailedError handling.""" - - class FailingNoticeTools(ToolSchemaFakeTools): - async def send_notice( - self, content: str, mentions: list[dict[str, str]] | None = None - ) -> Any: - raise RuntimeError("platform rejected the message") - - fake_client = FakeCodexClient() - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = FailingNoticeTools() - - await adapter.on_started("Agent", "A coding agent") - with ( - caplog.at_level(logging.ERROR, logger="band.core.delivery"), - pytest.raises(RuntimeError, match="platform rejected the message"), - ): - await deliver_message( - adapter, - make_platform_message(content="/approvals"), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert not tools.messages_sent - assert not reported_failures(tools) - assert any( - "Reply delivery failed" in record.message for record in caplog.records - ) - - -class TestMalformedPayloadTolerance: - """Adapter must survive notifications that are missing or misshapen.""" - - @pytest.mark.asyncio - async def test_error_event_with_non_dict_error_field(self) -> None: - """`error` notification where `error` is a string must not crash the turn.""" - events = [ - event_notification("error", {"error": "oops"}), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["message"] == "oops" - - @pytest.mark.asyncio - async def test_failed_turn_after_error_notification_reports_once(self) -> None: - """An `error` notification followed by a `turn/completed` with - status=failed for the same incident must report only one failure.""" - events = [ - event_notification("error", {"error": {"message": "boom"}}), - event_notification( - "turn/completed", - { - "turn": { - "id": "turn-1", - "status": "failed", - "items": [], - "error": {"message": "boom"}, - } - }, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - assert len(reported_failures(tools)) == 1 - - @pytest.mark.asyncio - async def test_failed_turn_with_falsy_scalar_error_uses_clean_fallback( - self, - ) -> None: - """A falsy, non-dict `error` (e.g. ``False``) must not become the - literal string "False" in the reported failure message.""" - events = [ - event_notification( - "turn/completed", - { - "turn": { - "id": "turn-1", - "status": "failed", - "items": [], - "error": False, - } - }, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - with pytest.raises(TurnResultAlreadyReported): - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["message"] == "Codex error: unknown" - - @pytest.mark.asyncio - async def test_turn_completed_without_items_key(self) -> None: - """turn/completed missing `items` is treated as an empty turn, not a crash.""" - events = [ - event_notification( - "turn/completed", - {"turn": {"id": "turn-1", "status": "completed"}}, - ), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - @pytest.mark.asyncio - async def test_turn_plan_updated_with_garbage_steps(self) -> None: - """Plan deltas containing non-list `steps` must be skipped, not crash.""" - events = [ - event_notification( - "turn/plan/updated", - {"plan": {"steps": "not-a-list"}}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(stream_plan_events=True) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - -class TestTurnLifecycleEventsDisabled: - @pytest.mark.asyncio - async def test_no_lifecycle_events_when_disabled(self) -> None: - """With emit_turn_lifecycle_events=False, neither started nor completed - lifecycle task events are emitted.""" - events = [ - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_turn_lifecycle_events=False) - ) - tools = ToolSchemaFakeTools() - await adapter.on_started("Agent", "A coding agent") - - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - lifecycle_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_lifecycle" - ] - assert lifecycle_events == [] - - -class TestTokenUsageCumulativeMonotonicity: - """Late events with smaller cumulative counters must not rewind state. - - Without the max-preserving guard, a late ``thread/tokenUsage/updated`` - from the previous turn can overwrite the cumulative totals with a - smaller value. The next real event of the current turn then computes - ``turn_delta = new - (rewound)`` and double-counts the gap. - """ - - def test_late_smaller_event_does_not_corrupt_next_delta(self) -> None: - - usage = CodexTokenUsage() - # End of previous turn: cumulative = 100. - usage.update({"usage": {"inputTokens": 100, "outputTokens": 0}}) - assert usage.input_tokens == 100 - - # Adapter starts a new turn. - usage.reset_turn_deltas() - - # Late event from the previous turn arrives with a smaller cumulative. - usage.update({"usage": {"inputTokens": 80, "outputTokens": 0}}) - # Cumulative must stay at 100 (monotonic), turn delta clamped to 0. - assert usage.input_tokens == 100 - assert usage.turn_input_tokens == 0 - - # First real event of the new turn: cumulative = 120. - usage.update({"usage": {"inputTokens": 120, "outputTokens": 0}}) - # Turn delta is 120 - 100 = 20, NOT 120 - 80 = 40. - assert usage.turn_input_tokens == 20 - assert usage.input_tokens == 120 - - -class TestStructuredErrorDetailCap: - """``additionalDetails`` is attacker-influenceable and must be capped.""" - - def test_long_additional_details_string_is_truncated(self) -> None: - - long_detail = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": long_detail, - } - ) - detail = failure.detail["codex_additional_details"] - assert isinstance(detail, str) - assert len(detail) < len(long_detail) - assert "truncated" in detail - - def test_structured_dict_additional_details_are_preserved(self) -> None: - """Only string details are capped; dict/list payloads pass through.""" - - payload = {"hint": "refresh token", "code": 401} - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": payload, - } - ) - assert failure.detail["codex_additional_details"] == payload - - def test_empty_additional_details_is_dropped(self) -> None: - """Empty strings are not echoed into detail.""" - - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": "", - } - ) - assert failure.detail is None - - def test_oversized_dict_additional_details_is_replaced_with_marker( - self, - ) -> None: - """Large non-string payloads must not slip past the byte cap. - - A hostile upstream that embeds a megabyte of nested JSON in - ``additionalDetails`` would otherwise inflate every downstream - WebSocket frame. When the serialized form exceeds the cap we - replace the whole payload with a truncated marker string. - """ - - # Build a dict whose JSON serialization comfortably exceeds the cap. - oversized_value = "x" * (_MAX_ERROR_DETAIL_CHARS + 500) - payload = {"nested": {"blob": oversized_value}} - - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": payload, - } - ) - detail = failure.detail["codex_additional_details"] - assert isinstance(detail, str) - assert "truncated" in detail - assert len(detail) < len(oversized_value) - - def test_unserializable_additional_details_is_dropped(self) -> None: - """Payloads that ``json.dumps`` can't handle without ``default=str`` - round-trip through ``default=str``; pathological unserializable - objects (e.g. a circular reference) must be dropped rather than - raising into the event-emission path.""" - - circular: dict[str, Any] = {} - circular["self"] = circular - - failure = build_agent_failure( - { - "codexErrorInfo": {"type": "Unauthorized"}, - "additionalDetails": circular, - } - ) - assert failure.detail is None - - -class TestDiffByteCap: - """``turn/diff/updated`` metadata is bounded in UTF-8 bytes, not chars.""" - - @pytest.mark.asyncio - async def test_multibyte_diff_respects_byte_budget(self) -> None: - """A diff built from 4-byte codepoints is capped to the byte budget, - not the character budget (which would be ~4× larger on the wire).""" - - # Each emoji is 4 UTF-8 bytes; use ~1.5× the byte budget worth. - emoji = "\U0001f600" - diff_chars = (_MAX_DIFF_METADATA_BYTES // 4) + 5000 - big_diff = emoji * diff_chars - assert len(big_diff.encode("utf-8")) > _MAX_DIFF_METADATA_BYTES - - events = [ - event_notification( - "turn/diff/updated", - {"diff": big_diff, "files": ["src/app.py"]}, - ), - turn_completed(), - ] - fake_client = FakeCodexClient(events=events) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(emit_diff_events=True) - ) - tools = ToolSchemaFakeTools() - - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - tools, - CodexSessionState(), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-1", - ) - - diff_events = [ - e - for e in tools.events_sent - if e["metadata"].get("codex_event_type") == "turn_diff" - ] - assert len(diff_events) == 1 - meta = diff_events[0]["metadata"] - emitted = meta["codex_diff"] - # The emitted diff (including the truncation marker) stays within a - # small overhead of the byte budget — nowhere near 4× it. - assert len(emitted.encode("utf-8")) <= _MAX_DIFF_METADATA_BYTES + 256 - assert meta["codex_diff_truncated"] is True - assert meta["codex_diff_original_bytes"] > _MAX_DIFF_METADATA_BYTES - - -class TestSlashCommandExtraction: - """``_extract_local_command`` reads a command only when one leads the message.""" - - @pytest.mark.parametrize( - "content", - [ - "@team/bot Please don't /approve req-1 yet", - "@team/bot do not /approve", - "@team/bot ignore the /decline suggestion", - "@team/bot use /tmp as scratch", - ], - ) - def test_prose_mentioning_a_command_is_not_a_command(self, content: str) -> None: - """Prose that argues *against* a command must not invoke it. - - ``/approve`` resolves a pending tool-execution request, and the handler - takes the first argument token as its id — so a scan that found a slash - word anywhere in the prefix turned "don't /approve req-1 yet" into an - approval of ``req-1``. - """ - assert CodexAdapter._extract_local_command(content) is None - - @pytest.mark.parametrize( - ("content", "expected"), - [ - ("/approve req-1", (CodexCommand.APPROVE, "req-1")), - ("@owner/agent-name /approve req-1", (CodexCommand.APPROVE, "req-1")), - # Every mentioned participant contributes a token to the block. - ( - "@owner/agent-name @owner/other-bot /approve req-1", - (CodexCommand.APPROVE, "req-1"), - ), - # Unresolved mentions stay in the platform's normalized @[[uuid]] form. - ( - "@[[3029eb1d-d998-4567-bdf3-d82fc6b89a58]] /approvals", - (CodexCommand.APPROVALS, ""), - ), - ("@team/bot /approve", (CodexCommand.APPROVE, "")), - # Any whitespace separates a command from its argument, not just " ". - ("@team/bot /approve\treq-1", (CodexCommand.APPROVE, "req-1")), - ("/approve\nreq-1", (CodexCommand.APPROVE, "req-1")), - ], - ) - def test_command_behind_the_mention_block_is_recognised( - self, content: str, expected: tuple[CodexCommand, str] - ) -> None: - """The delivery mention block must never hide a real command.""" - assert CodexAdapter._extract_local_command(content) == expected - - @pytest.mark.parametrize("content", ["@team/bot /", "@team/bot /notacommand x", ""]) - def test_non_commands_are_ignored(self, content: str) -> None: - assert CodexAdapter._extract_local_command(content) is None - - -class TestDoubleEmitStartupWarning: - """Enabling both turn-task channels warns operators once at startup.""" - - @pytest.mark.asyncio - async def test_warns_when_both_channels_enabled( - self, caplog: pytest.LogCaptureFixture - ) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - emit_turn_task_markers=True, emit_turn_lifecycle_events=True - ), - ) - with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): - await adapter.on_started("Agent", "A coding agent") - assert any( - "two task events per turn" in record.message for record in caplog.records - ) - - @pytest.mark.asyncio - async def test_no_warning_when_only_one_channel_enabled( - self, caplog: pytest.LogCaptureFixture - ) -> None: - fake_client = FakeCodexClient() - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig( - emit_turn_task_markers=True, emit_turn_lifecycle_events=False - ), - ) - with caplog.at_level(logging.WARNING, logger="band.adapters.codex"): - await adapter.on_started("Agent", "A coding agent") - assert not any( - "two task events per turn" in record.message for record in caplog.records - ) - - -class TestConfigEnvSourcing: - """Aliased fields source from CODEX_* env names only, never bare vars.""" - - @pytest.fixture(autouse=True) - def clean_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - for var in ( - "EMIT_TURN_TASK_MARKERS", - "CODEX_TURN_TASK_MARKERS", - "CODEX_EMIT_TURN_TASK_MARKERS", - "CODEX_COMMAND", - "CODEX_CODEX_COMMAND", - ): - monkeypatch.delenv(var, raising=False) - - def test_bare_env_var_never_populates_turn_task_markers( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("EMIT_TURN_TASK_MARKERS", "true") - - assert CodexAdapterConfig().emit_turn_task_markers is False - - def test_legacy_env_name_populates_turn_task_markers( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CODEX_TURN_TASK_MARKERS", "true") - - assert CodexAdapterConfig().emit_turn_task_markers is True - - def test_prefixed_field_name_env_populates_turn_task_markers( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CODEX_EMIT_TURN_TASK_MARKERS", "true") - - assert CodexAdapterConfig().emit_turn_task_markers is True - - def test_codex_ws_url_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("CODEX_WS_URL", "ws://elsewhere:9999") - - assert CodexAdapterConfig().codex_ws_url == "ws://elsewhere:9999" - - def test_codex_command_env_splits_shell_string( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """CODEX_COMMAND (the established name), not the doubly-prefixed default.""" - monkeypatch.setenv("CODEX_COMMAND", "custom-codex --args") - - assert CodexAdapterConfig().codex_command == ("custom-codex", "--args") - - def test_codex_command_kwarg_wins_over_env( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CODEX_COMMAND", "ignored --value") - - config = CodexAdapterConfig(codex_command=("explicit", "--kwarg")) - - assert config.codex_command == ("explicit", "--kwarg") - - -class TestReadRoomFileImagePassthrough: - @pytest.mark.asyncio - async def test_image_result_becomes_input_image_content_item(self) -> None: - class ImageTools(ToolSchemaFakeTools): - async def execute_tool_call_structured( - self, tool_name: str, arguments: dict[str, Any] - ) -> ToolCallOutcome: - return ToolCallOutcome( - value={ - "content": [ - { - "type": "image", - "data": "ZmFrZQ==", - "mimeType": "image/png", - } - ] - }, - ok=True, - ) - - turn = await run_codex_turn( - events=[ - tool_call_request(42, "band_read_room_file", {"file_id": "f1"}), - turn_completed(), - ], - tools=ImageTools(), - ) - - response_id, response_payload = turn.tool_response - assert response_id == 42 - assert response_payload["success"] is True - assert turn.content_items == [ - {"type": "inputImage", "imageUrl": "data:image/png;base64,ZmFrZQ=="} - ] - - @pytest.mark.asyncio - async def test_non_image_result_stays_input_text(self) -> None: - turn = await run_codex_turn( - events=[ - tool_call_request(42, "band_read_room_file", {"file_id": "f1"}), - turn_completed(), - ] - ) - - assert turn.content_items[0]["type"] == "inputText" - - -SKILL_ROOT = host_absolute_path("opt", "band", "skills") -REGISTER_SKILL_ROOT = ( - CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET, - {"extraRoots": [SKILL_ROOT]}, -) - - -class TestSkillRoots: - @pytest.mark.asyncio - async def test_roots_are_sent_first_after_initialize(self) -> None: - turn = await run_codex_turn( - events=[turn_completed()], - config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]), - ) - assert turn.client.requests[0] == REGISTER_SKILL_ROOT - - @pytest.mark.asyncio - async def test_no_roots_sends_nothing(self) -> None: - turn = await run_codex_turn(events=[turn_completed()]) - assert ( - CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET not in turn.client.request_methods - ) - - @pytest.mark.asyncio - async def test_every_room_process_gets_the_roots(self) -> None: - clients = { - room_id: FakeCodexClient(events=[turn_completed()]) - for room_id in ("room-1", "room-2") - } - adapter = CodexAdapter(config=CodexAdapterConfig(skill_roots=[SKILL_ROOT])) - patch_codex_clients_by_room(adapter, clients) - await adapter.on_started("Codex Agent", "A coding agent") - - for room_id in clients: - await send_bootstrap(adapter, room_id=room_id) - - assert all( - client.requests[0] == REGISTER_SKILL_ROOT for client in clients.values() - ) - - @pytest.mark.asyncio - async def test_a_restarted_process_gets_the_roots_again(self) -> None: - client = FakeCodexClient( - events=[ - event_notification("transport/closed", {"reason": "exited"}), - turn_completed("turn-2"), - ] - ) - adapter = make_codex_adapter( - client, config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]) - ) - await adapter.on_started("Codex Agent", "A coding agent") - - with pytest.raises(TurnResultAlreadyReported): - await send_bootstrap(adapter) - await send_bootstrap(adapter) - - assert client.requests.count(REGISTER_SKILL_ROOT) == 2 - - @pytest.mark.asyncio - async def test_rejected_roots_fail_the_room_start(self) -> None: - client = FakeCodexClient( - skill_roots_error=CodexJsonRpcError(code=-32601, message="Method not found") - ) - adapter = make_codex_adapter( - client, config=CodexAdapterConfig(skill_roots=[SKILL_ROOT]) - ) - await adapter.on_started("Codex Agent", "A coding agent") - - with pytest.raises(RuntimeError, match="Codex rejected skill_roots"): - await send_bootstrap(adapter) - assert client.requests == [REGISTER_SKILL_ROOT] - - def test_relative_roots_are_refused(self) -> None: - with pytest.raises(ValidationError, match="must be absolute"): - CodexAdapterConfig(skill_roots=["relative/skills"]) - - def test_roots_are_read_from_the_environment_as_json( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setenv("CODEX_SKILL_ROOTS", json.dumps([SKILL_ROOT])) - assert CodexAdapterConfig().skill_roots == [SKILL_ROOT] - - -class TestNoReply: - @pytest.mark.asyncio - async def test_no_reply_suppresses_the_closing_thought(self) -> None: - turn = await run_codex_turn( - events=[ - tool_call_request(1, BandTool.NO_REPLY, {"reason": "not for me"}), - final_text("Nothing to add."), - turn_completed(), - ] - ) - assert turn.tools.messages_sent == [] - assert reported_failures(turn.tools) == [] - - @pytest.mark.asyncio - async def test_no_reply_does_not_carry_into_the_next_turn( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room( - tool_call_request(1, BandTool.NO_REPLY), - turn_completed("turn-1"), - final_text("Second answer"), - turn_completed("turn-2"), - ) - - await room.send("First message") - with pytest.raises(TurnResultAlreadyReported): - await room.send("Second message") - - assert room.chat == [] - assert [ - e["content"] for e in events_of_type(room.deliveries[-1], "thought") - ] == ["Second answer"] - - -class TestNativeTextAuthority: - @pytest.mark.asyncio - async def test_a_tool_reply_suppresses_the_final_text(self) -> None: - turn = await run_codex_turn( - events=[ - tool_call_request( - 1, BandTool.SEND_MESSAGE, {"content": "Hi", "mentions": ["@a"]} - ), - final_text("Hi, again."), - turn_completed(), - ] - ) - - assert turn.tools.messages_sent == [] - - @pytest.mark.asyncio - async def test_a_policy_notification_never_stands_in_for_the_answer( - self, - ) -> None: - turn = await run_codex_turn( - events=[ - *parked_on_approval(final_text("Tests pass.")), - turn_completed(), - ], - config=CodexAdapterConfig( - approval_mode="auto_accept", approval_text_notifications=True - ), - ) - - assert turn.tools.chat == [ - "Approval requested (command: a). Policy decision: accept." - ] - assert not turn.tools.turn.complete - assert [e["content"] for e in events_of_type(turn.tools, "thought")] == [ - "Codex approval request handled automatically (accept).", - "Tests pass.", - ] - - -class StayQuietInput(BaseModel): - """Say nothing this turn.""" - - -class TestCustomToolEffect: - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("declare", "chat"), - [ - pytest.param( - declares_turn_effect(TurnEffect.DECLINE), [], id="declared-silence" - ), - pytest.param(undeclared, [], id="undeclared"), - ], - ) - async def test_only_a_declared_tool_settles_the_reply( - self, declare: Callable[..., Any], chat: list[str] - ) -> None: - async def stay_quiet(args: StayQuietInput) -> str: - return "quiet" - - turn = await run_codex_turn( - events=[ - tool_call_request(1, "stayquiet"), - final_text("Nothing to add."), - turn_completed(), - ], - additional_tools=[(StayQuietInput, declare(stay_quiet))], - ) - - assert turn.tools.chat == chat - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("declare", "complete"), - [ - pytest.param(declares_turn_effect(TurnEffect.ACT), True, id="declared"), - pytest.param(undeclared, False, id="undeclared-observes"), - ], - ) - async def test_the_tool_records_its_effect_on_the_turn( - self, declare: Callable[..., Any], complete: bool - ) -> None: - async def stay_quiet(args: StayQuietInput) -> str: - return "quiet" - - turn = await run_codex_turn( - events=[tool_call_request(1, "stayquiet"), turn_completed()], - additional_tools=[(StayQuietInput, declare(stay_quiet))], - ) - - assert turn.tools.turn.complete is complete - - -@pytest.mark.asyncio -async def test_an_interrupted_turn_is_settled_by_its_notice() -> None: - """The notice answers the room, so the turn is not also a missing reply.""" - turn = await run_codex_turn(events=[turn_completed(status="interrupted")]) - - assert turn.tools.chat == ["I stopped before completing this request."] - assert turn.tools.turn.complete - - -class TestDetachedTurnOutcome: - """A turn released to wait on a human is judged at its real end.""" - - @pytest.mark.asyncio - async def test_a_released_turn_that_ends_silently_is_reported_once( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room(*parked_on_approval(turn_completed())) - - await room.send("run tests") - await room.send("/approve req-10") - await room.settled() - - turn, approval = room.deliveries - assert failure_reports(turn) == [MISSING_REPLY_FAILURE] - assert failure_reports(approval) == [] - - @pytest.mark.asyncio - async def test_messages_during_a_released_turn_never_take_over_its_reply( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room( - *parked_on_approval(final_text("Tests pass."), turn_completed()) - ) - - await room.send("run tests") - with pytest.raises(InputNotAccepted): - await room.send("and lint too") - await room.send("/approve req-10") - await room.settled() - - turn, busy, approval = room.deliveries - assert [e["content"] for e in events_of_type(turn, "thought")] == [ - "Codex approval request handled automatically (accept).", - "Tests pass.", - ] - assert failure_reports(turn) == [MISSING_REPLY_FAILURE] - assert busy.chat == [] - assert not busy.turn.complete - assert turn.turn.complete and approval.turn.complete - assert failure_reports(busy) == failure_reports(approval) == [] - - @pytest.mark.asyncio - async def test_a_released_turn_ended_by_room_cleanup_posts_nothing( - self, codex_room: Callable[..., Awaitable[CodexRoom]] - ) -> None: - room = await codex_room(*parked_on_approval(turn_completed())) - - await room.send("run tests") - await room.adapter.on_cleanup(ROOM_ID) - - [turn] = room.deliveries - assert failure_reports(turn) == [] - - -@pytest.mark.parametrize("stream", [True, False]) -@pytest.mark.parametrize("emit_thoughts", [True, False]) -@pytest.mark.parametrize("completed", ["thinking", "thinking longer", "revised"]) -async def test_completed_native_items_reconcile_streamed_thoughts( - stream: bool, - emit_thoughts: bool, - completed: str, -) -> None: - turn = await run_codex_turn( - events=[ - agent_message_started("comment", phase="commentary"), - agent_message_delta("thinking", "comment"), - agent_message_completed(completed, "comment", phase="commentary"), - agent_message_completed("final", "answer"), - turn_completed(), - ], - config=CodexAdapterConfig(stream_commentary_events=stream), - emit={Emit.THOUGHTS} if emit_thoughts else set(), - ) - expected: list[str] = [] - if emit_thoughts: - expected = [completed, "final"] - if stream: - expected = ["thinking"] - if completed != "thinking": - expected.append( - " longer" if completed == "thinking longer" else "revised" - ) - expected.append("final") - assert [e["content"] for e in events_of_type(turn.tools, "thought")] == expected - assert turn.tools.chat == [] - assert not turn.tools.turn.complete - - -@pytest.mark.parametrize("effect", [BandTool.SEND_MESSAGE, BandTool.NO_REPLY]) -async def test_completed_native_text_waits_for_later_reply_effects(effect: str) -> None: - args = ( - {"content": "tool answer", "mentions": ["@a"]} - if effect == BandTool.SEND_MESSAGE - else {} - ) - turn = await run_codex_turn( - events=[ - agent_message_completed("early closing text"), - tool_call_request(1, effect, args), - turn_completed(), - ] - ) - assert events_of_type(turn.tools, "thought") == [] - assert turn.tools.turn.replied - - -@pytest.mark.parametrize("status", ["interrupted", "failed"]) -async def test_completed_native_text_is_suppressed_after_provider_failure( - status: str, -) -> None: - tools = ToolSchemaFakeTools() - if status == "failed": - with pytest.raises(TurnResultAlreadyReported): - await run_codex_turn( - tools=tools, - events=[ - agent_message_completed("not delivered"), - turn_completed(status=status), - ], - ) - else: - await run_codex_turn( - tools=tools, - events=[ - agent_message_completed("not delivered"), - turn_completed(status=status), - ], - ) - assert events_of_type(tools, "thought") == [] - - -@pytest.mark.parametrize("phase", [None, "final_answer", "commentary"]) -async def test_incomplete_native_items_have_no_closing_fallback( - phase: str | None, -) -> None: - turn = await run_codex_turn( - events=[ - agent_message_started(phase=phase), - agent_message_delta("partial"), - turn_completed(), - ] - ) - assert turn.tools.chat == [] - assert events_of_type(turn.tools, "thought") == [] - assert not turn.tools.turn.complete - - -@pytest.mark.parametrize( - "config", - [ - CodexAdapterConfig(), - CodexAdapterConfig(system_prompt="custom"), - CodexAdapterConfig(include_base_instructions=False), - ], -) -async def test_codex_transport_contract_is_in_default_and_custom_prompts( - config: CodexAdapterConfig, -) -> None: - turn = await run_codex_turn( - events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()], - config=config, - ) - inputs = turn.client.params_of(CodexRequestMethod.TURN_START)[0]["input"] - assert inputs[0]["text"].count(COMMUNICATION_INSTRUCTIONS) == 1 - - -async def test_failed_native_thought_delivery_does_not_settle_the_turn() -> None: - tools = ToolSchemaFakeTools() - tools.send_event_error = RuntimeError("telemetry unavailable") - await run_codex_turn( - tools=tools, - events=[agent_message_completed("closing narration"), turn_completed()], - ) - assert not tools.turn.complete - assert tools.chat == [] - - -@pytest.mark.parametrize( - "phase, include_phase", - [(None, False), (None, True), ("final_answer", True), ("commentary", True)], -) -async def test_completed_native_messages_without_reply_effects_remain_thoughts( - phase: str | None, - include_phase: bool, -) -> None: - completed = agent_message_completed("native", phase=phase) - if not include_phase: - completed.params["item"].pop("phase") - turn = await run_codex_turn(events=[completed, turn_completed()]) - assert [event["content"] for event in events_of_type(turn.tools, "thought")] == [ - "native" - ] - assert turn.tools.chat == [] - assert not turn.tools.turn.complete - - -async def test_restored_codex_thread_receives_the_mandatory_transport_contract() -> ( - None -): - client = FakeCodexClient( - events=[tool_call_request(1, BandTool.NO_REPLY), turn_completed()] - ) - adapter = make_codex_adapter( - client, config=CodexAdapterConfig(system_prompt="custom") - ) - await adapter.on_started("Agent", "A coding agent") - await deliver_message( - adapter, - make_platform_message(), - ToolSchemaFakeTools(), - CodexSessionState(thread_id="restored"), - None, - None, - is_session_bootstrap=True, - room_id=ROOM_ID, - ) - inputs = client.params_of(CodexRequestMethod.TURN_START)[0]["input"] - assert inputs[0]["text"].count(COMMUNICATION_INSTRUCTIONS) == 1 - assert client.params_of(CodexRequestMethod.THREAD_RESUME) - - -class CountingCloseClient(FakeCodexClient): - """Counts physical closes, optionally holding each one open.""" - - def __init__(self, *, events: list[RpcEvent], hold: bool = False) -> None: - super().__init__(events=events) - self.closes = 0 - self.closing = asyncio.Event() - self.finish_close = asyncio.Event() - self.close_finished = asyncio.Event() - if not hold: - self.finish_close.set() - - async def close(self) -> None: - self.closes += 1 - self.closing.set() - await self.finish_close.wait() - await super().close() - self.close_finished.set() - - -@requires_eager_tasks -async def test_cleanup_readmits_and_recloses_the_room_under_an_eager_task_factory() -> ( - None -): - client = CountingCloseClient(events=[turn_completed(), turn_completed("turn-2")]) - adapter = make_codex_adapter(client) - with eager_tasks(): - await adapter.on_started("Codex Agent", "A coding agent") - await send_bootstrap(adapter) - await adapter.on_cleanup(ROOM_ID) - await send_bootstrap(adapter) - await adapter.on_cleanup(ROOM_ID) - - assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 - assert client.closes == 2 - - -@pytest.mark.looptime -async def test_concurrent_room_cleanups_share_one_close() -> None: - client = CountingCloseClient(events=[turn_completed()], hold=True) - adapter = make_codex_adapter(client) - await adapter.on_started("Codex Agent", "A coding agent") - await send_bootstrap(adapter) - room_cleanup = asyncio.create_task(adapter.on_cleanup(ROOM_ID)) - await client.closing.wait() - every_cleanup = asyncio.create_task(adapter.cleanup_all()) - # Fake time advances only once both cleanups are waiting on the close. - await asyncio.sleep(1) - client.finish_close.set() - - await asyncio.gather(room_cleanup, every_cleanup) - assert client.closes == 1 - - -async def test_cleanup_cancelled_during_close_still_readmits_the_room() -> None: - client = CountingCloseClient( - events=[turn_completed(), turn_completed("turn-2")], hold=True - ) - adapter = make_codex_adapter(client) - await adapter.on_started("Codex Agent", "A coding agent") - await send_bootstrap(adapter) - cleanup = asyncio.create_task(adapter.on_cleanup(ROOM_ID)) - await client.closing.wait() - cleanup.cancel() - with pytest.raises(asyncio.CancelledError): - await cleanup - client.finish_close.set() - await client.close_finished.wait() - - await adapter.on_cleanup(ROOM_ID) - await send_bootstrap(adapter) - assert len(client.params_of(CodexRequestMethod.TURN_START)) == 2 - - -@pytest.mark.parametrize("retain", [False, True]) -async def test_stop_callbacks_share_worker_cleanup(retain: bool) -> None: - started, cleaning, release = (asyncio.Event() for _ in range(3)) - cleanup: list[str] = [] - - class CleaningClient(FakeCodexClient): - async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - started.set() - try: - await asyncio.Future[None]() - finally: - cleaning.set() - await release.wait() - cleanup.append("completed") - raise AssertionError("The cancelled receive cannot return") - - client = CleaningClient() - adapter = make_codex_adapter(client) - room = CodexRoom(adapter, client) - sending = asyncio.create_task(room.send("work")) - await started.wait() - sending.cancel() - await cleaning.wait() - callbacks = [ - asyncio.create_task(adapter.on_interrupt(ROOM_ID, ControlMode.STOP)) - for _ in range(2) - ] - try: - if retain: - await asyncio.gather(*callbacks) - with pytest.raises(CleanupIncompleteError): - await adapter.on_cleanup(ROOM_ID) - assert not client.closed - with pytest.raises(InputNotAccepted): - await room.send("overlap") - release.set() - await asyncio.gather(*callbacks) - with pytest.raises(asyncio.CancelledError): - await sending - await adapter.on_cleanup(ROOM_ID) - assert cleanup == ["completed"] - assert client.closed - finally: - release.set() - await asyncio.gather(sending, *callbacks, return_exceptions=True) - await adapter.cleanup_all() diff --git a/tests/adapters/test_copilot_acp_adapter.py b/tests/adapters/test_copilot_acp_adapter.py index d70b1f757..b21519cd4 100644 --- a/tests/adapters/test_copilot_acp_adapter.py +++ b/tests/adapters/test_copilot_acp_adapter.py @@ -42,6 +42,7 @@ DEFAULT_ROOM, AcpSession, launch_for, + room_holder, started_acp_adapter, ) @@ -94,7 +95,7 @@ def test_no_profile_uses_default_noop(self) -> None: # collecting client the runtime builds falls back to the no-op profile. adapter = CopilotACPAdapter() assert adapter._profile is None - client = adapter._build_runtime()._client_factory() + client = adapter._build_runtime(room_holder(adapter), 1)._client_factory() assert isinstance(client._profile, NoopACPClientProfile) @pytest.mark.parametrize( @@ -159,6 +160,11 @@ def _echo(text: str) -> str: "claude-haiku-4.5": (), } +# Event-loop ticks that cover every step of a model+effort switch, with +# headroom: a cleanup after tick 15 finds the switch already done. +SWITCH_SWEEP_TICKS = 24 +ENDED_MID_SWITCH = f"room {DEFAULT_ROOM}'s ACP session ended mid-switch" + def copilot(current: str = "claude-sonnet-5") -> FakeACPAgent: """A fake ``copilot --acp`` advertising Copilot's model catalog.""" @@ -475,7 +481,7 @@ async def test_a_switch_queued_behind_a_room_cleanup_names_the_ended_session( ) -> None: agent = copilot() - async with copilot_room(agent) as session: + async with copilot_room(agent, turn_settle_timeout_s=0) as session: await session.send("Hello") gate = agent.holds_config_replies() first = asyncio.create_task(switch_room(session, model="gpt-5.4")) @@ -486,7 +492,7 @@ async def test_a_switch_queued_behind_a_room_cleanup_names_the_ended_session( results = await asyncio.gather(first, queued, return_exceptions=True) assert isinstance(results[1], BandConfigError) - assert str(results[1]) == "room room-1's ACP session ended mid-switch" + assert str(results[1]) == ENDED_MID_SWITCH @pytest.mark.asyncio async def test_a_cleanup_at_any_point_of_a_switch_names_the_ended_session( @@ -494,15 +500,30 @@ async def test_a_cleanup_at_any_point_of_a_switch_names_the_ended_session( ) -> None: # Sweeps where the cleanup lands: before the model reply, between the # model and effort steps, and after the switch completes. - outcomes = {await self._switch_cleaned_up_after(ticks) for ticks in range(12)} + outcomes = { + await self._switch_cleaned_up_after(ticks, turn_settle_timeout_s=0) + for ticks in range(SWITCH_SWEEP_TICKS) + } - assert outcomes <= {"ok", "room room-1's ACP session ended mid-switch"} - assert "room room-1's ACP session ended mid-switch" in outcomes + assert outcomes == {"ok", ENDED_MID_SWITCH} + + @pytest.mark.asyncio + async def test_a_cleanup_at_any_point_of_a_switch_lets_it_finish( + self, + ) -> None: + """Within the settle grace a running switch completes before its + session ends, wherever the cleanup lands.""" + outcomes = { + await self._switch_cleaned_up_after(ticks) + for ticks in range(SWITCH_SWEEP_TICKS) + } + + assert outcomes == {"ok"} @staticmethod - async def _switch_cleaned_up_after(ticks: int) -> str: + async def _switch_cleaned_up_after(ticks: int, **config: Any) -> str: agent = copilot() - async with copilot_room(agent) as session: + async with copilot_room(agent, **config) as session: await session.send("Hello") gate = agent.holds_config_replies() switch = asyncio.create_task( diff --git a/tests/adapters/test_cursor_acp_adapter.py b/tests/adapters/test_cursor_acp_adapter.py deleted file mode 100644 index 11900e19c..000000000 --- a/tests/adapters/test_cursor_acp_adapter.py +++ /dev/null @@ -1,1704 +0,0 @@ -"""Behavioral tests for the Cursor ACP backend.""" - -from __future__ import annotations - -import asyncio -import re -import sys -from collections.abc import AsyncIterator, Awaitable, Callable -from datetime import UTC, datetime -from pathlib import Path -from types import SimpleNamespace -from typing import Any, cast -from uuid import uuid4 - -import pytest -import pytest_asyncio -from acp.helpers import start_tool_call, update_tool_call -from acp.schema import ( - AllowedOutcome, - NewSessionResponse, - PermissionOption, - ToolCallUpdate, -) -from pydantic import BaseModel, create_model - -from band.adapters.cursor_acp import ( - DECISION_UNAUTHORIZED_MESSAGE, - DEFAULT_CURSOR_ACP_COMMAND, - CursorACPAdapter, - CursorACPAdapterConfig, - CursorTurn, -) -from band.client.streaming import ControlMode -from band.core.protocols import AgentToolsProtocol -from band.core.types import AgentInput, Capability, HistoryProvider, PlatformMessage -from band.integrations.acp.client_adapter import ACPPermissionRequest -from band.integrations.acp.client_runtime import ACPRuntime -from band.integrations.acp.cursor import PLAN_REQUESTED_TEMPLATE -from band.integrations.acp.permissions import ALLOW_ALWAYS_KIND, ALLOW_ONCE_KIND -from band.integrations.acp.types import ACPToolCall -from band.runtime.custom_tools import CustomToolDef -from band.runtime.tools.registry import BAND_MCP_SERVER_NAME, mcp_tool_spelling -from band.runtime.tools.types import BandTool -from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools, failure_reports -from tests.integrations.acp.acp_toolkit.agent import ( - FakeACPAgent, - Gate, - allow_or_reject, -) -from tests.integrations.acp.acp_toolkit.harness import ( - AcpSession, - launch_for, - pair_in_process, - started_acp_adapter, -) -from tests.mcpclient import STORE_MEMORY_ARGS, crash_backend - - -class DecisionTools(FakeAgentTools): - """Room tools that enforce the real mention contract (unlike a hand-rolled - fake, this raises on a mention-less send -- see FakeAgentTools.send_notice) - and signal once a pending decision is visible in the room. - - Decision prompts and replies are the adapter's own posts, so they go - through ``send_notice``. - """ - - def __init__(self) -> None: - super().__init__() - self.prompt_sent = asyncio.Event() - - async def send_notice( - self, content: str, mentions: list[str] | list[dict[str, str]] | None = None - ) -> object: - result = await super().send_notice(content, mentions) - self.prompt_sent.set() - return result - - @property - def messages(self) -> list[str]: - return [cast(str, sent["content"]) for sent in self.messages_sent] - - -class FailingDecisionTools(DecisionTools): - def __init__(self, *, fail_after: int = 0) -> None: - super().__init__() - self._fail_after = fail_after - - async def send_notice( - self, content: str, mentions: list[str] | list[dict[str, str]] | None = None - ) -> object: - if len(self.messages_sent) >= self._fail_after: - raise RuntimeError("room delivery failed") - return await super().send_notice(content, mentions) - - -def _turn( - room_id: str, - tools: AgentToolsProtocol, - requester_id: str | None, - session_id: str | None = None, -) -> CursorTurn: - return CursorTurn( - room_id=room_id, - tools=tools, - requester_id=requester_id, - release=asyncio.get_running_loop().create_future(), - session_id=session_id, - ) - - -def room_message( - content: str, *, room_id: str = "room-1", sender_id: str = "user-1" -) -> PlatformMessage: - return PlatformMessage( - id=str(uuid4()), - room_id=room_id, - content=content, - sender_id=sender_id, - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ) - - -def decision_token(prompt: str) -> str: - """The token a decision prompt asks the room to answer with.""" - match = re.search(r"`/cursor \w+ (\S+)", prompt) - assert match is not None, prompt - return match[1] - - -def said(tools: FakeAgentTools) -> list[str]: - return [cast(str, sent["content"]) for sent in tools.messages_sent] - - -def permission_requests(tools: FakeAgentTools) -> list[str]: - return [message for message in said(tools) if "needs permission" in message] - - -class CursorRoom: - """A Cursor room whose ``agent acp`` peer is a scripted in-process ACP - agent: each message goes through ``on_event`` with its own tools, as the - runtime delivers it, so every turn is judged.""" - - def __init__(self, adapter: CursorACPAdapter, agent: FakeACPAgent) -> None: - self.adapter = adapter - self.agent = agent - self._bootstrapped = False - - async def send(self, content: str) -> FakeAgentTools: - """Deliver ``content``; return the tools its turn posted through.""" - tools = FakeAgentTools(room_id="room-1") - bootstrap, self._bootstrapped = not self._bootstrapped, True - await self.adapter.on_event( - AgentInput( - msg=room_message(content), - tools=tools, - history=HistoryProvider(raw=[]), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=bootstrap, - room_id="room-1", - ) - ) - return tools - - async def turns_finished(self) -> None: - """Wait for every turn still running after its message returned.""" - await asyncio.gather(*self.adapter._background_tasks, return_exceptions=True) - - -# The adapter's ACP runtime lives on the loop that started it, so setup, the -# test and teardown share the test's own loop. -@pytest_asyncio.fixture(loop_scope="function") -async def cursor_room() -> AsyncIterator[Callable[..., Awaitable[CursorRoom]]]: - adapters: list[CursorACPAdapter] = [] - - async def open_room( - agent: FakeACPAgent, - *, - capabilities: set[Capability] | None = None, - additional_tools: list[CustomToolDef] | None = None, - **config: Any, - ) -> CursorRoom: - adapter = CursorACPAdapter( - CursorACPAdapterConfig(**config), - capabilities=capabilities, - additional_tools=additional_tools, - ) - pair_in_process(adapter, agent) - await adapter.on_started("Cursor", "Cursor agent under test") - adapters.append(adapter) - return CursorRoom(adapter, agent) - - yield open_room - for adapter in adapters: - await adapter.stop() - - -def cursor_in( - workspace_root: Path, config: CursorACPAdapterConfig | None = None -) -> CursorACPAdapter: - """A Cursor adapter whose room workspaces live under ``workspace_root``.""" - return CursorACPAdapter( - config, workspace_for_room=lambda room_id: str(workspace_root / room_id) - ) - - -class EchoInput(BaseModel): - text: str - - -def echo(arguments: EchoInput) -> str: - return arguments.text - - -def custom_reply() -> str: - return "custom reply" - - -def cursor_custom_tools( - echo_handler: Callable[[EchoInput], str], -) -> list[CustomToolDef]: - return [ - (EchoInput, echo_handler), - ( - create_model( - f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, BandTool.SEND_MESSAGE)}Input" - ), - custom_reply, - ), - ] - - -@pytest.fixture -def cursor_approval_room( - cursor_room: Callable[..., Awaitable[CursorRoom]], -) -> Callable[[str, str], Awaitable[CursorRoom]]: - async def open_room(title: str, approval_mode: str) -> CursorRoom: - return await cursor_room( - FakeACPAgent() - .will_ask_permission(title=title) - .will_call_mcp_tool( - "reply", - BandTool.SEND_MESSAGE, - arguments={"content": "finished", "mentions": ["@user-1"]}, - ), - approval_mode=approval_mode, - capabilities={Capability.MEMORY}, - additional_tools=cursor_custom_tools(echo), - decision_timeout_s=0.1, - ) - - return open_room - - -@pytest.mark.asyncio -@pytest.mark.parametrize("approval_mode", ["manual", "auto_accept", "auto_decline"]) -@pytest.mark.parametrize( - "title", - [ - pytest.param("band-band_store_memory: band_store_memory", id="memory"), - pytest.param( - "band-band_send_message: band_send_message", - id="reply-with-custom-name-collision", - ), - pytest.param("band-echo: echo", id="custom-tool"), - ], -) -async def test_registered_band_tools_are_approved_without_asking_the_room( - cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], - approval_mode: str, - title: str, -) -> None: - room = await cursor_approval_room(title, approval_mode) - tools = await room.send("run the tool") - await room.turns_finished() - - assert room.agent.approved is True - assert permission_requests(tools) == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("approval_mode", "approved", "room_requests"), - [ - ("manual", False, 1), - ("auto_accept", True, 0), - ("auto_decline", False, 0), - ], -) -@pytest.mark.parametrize( - "title", - [ - pytest.param("other-band_send_message: band_send_message", id="other-server"), - pytest.param( - "band-band_store_memory: band_send_message", id="mismatched-identity" - ), - pytest.param("shell", id="native-tool"), - pytest.param("shell: shell", id="native-display-title"), - pytest.param("band-unknown: unknown", id="unregistered-tool"), - ], -) -async def test_other_tools_follow_cursor_approval_policy( - cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], - approval_mode: str, - title: str, - approved: bool, - room_requests: int, -) -> None: - room = await cursor_approval_room(title, approval_mode) - tools = await room.send("run the tool") - await room.turns_finished() - - assert room.agent.approved is approved - assert len(permission_requests(tools)) == room_requests - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("approval_mode", "first_edit", "second_edit"), - [ - ("manual", False, True), - ("auto_accept", True, True), - ("auto_decline", False, False), - ], -) -async def test_project_workflow_keeps_band_tools_available_across_human_gates_and_rejoin( - cursor_room: Callable[..., Awaitable[CursorRoom]], - tmp_path: Path, - approval_mode: str, - first_edit: bool, - second_edit: bool, -) -> None: - project = tmp_path / "project.txt" - project.write_text("original") - audit = tmp_path / "audit.txt" - marker = "project workflow completed" - - def record_work(arguments: EchoInput) -> str: - with audit.open("a") as stream: - stream.write(arguments.text + "\n") - return arguments.text - - async def edit_project(agent: FakeACPAgent, session_id: str) -> None: - await agent.emit(session_id, start_tool_call("native-edit", "shell")) - process = await asyncio.create_subprocess_exec( - sys.executable, - "-c", - "from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])", - str(project), - "repaired", - ) - if await process.wait() != 0: - raise RuntimeError("project edit failed") - await agent.emit( - session_id, update_tool_call("native-edit", raw_output="project repaired") - ) - - agent = ( - FakeACPAgent() - .will_ask_permission(title="band-echo: echo") - .will_call_mcp_tool( - "custom-work", "echo", arguments={"text": marker}, requires_approval=True - ) - ) - agent.will_ask_permission(title="shell").will_execute_if_approved(edit_project) - for tool, arguments in [ - (BandTool.STORE_MEMORY, {**STORE_MEMORY_ARGS, "content": marker}), - (BandTool.SEND_MESSAGE, {"content": marker, "mentions": ["@user-1"]}), - ]: - agent.will_ask_permission( - title=f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, tool)}: {tool}" - ).will_call_mcp_tool(tool, tool, arguments=arguments, requires_approval=True) - agent.will_say("I already posted the result.") - room = await cursor_room( - agent, - approval_mode=approval_mode, - capabilities={Capability.MEMORY}, - additional_tools=cursor_custom_tools(record_work), - ) - - for attempt, edited in enumerate((first_edit, second_edit)): - project.write_text("original") - turn = await room.send("repair the project and record the result") - if approval_mode == "manual": - assert project.read_text() == "original" - [request] = permission_requests(turn) - assert "shell" in request - command = "deny" if attempt == 0 else "select" - option = "" if attempt == 0 else " allow-1" - decision = await room.send( - f"/cursor {command} {decision_token(request)}{option}" - ) - assert failure_reports(decision) == [] - await room.turns_finished() - - assert project.read_text() == ("repaired" if edited else "original") - assert audit.read_text().splitlines() == [marker] * (attempt + 1) - assert turn.memory_contents == [marker] - assert said(turn) == [*permission_requests(turn), marker] - assert turn.turn.replied - assert failure_reports(turn) == [] - await room.adapter.on_cleanup("room-1") - - -class TestCursorACPAdapterConfig: - @pytest.mark.parametrize( - ("settings", "error"), - [ - ({"api_key": "a", "auth_token": "b"}, "either api_key or auth_token"), - ({"command": ()}, "requires a command"), - ( - {"auth_method": "api_key"}, - "auth_method\n Input should be 'cursor_login'", - ), - ( - {"decision_timeout_s": 0.0}, - "decision_timeout_s\n Input should be greater than 0", - ), - ( - {"max_pending_decisions": 0}, - "max_pending_decisions\n Input should be greater than 0", - ), - ( - {"decision_timeout_s": 300.0, "turn_timeout_s": 300.0}, - "decision_timeout_s must be less than turn_timeout_s", - ), - ( - {"decision_timeout_s": 900.0}, - "decision_timeout_s must be less than turn_timeout_s", - ), - ], - ids=[ - "ambiguous-auth", - "empty-command", - "foreign-auth-method", - "non-positive-decision-timeout", - "non-positive-max-pending", - "decision-timeout-equals-turn-timeout", - "decision-timeout-reaching-the-default-turn-timeout", - ], - ) - def test_rejects_invalid_settings( - self, settings: dict[str, object], error: str - ) -> None: - with pytest.raises(ValueError, match=error): - CursorACPAdapterConfig.model_validate(settings) - - def test_authorized_senders_load_from_any_list_of_ids(self) -> None: - config = CursorACPAdapterConfig.model_validate( - {"decision_authorized_senders": ["owner", "owner", "admin"]} - ) - - assert config.decision_authorized_senders == frozenset({"owner", "admin"}) - - def test_cwd_and_a_workspace_resolver_are_exclusive(self, tmp_path: Path) -> None: - with pytest.raises(ValueError, match="either cwd or workspace_for_room"): - CursorACPAdapter( - CursorACPAdapterConfig(cwd=str(tmp_path)), - workspace_for_room=lambda room_id: str(tmp_path / room_id), - ) - - -class TestCursorACPAdapterLaunch: - @pytest.mark.asyncio - async def test_launches_agent_acp_without_authenticate( - self, tmp_path: Path - ) -> None: - adapter = cursor_in(tmp_path) - - launch = await launch_for(adapter) - - assert launch.command == DEFAULT_CURSOR_ACP_COMMAND - assert launch.auth_method is None - assert adapter._profile is adapter._cursor_profile - - @pytest.mark.parametrize( - ("config", "env"), - [ - pytest.param( - CursorACPAdapterConfig(api_key="key"), - {"CURSOR_API_KEY": "key"}, - id="api-key", - ), - pytest.param( - CursorACPAdapterConfig(auth_token="token", env={"OTHER": "x"}), - {"OTHER": "x", "CURSOR_AUTH_TOKEN": "token"}, - id="auth-token-merged-into-env", - ), - pytest.param( - CursorACPAdapterConfig( - api_key="shortcut", env={"CURSOR_API_KEY": "environment"} - ), - {"CURSOR_API_KEY": "environment"}, - id="env-wins-over-api-key", - ), - ], - ) - @pytest.mark.asyncio - async def test_credentials_reach_the_agent_environment( - self, config: CursorACPAdapterConfig, env: dict[str, str], tmp_path: Path - ) -> None: - launch = await launch_for(cursor_in(tmp_path, config)) - - assert launch.env == env - - @pytest.mark.asyncio - async def test_cwd_becomes_a_room_workspace_root(self, tmp_path: Path) -> None: - adapter = CursorACPAdapter(CursorACPAdapterConfig(cwd=str(tmp_path))) - - launch = await launch_for(adapter, "room-a") - - assert launch.cwd == str(tmp_path / "room-a") - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("approval_mode", "persistent", "approved", "room_requests"), - [ - ("manual", False, True, 0), - ("auto_accept", False, True, 0), - ("auto_decline", False, True, 0), - ("manual", True, False, 1), - ("auto_accept", True, True, 0), - ("auto_decline", True, False, 0), - ], -) -async def test_raw_input_band_permission_obeys_grant_duration( - cursor_room: Callable[..., Awaitable[CursorRoom]], - approval_mode: str, - persistent: bool, - approved: bool, - room_requests: int, -) -> None: - agent = FakeACPAgent() - - @agent.on_prompt - async def ask_band_permission(peer: FakeACPAgent, session_id: str) -> None: - response = await peer.ask_permission( - session_id, - ToolCallUpdate( - toolCallId="reply", - title="band-band_store_memory: band_store_memory" - if persistent - else "Reply to room", - rawInput=None - if persistent - else { - "providerIdentifier": BAND_MCP_SERVER_NAME, - "toolName": BandTool.STORE_MEMORY, - "args": {}, - }, - ), - [ - PermissionOption( - optionId="grant", - name="Allow", - kind=ALLOW_ALWAYS_KIND if persistent else ALLOW_ONCE_KIND, - ) - ], - ) - peer.approved = isinstance(response.outcome, AllowedOutcome) - await peer.call_mcp_tool( - session_id=session_id, - server=BAND_MCP_SERVER_NAME, - tool_name=BandTool.SEND_MESSAGE, - arguments={"content": "finished", "mentions": ["@user-1"]}, - ) - - room = await cursor_room( - agent, - approval_mode=approval_mode, - capabilities={Capability.MEMORY}, - decision_timeout_s=0.1, - ) - tools = await room.send("reply") - await room.turns_finished() - - assert agent.approved is approved - assert len(permission_requests(tools)) == room_requests - - -class TestCursorACPAdapterDecisions: - @pytest.mark.asyncio - async def test_stale_runtime_cleanup_preserves_live_turn_permission(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - adapter._runtimes["room-1"] = ACPRuntime(command=[]) - adapter._active_turn = _turn("room-1", tools, "user-1", "session-1") - stale_runtime = ACPRuntime(command=[]) - - await adapter.on_cleanup("room-1", expected_runtime=stale_runtime) - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall( - "reply", BandTool.SEND_MESSAGE, {}, mcp_server=BAND_MCP_SERVER_NAME - ), - options=( - PermissionOption( - optionId="once", name="Allow once", kind=ALLOW_ONCE_KIND - ), - ), - ) - - assert await adapter._resolve_cursor_permission(request) == "once" - assert tools.messages == [] - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "inactive", - ["absent", "other-room", "other-session", "interrupted", "cleaned-up"], - ) - @pytest.mark.parametrize("approval_mode", ["manual", "auto_accept", "auto_decline"]) - async def test_band_permission_requires_a_matching_live_turn( - self, inactive: str, approval_mode: str - ) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter(CursorACPAdapterConfig(approval_mode=approval_mode)) - if inactive != "absent": - adapter._active_turn = _turn( - "other-room" if inactive == "other-room" else "room-1", - tools, - "user-1", - "other-session" if inactive == "other-session" else "session-1", - ) - if inactive == "interrupted": - await adapter.on_interrupt("room-1", ControlMode.STOP) - if inactive == "cleaned-up": - await adapter.on_cleanup("room-1") - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall( - "reply", - adapter._canonical_tool_name( - "band-band_send_message: band_send_message" - ), - {}, - mcp_server=BAND_MCP_SERVER_NAME, - ), - options=( - PermissionOption( - optionId="once", name="Allow once", kind=ALLOW_ONCE_KIND - ), - ), - ) - - assert await adapter._resolve_cursor_permission(request) is None - assert tools.messages == [] - - @pytest.mark.asyncio - async def test_manual_question_requires_a_valid_room_answer(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - turn = _turn("room-1", tools, "user-1", "session-1") - adapter._active_turn = turn - - pending = asyncio.create_task( - adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "mode", - "prompt": "Choose mode", - "options": [ - {"id": "agent", "label": "Agent"}, - {"id": "plan", "label": "Plan"}, - ], - } - ] - }, - ) - ) - await tools.prompt_sent.wait() - assert "agent=Agent" in tools.messages[0] - assert "plan=Plan" in tools.messages[0] - token = next(iter(adapter._pending_decisions)) - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor answer {token} mode=plan", sender_id="user-1" - ), - ), - tools, - "room-1", - ) - - assert handled is True - assert await pending == { - "outcome": { - "outcome": "answered", - "answers": [{"questionId": "mode", "selectedOptionIds": ["plan"]}], - } - } - - @pytest.mark.asyncio - async def test_manual_multi_question_rejects_an_unauthorized_answer_then_resolves( - self, - ) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter( - CursorACPAdapterConfig(decision_authorized_senders=frozenset({"owner"})) - ) - turn = _turn("room-1", tools, "requester", "session-1") - adapter._active_turn = turn - pending = asyncio.create_task( - adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "files", - "prompt": "Choose files", - "allowMultiple": True, - "options": [ - {"id": "readme", "label": "README"}, - {"id": "config", "label": "Config"}, - ], - }, - { - "id": "mode", - "prompt": "Choose mode", - "options": [{"id": "plan", "label": "Plan"}], - }, - ] - }, - ) - ) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=(f"/cursor answer {token} files=readme,config mode=plan"), - sender_id="intruder", - ), - ), - tools, - "room-1", - ) - - assert not pending.done() - assert tools.messages[-1] == DECISION_UNAUTHORIZED_MESSAGE - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=(f"/cursor answer {token} files=readme,config mode=plan"), - sender_id="owner", - ), - ), - tools, - "room-1", - ) - - assert await pending == { - "outcome": { - "outcome": "answered", - "answers": [ - { - "questionId": "files", - "selectedOptionIds": ["readme", "config"], - }, - {"questionId": "mode", "selectedOptionIds": ["plan"]}, - ], - } - } - - @pytest.mark.asyncio - async def test_a_prompt_less_question_is_visible_and_answerable(self) -> None: - """Regression: a question with no string ``prompt`` was silently - absent from the summary shown to the user while still being - required by _answer_result, making a complete answer impossible to - construct. It must fall back to a displayable label (its id).""" - tools = DecisionTools() - adapter = CursorACPAdapter() - turn = _turn("room-1", tools, "user-1", "session-1") - adapter._active_turn = turn - - pending = asyncio.create_task( - adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "confirm", - "options": [ - {"id": "yes", "label": "Yes"}, - {"id": "no", "label": "No"}, - ], - } - ] - }, - ) - ) - await tools.prompt_sent.wait() - assert "confirm" in tools.messages[0] - token = next(iter(adapter._pending_decisions)) - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor answer {token} confirm=yes", sender_id="user-1" - ), - ), - tools, - "room-1", - ) - - assert await pending == { - "outcome": { - "outcome": "answered", - "answers": [{"questionId": "confirm", "selectedOptionIds": ["yes"]}], - } - } - - @pytest.mark.asyncio - async def test_a_question_with_no_answerable_options_cancels_the_whole_exchange( - self, - ) -> None: - """Regression: a question with an empty/unparseable options list used - to silently vanish from the projected choices, so the exchange could - be marked 'answered' without Cursor's required question ever being - answered. It must cancel outright instead.""" - adapter = CursorACPAdapter() - turn = _turn("room-1", DecisionTools(), "user-1", "session-1") - - result = await adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "q1", - "prompt": "Pick one", - "options": [{"id": "a", "label": "A"}], - }, - {"id": "q2", "prompt": "Malformed", "options": []}, - ] - }, - ) - - assert result == {"outcome": {"outcome": "cancelled"}} - - @pytest.mark.asyncio - async def test_a_duplicate_question_id_keeps_the_first_occurrence(self) -> None: - """Regression: two questions sharing an id used to silently collapse - to whichever was processed last, discarding the earlier question's - options with no signal.""" - adapter = CursorACPAdapter(CursorACPAdapterConfig(question_mode="auto_first")) - turn = _turn("room-1", DecisionTools(), "user-1", "session-1") - - result = await adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "q1", - "prompt": "First q1", - "options": [{"id": "a", "label": "A"}], - }, - { - "id": "q1", - "prompt": "Second q1", - "options": [{"id": "b", "label": "B"}], - }, - ] - }, - ) - - assert result == { - "outcome": { - "outcome": "answered", - "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], - } - } - - def test_duplicate_question_answer_is_rejected(self) -> None: - result = CursorACPAdapter._answer_result( - ["mode=agent", "mode=plan"], - {"mode": ("agent", "plan")}, - frozenset(), - ) - - assert not isinstance(result, dict) - - @pytest.mark.asyncio - async def test_auto_question_uses_the_first_advertised_option(self) -> None: - adapter = CursorACPAdapter(CursorACPAdapterConfig(question_mode="auto_first")) - turn = _turn("room-1", DecisionTools(), "user-1", "session-1") - - result = await adapter._resolve_question( - turn, - { - "questions": [ - { - "id": "mode", - "prompt": "Choose mode", - "options": [ - {"id": "agent", "label": "Agent"}, - {"id": "plan", "label": "Plan"}, - ], - } - ] - }, - ) - - assert cast(dict[str, object], cast(dict[str, object], result)["outcome"])[ - "answers" - ] == [{"questionId": "mode", "selectedOptionIds": ["agent"]}] - - @pytest.mark.asyncio - async def test_manual_permission_can_be_denied_from_the_room(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - adapter._active_turn = _turn("room-1", tools, "user-1", "session-1") - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall("call-1", "shell", {}), - options=( - PermissionOption( - optionId="allow-once", name="Allow", kind="allow_once" - ), - ), - ) - pending = asyncio.create_task(adapter._resolve_cursor_permission(request)) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace(content=f"/cursor deny {token}", sender_id="user-1"), - ), - tools, - "room-1", - ) - - assert await pending is None - - @pytest.mark.asyncio - async def test_manual_permission_can_select_an_advertised_option(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - adapter._active_turn = _turn("room-1", tools, "user-1", "session-1") - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall("call-1", "shell", {}), - options=( - PermissionOption( - optionId="allow-once", name="Allow", kind="allow_once" - ), - ), - ) - pending = asyncio.create_task(adapter._resolve_cursor_permission(request)) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor select {token} allow-once", sender_id="user-1" - ), - ), - tools, - "room-1", - ) - - assert await pending == "allow-once" - - @pytest.mark.asyncio - async def test_an_always_grant_settles_a_parallel_repeat_without_asking( - self, - ) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - adapter._active_turn = _turn("room-1", tools, "user-1", "session-1") - - def request(call_id: str) -> ACPPermissionRequest: - return ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall(call_id, "`echo x >> out.txt`", {}), - options=( - PermissionOption( - optionId="allow-once", name="Allow once", kind="allow_once" - ), - PermissionOption( - optionId="allow-always", - name="Allow always", - kind="allow_always", - ), - ), - ) - - first = asyncio.create_task(adapter._resolve_cursor_permission(request("a"))) - repeat = asyncio.create_task(adapter._resolve_cursor_permission(request("b"))) - await tools.prompt_sent.wait() - [token] = adapter._pending_decisions - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor select {token} allow-always", sender_id="user-1" - ), - ), - tools, - "room-1", - ) - - assert await asyncio.gather(first, repeat) == ["allow-always"] * 2 - assert len(permission_requests(tools)) == 1 - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("plan_mode", "outcome"), - [("auto_accept", "accepted"), ("auto_decline", "rejected")], - ) - async def test_automatic_plan_policy_returns_its_outcome( - self, plan_mode: str, outcome: str - ) -> None: - adapter = CursorACPAdapter( - CursorACPAdapterConfig.model_validate({"plan_mode": plan_mode}) - ) - - result = await adapter._resolve_plan( - _turn("room-1", DecisionTools(), "user-1", "session-1"), - {"plan": "Plan"}, - ) - - assert result == {"outcome": {"outcome": outcome}} - - @pytest.mark.asyncio - async def test_manual_plan_accepts_a_room_command(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - pending = asyncio.create_task( - adapter._resolve_plan( - _turn("room-1", tools, "user-1", "session-1"), - {"plan": "Plan"}, - ) - ) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - assert tools.messages == [ - PLAN_REQUESTED_TEMPLATE.format(plan="Plan", token=token) - ] - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace(content=f"/cursor accept {token}", sender_id="user-1"), - ), - tools, - "room-1", - ) - - assert await pending == {"outcome": {"outcome": "accepted"}} - - @pytest.mark.asyncio - async def test_automatic_permission_policies_use_offered_options(self) -> None: - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall("call-1", "shell", {}), - options=( - PermissionOption( - optionId="allow-once", name="Allow", kind="allow_once" - ), - ), - ) - - accepted = CursorACPAdapter(CursorACPAdapterConfig(approval_mode="auto_accept")) - declined = CursorACPAdapter( - CursorACPAdapterConfig(approval_mode="auto_decline") - ) - - assert await accepted._resolve_cursor_permission(request) == "allow-once" - assert await declined._resolve_cursor_permission(request) is None - - @pytest.mark.asyncio - async def test_decision_delivery_failure_cleans_up_the_pending_token(self) -> None: - adapter = CursorACPAdapter() - - result = await adapter._wait_for_decision( - kind="plan", - turn=_turn("room-1", FailingDecisionTools(), "user-1", "session-1"), - prompt="Plan {token}", - ) - - assert result is None - assert len(adapter._pending_decisions) == 0 - - @pytest.mark.asyncio - @pytest.mark.looptime - async def test_timeout_notice_failure_still_cancels_the_decision(self) -> None: - adapter = CursorACPAdapter() - - result = await adapter._wait_for_decision( - kind="plan", - turn=_turn( - "room-1", FailingDecisionTools(fail_after=1), "user-1", "session-1" - ), - prompt="Plan {token}", - ) - - assert result is None - assert len(adapter._pending_decisions) == 0 - - @pytest.mark.asyncio - async def test_a_reply_that_claims_while_the_prompt_send_fails_still_wins( - self, - ) -> None: - """`/cursor decisions` lists a token before its prompt lands; a reply - claiming it while that send fails owns the answer.""" - tools = DecisionTools() - failing_prompt = tools.hold_message("Plan ", error=RuntimeError("network down")) - adapter = CursorACPAdapter() - pending = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-1", tools, "user-1", "session-1"), - prompt="Plan {token}", - ) - ) - - async with failing_prompt: - token = next(iter(adapter._pending_decisions)) - await adapter._handle_control_message( - room_message(f"/cursor accept {token}"), tools, "room-1" - ) - - assert await pending == {"outcome": {"outcome": "accepted"}} - assert tools.messages == [f"Cursor plan decision `{token}` resolved."] - - @pytest.mark.asyncio - @pytest.mark.looptime - async def test_a_late_reply_during_the_timeout_notice_is_not_reported_as_resolved( - self, - ) -> None: - """A room reply for a token whose timeout notice is still being sent - must be told "not pending" -- the timeout already cancelled it.""" - tools = DecisionTools() - timeout_notice = tools.hold_message("timed out") - adapter = CursorACPAdapter() - pending = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-1", tools, "user-1", "session-1"), - prompt="Plan {token}", - ) - ) - await tools.prompt_sent.wait() - [token] = [entry.token for entry in adapter._pending_decisions.unclaimed()] - - async with timeout_notice: - await adapter._handle_control_message( - room_message(f"/cursor accept {token}"), tools, "room-1" - ) - assert tools.messages[-1] == f"Cursor decision `{token}` is not pending." - - assert await pending is None - - @pytest.mark.asyncio - async def test_a_reply_for_an_already_claimed_token_is_told_not_pending( - self, - ) -> None: - """The second claim-guard gap the shared registry closes: nothing in - the original code stopped _handle_control_message from resolving a - token something else (a timeout, in production) had already claimed - a moment earlier -- only the timeout-notice-send window above was - guarded. Simulate that by claiming the token directly before the - room command arrives: the command must report "not pending", never - touch the future, and never say "resolved".""" - tools = DecisionTools() - adapter = CursorACPAdapter() - turn = _turn("room-1", tools, "user-1", "session-1") - - pending_task = asyncio.create_task( - adapter._wait_for_decision(kind="plan", turn=turn, prompt="Plan {token}") - ) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - - claimed = adapter._pending_decisions.try_claim(token) - assert claimed is not None - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace(content=f"/cursor accept {token}", sender_id="user-1"), - ), - tools, - "room-1", - ) - - assert handled is True - assert tools.messages[-1] == f"Cursor decision `{token}` is not pending." - assert not claimed.payload.future.done() - - claimed.payload.future.set_result(None) - assert await pending_task is None - - @pytest.mark.asyncio - async def test_room_cleanup_cancels_only_its_pending_decision(self) -> None: - first_tools = DecisionTools() - second_tools = DecisionTools() - adapter = CursorACPAdapter() - first = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-1", first_tools, "user-1", "session-1"), - prompt="Plan {token}", - ) - ) - second = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-2", second_tools, "user-2", "session-2"), - prompt="Plan {token}", - ) - ) - await asyncio.gather( - first_tools.prompt_sent.wait(), second_tools.prompt_sent.wait() - ) - tokens = { - entry.payload.room_id: entry.token - for entry in adapter._pending_decisions.unclaimed() - } - - await adapter.on_cleanup("room-1") - - assert await first is None - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor accept {tokens['room-1']}", sender_id="user-1" - ), - ), - first_tools, - "room-1", - ) - assert first_tools.messages[-1] == ( - f"Cursor decision `{tokens['room-1']}` is not pending." - ) - - await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace( - content=f"/cursor accept {tokens['room-2']}", sender_id="user-2" - ), - ), - second_tools, - "room-2", - ) - - assert await second == {"outcome": {"outcome": "accepted"}} - - @pytest.mark.asyncio - async def test_a_room_reply_resolves_the_decision_it_was_sent_for( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - """on_message must return once a decision opens, not after the whole - turn -- otherwise the very reply meant to resolve it would queue - behind the still-open turn and every manual decision would time - out. The room delivers both messages sequentially, as - ExecutionContext does, to a real ACP turn.""" - room = await cursor_room( - FakeACPAgent() - .will_ask_permission(title="shell", options=allow_or_reject("allow-once")) - .will_call_mcp_tool( - "reply", - "band_send_message", - arguments={"content": "ran it", "mentions": ["@alice"]}, - ) - ) - - turn = await room.send("run it") - [prompt] = said(turn) - reply = await room.send(f"/cursor select {decision_token(prompt)} allow-once") - await turn.until_said("ran it") - - assert room.agent.approved is True - assert said(reply) == [ - f"Cursor permission decision `{decision_token(prompt)}` resolved." - ] - - -class TestCursorACPAdapterDetachedTurn: - """A turn parked on a decision releases its message early, so it is - judged at its real end instead of when ``on_event`` returns.""" - - @pytest.mark.asyncio - async def test_a_detached_turn_that_ends_with_nothing_is_reported_after_release( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - room = await cursor_room( - FakeACPAgent().will_ask_permission( - title="shell", options=allow_or_reject("allow-once") - ) - ) - - # on_event returning normally is what keeps the delivery PROCESSED. - turn = await room.send("run it") - [prompt] = said(turn) - assert failure_reports(turn) == [] - - await room.send(f"/cursor deny {decision_token(prompt)}") - await room.turns_finished() - - assert failure_reports(turn) == [MISSING_REPLY_FAILURE] - - @pytest.mark.asyncio - async def test_a_decision_reply_settles_on_its_own_tools( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - """A decision reply is a whole turn of its own: its notice settles - that message's tools, and neither it nor the decision prompt stands - in for the parked turn's answer, which still relays there.""" - room = await cursor_room( - FakeACPAgent() - .will_ask_permission(title="shell", options=allow_or_reject("allow-once")) - .will_call_mcp_tool( - "reply", - "band_send_message", - arguments={"content": "ran it", "mentions": ["@alice"]}, - ) - ) - turn = await room.send("run it") - [prompt] = said(turn) - - reply = await room.send(f"/cursor select {decision_token(prompt)} allow-once") - await room.turns_finished() - - assert reply.turn.complete - assert not reply.turn.replied - assert failure_reports(reply) == [] - assert said(turn) == [prompt, "ran it"] - assert failure_reports(turn) == [] - - @pytest.mark.asyncio - async def test_a_turn_cancelled_by_cleanup_is_not_judged( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - room = await cursor_room( - FakeACPAgent().will_ask_permission( - title="shell", options=allow_or_reject("allow-once") - ) - ) - turn = await room.send("run it") - - await room.adapter.on_cleanup("room-1") - await room.turns_finished() - - # The stopped connection fails the turn with the ACP error it raised. - assert MISSING_REPLY_FAILURE not in failure_reports(turn) - - @pytest.mark.asyncio - async def test_an_interrupted_detached_turn_is_not_reported( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - """A room /stop ends the parked turn; the adapter settled it.""" - room = await cursor_room( - FakeACPAgent().will_ask_permission( - title="shell", options=allow_or_reject("allow-once") - ) - ) - turn = await room.send("run it") - - await room.adapter.on_interrupt("room-1", ControlMode.STOP) - await room.turns_finished() - - assert failure_reports(turn) == [] - - @pytest.mark.asyncio - async def test_a_cancelled_detached_turn_posts_nothing( - self, cursor_room: Callable[..., Awaitable[CursorRoom]] - ) -> None: - room = await cursor_room( - FakeACPAgent().will_ask_permission( - title="shell", options=allow_or_reject("allow-once") - ) - ) - turn = await room.send("run it") - [prompt] = said(turn) - - for task in room.adapter._background_tasks: - task.cancel() - await room.turns_finished() - - assert said(turn) == [prompt] - assert failure_reports(turn) == [] - - -class TestCursorACPAdapterControlMessages: - @pytest.mark.asyncio - async def test_bare_cursor_lists_pending_decisions(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, SimpleNamespace(content="/cursor", sender_id="user-1") - ), - tools, - "room-1", - ) - - assert handled is True - assert tools.messages[-1] == "Pending Cursor decisions: none" - - @pytest.mark.asyncio - async def test_cursor_decisions_lists_pending_decisions(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace(content="/cursor decisions", sender_id="user-1"), - ), - tools, - "room-1", - ) - - assert handled is True - assert tools.messages[-1] == "Pending Cursor decisions: none" - - @pytest.mark.asyncio - async def test_a_two_word_command_shows_usage(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, - SimpleNamespace(content="/cursor accept", sender_id="user-1"), - ), - tools, - "room-1", - ) - - assert handled is True - assert tools.messages[-1] == ( - "Use `/cursor decisions` to list pending Cursor decisions." - ) - - @pytest.mark.asyncio - async def test_a_structurally_invalid_decision_command_is_rejected(self) -> None: - tools = DecisionTools() - adapter = CursorACPAdapter() - pending = asyncio.create_task( - adapter._resolve_plan( - _turn("room-1", tools, "user-1", "session-1"), - {"plan": "Plan"}, - ) - ) - await tools.prompt_sent.wait() - token = next(iter(adapter._pending_decisions)) - - handled = await adapter._handle_control_message( - cast( - PlatformMessage, - # "select" is a permission verb, not a plan verb. - SimpleNamespace( - content=f"/cursor select {token} allow-once", sender_id="user-1" - ), - ), - tools, - "room-1", - ) - - assert handled is True - assert tools.messages[-1] == ( - f"That command is not valid for Cursor plan decision `{token}`." - ) - assert not pending.done() - adapter._cancel_all_decisions() - await pending - - @pytest.mark.asyncio - async def test_manual_permission_denies_silently_with_no_matching_active_turn( - self, - ) -> None: - """No active turn for this room/session means there is no room to - relay the decision to; it must deny without sending anything.""" - adapter = CursorACPAdapter() - request = ACPPermissionRequest( - room_id="room-1", - session_id="session-1", - tool_call=ACPToolCall("call-1", "shell", {}), - options=( - PermissionOption( - optionId="allow-once", name="Allow", kind="allow_once" - ), - ), - ) - - result = await adapter._resolve_cursor_permission(request) - - assert result is None - - @pytest.mark.asyncio - async def test_exceeding_max_pending_decisions_evicts_the_oldest(self) -> None: - adapter = CursorACPAdapter(CursorACPAdapterConfig(max_pending_decisions=1)) - first_tools = DecisionTools() - second_tools = DecisionTools() - - first = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-1", first_tools, "user-1", "session-1"), - prompt="Plan {token}", - ) - ) - await first_tools.prompt_sent.wait() - assert len(adapter._pending_decisions) == 1 - - second = asyncio.create_task( - adapter._wait_for_decision( - kind="plan", - turn=_turn("room-2", second_tools, "user-2", "session-2"), - prompt="Plan {token}", - ) - ) - await second_tools.prompt_sent.wait() - - assert await first is None - assert len(adapter._pending_decisions) == 1 - adapter._cancel_all_decisions() - await second - - -async def leave_the_room(adapter: CursorACPAdapter, session: AcpSession) -> None: - await adapter.on_cleanup("room-1") - - -async def replace_its_band_server( - adapter: CursorACPAdapter, session: AcpSession -) -> None: - await crash_backend(adapter._mcp) - await session.send("after the crash", room="room-1") - await adapter._drain_background_tasks() - - -@pytest.mark.asyncio -@pytest.mark.parametrize("release", [leave_the_room, replace_its_band_server]) -async def test_a_released_sessions_todos_are_forgotten( - release: Callable[[CursorACPAdapter, AcpSession], Awaitable[None]], -) -> None: - agent = FakeACPAgent().will_update_cursor_todos("ship it").will_say("ok") - adapter = CursorACPAdapter( - CursorACPAdapterConfig(command="fake-agent", inject_band_tools=True) - ) - - async with started_acp_adapter(adapter, agent) as session: - await session.send("plan it", room="room-1") - released = session.session_id("room-1") - await release(adapter, session) - - assert released not in adapter._cursor_profile._todos_by_session - - -@pytest.mark.asyncio -async def test_a_turn_still_running_at_cleanup_leaves_no_todos() -> None: - """A turn left running detached keeps updating todos until the runtime's - stop closes its connection; none of that may outlive the cleanup.""" - agent = FakeACPAgent() - updating = agent.keeps_updating_cursor_todos() - adapter = CursorACPAdapter(CursorACPAdapterConfig(command="fake-agent")) - - async with started_acp_adapter(adapter, agent) as session: - turn = asyncio.create_task(session.send("plan it", room="room-1")) - await updating.wait() - released = session.session_id("room-1") - await adapter.on_cleanup("room-1") - - assert released not in adapter._cursor_profile._todos_by_session - turn.cancel() - await asyncio.gather(turn, return_exceptions=True) - - -@pytest.mark.asyncio -async def test_a_cleanup_cancelled_mid_stop_still_releases_the_session() -> None: - """A rejoined room gets fresh context while its old runtime is exiting; - cancellation still releases the retired session's todo state.""" - agent = FakeACPAgent().will_update_cursor_todos("ship it").will_say("ok") - exiting = agent.exits_slowly() - adapter = CursorACPAdapter(CursorACPAdapterConfig(command="fake-agent")) - - async with started_acp_adapter(adapter, agent) as session: - await session.send("plan it", room="room-1") - released = session.session_id("room-1") - cleanup = asyncio.create_task(adapter.on_cleanup("room-1")) - await exiting.received.wait() - - await session.send("rejoin the room", room="room-1") - assert session.session_id("room-1") != released - assert "[System Context]" in agent.prompt_texts()[-1] - cleanup.cancel() - await asyncio.gather(cleanup, return_exceptions=True) - assert released not in adapter._cursor_profile._todos_by_session - exiting.release.set() - - -class SetupGatedAgent(FakeACPAgent): - def __init__(self) -> None: - super().__init__() - self.setup = Gate() - - async def new_session( - self, cwd: str, mcp_servers: Any = None, **kwargs: Any - ) -> NewSessionResponse: - self.setup.received.set() - await self.setup.release.wait() - return await super().new_session(cwd, mcp_servers, **kwargs) - - -@pytest.mark.asyncio -async def test_interrupt_during_session_setup_prevents_prompt( - cursor_room: Callable[..., Awaitable[CursorRoom]], -) -> None: - agent = SetupGatedAgent() - room = await cursor_room(agent) - sending = asyncio.create_task(room.send("start")) - try: - await agent.setup.received.wait() - await room.adapter.on_interrupt("room-1", ControlMode.STOP) - agent.setup.release.set() - await asyncio.gather(sending, return_exceptions=True) - await room.turns_finished() - finally: - agent.setup.release.set() - await asyncio.gather(sending, return_exceptions=True) - assert agent.prompts == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize("approval_mode", ["manual", "auto_decline"]) -async def test_registered_native_title_uses_configured_permission_policy( - cursor_approval_room: Callable[[str, str], Awaitable[CursorRoom]], - approval_mode: str, -) -> None: - room = await cursor_approval_room("echo", approval_mode) - tools = await room.send("run native echo") - await room.turns_finished() - assert room.agent.approved is False - assert len(permission_requests(tools)) == (1 if approval_mode == "manual" else 0) - - -@pytest.mark.asyncio -async def test_foreign_mcp_identity_cannot_use_a_band_display_title( - cursor_room: Callable[..., Awaitable[CursorRoom]], -) -> None: - agent = FakeACPAgent() - - @agent.on_prompt - async def ask_foreign_permission(peer: FakeACPAgent, session_id: str) -> None: - response = await peer.ask_permission( - session_id, - ToolCallUpdate( - toolCallId="foreign", - title="band-band_send_message: band_send_message", - rawInput={ - "providerIdentifier": "foreign", - "toolName": BandTool.SEND_MESSAGE, - "args": {}, - }, - ), - [ - PermissionOption( - optionId="once", name="Allow once", kind=ALLOW_ONCE_KIND - ) - ], - ) - peer.approved = isinstance(response.outcome, AllowedOutcome) - await peer.call_mcp_tool( - session_id=session_id, - server=BAND_MCP_SERVER_NAME, - tool_name=BandTool.SEND_MESSAGE, - arguments={"content": "finished", "mentions": ["@user-1"]}, - ) - - room = await cursor_room(agent, approval_mode="auto_decline") - await room.send("run") - await room.turns_finished() - assert agent.approved is False diff --git a/tests/adapters/test_omp_acp_adapter.py b/tests/adapters/test_omp_acp_adapter.py index ba052389f..b1402c093 100644 --- a/tests/adapters/test_omp_acp_adapter.py +++ b/tests/adapters/test_omp_acp_adapter.py @@ -22,7 +22,7 @@ OmpACPCollectingClient, ) from band.integrations.acp.client_adapter import ACPPermissionRequest -from band.integrations.acp.client_types import ACPClientSessionState +from band.integrations.acp.client_types import ACPRoom from band.integrations.acp.room_emitter import RoomTurnEmitter from band.integrations.acp.types import ChunkType, CollectedChunk from band.integrations.omp import ( @@ -37,8 +37,17 @@ XD_MCP_PREFIX, ) from band.testing import FakeAgentTools -from tests.integrations.acp.acp_toolkit import launch_for -from tests.integrations.acp.conftest import make_platform_message +from tests.integrations.acp.acp_toolkit import ( + inject_acp_spawn, + launch_for, + room_holder, +) +from tests.integrations.acp.acp_toolkit.harness import ( + SCRIPTED_SESSION, + scripted_session_spawn, + send_to, + torn_down, +) def omp_in( @@ -189,7 +198,7 @@ def _echo(text: str) -> str: def test_runtime_client_factory_is_omp_collecting_client(self) -> None: adapter = OmpACPAdapter() - client = adapter._runtime_client_factory() + client = adapter._runtime_client_factory(room_holder(adapter), 1) assert isinstance(client, OmpACPCollectingClient) @@ -224,7 +233,7 @@ def test_runtime_omits_subprocess_cwd_but_keeps_the_omp_cwd_flag( ) -> None: adapter = OmpACPAdapter() - runtime = adapter._build_runtime(str(tmp_path)) + runtime = adapter._build_runtime(ACPRoom("room-1", workspace=str(tmp_path)), 1) assert runtime._cwd is None assert f"--cwd={tmp_path}" in runtime._command @@ -322,7 +331,7 @@ async def approve(_request: ACPPermissionRequest) -> str: return OMP_APPROVE_OPTION_ID adapter._resolve_permission = approve - client = adapter._runtime_client_factory() + client = adapter._runtime_client_factory(room_holder(adapter), 1) handler = adapter._make_elicitation_handler(MagicMock(), "room-1", "sess-live") assert handler is not None client.set_elicitation_handler("sess-live", handler) @@ -400,7 +409,7 @@ async def deny(_request: ACPPermissionRequest) -> None: adapter._resolve_permission = deny emitter = MagicMock() emitter.open_permission = AsyncMock() - client = adapter._runtime_client_factory() + client = adapter._runtime_client_factory(room_holder(adapter), 1) handler = adapter._make_elicitation_handler(emitter, "room-1", "sess-deny") assert handler is not None client.set_elicitation_handler("sess-deny", handler) @@ -431,26 +440,13 @@ class TestOmpElicitationHandlerWiring: async def test_elicitation_handler_wired_on_message(self) -> None: """``on_message`` must register the OMP form elicitation handler.""" adapter = OmpACPAdapter(OmpACPAdapterConfig(inject_band_tools=False)) - runtime = await adapter._runtime_for("room-123") - runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "acp-session-123" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._conn.prompt = AsyncMock() - runtime._client = adapter._runtime_client_factory() + inject_acp_spawn(adapter, scripted_session_spawn()) - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - assert "acp-session-123" in runtime._client._elicitation_handlers + async with torn_down(adapter): + await send_to(adapter, "room-123") + client = adapter._rooms["room-123"].runtime.client + + assert SCRIPTED_SESSION in client._elicitation_handlers class TestOmpDeterministicMcpReply: diff --git a/tests/adapters/test_room_workspace_isolation.py b/tests/adapters/test_room_workspace_isolation.py index 19f666bd8..dea08c437 100644 --- a/tests/adapters/test_room_workspace_isolation.py +++ b/tests/adapters/test_room_workspace_isolation.py @@ -5,212 +5,157 @@ import os from collections.abc import Callable from pathlib import Path -from typing import cast import pytest -from band.adapters.codex import CodexAdapter, CodexAdapterConfig, CodexSessionState -from band.core.protocols import AgentToolsProtocol +from band.adapters.codex import CodexAdapter, CodexAdapterConfig from band.integrations.acp.client_adapter import ( ACPClientAdapter, ACPClientAdapterConfig, ) from band.integrations.codex import CodexRequestMethod -from band.testing import FakeAgentTools -from band.workspaces import is_host_absolute, resolve_room_workspace - - -def test_default_workspace_is_created_per_room( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - - first = resolve_room_workspace("room-a", None) - second = resolve_room_workspace("room-b", None) - - assert [first, second] == [ - str(tmp_path / ".band-workspaces" / "room-a"), - str(tmp_path / ".band-workspaces" / "room-b"), - ] - assert Path(first).is_dir() - assert Path(second).is_dir() - - -@pytest.mark.parametrize("spell", [str, Path.as_posix], ids=["native", "posix"]) -def test_custom_workspace_is_created_on_first_resolution( - tmp_path: Path, spell: Callable[[Path], str] -) -> None: - workspace = tmp_path / "nested" / "room-a" - - resolved = resolve_room_workspace("room-a", lambda _room_id: spell(workspace)) - - assert Path(resolved) == workspace - assert workspace.is_dir() - - -@pytest.mark.parametrize( - ("path", "accepted_on"), - [ - pytest.param("workspace", set(), id="relative"), - pytest.param("/workspace", {"posix"}, id="rooted-without-drive"), - pytest.param(r"\workspace", set(), id="backslash-rooted"), - pytest.param(r"C:\workspace", {"nt"}, id="drive"), - pytest.param("C:/workspace", {"nt"}, id="drive-forward-slashes"), - pytest.param(r"C:workspace", set(), id="drive-relative"), - pytest.param(r"\\server\share\workspace", {"nt"}, id="unc"), - ], +from band.workspaces import RoomWorkspaces, is_host_absolute, resolve_room_workspace +from tests.adapters.codex.kit import ( + answered_turn, + patch_codex_clients_by_room, + send_bootstrap, ) -def test_is_host_absolute_follows_the_running_os( - path: str, accepted_on: set[str] -) -> None: - assert is_host_absolute(path) is (os.name in accepted_on) - - -@pytest.mark.asyncio -async def test_adapters_refuse_a_relative_workspace( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.chdir(tmp_path) - codex = CodexAdapter( - CodexAdapterConfig(workspace_for_room=lambda _room_id: "workspace") - ) - acp = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), - workspace_for_room=lambda _room_id: "workspace", +from tests.adapters.codexturns import FakeCodexClient + + +class TestRoomWorkspaceIsolation: + def test_default_workspace_is_created_per_room( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.chdir(tmp_path) + + first = resolve_room_workspace("room-a", None) + second = resolve_room_workspace("room-b", None) + + assert [first, second] == [ + str(tmp_path / ".band-workspaces" / "room-a"), + str(tmp_path / ".band-workspaces" / "room-b"), + ] + assert Path(first).is_dir() + assert Path(second).is_dir() + + @pytest.mark.parametrize("spell", [str, Path.as_posix], ids=["native", "posix"]) + def test_custom_workspace_is_created_on_first_resolution( + self, tmp_path: Path, spell: Callable[[Path], str] + ) -> None: + workspace = tmp_path / "nested" / "room-a" + + resolved = resolve_room_workspace("room-a", lambda _room_id: spell(workspace)) + + assert Path(resolved) == workspace + assert workspace.is_dir() + + @pytest.mark.parametrize( + ("path", "accepted_on"), + [ + pytest.param("workspace", set(), id="relative"), + pytest.param("/workspace", {"posix"}, id="rooted-without-drive"), + pytest.param(r"\workspace", set(), id="backslash-rooted"), + pytest.param(r"C:\workspace", {"nt"}, id="drive"), + pytest.param("C:/workspace", {"nt"}, id="drive-forward-slashes"), + pytest.param(r"C:workspace", set(), id="drive-relative"), + pytest.param(r"\\server\share\workspace", {"nt"}, id="unc"), + ], ) + def test_is_host_absolute_follows_the_running_os( + self, path: str, accepted_on: set[str] + ) -> None: + assert is_host_absolute(path) is (os.name in accepted_on) + + def test_adapters_refuse_a_relative_workspace( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.chdir(tmp_path) + codex = CodexAdapter( + CodexAdapterConfig(workspace_for_room=lambda _room_id: "workspace") + ) + acp = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), + workspace_for_room=lambda _room_id: "workspace", + ) - with pytest.raises(ValueError, match="must return an absolute path"): - codex._room_client("room-a") - with pytest.raises(ValueError, match="must return an absolute path"): - await acp._runtime_for("room-a") - assert not (tmp_path / "workspace").exists() - - -@pytest.mark.asyncio -async def test_adapters_reject_a_custom_workspace_shared_by_live_rooms( - tmp_path: Path, -) -> None: - workspace = str(tmp_path / "workspace") - - def resolver(_room_id: str) -> str: - return workspace - - codex = CodexAdapter(CodexAdapterConfig(workspace_for_room=resolver)) - acp = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), workspace_for_room=resolver - ) - - codex._room_client("room-a") - await acp._runtime_for("room-a") - - with pytest.raises(ValueError, match="both 'room-a' and 'room-b'"): - codex._room_client("room-b") - with pytest.raises(ValueError, match="both 'room-a' and 'room-b'"): - await acp._runtime_for("room-b") - - -@pytest.mark.asyncio -async def test_model_override_survives_codex_client_rebuild() -> None: - class Client: - def __init__(self, model: str) -> None: - self.model = model - self.model_list_calls = 0 - - async def connect(self) -> None: - return None - - async def initialize(self, **_kwargs: object) -> None: - return None - - async def request( - self, method: str, _params: dict[str, object] - ) -> dict[str, object]: - assert method == CodexRequestMethod.MODEL_LIST - self.model_list_calls += 1 - return {"data": [{"id": self.model, "hidden": False}]} - - async def close(self) -> None: - return None - - clients = [Client("auto-model-a"), Client("auto-model-b")] - - def build_client(_config: object) -> Client: - return clients.pop(0) - - adapter = CodexAdapter(CodexAdapterConfig(model=None)) - adapter._build_client = build_client # type: ignore[method-assign] - adapter._room_client("room-a") - adapter._active_room.set("room-a") - state = adapter._require_active_client_state() - state.model_override = "room-model" - - await adapter._ensure_client_ready() - adapter._client = None - adapter._initialized = False - await adapter._ensure_client_ready() - - assert adapter._selected_model == "room-model" - assert clients == [] - - -def test_codex_rejects_the_former_shared_cwd_option() -> None: - with pytest.raises(ValueError, match="workspace_for_room or the default"): - CodexAdapter(CodexAdapterConfig(cwd="/workspace")) - - -def test_codex_config_ignores_an_ambient_codex_cwd_env_var( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """``CODEX_CWD`` is a plausible ambient env var (a Codex CLI convention, - this repo's own E2E harness) unrelated to this banned kwarg; it must never - silently populate ``cwd`` and trip the rejection above.""" - monkeypatch.setenv("CODEX_CWD", "/some/unrelated/directory") - - config = CodexAdapterConfig(workspace_for_room=lambda _room_id: "/tmp/x") - - assert config.cwd is None - CodexAdapter(config) # must not raise - - -@pytest.mark.asyncio -async def test_codex_starts_each_thread_in_its_room_workspace(tmp_path: Path) -> None: - class Client: - def __init__(self) -> None: - self.params: dict[str, object] | None = None + with pytest.raises(ValueError, match="must return an absolute path"): + codex._room_client("room-a") + with pytest.raises(ValueError, match="must return an absolute path"): + acp._room("room-a") + assert not (tmp_path / "workspace").exists() - async def request( - self, method: str, params: dict[str, object] - ) -> dict[str, object]: - assert method == CodexRequestMethod.THREAD_START - self.params = params - return {"thread": {"id": "thread"}} + def test_adapters_reject_a_custom_workspace_shared_by_live_rooms( + self, + tmp_path: Path, + ) -> None: + workspace = str(tmp_path / "workspace") - def workspace_for_room(room_id: str) -> str: - return str(tmp_path / room_id) + def resolver(_room_id: str) -> str: + return workspace - adapter = CodexAdapter( - CodexAdapterConfig( - model="gpt-5.5", - workspace_for_room=workspace_for_room, + codex = CodexAdapter(CodexAdapterConfig(workspace_for_room=resolver)) + acp = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), workspace_for_room=resolver ) - ) - tools = cast(AgentToolsProtocol, FakeAgentTools()) - clients: list[Client] = [] - for room_id in ("room-a", "room-b"): - adapter._room_client(room_id) - adapter._active_room.set(room_id) - client = Client() - adapter._client = client # type: ignore[assignment] - adapter._selected_model = "gpt-5.5" - clients.append(client) - await adapter._ensure_thread( - room_id=room_id, - history=CodexSessionState(), - tools=tools, - is_session_bootstrap=False, + + codex._room_client("room-a") + acp._room("room-a") + + with pytest.raises(ValueError, match="both 'room-a' and 'room-b'"): + codex._room_client("room-b") + with pytest.raises(ValueError, match="both 'room-a' and 'room-b'"): + acp._room("room-b") + + def test_a_failed_room_setup_gives_its_workspace_back(self, tmp_path: Path) -> None: + workspaces = RoomWorkspaces(lambda _room_id: str(tmp_path / "workspace")) + + with pytest.raises(RuntimeError), workspaces.claiming("room-a"): + raise RuntimeError("room setup failed") + + workspaces.claim("room-b") + + assert workspaces.rooms == ("room-b",) + + def test_codex_rejects_the_former_shared_cwd_option(self) -> None: + with pytest.raises(ValueError, match="workspace_for_room or the default"): + CodexAdapter(CodexAdapterConfig(cwd="/workspace")) + + def test_codex_config_ignores_an_ambient_codex_cwd_env_var( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """``CODEX_CWD`` is a plausible ambient env var (a Codex CLI convention, + this repo's own E2E harness) unrelated to this banned kwarg; it must never + silently populate ``cwd`` and trip the rejection above.""" + monkeypatch.setenv("CODEX_CWD", "/some/unrelated/directory") + + config = CodexAdapterConfig(workspace_for_room=lambda _room_id: "/tmp/x") + + assert config.cwd is None + CodexAdapter(config) # must not raise + + @pytest.mark.asyncio + async def test_codex_starts_each_thread_in_its_room_workspace( + self, tmp_path: Path + ) -> None: + def workspace_for_room(room_id: str) -> str: + return str(tmp_path / room_id) + + rooms = ("room-a", "room-b") + clients = { + room_id: FakeCodexClient(events=answered_turn()) for room_id in rooms + } + adapter = CodexAdapter( + CodexAdapterConfig(workspace_for_room=workspace_for_room) ) + patch_codex_clients_by_room(adapter, clients) + await adapter.on_started("Codex Agent", "A coding agent") + + for room_id in rooms: + await send_bootstrap(adapter, room_id=room_id) - assert [client.params["cwd"] for client in clients if client.params] == [ - resolve_room_workspace("room-a", workspace_for_room), - resolve_room_workspace("room-b", workspace_for_room), - ] + assert [ + clients[room_id].params_of(CodexRequestMethod.THREAD_START)[0]["cwd"] + for room_id in rooms + ] == [resolve_room_workspace(room_id, workspace_for_room) for room_id in rooms] diff --git a/tests/adapters/test_workspace_lifecycle.py b/tests/adapters/test_workspace_lifecycle.py deleted file mode 100644 index a52aee021..000000000 --- a/tests/adapters/test_workspace_lifecycle.py +++ /dev/null @@ -1,380 +0,0 @@ -"""One workspace ownership contract through both adapters and real children.""" - -from __future__ import annotations - -import asyncio -import sys -from collections.abc import AsyncIterator, Mapping, Sequence -from dataclasses import dataclass, field -from datetime import UTC, datetime -from pathlib import Path - -import pytest -import pytest_asyncio -from claude_agent_sdk import ClaudeAgentOptions, CLIConnectionError - -from band.adapters.claude_sdk import ( - ClaudeApprovalOptions, - ClaudeSDKAdapter, - ClaudeSDKAdapterConfig, -) -from band.adapters.codex import CodexAdapter, CodexAdapterConfig -from band.core.exceptions import InputNotAccepted -from band.core.protocols import TurnResultAlreadyReported -from band.core.types import AgentInput, HistoryProvider, PlatformMessage -from band.integrations.claude_sdk import transport -from band.integrations.codex import CodexStdioClient -from tests.adapters.claude_sdk.fakecli import Hold -from tests.adapters.claude_sdk.process import WorkspacePeer -from tests.adapters.roompeer import PeerCommand, write_instruction -from tests.framework_conformance.turnprobes import DispatchingFakeTools -from tests.paths import REPO_ROOT - - -class CodexPeer(CodexStdioClient): - """Inject a failed or held close at the real transport boundary.""" - - refuse_close = False - closing: Hold | None = None - - async def _close_process(self) -> None: - if self.closing is not None: - self.closing.reached.set() - await self.closing.released.wait() - if self.refuse_close: - raise RuntimeError("subprocess cleanup failed") - await super()._close_process() - - @property - def exited(self) -> bool: - return self._proc is not None and self._proc.returncode is not None - - -class ClaudePeer(WorkspacePeer): - closing: Hold | None = None - - async def close(self) -> None: - if self.closing is not None: - self.closing.reached.set() - await self.closing.released.wait() - await super().close() - - -@dataclass -class WorkspaceHost: - adapter: CodexAdapter | ClaudeSDKAdapter - root: Path - fail_startup: Path - hold_startup: Path - shared: bool = True - children: list[CodexPeer | ClaudePeer] = field(default_factory=list) - - def workspace(self, room_id: str) -> str: - return str(self.root / ("shared" if self.shared else room_id)) - - async def send(self, room_id: str, content: str) -> list[str]: - tools = DispatchingFakeTools(room_id=room_id) - await self.adapter.on_event( - AgentInput( - msg=PlatformMessage( - id=content, - room_id=room_id, - content=content, - sender_id="user", - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ), - tools=tools, - history=HistoryProvider(raw=[]), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id=room_id, - ) - ) - return [message["content"] for message in tools.messages_sent] - - async def settled(self, room_id: str) -> None: - owner = self.adapter._turn_tasks.get(room_id) - if owner is not None: - await asyncio.wait({owner.task}) - - @property - def exited(self) -> list[bool]: - return [child.exited for child in self.children] - - -@pytest_asyncio.fixture(params=["codex", "claude"], loop_scope="function") -async def host( - request: pytest.FixtureRequest, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> AsyncIterator[WorkspaceHost]: - failure = tmp_path / "fail-startup" - hold = tmp_path / "hold-startup" - arguments = ("--fail-startup", str(failure), "--hold-startup", str(hold)) - if request.param == "codex": - adapter = CodexAdapter( - CodexAdapterConfig( - model="peer", - workspace_for_room=lambda room: host.workspace(room), - codex_command=( - sys.executable, - "-u", - str(REPO_ROOT / "tests/adapters/roompeer.py"), - *arguments, - "--stay", - ), - ) - ) - - def codex_child( - *, - command: Sequence[str] | None, - cwd: str | None, - env: Mapping[str, str] | None, - ) -> CodexPeer: - child = CodexPeer(command=command, cwd=cwd, env=env) - host.children.append(child) - return child - - monkeypatch.setattr("band.adapters.codex.CodexStdioClient", codex_child) - else: - adapter = ClaudeSDKAdapter( - ClaudeSDKAdapterConfig( - approvals=ClaudeApprovalOptions( - mode="auto_accept", text_notifications=False - ) - ), - workspace_for_room=lambda room: host.workspace(room), - ) - - def claude_child(*, prompt: str, options: ClaudeAgentOptions) -> ClaudePeer: - child = ClaudePeer(prompt=prompt, options=options) - child.arguments = arguments - host.children.append(child) - return child - - monkeypatch.setattr(transport, "SubprocessCLITransport", claude_child) - host = WorkspaceHost(adapter, tmp_path, failure, hold) - await adapter.on_started("Peer", "A subprocess under test") - try: - yield host - finally: - failure.unlink(missing_ok=True) - hold.unlink(missing_ok=True) - for child in host.children: - child.refuse_close = False - if child.closing is not None: - child.closing.released.set() - try: - await adapter.cleanup_all() - finally: - for child in host.children: - await child.close() - - -async def test_completed_turns_retain_children_until_explicit_cleanup( - host: WorkspaceHost, -) -> None: - host.shared = False - for room in ("room-a", "room-b", "room-c"): - assert await host.send(room, write_instruction(room)) == [room] - assert host.exited == [False, False, False] - assert await host.send("room-a", PeerCommand.READ) == ["Read: room-a"] - assert host.exited == [False, False, False] - await host.adapter.cleanup_all() - assert host.exited == [True, True, True] - - -async def test_failed_cleanup_retains_ownership_and_blocks_replacement( - host: WorkspaceHost, -) -> None: - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - host.children[0].refuse_close = True - with pytest.raises(RuntimeError, match="cleanup failed"): - await host.adapter.on_cleanup("room-a") - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - with pytest.raises(InputNotAccepted, match="closing"): - await host.send("room-a", write_instruction("changed")) - assert host.exited == [False] - assert (Path(host.workspace("room-a")) / "notes.txt").read_text() == "alpha" - host.children[0].refuse_close = False - await host.adapter.on_cleanup("room-a") - assert host.exited == [True] - assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] - assert host.exited == [True, False] - - -async def test_failed_startup_keeps_membership_claim_until_leave( - host: WorkspaceHost, -) -> None: - host.fail_startup.touch() - with pytest.raises(Exception, match="startup failed"): - await host.send("room-a", write_instruction("alpha")) - assert host.exited == [True] - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - await host.adapter.on_cleanup("room-a") - host.fail_startup.unlink() - assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] - - -async def test_failed_room_cleanup_can_recover_in_the_same_workspace( - host: WorkspaceHost, -) -> None: - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - host.children[0].refuse_close = True - with pytest.raises(RuntimeError, match="cleanup failed"): - await host.adapter.on_cleanup("room-a") - host.children[0].refuse_close = False - await host.adapter.on_cleanup("room-a") - assert await host.send("room-a", PeerCommand.READ) == ["Read: alpha"] - assert host.exited == [True, False] - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - - -async def test_cancelled_startup_keeps_ownership_and_can_recover( - host: WorkspaceHost, -) -> None: - host.hold_startup.touch() - opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) - while not (Path(host.workspace("room-a")) / "starting.txt").exists(): - await asyncio.sleep(0.01) - opening.cancel() - with pytest.raises(asyncio.CancelledError): - await opening - host.hold_startup.unlink() - await host.settled("room-a") - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - assert host.exited.count(False) == 1 - await host.adapter.on_cleanup("room-a") - assert all(host.exited) - - -async def test_cancelled_cleanup_can_be_completed_before_transfer( - host: WorkspaceHost, -) -> None: - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - barrier = host.children[0].closing = Hold() - leaving = asyncio.create_task(host.adapter.on_cleanup("room-a")) - async with barrier: - leaving.cancel() - with pytest.raises(asyncio.CancelledError): - await leaving - assert host.exited == [False] - await host.adapter.on_cleanup("room-a") - assert host.exited == [True] - assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] - - -async def test_shutdown_attempts_every_room_and_retains_failed_children( - host: WorkspaceHost, -) -> None: - host.shared = False - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] - host.children[0].refuse_close = True - with pytest.raises(ExceptionGroup, match="cleanup failed"): - await host.adapter.cleanup_all() - assert host.exited == [False, True] - with pytest.raises(ExceptionGroup, match="cleanup failed"): - await host.adapter.on_started("Peer", "Restarting after failed cleanup") - assert host.exited == [False, True] - host.children[0].refuse_close = False - await host.adapter.on_started("Peer", "Restarting after successful cleanup") - assert host.exited == [True, True] - assert await host.send("room-a", PeerCommand.READ) == ["Read: alpha"] - - -@pytest.mark.parametrize("loss", [PeerCommand.LOSE_TRANSPORT, PeerCommand.EXIT_PROCESS]) -async def test_transport_loss_reaps_child_and_preserves_workspace_until_leave( - host: WorkspaceHost, - loss: PeerCommand, -) -> None: - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - original = Path(host.workspace("room-a")) - host.root = host.root / "changed" - with pytest.raises((TurnResultAlreadyReported, CLIConnectionError)): - await host.send("room-a", loss) - assert host.exited == [True] - assert await host.send("room-a", PeerCommand.READ) == ["Read: alpha"] - assert host.exited == [True, False] - - new_directory = Path(host.workspace("room-a")) - new_directory.mkdir(parents=True) - (new_directory / "notes.txt").write_text("bravo") - await host.adapter.on_cleanup("room-a") - assert await host.send("room-a", PeerCommand.READ) == ["Read: bravo"] - assert (original / "notes.txt").read_text() == "alpha" - - -async def test_failed_startup_cleanup_blocks_replacement_until_retry( - host: WorkspaceHost, -) -> None: - host.fail_startup.touch() - host.hold_startup.touch() - opening = asyncio.create_task(host.send("room-a", write_instruction("alpha"))) - while not (Path(host.workspace("room-a")) / "starting.txt").exists(): - await asyncio.sleep(0.01) - host.children[0].refuse_close = True - host.hold_startup.unlink() - with pytest.raises(RuntimeError, match="cleanup failed"): - await opening - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - host.fail_startup.unlink() - with pytest.raises(RuntimeError, match="cleanup failed"): - await host.send("room-a", write_instruction("alpha")) - assert host.exited == [False] - host.children[0].refuse_close = False - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - assert host.exited == [True, False] - - -async def test_transport_loss_with_failed_cleanup_can_recover_safely( - host: WorkspaceHost, -) -> None: - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - host.children[0].refuse_close = True - with pytest.raises((RuntimeError, TurnResultAlreadyReported)): - await host.send("room-a", PeerCommand.LOSE_TRANSPORT) - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - with pytest.raises(RuntimeError, match="cleanup failed"): - await host.send("room-a", PeerCommand.READ) - assert len(host.children) == 1 - host.children[0].refuse_close = False - assert await host.send("room-a", PeerCommand.READ) == ["Read: alpha"] - assert host.exited == [True, False] - - -@pytest.mark.parametrize("host", ["codex"], indirect=True) -async def test_close_timeout_retains_ownership_until_retry(host: WorkspaceHost) -> None: - assert isinstance(host.adapter, CodexAdapter) - assert await host.send("room-a", write_instruction("alpha")) == ["alpha"] - host.adapter.config = host.adapter.config.model_copy( - update={"client_close_timeout_s": 0.05} - ) - barrier = host.children[0].closing = Hold() - with pytest.raises(TimeoutError): - await host.adapter.on_cleanup("room-a") - await barrier.reached.wait() - assert host.exited == [False] - with pytest.raises(ValueError, match="both"): - await host.send("room-b", write_instruction("bravo")) - with pytest.raises(InputNotAccepted, match="closing"): - await host.send("room-a", write_instruction("changed")) - assert host.exited == [False] - host.adapter.config = host.adapter.config.model_copy( - update={"client_close_timeout_s": None} - ) - barrier.released.set() - await host.adapter.on_cleanup("room-a") - assert host.exited == [True] - assert await host.send("room-b", write_instruction("bravo")) == ["bravo"] diff --git a/tests/adapters/waiting.py b/tests/adapters/waiting.py new file mode 100644 index 000000000..c4d078454 --- /dev/null +++ b/tests/adapters/waiting.py @@ -0,0 +1,30 @@ +"""One bounded wait for state a test cannot hold at a barrier.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable + +# Real time under a wall clock, instant under looptime; either way it bounds a +# wait that would otherwise never end instead of counting loop turns. +UNTIL_TIMEOUT_S = 15.0 +POLL_STEP_S = 0.001 +# Loop turns ``spin`` gives runnable tasks; a bound, not a delay. +SPIN_TURNS = 200 + + +async def until(done: Callable[[], bool], *, failure: str) -> None: + """Wait until ``done`` holds, failing with ``failure`` if it never does.""" + try: + async with asyncio.timeout(UNTIL_TIMEOUT_S): + while not done(): + await asyncio.sleep(POLL_STEP_S) + except TimeoutError: + raise AssertionError(failure) from None + + +async def spin() -> None: + """Give every runnable task its loop turns, for a "nothing happened" + check on a step that exposes no state to wait for.""" + for _ in range(SPIN_TURNS): + await asyncio.sleep(0) diff --git a/tests/core/retention/conftest.py b/tests/core/retention/conftest.py index 9dd19ba2c..f88d464c9 100644 --- a/tests/core/retention/conftest.py +++ b/tests/core/retention/conftest.py @@ -6,22 +6,36 @@ import pytest_asyncio -from tests.core.retention.kit import ResidenceHost +from tests.core.retention.kit import ( + IMPATIENT_TRANSITION_S, + SETTLE_GRACE_S, + ResidenceHost, +) @pytest_asyncio.fixture(loop_scope="function") async def host() -> AsyncIterator[ResidenceHost]: - host = ResidenceHost() - try: + async with ResidenceHost() as host: yield host - finally: - await host.close() @pytest_asyncio.fixture(loop_scope="function") async def impatient_host() -> AsyncIterator[ResidenceHost]: - host = ResidenceHost(transition_timeout=0.05) - try: + async with ResidenceHost(transition_timeout=IMPATIENT_TRANSITION_S) as host: + yield host + + +@pytest_asyncio.fixture(loop_scope="function") +async def graceful_host() -> AsyncIterator[ResidenceHost]: + async with ResidenceHost(timeout=None, settle_grace=SETTLE_GRACE_S) as host: + yield host + + +@pytest_asyncio.fixture(loop_scope="function") +async def graceful_impatient_host() -> AsyncIterator[ResidenceHost]: + async with ResidenceHost( + timeout=None, + transition_timeout=IMPATIENT_TRANSITION_S, + settle_grace=SETTLE_GRACE_S, + ) as host: yield host - finally: - await host.close() diff --git a/tests/core/retention/kit.py b/tests/core/retention/kit.py index c4e1f5d9c..a0db66ed3 100644 --- a/tests/core/retention/kit.py +++ b/tests/core/retention/kit.py @@ -3,8 +3,11 @@ from __future__ import annotations import asyncio +import logging from collections.abc import Callable from dataclasses import dataclass, field +from types import TracebackType +from typing import Self from band.core.retention import ( ProviderActivity, @@ -14,6 +17,7 @@ RoomRetention, RoomRetentionConfig, ) +from tests.adapters.hold import Hold @dataclass @@ -54,16 +58,6 @@ async def advance(self, seconds: float) -> None: await asyncio.sleep(0) -@dataclass -class Barrier: - reached: asyncio.Event = field(default_factory=asyncio.Event) - released: asyncio.Event = field(default_factory=asyncio.Event) - - async def wait(self) -> None: - self.reached.set() - await self.released.wait() - - @dataclass class Resident: room: RetainedRoom = field(init=False) @@ -72,14 +66,17 @@ class Resident: resume_requests: list[bool] = field(default_factory=list) releases: list[ReleaseCause] = field(default_factory=list) suspended: asyncio.Queue[None] = field(default_factory=asyncio.Queue) - opening: Barrier | None = None - closing: Barrier | None = None - leaving: Barrier | None = None + opening: Hold | None = None + closing: Hold | None = None + leaving: Hold | None = None startup_activity: ProviderActivity = ProviderActivity.QUIESCENT busy: bool = False fail_close: bool = False fail_acquire: bool = False fail_dispose: bool = False + resists_cancel: bool = False + # Set once a resistant start took its cancellation and parked to finish. + resisted: asyncio.Event = field(default_factory=asyncio.Event) async def acquire(self, generation: int, resume: bool) -> None: assert not self.live, "replacement started before the previous handle closed" @@ -88,10 +85,20 @@ async def acquire(self, generation: int, resume: bool) -> None: self.resume_requests.append(resume) self.room.observe(self.startup_activity, generation=generation) if self.opening is not None: - await self.opening.wait() + await self._open(self.opening) if self.fail_acquire: raise RuntimeError("resume rejected") + async def _open(self, opening: Hold) -> None: + """Wait at the opening barrier; a resistant start finishes there anyway.""" + try: + await opening.wait() + except asyncio.CancelledError: + if not self.resists_cancel: + raise + self.resisted.set() + await opening.released.wait() + async def release(self, cause: ReleaseCause) -> ReleaseOutcome: if cause is not ReleaseCause.FINAL: assert self.room.snapshot.leases == 0, "released under an active lease" @@ -124,9 +131,20 @@ def observe(self, activity: ProviderActivity, reason: str = "") -> bool: ) +#: The settle grace a graceful host's disposal gives accepted work. +SETTLE_GRACE_S = 5.0 +#: An impatient host's transition timeout, which any held transition outlasts. +IMPATIENT_TRANSITION_S = 0.05 + + class ResidenceHost: + """Residents under one owner; leaving ``async with`` closes the host.""" + def __init__( - self, timeout: float | None = 10, transition_timeout: float = 10.0 + self, + timeout: float | None = 10, + transition_timeout: float = 10.0, + settle_grace: float = 0.0, ) -> None: self.clock = ManualClock() self.owner = RoomRetention( @@ -134,6 +152,7 @@ def __init__( idle_timeout_s=timeout, transition_timeout_s=transition_timeout ), clock=self.clock, + settle_grace_s=settle_grace, ) self.residents: list[Resident] = [] @@ -148,6 +167,17 @@ def room(self, name: str = "room") -> Resident: self.residents.append(resident) return resident + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.close() + async def close(self) -> None: for resident in self.residents: resident.fail_close = resident.fail_acquire = resident.fail_dispose = False @@ -161,3 +191,19 @@ async def close(self) -> None: def armed(clock: ManualClock) -> list[float]: """Deadlines of the timers still waiting to fire.""" return [timer.when for timer in clock.timers if not timer.cancelled] + + +#: The logger every retention module logs under. +RETENTION_LOGGER = "band.core.retention" + + +def retention_records(records: list[logging.LogRecord]) -> list[logging.LogRecord]: + """The records the retention loggers emitted.""" + return [record for record in records if record.name.startswith(RETENTION_LOGGER)] + + +def retention_logs(records: list[logging.LogRecord]) -> list[tuple[int, str]]: + """Each retention log line as (level, message).""" + return [ + (record.levelno, record.getMessage()) for record in retention_records(records) + ] diff --git a/tests/core/retention/test_acquisition.py b/tests/core/retention/test_acquisition.py index 9a3b45758..b59a41713 100644 --- a/tests/core/retention/test_acquisition.py +++ b/tests/core/retention/test_acquisition.py @@ -6,7 +6,8 @@ import pytest -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.core.retention.kit import ResidenceHost pytestmark = pytest.mark.timeout(10) @@ -36,9 +37,9 @@ async def test_successor_waits_for_release_before_lease_and_preparation( ) -> None: resident = host.room() await resident.turn() - resident.closing = Barrier() + resident.closing = Hold() await host.clock.advance(10) - await resident.closing.reached.wait() + await resident.closing.arrived() prepared: list[int] = [] async def prepare() -> None: diff --git a/tests/core/retention/test_authority.py b/tests/core/retention/test_authority.py index 48a3b82d8..83b746687 100644 --- a/tests/core/retention/test_authority.py +++ b/tests/core/retention/test_authority.py @@ -4,6 +4,7 @@ import pytest +from band.core.exceptions import CleanupIncompleteError from band.core.retention import ( ProviderActivity, ) @@ -46,3 +47,17 @@ async def test_stale_holder_events_cannot_mutate_a_rejoined_room( assert not old.room.recover(generation=1, reason="stale") assert new.room.snapshot.provider_activity is ProviderActivity.ACTIVE assert not new.room.snapshot.pending_cleanup + + async def test_a_stopping_owner_revokes_authority_before_disposal_starts( + self, host: ResidenceHost + ) -> None: + resident = host.room() + await resident.turn() + + # Stop requested from inside a turn returns before its shutdown runs. + async with resident.room.use(): + with pytest.raises(CleanupIncompleteError): + await host.owner.stop() + assert resident.room.snapshot.pending_release is None + assert not resident.room.has_authority(resident.room.generation) + await host.owner.stop() diff --git a/tests/core/retention/test_cleanup_deadline.py b/tests/core/retention/test_cleanup_deadline.py new file mode 100644 index 000000000..0b37b92a0 --- /dev/null +++ b/tests/core/retention/test_cleanup_deadline.py @@ -0,0 +1,32 @@ +"""An adapter's cleanup deadline counts only its own overrun as unfinished.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from band.core.retention import RoomRetentionConfig +from band.core.retention.cleanup import cleanup_deadline, finish_by + +pytestmark = pytest.mark.timeout(10) + + +async def _raise_timeout() -> None: + raise TimeoutError("backend's own timeout") + + +class TestCleanupDeadline: + @pytest.mark.looptime + async def test_a_step_past_the_deadline_is_unfinished_cleanup(self) -> None: + deadline = cleanup_deadline(RoomRetentionConfig(transition_timeout_s=1.0)) + + with pytest.raises(CleanupIncompleteError, match="MCP close still running"): + await finish_by(deadline, asyncio.sleep(5), what="MCP close") + + async def test_a_timeout_the_cleanup_raised_itself_is_its_failure(self) -> None: + deadline = cleanup_deadline(RoomRetentionConfig(transition_timeout_s=10.0)) + + with pytest.raises(TimeoutError, match="backend's own timeout"): + await finish_by(deadline, _raise_timeout(), what="MCP close") diff --git a/tests/core/retention/test_disposal.py b/tests/core/retention/test_disposal.py index 4727c81ba..8e1f0471a 100644 --- a/tests/core/retention/test_disposal.py +++ b/tests/core/retention/test_disposal.py @@ -6,13 +6,14 @@ import pytest -from band.core.exceptions import CleanupIncompleteError +from band.core.exceptions import CleanupIncompleteError, RoomClosingError from band.core.retention import ( ProviderActivity, ReleaseCause, Residency, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.core.retention.kit import ResidenceHost pytestmark = pytest.mark.timeout(10) @@ -25,9 +26,9 @@ async def test_leave_wins_against_wake_during_close( ) -> None: resident = host.room() await resident.turn() - resident.closing = Barrier() + resident.closing = Hold() await host.clock.advance(10) - await resident.closing.reached.wait() + await resident.closing.arrived() leaving = asyncio.create_task(resident.room.dispose()) wake = asyncio.create_task(resident.turn()) resident.closing.released.set() @@ -44,14 +45,14 @@ async def test_a_failed_dispose_after_an_idle_release_retries_final( ) -> None: resident = host.room() await resident.turn() - resident.closing = Barrier() - resident.leaving = Barrier() + resident.closing = Hold() + resident.leaving = Hold() await host.clock.advance(10) - await resident.closing.reached.wait() + await resident.closing.arrived() leaving = asyncio.create_task(resident.room.dispose()) await asyncio.sleep(0) resident.closing.released.set() - await resident.leaving.reached.wait() + await resident.leaving.arrived() resident.fail_dispose = True resident.leaving.released.set() with pytest.raises(RuntimeError, match="dispose refused"): @@ -70,9 +71,9 @@ async def test_cancelled_disposal_waiter_leaves_retryable_owned_cleanup( ) -> None: resident = host.room() await resident.turn() - resident.leaving = Barrier() + resident.leaving = Hold() leaving = asyncio.create_task(resident.room.dispose()) - await resident.leaving.reached.wait() + await resident.leaving.arrived() leaving.cancel() with pytest.raises(asyncio.CancelledError): await leaving @@ -100,15 +101,12 @@ async def test_failed_disposal_retains_room_registration( assert not resident.observe(ProviderActivity.QUIESCENT) async def test_disabled_retention_still_disposes_owned_resources(self) -> None: - host = ResidenceHost(timeout=None) - try: + async with ResidenceHost(timeout=None) as host: resident = host.room() await resident.turn() assert resident.room.snapshot.deadline is None await host.owner.stop() assert not resident.live - finally: - await host.close() async def test_disposal_report_timeout_is_joined_without_a_second_close( self, @@ -116,7 +114,7 @@ async def test_disposal_report_timeout_is_joined_without_a_second_close( ) -> None: resident = impatient_host.room() await resident.turn() - resident.closing = Barrier() + resident.closing = Hold() with pytest.raises(CleanupIncompleteError): await resident.room.dispose() assert resident.room.snapshot.state is Residency.SUSPENDING @@ -164,7 +162,7 @@ async def hold_a_lease() -> None: await asyncio.sleep(0) disposal = asyncio.create_task(resident.room.dispose()) - with pytest.raises(RuntimeError, match="is closing"): + with pytest.raises(RoomClosingError): await asyncio.wait_for(waiter, timeout=1) assert not holder.done() diff --git a/tests/core/retention/test_disposal_grace.py b/tests/core/retention/test_disposal_grace.py new file mode 100644 index 000000000..6aa8a0001 --- /dev/null +++ b/tests/core/retention/test_disposal_grace.py @@ -0,0 +1,237 @@ +"""Final disposal closes admission at once, then lets accepted work finish +within the owner's settle grace before cancelling what remains.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from band.core.retention import ReleaseCause, Residency +from tests.adapters.hold import Hold +from tests.adapters.waiting import spin, until +from tests.core.retention.kit import ( + SETTLE_GRACE_S, + ResidenceHost, + Resident, + armed, +) + +pytestmark = pytest.mark.timeout(10) + + +async def parked_work(resident: Resident, barrier: Hold) -> asyncio.Task[None]: + """Owned work leasing the room until ``barrier`` is released.""" + + async def work() -> None: + async with resident.room.use(): + await barrier.wait() + + task = asyncio.create_task(work()) + resident.room.own(task) + await barrier.arrived() + return task + + +async def grace_started(host: ResidenceHost, rooms: int = 1) -> None: + """Let a started disposal or shutdown arm ``rooms`` settle graces.""" + await until( + lambda: len(armed(host.clock)) >= rooms, failure="no settle grace was armed" + ) + + +async def closes_on_its_own(resident: Resident) -> None: + """Let an owned disposal finish with no further ``dispose()`` call.""" + await until( + lambda: resident.room.snapshot.state is Residency.CLOSED, + failure="the owned disposal never finished", + ) + + +async def leaving(host: ResidenceHost, resident: Resident) -> asyncio.Task[None]: + """A leave whose disposal is inside its settle grace.""" + disposal = asyncio.create_task(resident.room.dispose()) + await grace_started(host) + return disposal + + +class TestDisposalGrace: + async def test_work_arriving_during_the_grace_is_refused( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + parked = Hold() + work = await parked_work(resident, parked) + disposal = await leaving(graceful_host, resident) + + late = asyncio.create_task(resident.turn()) + with pytest.raises(RoomClosingError): + resident.room.own(late) + + assert resident.room.closing and not work.done() + parked.released.set() + await disposal + + async def test_work_finishing_within_the_grace_is_never_cancelled( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + parked = Hold() + work = await parked_work(resident, parked) + disposal = await leaving(graceful_host, resident) + + await graceful_host.clock.advance(SETTLE_GRACE_S - 1) + assert not work.done() and resident.releases == [] + parked.released.set() + await disposal + + assert not work.cancelled() + assert armed(graceful_host.clock) == [] + assert resident.releases == [ReleaseCause.FINAL] + assert resident.room.snapshot.state is Residency.CLOSED + + async def test_work_outlasting_the_grace_is_cancelled_once( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + work = await parked_work(resident, Hold()) + disposal = await leaving(graceful_host, resident) + + await graceful_host.clock.advance(SETTLE_GRACE_S) + await disposal + + assert work.cancelled() and work.cancelling() == 1 + assert resident.releases == [ReleaseCause.FINAL] + + async def test_a_report_timeout_in_the_grace_leaves_one_joinable_disposal( + self, graceful_impatient_host: ResidenceHost + ) -> None: + resident = graceful_impatient_host.room() + parked = Hold() + work = await parked_work(resident, parked) + + with pytest.raises(CleanupIncompleteError): + await resident.room.dispose() + assert not work.done() and resident.live + + parked.released.set() + await work + await closes_on_its_own(resident) + await resident.room.dispose() + + assert not work.cancelled() + assert resident.releases == [ReleaseCause.FINAL] + + async def test_a_cancelled_leave_leaves_its_disposal_owned( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + parked = Hold() + await parked_work(resident, parked) + disposal = await leaving(graceful_host, resident) + grace = armed(graceful_host.clock) + + disposal.cancel() + with pytest.raises(asyncio.CancelledError): + await disposal + assert armed(graceful_host.clock) == grace + parked.released.set() + await closes_on_its_own(resident) + await resident.room.dispose() + + assert resident.releases == [ReleaseCause.FINAL] + assert resident.room.snapshot.state is Residency.CLOSED + + async def test_a_release_failing_after_the_grace_is_retried_by_the_next_leave( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + await resident.turn() + resident.fail_close = True + work = await parked_work(resident, Hold()) + disposal = await leaving(graceful_host, resident) + await graceful_host.clock.advance(SETTLE_GRACE_S) + with pytest.raises(RuntimeError, match="close refused"): + await disposal + assert work.cancelled() + + resident.fail_close = False + await resident.room.dispose() + + assert resident.releases == [ReleaseCause.FINAL, ReleaseCause.FINAL] + assert resident.room.snapshot.state is Residency.CLOSED + + async def test_shutdown_gives_every_room_the_same_grace( + self, graceful_host: ResidenceHost + ) -> None: + first, second = graceful_host.room("first"), graceful_host.room("second") + works = [ + await parked_work(first, Hold()), + await parked_work(second, Hold()), + ] + stopping = asyncio.create_task(graceful_host.owner.stop()) + await grace_started(graceful_host, rooms=2) + + await graceful_host.clock.advance(SETTLE_GRACE_S - 1) + assert not any(work.done() for work in works) + await graceful_host.clock.advance(1) + await stopping + + assert all(work.cancelled() for work in works) + + async def test_work_requesting_its_own_leave_is_spared_through_the_grace( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + unwinding = Hold() + + async def leave_from_inside() -> None: + async with resident.room.use(): + with pytest.raises(CleanupIncompleteError): + await resident.room.dispose() + await unwinding.wait() + + work = asyncio.create_task(leave_from_inside()) + resident.room.own(work) + await unwinding.arrived() + await graceful_host.clock.advance(SETTLE_GRACE_S) + await asyncio.sleep(0) + assert not work.done() + + unwinding.released.set() + await work + await resident.room.dispose() + + assert resident.releases == [ReleaseCause.FINAL] + + async def test_work_its_owner_already_cancelled_finishes_its_own_cleanup( + self, graceful_host: ResidenceHost + ) -> None: + resident = graceful_host.room() + parked, cleanup = asyncio.Event(), Hold() + + async def cancelled_by_its_owner() -> None: + async with resident.room.use(): + try: + parked.set() + await asyncio.Future[None]() + finally: + await cleanup.wait() + + work = asyncio.create_task(cancelled_by_its_owner()) + resident.room.own(work) + await parked.wait() + work.cancel() + await cleanup.arrived() + disposal = asyncio.create_task(resident.room.dispose()) + await spin() + + # No grace is armed for it, and disposal does not cancel it again. + assert armed(graceful_host.clock) == [] + cleanup.released.set() + with pytest.raises(asyncio.CancelledError): + await work + await disposal + + assert resident.releases == [ReleaseCause.FINAL] diff --git a/tests/core/retention/test_failed_close.py b/tests/core/retention/test_failed_close.py index 6cc100e99..89cd3a176 100644 --- a/tests/core/retention/test_failed_close.py +++ b/tests/core/retention/test_failed_close.py @@ -10,7 +10,8 @@ ProviderActivity, ReleaseCause, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.core.retention.kit import ResidenceHost pytestmark = pytest.mark.timeout(10) @@ -79,9 +80,9 @@ async def test_cancelled_wake_waiter_does_not_cancel_close_or_duplicate_acquisit ) -> None: resident = host.room() await resident.turn() - resident.closing = Barrier() + resident.closing = Hold() await host.clock.advance(10) - await resident.closing.reached.wait() + await resident.closing.arrived() admitted = asyncio.Event() async def wake_room() -> None: diff --git a/tests/core/retention/test_failure_logging.py b/tests/core/retention/test_failure_logging.py new file mode 100644 index 000000000..e914e844d --- /dev/null +++ b/tests/core/retention/test_failure_logging.py @@ -0,0 +1,126 @@ +"""An owned task's failure is logged once: by its joiner, or here when unjoined.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +import pytest + +from band.core.retention.cleanup import report_owned, spawn_owned +from tests.adapters.hold import Hold +from tests.adapters.waiting import spin, until +from tests.core.retention.kit import ( + RETENTION_LOGGER, + ResidenceHost, + retention_logs, + retention_records, +) + +pytestmark = pytest.mark.timeout(10) + + +@pytest.fixture(autouse=True) +def capture_retention(caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG, logger=RETENTION_LOGGER) + + +class TestFailureLogging: + """A failure a caller re-raises is left to that caller's log.""" + + async def test_a_joined_acquire_failure_is_left_to_its_caller( + self, host: ResidenceHost, caplog: pytest.LogCaptureFixture + ) -> None: + resident = host.room() + resident.fail_acquire = True + with pytest.raises(RuntimeError, match="resume rejected"): + await resident.turn() + await resident.suspended.get() + assert retention_logs(caplog.records) == [] + + async def test_a_failed_shutdown_is_left_to_the_stopping_caller( + self, host: ResidenceHost, caplog: pytest.LogCaptureFixture + ) -> None: + resident = host.room() + await resident.turn() + resident.fail_close = True + with pytest.raises(BaseExceptionGroup): + await host.owner.stop() + assert retention_logs(caplog.records) == [] + + async def test_an_unjoined_idle_release_failure_is_logged_once( + self, host: ResidenceHost, caplog: pytest.LogCaptureFixture + ) -> None: + resident = host.room() + await resident.turn() + resident.fail_close = True + await host.clock.advance(10) + await until( + lambda: bool(retention_logs(caplog.records)), + failure="the idle release failure was never logged", + ) + (record,) = retention_records(caplog.records) + assert (record.levelno, record.getMessage()) == ( + logging.WARNING, + "Room room residency transition failed: close refused", + ) + assert record.exc_info is not None + + async def test_a_failure_raised_as_its_wait_times_out_is_left_to_its_caller( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """The wait's timeout and the task's failure land in one loop step, so + the done callback runs after the caller stopped waiting.""" + + async def failing_cleanup() -> None: + await asyncio.sleep(0.05) + raise RuntimeError("close failed") + + cleanup = spawn_owned(failing_cleanup(), label="cleanup") + # Blocks the loop until both the cleanup's sleep and the wait's timeout expire. + asyncio.get_running_loop().call_soon(time.sleep, 0.2) + with pytest.raises(RuntimeError, match="close failed"): + await report_owned(cleanup, 0.1) + await asyncio.sleep(0) + assert retention_logs(caplog.records) == [] + + async def test_a_failure_its_cancelled_caller_never_saw_is_logged_once( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """The task fails, then its caller is cancelled in the same loop step, + so the done callback runs while that caller still counts as reporting.""" + gate: asyncio.Future[None] = asyncio.get_running_loop().create_future() + + async def failing_cleanup() -> None: + await gate + raise RuntimeError("close failed") + + cleanup = spawn_owned(failing_cleanup(), label="cleanup") + caller = asyncio.create_task(report_owned(cleanup, None)) + await spin() + gate.set_result(None) + asyncio.get_running_loop().call_soon(caller.cancel) + with pytest.raises(asyncio.CancelledError): + await caller + await spin() + assert retention_logs(caplog.records) == [ + (logging.WARNING, "cleanup failed: close failed") + ] + + async def test_an_abandoned_start_cancelled_for_recovery_is_logged( + self, impatient_host: ResidenceHost, caplog: pytest.LogCaptureFixture + ) -> None: + resident = impatient_host.room() + resident.opening = Hold() + with pytest.raises(TimeoutError, match="start abandoned"): + await resident.turn() + assert retention_logs(caplog.records) == [ + ( + logging.WARNING, + ( + "Room room: abandoned acquisition (generation 1) " + "cancelled for recovery" + ), + ) + ] diff --git a/tests/core/retention/test_idle_scheduling.py b/tests/core/retention/test_idle_scheduling.py index 791674016..39c09e171 100644 --- a/tests/core/retention/test_idle_scheduling.py +++ b/tests/core/retention/test_idle_scheduling.py @@ -8,7 +8,8 @@ ProviderActivity, Residency, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.core.retention.kit import ResidenceHost pytestmark = pytest.mark.timeout(10) @@ -97,9 +98,9 @@ async def test_slow_close_does_not_block_another_room( fast = host.room("fast") await slow.turn() await fast.turn() - slow.closing = Barrier() + slow.closing = Hold() await host.clock.advance(10) - await slow.closing.reached.wait() + await slow.closing.arrived() await fast.suspended.get() await fast.turn() assert slow.live and fast.live diff --git a/tests/core/retention/test_owned_work.py b/tests/core/retention/test_owned_work.py index ede6c318c..58c63559d 100644 --- a/tests/core/retention/test_owned_work.py +++ b/tests/core/retention/test_owned_work.py @@ -6,12 +6,14 @@ import pytest -from band.core.exceptions import CleanupIncompleteError +from band.core.exceptions import CleanupIncompleteError, RoomClosingError from band.core.retention import ( ReleaseCause, Residency, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.conftest import eager_tasks, requires_eager_tasks +from tests.core.retention.kit import ResidenceHost pytestmark = pytest.mark.timeout(10) @@ -25,7 +27,7 @@ async def test_owned_queued_work_revokes_idle_until_it_actually_finishes( ) -> None: resident = host.room() await resident.turn() - queued = Barrier() + queued = Hold() async def detached_turn() -> None: await queued.wait() @@ -66,7 +68,7 @@ async def test_final_cleanup_cancels_and_joins_owned_work( ) -> None: resident = host.room() await resident.turn() - parked = Barrier() + parked = Hold() async def parked_decision() -> None: async with resident.room.use(): @@ -74,12 +76,12 @@ async def parked_decision() -> None: task = asyncio.create_task(parked_decision()) resident.room.own(task) - await parked.reached.wait() + await parked.arrived() await resident.room.dispose() assert task.cancelled() assert resident.releases == [ReleaseCause.FINAL] rejected = asyncio.create_task(resident.turn()) - with pytest.raises(RuntimeError, match="closing"): + with pytest.raises(RoomClosingError): resident.room.own(rejected) with pytest.raises(asyncio.CancelledError): await rejected @@ -156,3 +158,41 @@ async def leave_from_owned_work() -> None: await task await resident.room.dispose() assert resident.releases == [ReleaseCause.FINAL] + + @requires_eager_tasks + async def test_started_work_runs_only_once_enrolled( + self, host: ResidenceHost + ) -> None: + resident = host.room() + enrolled_when_run: list[bool] = [] + + async def work() -> None: + current = asyncio.current_task() + enrolled_when_run.append(resident.room.enrolls(current)) + + with eager_tasks(): + task = resident.room.start_owned(work) + await task + + assert enrolled_when_run == [True] + + @requires_eager_tasks + async def test_work_started_on_a_closing_room_never_runs( + self, host: ResidenceHost + ) -> None: + resident = host.room() + await resident.turn() + resident.closing = Hold() + disposal = asyncio.create_task(resident.room.dispose()) + await resident.closing.arrived() + ran: list[bool] = [] + + async def work() -> None: + ran.append(True) + + with eager_tasks(), pytest.raises(RoomClosingError): + resident.room.start_owned(work) + resident.closing.released.set() + await disposal + + assert ran == [] diff --git a/tests/core/retention/test_recovery.py b/tests/core/retention/test_recovery.py index b6132b316..5f7fbeff8 100644 --- a/tests/core/retention/test_recovery.py +++ b/tests/core/retention/test_recovery.py @@ -11,11 +11,23 @@ ReleaseCause, Residency, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.adapters.waiting import until +from tests.core.retention.kit import ResidenceHost, Resident pytestmark = pytest.mark.timeout(10) +async def hung_start(resident: Resident) -> tuple[asyncio.Task[None], Hold]: + """A turn waiting at a start's held opening; later starts don't wait.""" + opening = Hold() + resident.opening = opening + wake = asyncio.create_task(resident.turn()) + await opening.arrived() + resident.opening = None + return wake, opening + + class TestRecovery: """A failed or abandoned acquisition is recovered, never idled.""" @@ -24,9 +36,9 @@ async def test_abandoned_startup_is_recovered_before_reuse( host: ResidenceHost, ) -> None: resident = host.room() - resident.opening = Barrier() + resident.opening = Hold() wake = asyncio.create_task(resident.turn()) - await resident.opening.reached.wait() + await resident.opening.arrived() wake.cancel() with pytest.raises(asyncio.CancelledError): await wake @@ -65,10 +77,10 @@ async def test_failed_acquisition_is_recovered_never_idled( resident = host.room() resident.startup_activity = ProviderActivity.ACTIVE resident.fail_acquire = True - resident.closing = Barrier() + resident.closing = Hold() with pytest.raises(RuntimeError, match="resume rejected"): await resident.turn() - await resident.closing.reached.wait() + await resident.closing.arrived() assert resident.releases == [ReleaseCause.RECOVERY] resident.closing.released.set() await resident.suspended.get() @@ -119,7 +131,7 @@ async def test_acquisition_report_timeout_keeps_it_owned_for_recovery( impatient_host: ResidenceHost, ) -> None: resident = impatient_host.room() - resident.opening = Barrier() + resident.opening = Hold() with pytest.raises(TimeoutError): await resident.turn() assert resident.room.snapshot.state is Residency.ACQUIRING @@ -129,14 +141,64 @@ async def test_acquisition_report_timeout_keeps_it_owned_for_recovery( assert resident.releases == [ReleaseCause.RECOVERY] assert resident.resume_requests == [False, False] + @pytest.mark.parametrize("stopped", [True, False], ids=["stopped", "timed-out"]) + async def test_an_abandoned_start_that_never_finishes_is_recovered( + self, impatient_host: ResidenceHost, stopped: bool + ) -> None: + resident = impatient_host.room() + wake, _ = await hung_start(resident) + if stopped: + wake.cancel() + with pytest.raises(asyncio.CancelledError if stopped else TimeoutError): + await wake + await resident.suspended.get() + await resident.turn() + assert resident.releases == [ReleaseCause.RECOVERY] + assert resident.room.snapshot.generation == 2 + + async def test_an_operation_joining_an_abandoned_start_is_served( + self, host: ResidenceHost + ) -> None: + resident = host.room() + wake, _ = await hung_start(resident) + joiner = asyncio.create_task(resident.turn()) + await until( + lambda: resident.room.snapshot.accepted == 2, + failure="the joining operation was never accepted", + ) + wake.cancel() + with pytest.raises(asyncio.CancelledError): + await wake + await joiner + assert resident.releases == [ReleaseCause.RECOVERY] + assert resident.room.snapshot.generation == 2 + + async def test_a_start_resisting_cancellation_is_not_closed_while_it_runs( + self, host: ResidenceHost + ) -> None: + resident = host.room() + resident.resists_cancel = True + wake, opening = await hung_start(resident) + wake.cancel() + with pytest.raises(asyncio.CancelledError): + await wake + await resident.resisted.wait() + assert resident.releases == [] + assert resident.room.snapshot.state is Residency.ACQUIRING + opening.released.set() + await resident.suspended.get() + await resident.turn() + assert resident.releases == [ReleaseCause.RECOVERY] + assert resident.room.snapshot.generation == 2 + async def test_recovery_during_an_idle_release_is_not_a_strict_resume( self, host: ResidenceHost ) -> None: resident = host.room() await resident.turn() - resident.closing = Barrier() + resident.closing = Hold() await host.clock.advance(10) - await resident.closing.reached.wait() + await resident.closing.arrived() generation = resident.room.snapshot.generation assert resident.room.recover(generation=generation, reason="crashed") is True resident.closing.released.set() diff --git a/tests/core/retention/test_renewal.py b/tests/core/retention/test_renewal.py new file mode 100644 index 000000000..afe013096 --- /dev/null +++ b/tests/core/retention/test_renewal.py @@ -0,0 +1,44 @@ +"""A restart renews a stopped owner only once its stop confirmed every room +closed, keeping its settings.""" + +from __future__ import annotations + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from tests.core.retention.kit import ResidenceHost + +pytestmark = pytest.mark.timeout(10) + + +class TestRenewal: + async def test_an_owner_that_never_stopped_is_kept( + self, host: ResidenceHost + ) -> None: + assert host.owner.renewed() is host.owner + + async def test_a_confirmed_stop_renews_with_the_same_settings( + self, graceful_host: ResidenceHost + ) -> None: + stopped = graceful_host.owner + await graceful_host.room().turn() + await stopped.stop() + + renewed = stopped.renewed() + + assert renewed is not stopped and not renewed.stopping + assert (renewed.config, renewed.clock, renewed.settle_grace_s) == ( + stopped.config, + stopped.clock, + stopped.settle_grace_s, + ) + + async def test_a_failed_stop_refuses_renewal(self, host: ResidenceHost) -> None: + resident = host.room() + await resident.turn() + resident.fail_close = True + with pytest.raises(BaseExceptionGroup): + await host.owner.stop() + + with pytest.raises(CleanupIncompleteError): + host.owner.renewed() diff --git a/tests/core/retention/test_shutdown.py b/tests/core/retention/test_shutdown.py index d6c3e218c..2defd8d11 100644 --- a/tests/core/retention/test_shutdown.py +++ b/tests/core/retention/test_shutdown.py @@ -3,13 +3,17 @@ from __future__ import annotations import asyncio +import logging import pytest +from band.core.exceptions import CleanupIncompleteError, RoomClosingError from band.core.retention import ( ReleaseCause, ) -from tests.core.retention.kit import Barrier, ResidenceHost +from tests.adapters.hold import Hold +from tests.adapters.waiting import spin +from tests.core.retention.kit import RETENTION_LOGGER, ResidenceHost, retention_logs pytestmark = pytest.mark.timeout(10) @@ -43,9 +47,9 @@ async def test_cancelled_shutdown_keeps_cleanup_owned_and_blocks_wake( ) -> None: resident = host.room() await resident.turn() - resident.leaving = Barrier() + resident.leaving = Hold() stopping = asyncio.create_task(host.owner.stop()) - await resident.leaving.reached.wait() + await resident.leaving.arrived() stopping.cancel() with pytest.raises(asyncio.CancelledError): await stopping @@ -62,8 +66,59 @@ async def test_shutdown_preempts_queued_provider_acquisition( resident = host.room() wake = asyncio.create_task(resident.turn()) stopping = asyncio.create_task(host.owner.stop()) - with pytest.raises(RuntimeError, match="closing"): + with pytest.raises(RoomClosingError): await wake await stopping assert resident.resume_requests == [] assert not resident.live + + async def test_concurrent_stops_and_a_leave_join_one_close( + self, + host: ResidenceHost, + ) -> None: + resident = host.room() + await resident.turn() + resident.closing = Hold() + callers = [ + asyncio.create_task(host.owner.stop()), + asyncio.create_task(host.owner.stop()), + asyncio.create_task(resident.room.dispose()), + ] + await resident.closing.arrived() + await spin() + assert not any(caller.done() for caller in callers) + resident.closing.released.set() + await asyncio.gather(*callers) + assert resident.releases == [ReleaseCause.FINAL] + assert resident.identity is None + + async def test_stops_that_stopped_waiting_leave_one_shutdown_failure( + self, + impatient_host: ResidenceHost, + caplog: pytest.LogCaptureFixture, + ) -> None: + """Every caller gives up before the held disposal fails, so only the + shutdown they all joined is left to log that failure.""" + caplog.set_level(logging.WARNING, logger=RETENTION_LOGGER) + resident = impatient_host.room() + await resident.turn() + resident.leaving = Hold() + resident.fail_dispose = True + callers = [ + asyncio.create_task(impatient_host.owner.stop()), + asyncio.create_task(impatient_host.owner.stop()), + asyncio.create_task(resident.room.dispose()), + ] + outcomes = await asyncio.gather(*callers, return_exceptions=True) + assert [type(outcome) for outcome in outcomes] == [CleanupIncompleteError] * 3 + resident.leaving.released.set() + await spin() + assert retention_logs(caplog.records) == [ + ( + logging.WARNING, + ( + "Room retention shutdown failed: " + "Room cleanup remains incomplete (1 sub-exception)" + ), + ) + ] diff --git a/tests/e2e/baseline/README.md b/tests/e2e/baseline/README.md index 6c939c67e..e7569d5dd 100644 --- a/tests/e2e/baseline/README.md +++ b/tests/e2e/baseline/README.md @@ -237,7 +237,7 @@ These three are why the toolkit is shaped the way it is — keep them when exten | `smoke/` | proof tests that exercise the tools end to end — read these as worked examples — grouped by subject (below) | | `smoke/samples/` | shared driving glue (not tests): `sample_agents.py`, `sample_tools.py` | | `smoke/matrix/` | runs across the adapter matrix: `test_adapter_matrix.py`, `test_capability_matrix.py` (memory store + recall), `test_context_recall.py` (in-session + rejoin), `test_rehydration_offline.py` / `test_rehydration_partial.py` (cold-boot / partial-reboot `/context` recall), `test_rehydration_cross_framework.py` (a different-framework `peer=` authors, A rehydrates), `test_room_isolation.py`, `test_noisy_room.py`, `test_tool_round_trip.py` (custom-tool subgroup) | -| `smoke/behavior/` | platform/transport + scenario behavior: `test_delivery_status.py`, `test_processing_barrier.py`, `test_isolation.py`, `test_duplicate_start.py` (the platform refuses a second start of a live agent id under `ConflictPolicy.REJECT`), `test_agent_scenarios.py`, `test_approvals.py` (chat-mediated approve/decline/timeout for every coding agent; builds via `smoke/samples/approvals.py`) | +| `smoke/behavior/` | platform/transport + scenario behavior: `test_delivery_status.py`, `test_processing_barrier.py`, `test_isolation.py`, `test_duplicate_start.py` (the platform refuses a second start of a live agent id under `ConflictPolicy.REJECT`), `test_room_rejoin.py` (a Codex, Claude SDK or ACP agent removed from a room and re-added answers again), `test_agent_scenarios.py`, `test_approvals.py` (chat-mediated approve/decline/timeout for every coding agent; builds via `smoke/samples/approvals.py`) | | `smoke/inspection/` | `capture.*` observation worked-examples: `test_tool_calls.py`, `test_events.py`, `test_memory.py`, `test_usage.py` (the `Emit.USAGE` fan), plus `test_next_actionable_semantics.py` (a platform `/next` invariant, `@lane`-pinned since it runs no adapter) | | `smoke/adapters/` | adapter-specific showcases: `test_a2a.py`, `test_a2a_gateway.py`, `test_a2a_roundtrip.py` (+ their shared `a2aServer.py` fixture -- a scripted, non-Band A2A counterparty), `test_agno.py`, `test_copilot_acp.py`, `test_copilot_sdk.py`, `test_crewai.py`, `test_letta.py`, `test_parlant.py` | diff --git a/tests/e2e/baseline/expected-cells.json b/tests/e2e/baseline/expected-cells.json index ddff3c48d..3e3780722 100644 --- a/tests/e2e/baseline/expected-cells.json +++ b/tests/e2e/baseline/expected-cells.json @@ -1375,6 +1375,51 @@ "status": "pass", "reason": null }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_room_rejoin.py::TestRoomRejoin::test_agent_replies_after_confirmed_leave", + "adapter": "claude_sdk", + "lanes": [ + "core" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_room_rejoin.py::TestRoomRejoin::test_agent_replies_after_confirmed_leave", + "adapter": "codex", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_room_rejoin.py::TestRoomRejoin::test_agent_replies_after_confirmed_leave", + "adapter": "copilot_acp", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_room_rejoin.py::TestRoomRejoin::test_agent_replies_after_confirmed_leave", + "adapter": "cursor_acp", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, + { + "test": "tests/e2e/baseline/smoke/behavior/test_room_rejoin.py::TestRoomRejoin::test_agent_replies_after_confirmed_leave", + "adapter": "omp_acp", + "lanes": [ + "backends" + ], + "status": "pass", + "reason": null + }, { "test": "tests/e2e/baseline/smoke/inspection/test_events.py::test_event_emitted[error]", "adapter": "suite", diff --git a/tests/e2e/baseline/smoke/adapters/test_copilot_acp.py b/tests/e2e/baseline/smoke/adapters/test_copilot_acp.py index cbdc63829..cfd1a0cd1 100644 --- a/tests/e2e/baseline/smoke/adapters/test_copilot_acp.py +++ b/tests/e2e/baseline/smoke/adapters/test_copilot_acp.py @@ -363,12 +363,12 @@ async def copilot_persisted_selection( ) spawner = CopilotACPAdapter(config) - workspace = spawner._workspaces.claim(room_id) - runtime = spawner._build_runtime(workspace) + room = spawner._room(room_id) + runtime = spawner._build_runtime(room, room.generation) await runtime.start() try: loaded = await runtime.load_session_response( - cwd=workspace, session_id=session_id, mcp_servers=[] + cwd=room.workspace, session_id=session_id, mcp_servers=[] ) finally: await runtime.stop() diff --git a/tests/e2e/baseline/smoke/behavior/test_room_rejoin.py b/tests/e2e/baseline/smoke/behavior/test_room_rejoin.py new file mode 100644 index 000000000..362ac7193 --- /dev/null +++ b/tests/e2e/baseline/smoke/behavior/test_room_rejoin.py @@ -0,0 +1,82 @@ +"""A native agent serves a room again after its leave cleanup completes.""" + +from __future__ import annotations + +import pytest + +from tests.e2e.baseline.agents import Adapter, per_adapter +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.sample_agents import ( + LIVENESS_REPLY_PROMPT, + liveness_probe, + unique_marker, +) +from tests.e2e.baseline.timeouts import slow_turn_budget +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.lifecycle import observe_room_cleanup +from tests.e2e.baseline.toolkit.provisioning import AdapterCell, running_agent +from tests.e2e.baseline.toolkit.user_ops import UserOps + +# Two replies and confirmed leave cleanup each have a bounded barrier. +BUDGET = slow_turn_budget(BaselineSettings().e2e_timeout, barriers=3) + + +class TestRoomRejoin: + @per_adapter( + Adapter.CLAUDE_SDK, + Adapter.CODEX, + Adapter.COPILOT_ACP, + Adapter.CURSOR_ACP, + Adapter.OMP_ACP, + prompt=LIVENESS_REPLY_PROMPT, + ) + @pytest.mark.timeout(extra=BUDGET.extra_s) + @pytest.mark.asyncio(loop_scope="session") + async def test_agent_replies_after_confirmed_leave( + self, + cell: AdapterCell, + user_ops: UserOps, + reply_capture: CaptureFactory, + ) -> None: + identity = await cell.provision() + room_id = await cell.resources.provision_room( + title=f"e2e-rejoin-{cell.adapter_id}", participants=[identity.id] + ) + adapter = cell.build() + before, after = unique_marker("before"), unique_marker("after") + + with ( + observe_room_cleanup(adapter, room_id) as cleanup, + cell.resources.track_running(identity.id), + ): + async with ( + running_agent(identity, adapter, cell.settings), + reply_capture(room_id) as capture, + ): + mid = await user_ops.send_message( + room_id, + liveness_probe(before), + mention_id=identity.id, + mention_name=identity.name, + ) + replies = await capture.wait_for_reply( + mid, identity.id, deadline_s=BUDGET.deadline_s + ) + replies.assert_contains_any([before]) + + cleanup.completed.clear() + await user_ops.remove_participant(room_id, identity.id) + await cleanup.wait(deadline_s=BUDGET.deadline_s) + await user_ops.add_participant(room_id, identity.id) + + mark = capture.messages.snapshot() + mid = await user_ops.send_message( + room_id, + liveness_probe(after), + mention_id=identity.id, + mention_name=identity.name, + ) + replies = await capture.wait_for_reply( + mid, identity.id, since=mark, deadline_s=BUDGET.deadline_s + ) + replies.assert_contains_any([after]) diff --git a/tests/e2e/baseline/toolkit/lifecycle.py b/tests/e2e/baseline/toolkit/lifecycle.py new file mode 100644 index 000000000..90bc9f6eb --- /dev/null +++ b/tests/e2e/baseline/toolkit/lifecycle.py @@ -0,0 +1,42 @@ +"""Observe completed room cleanup without changing the adapter's lifecycle.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from unittest.mock import patch + +from band.core.protocols import FrameworkAdapter + + +@dataclass +class RoomCleanup: + room_id: str + completed: asyncio.Event = field(default_factory=asyncio.Event) + + async def wait(self, *, deadline_s: float) -> None: + try: + async with asyncio.timeout(deadline_s): + await self.completed.wait() + except TimeoutError: + raise TimeoutError( + f"Room {self.room_id} cleanup did not complete before rejoin" + ) from None + + +@contextmanager +def observe_room_cleanup( + adapter: FrameworkAdapter, room_id: str +) -> Iterator[RoomCleanup]: + cleanup = RoomCleanup(room_id) + original = adapter.on_cleanup + + async def observed(room: str) -> None: + await original(room) + if room == room_id: + cleanup.completed.set() + + with patch.object(adapter, "on_cleanup", observed): + yield cleanup diff --git a/tests/framework_conformance/test_adapter_conformance.py b/tests/framework_conformance/test_adapter_conformance.py index 245f6ee55..0f0d0d072 100644 --- a/tests/framework_conformance/test_adapter_conformance.py +++ b/tests/framework_conformance/test_adapter_conformance.py @@ -305,7 +305,7 @@ def test_accepts_files_capability(self, adapter_config): # strands test_strands_adapter.py (ToolResultContent image block) # nooa tests/adapters/nooa/ (nooa.Image the model can show()) # copilot_sdk copilot_sdk/test_tool_bridging.py (ToolBinaryResult) -# codex test_codex_adapter.py ("inputImage" content item -- wire-shape +# codex codex/ ("inputImage" content item -- wire-shape # verified against codex-cli's generated schema; whether # codex-rs accepts the data: URI isn't checkable from Python) # pydantic_ai test_pydantic_ai_adapter.py (messages.BinaryContent) diff --git a/tests/integrations/acp/acp_toolkit/__init__.py b/tests/integrations/acp/acp_toolkit/__init__.py index 3c15d3b86..0d6f47424 100644 --- a/tests/integrations/acp/acp_toolkit/__init__.py +++ b/tests/integrations/acp/acp_toolkit/__init__.py @@ -35,13 +35,22 @@ Reply, RoomActivity, TranscriptTools, + accepted_work, + accepted_work_finished, acp_adapter, + decision_command, + decision_token, fake_agent_config, inject_acp_spawn, launch_for, live_line, make_acp_connection, + pending_decisions, + released, + room_holder, + send_to, started_acp_adapter, + started_runtime, ) __all__ = [ @@ -54,12 +63,21 @@ "Reply", "RoomActivity", "TranscriptTools", + "accepted_work", + "accepted_work_finished", "acp_adapter", + "decision_command", + "decision_token", "fake_agent_config", "inject_acp_spawn", "launch_for", "live_line", "make_acp_connection", + "pending_decisions", + "released", + "room_holder", "select_option", + "send_to", "started_acp_adapter", + "started_runtime", ] diff --git a/tests/integrations/acp/acp_toolkit/agent.py b/tests/integrations/acp/acp_toolkit/agent.py index 6284f97dc..eb39a0b6e 100644 --- a/tests/integrations/acp/acp_toolkit/agent.py +++ b/tests/integrations/acp/acp_toolkit/agent.py @@ -39,6 +39,10 @@ ) from mcp.types import Tool +from band.integrations.acp.client_profiles import ( + CURSOR_ASK_QUESTION_METHOD, + CURSOR_CREATE_PLAN_METHOD, +) from band.integrations.acp.model_selection import ( MODEL_CATEGORY, THOUGHT_LEVEL_CATEGORY, @@ -57,6 +61,9 @@ MODEL_OPTION_ID = "model" EFFORT_OPTION_ID = "reasoning_effort" +# What ``fails_next_exit`` raises from the connection's shutdown. +AGENT_EXIT_FAILURE = "agent exit failed" + @dataclass class Gate: @@ -65,6 +72,10 @@ class Gate: received: asyncio.Event = field(default_factory=asyncio.Event) release: asyncio.Event = field(default_factory=asyncio.Event) + async def hold(self) -> None: + self.received.set() + await self.release.wait() + class FakeACPAgent: """A scripted, in-process ACP agent (the peer an ACP client drives). @@ -101,11 +112,19 @@ def __init__( self._config_options = list(config_options) self._config_option_handler: ConfigOptionHandler | None = None self._reply_gate: Gate | None = None + self._reply_option: str | None = None self._hangs_up_on_config = False # Closes this agent's end of the transport; the harness binds it. self.hang_up: Callable[[], None] = lambda: None # Holds the connection's shutdown, as a subprocess slow to exit. self.exit_gate: Gate | None = None + # Fails the next connection's shutdown, as a transport whose exit raised. + self.exit_failure: Exception | None = None + # Holds the next initialize, as a process slow to start. + self._initialize_gate: Gate | None = None + self._session_load_gate: Gate | None = None + self._close_gate: Gate | None = None + self._new_session_error: RequestError | None = None # Observability for assertions: self.sessions: list[dict[str, Any]] = [] self._mcp_servers_by_session: dict[str, list[Any]] = {} @@ -115,6 +134,7 @@ def __init__( self.auth_methods: list[str] = [] self.config_option_requests: list[tuple[str, str, str]] = [] self.closed_sessions: list[str] = [] + self.cancelled_sessions: list[str] = [] self.approved: bool | None = None # -- scripting --------------------------------------------------------------- @@ -138,8 +158,9 @@ def hangs_up_on_next_config_option(self) -> None: as a crashed agent process does.""" self._hangs_up_on_config = True - def holds_config_replies(self) -> Gate: - """Apply each ``set_config_option`` at once but reply only on release.""" + def holds_config_replies(self, option_id: str | None = None) -> Gate: + """Apply matching config selections at once but reply only on release.""" + self._reply_option = option_id self._reply_gate = Gate() return self._reply_gate @@ -148,6 +169,35 @@ def exits_slowly(self) -> Gate: self.exit_gate = Gate() return self.exit_gate + def fails_next_exit(self) -> None: + """Raise from the next connection's shutdown, once.""" + self.exit_failure = RuntimeError(AGENT_EXIT_FAILURE) + + def holds_session_closes(self) -> Gate: + """Hold every ``session/close`` until release, before recording it.""" + self._close_gate = Gate() + return self._close_gate + + def advertises(self, *options: SessionConfigOption) -> FakeACPAgent: + """Advertise exactly ``options`` from now on, as a restarted agent may.""" + self._config_options = list(options) + return self + + def fails_next_new_session(self) -> FakeACPAgent: + """Refuse the next ``session/new``, once.""" + self._new_session_error = RequestError.internal_error({"detail": "no session"}) + return self + + def holds_next_initialize(self) -> Gate: + """Hold the next connection's ``initialize`` until release.""" + self._initialize_gate = Gate() + return self._initialize_gate + + def holds_next_session_load(self) -> Gate: + """Hold the next ``session/load`` until release.""" + self._session_load_gate = Gate() + return self._session_load_gate + def advertises_models( self, efforts_by_model: Mapping[str, Sequence[str]], @@ -442,8 +492,14 @@ def will_plan(self, *steps: str) -> FakeACPAgent: ) return self - def will_update_cursor_todos(self, *contents: str) -> FakeACPAgent: - self._script.append(lambda a, sid: a.update_cursor_todos(sid, *contents)) + def will_update_cursor_todos( + self, *contents: str, sessionless: bool = False + ) -> FakeACPAgent: + self._script.append( + lambda a, sid: a.update_cursor_todos( + sid, *contents, sessionless=sessionless + ) + ) return self def keeps_updating_cursor_todos(self) -> asyncio.Event: @@ -497,14 +553,24 @@ async def _action(a: FakeACPAgent, sid: str) -> None: async def say(self, session_id: str, text: str) -> None: await self.emit(session_id, update_agent_message_text(text)) - async def update_cursor_todos(self, session_id: str, *contents: str) -> None: - """Push Cursor's ``cursor/update_todos`` extension notification.""" - todos = [ - {"id": f"t{index}", "content": content, "status": "pending"} - for index, content in enumerate(contents) - ] + async def update_cursor_todos( + self, + session_id: str, + *contents: str, + sessionless: bool = False, + merge: bool = False, + todo_prefix: str = "t", + ) -> None: + """Push Cursor's ``cursor/update_todos`` extension notification; + ``sessionless`` omits its session id, as Cursor itself does.""" await self._conn_for(session_id).ext_notification( - "cursor/update_todos", {"sessionId": session_id, "todos": todos} + "cursor/update_todos", + cursor_todos( + *contents, + session_id=None if sessionless else session_id, + merge=merge, + todo_prefix=todo_prefix, + ), ) def _conn_for(self, session_id: str) -> AgentSideConnection: @@ -533,6 +599,31 @@ async def push_config_options( ), ) + async def ask_cursor_question( + self, session_id: str, question_id: str, *option_ids: str + ) -> dict[str, Any]: + """Send Cursor's sessionless ``cursor/ask_question`` and return the reply.""" + return await self._conn_for(session_id).ext_method( + CURSOR_ASK_QUESTION_METHOD, + { + "questions": [ + { + "id": question_id, + "prompt": question_id, + "options": [ + {"id": option, "label": option} for option in option_ids + ], + } + ] + }, + ) + + async def ask_cursor_plan(self, session_id: str, plan: str) -> dict[str, Any]: + """Send Cursor's sessionless plan request and return its native outcome.""" + return await self._conn_for(session_id).ext_method( + CURSOR_CREATE_PLAN_METHOD, {"plan": plan} + ) + async def ask_permission( self, session_id: str, tool_call: Any, options: list[Any] ) -> Any: @@ -588,6 +679,9 @@ async def initialize( self, protocol_version: int, client_capabilities: Any = None, **kwargs: Any ) -> InitializeResponse: del client_capabilities, kwargs + if (gate := self._initialize_gate) is not None: + self._initialize_gate = None + await gate.hold() return InitializeResponse( protocol_version=protocol_version, agent_capabilities=AgentCapabilities( @@ -599,6 +693,10 @@ async def initialize( ), ) + async def cancel(self, session_id: str, **kwargs: Any) -> None: + del kwargs + self.cancelled_sessions.append(session_id) + async def authenticate(self, method_id: str, **kwargs: Any) -> None: """Accept any auth method (Cursor's ``cursor_login``, for one).""" del kwargs @@ -609,6 +707,9 @@ async def load_session( ) -> LoadSessionResponse: del cwd, kwargs self.session_load_requests.append(session_id) + if (gate := self._session_load_gate) is not None: + self._session_load_gate = None + await gate.hold() if self._session_load_error is not None: raise self._session_load_error if session_id not in self._persisted_sessions: @@ -621,6 +722,9 @@ async def new_session( self, cwd: str, mcp_servers: Any = None, **kwargs: Any ) -> NewSessionResponse: del kwargs + if (error := self._new_session_error) is not None: + self._new_session_error = None + raise error sid = f"fake-session-{len(self.sessions) + 1}" self.sessions.append( {"session_id": sid, "cwd": cwd, "mcp_servers": list(mcp_servers or [])} @@ -644,9 +748,8 @@ async def set_config_option( self.hang_up() await asyncio.Event().wait() self._config_options = await self._select(session_id, config_id, value) - if self._reply_gate is not None: - self._reply_gate.received.set() - await self._reply_gate.release.wait() + if self._reply_gate is not None and self._reply_option in (None, config_id): + await self._reply_gate.hold() return SetSessionConfigOptionResponse(config_options=self._config_options) async def _select( @@ -671,6 +774,8 @@ def current_value(self, option_id: str) -> str | None: async def close_session(self, session_id: str, **kwargs: Any) -> None: """Record that the client closed a session before prompting it.""" del kwargs + if (gate := self._close_gate) is not None: + await gate.hold() self.closed_sessions.append(session_id) def config_selections(self, session_id: str | None = None) -> list[tuple[str, str]]: @@ -737,3 +842,18 @@ def select_option( for value in values ], ) + + +def cursor_todos( + *contents: str, + session_id: str | None = None, + merge: bool = False, + todo_prefix: str = "t", +) -> dict[str, Any]: + """A ``cursor/update_todos`` payload; Cursor itself sends no session id.""" + todos = [ + {"id": f"{todo_prefix}{index}", "content": content, "status": "pending"} + for index, content in enumerate(contents) + ] + session = {} if session_id is None else {"sessionId": session_id} + return {**session, "todos": todos, **({"merge": True} if merge else {})} diff --git a/tests/integrations/acp/acp_toolkit/harness.py b/tests/integrations/acp/acp_toolkit/harness.py index fe0efefce..b21ca1894 100644 --- a/tests/integrations/acp/acp_toolkit/harness.py +++ b/tests/integrations/acp/acp_toolkit/harness.py @@ -4,35 +4,64 @@ import asyncio import contextlib +import re import socket from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import Any, Literal +from functools import partial +from typing import Any, Literal, NamedTuple from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 from acp import connect_to_agent from acp.agent.connection import AgentSideConnection - -from band.core.types import PlatformMessage +from acp.schema import NewSessionResponse + +from band.adapters.cursor_acp import CursorACPAdapter +from band.core.protocols import AgentToolsProtocol +from band.core.retention import Residency +from band.core.types import ( + AgentInput, + HistoryProvider, + MessageType, + PlatformMessage, +) from band.integrations.acp.client_adapter import ( ACPClientAdapter, ACPClientAdapterConfig, _resolve_launcher, ) from band.integrations.acp.client_runtime import ACPRuntime -from band.integrations.acp.client_types import ACPClientSessionState, BandACPClient +from band.integrations.acp.client_types import ACPClientSessionState, ACPRoom +from band.integrations.acp.cursor import ROOM_COMMAND, CursorCommandWord from band.integrations.acp.types import ToolCallRoomEvent, ToolResultRoomEvent from band.testing import FakeAgentTools -from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.adapters.waiting import until +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent, Gate # The room ``AcpSession.send`` talks to unless told otherwise. DEFAULT_ROOM = "room-1" _SESSION_EVENT_MARKER = "acp_client_session_id" # the adapter's trailing task event +RELEASED = frozenset({Residency.SUSPENDED, Residency.CLOSED}) + +# A command naming a pending Cursor decision, capturing its word and token. +_DECISION_COMMAND = re.compile( + re.escape(f"`{ROOM_COMMAND} ") + + f"({'|'.join(map(re.escape, CursorCommandWord))})" + + r" ([^`\s]+)" +) + + +class DecisionCommand(NamedTuple): + """The first command a Cursor decision prompt asks the room to reply with.""" + + word: CursorCommandWord + token: str + @dataclass(frozen=True) class RoomActivity: @@ -147,6 +176,25 @@ def last_kwargs(self) -> dict[str, Any]: return self.calls[-1][1] +SCRIPTED_SESSION = "acp-session-123" + + +def scripted_session_spawn(session_id: str = SCRIPTED_SESSION) -> FakeSpawn: + """A spawn whose scripted connection opens ``session_id`` for every room.""" + conn = make_acp_connection() + conn.new_session = AsyncMock(return_value=NewSessionResponse(session_id=session_id)) + return FakeSpawn(conn=conn) + + +@asynccontextmanager +async def torn_down(adapter: ACPClientAdapter) -> AsyncIterator[None]: + """Tear down every room ``adapter`` opened, on exit.""" + try: + yield + finally: + await adapter.cleanup_all() + + @dataclass(frozen=True) class Launch: """How an adapter launched and set up its agent process for one room.""" @@ -159,6 +207,91 @@ class Launch: client_capabilities: object | None +class SlowFailureReports(TranscriptTools): + """Room tools whose first ``lost`` failure posts are lost, as in a REST + outage, and whose next one lands only once ``reporting`` is released.""" + + def __init__(self, *, lost: int) -> None: + super().__init__() + self.reporting = Gate() + self._lost = lost + + async def send_event( + self, content: str, message_type: str, metadata: dict[str, Any] | None = None + ) -> Any: + if message_type == MessageType.ERROR: + if self._lost: + self._lost -= 1 + raise ConnectionError("Band REST unavailable") + await self.reporting.hold() + return await super().send_event(content, message_type, metadata) + + +def pending_decisions(adapter: CursorACPAdapter) -> dict[str, list[str]]: + """The open decision tokens of each room that has any.""" + return { + room_id: list(decisions) + for room_id, decisions in adapter._pending_decisions.items() + if decisions + } + + +async def started_runtime( + adapter: ACPClientAdapter, room: str = DEFAULT_ROOM +) -> ACPRuntime: + """A started runtime built by ``adapter`` for ``room``, outside residency.""" + runtime = adapter._build_runtime(room_holder(adapter, room), 1) + await runtime.start() + return runtime + + +async def released(room: ACPRoom) -> None: + """Let a release already started for ``room`` confirm closure.""" + await until( + lambda: room.retained.snapshot.state in RELEASED, + failure=f"{room.room_id} never released", + ) + + +def accepted_work(room: ACPRoom) -> list[asyncio.Task[Any]]: + """The tasks ``room`` holds as accepted work, such as a turn still running + detached after its message returned.""" + current = asyncio.current_task() + return [ + task + for task in asyncio.all_tasks() + if task is not current and room.retained.enrolls(task) + ] + + +async def accepted_work_finished(room: ACPRoom) -> None: + """Wait for every task ``room`` holds as accepted work.""" + await asyncio.gather(*accepted_work(room), return_exceptions=True) + + +def room_holder(adapter: ACPClientAdapter, room: str = DEFAULT_ROOM) -> ACPRoom: + """A holder for ``room`` with its workspace, not registered for residency.""" + return ACPRoom(room_id=room, workspace=adapter._workspaces.claim(room)) + + +def decision_command(text: str) -> DecisionCommand | None: + """The command ``text`` asks the room to reply with, when it is a Cursor + decision prompt.""" + match = _DECISION_COMMAND.search(text) + return ( + None + if match is None + else DecisionCommand(CursorCommandWord(match[1]), match[2]) + ) + + +def decision_token(prompt: str) -> str: + """The token a Cursor decision prompt asks the room to answer with.""" + command = decision_command(prompt) + assert command is not None, prompt + return command.token + + async def launch_for(adapter: ACPClientAdapter, room: str = DEFAULT_ROOM) -> Launch: """Start ``room``'s runtime through the adapter's real ``_build_runtime`` and record the subprocess launch, which is replaced by a scripted connection.""" @@ -167,8 +300,7 @@ async def launch_for(adapter: ACPClientAdapter, room: str = DEFAULT_ROOM) -> Lau patch("band.integrations.acp.client_adapter.spawn_agent_process", spawn), patch("band.integrations.acp.client_adapter.shutil.which", return_value=None), ): - runtime = await adapter._runtime_for(room) - await runtime.start() + runtime = await started_runtime(adapter, room) await runtime.stop() command, kwargs = spawn.last_call authenticate = spawn.conn.authenticate.await_args @@ -189,16 +321,13 @@ def inject_acp_spawn( ) -> None: """Patch ``adapter._build_runtime`` so each room runtime uses ``spawn``.""" - def _build_runtime(workspace: str | None = None) -> ACPRuntime: + def _build_runtime(room: ACPRoom, generation: int) -> ACPRuntime: return ACPRuntime( command=_resolve_launcher(adapter._spawn_command(None)), env=adapter._spawn_env(), - cwd=workspace, + cwd=room.workspace, auth_method=adapter.config.auth_method, - client_factory=lambda: BandACPClient( - profile=adapter._profile, - canonicalize_tool_name=adapter._canonical_tool_name, - ), + client_factory=partial(adapter._runtime_client_factory, room, generation), spawn_process=spawn, pass_builtin_transport_options=False, ) @@ -326,6 +455,7 @@ async def send( room_context: list[dict[str, Any]] | None = None, participants_msg: str | None = None, contacts_msg: str | None = None, + tools: TranscriptTools | None = None, ) -> Reply: """Deliver ``content`` to ``room`` and return what the adapter posted back. @@ -336,7 +466,7 @@ async def send( rehydration path). ``participants_msg``/``contacts_msg`` model the change-triggered roster/contacts updates the runtime attaches to a turn. """ - tools = TranscriptTools() + tools = tools or TranscriptTools() if room_context is not None: tools.set_room_context(room_context) self._last_tools = tools @@ -351,13 +481,50 @@ async def send( ) return tools.reply + async def send_judged( + self, + content: str, + *, + room: str = DEFAULT_ROOM, + tools: TranscriptTools | None = None, + ) -> Reply: + """Deliver ``content`` through ``on_event``, as the runtime does, so + its turn is judged; return what the adapter posted back.""" + tools = self._last_tools = tools or TranscriptTools() + await self.adapter.on_event( + AgentInput( + msg=_message(content, room), + tools=tools, + history=HistoryProvider(raw=[]), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=room, + ) + ) + return tools.reply + + def room(self, room: str = DEFAULT_ROOM) -> ACPRoom: + return self.adapter._rooms[room] + def session_id(self, room: str) -> str: - return self.adapter._room_to_session[room].session_id + session = self.room(room).session + assert session is not None, f"{room} has no session" + return session.session_id + + def runtime(self, room: str = DEFAULT_ROOM) -> ACPRuntime: + runtime = self.room(room).runtime + assert runtime is not None, f"{room} has no process" + return runtime def runtime_keeps_output_of(self, session_id: str, *, room: str) -> bool: """Whether the room's runtime still holds what a turn collected for ``session_id``; a retired session's must not linger.""" - return bool(self.adapter._runtimes[room].get_collected_chunks(session_id)) + return bool(self.runtime(room).get_collected_chunks(session_id)) + + async def retired_sessions_closed(self, room: str = DEFAULT_ROOM) -> None: + """Wait for the room's background closes of retired sessions.""" + await asyncio.gather(*self.room(room).session_closes) def fake_agent_config(**settings: Any) -> ACPClientAdapterConfig: @@ -450,8 +617,10 @@ async def _spawn( async def _exit(agent: FakeACPAgent) -> None: if (gate := agent.exit_gate) is not None: - gate.received.set() - await gate.release.wait() + await gate.hold() + if (failure := agent.exit_failure) is not None: + agent.exit_failure = None + raise failure LIVE_SENDER_NAME = "Peer" @@ -466,6 +635,25 @@ def live_line(content: str) -> str: return f"[{LIVE_SENDER_NAME}]: {content}" +async def send_to( + adapter: ACPClientAdapter, + room_id: str, + tools: AgentToolsProtocol | None = None, + *, + content: str = "Hello", +) -> None: + """Deliver one non-bootstrap message to ``room_id``, as the runtime does.""" + await adapter.on_message( + _message(content, room_id), + tools or TranscriptTools(), + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=room_id, + ) + + def _message(content: str, room_id: str) -> PlatformMessage: return PlatformMessage( id=str(uuid4()), diff --git a/tests/integrations/acp/acp_toolkit/lifecycle.py b/tests/integrations/acp/acp_toolkit/lifecycle.py new file mode 100644 index 000000000..0c3b664f3 --- /dev/null +++ b/tests/integrations/acp/acp_toolkit/lifecycle.py @@ -0,0 +1,170 @@ +"""The OMP, Cursor and Copilot ACP adapters' bridge into the common Room +lifecycle suite (``tests/adapters/lifecycle``). + +Each room process is a real stdio ACP agent (:mod:`workspacepeer`) started +through the adapter's production runtime; only the spawn is wrapped, to hold +its start and hold or refuse its exit. +""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Sequence +from contextlib import AbstractAsyncContextManager +from functools import partial +from typing import Any, Literal, Self + +import pytest +from acp import spawn_agent_process + +from band.adapters.copilot_acp import CopilotACPAdapter, CopilotACPAdapterConfig +from band.adapters.cursor_acp import CursorACPAdapter, CursorACPAdapterConfig +from band.adapters.omp_acp import OmpACPAdapter, OmpACPAdapterConfig +from band.core.retention import RetainedRoom +from band.integrations.acp import client_adapter, client_runtime +from band.integrations.acp.client_adapter import ACPClientAdapter +from band.integrations.omp import OMP_ACP_SUBCOMMAND, OMP_CWD_FLAG +from tests.adapters.hold import Hold +from tests.adapters.lifecycle.kit import LifecycleBridge, RetentionHost, pause_at +from tests.adapters.roompeer import CLEANUP_FAILURE +from tests.paths import REPO_ROOT + +ACPProfile = Literal["omp_acp", "cursor_acp", "copilot_acp"] + +PEER_MODULE = "tests.integrations.acp.acp_toolkit.workspacepeer" +# How OMP is told its room workspace, which it gets instead of a launch cwd. +OMP_CWD_PREFIX = f"{OMP_CWD_FLAG}=" + + +class LifecycleChild: + """One room's ACP agent process, with the suite's held start and its held + or refused exit injected at the spawn.""" + + def __init__(self, spawn: AbstractAsyncContextManager[Any]) -> None: + self.refuse_close = False + self.starting: Hold | None = None + self.closing: Hold | None = None + self._spawn = spawn + self._process: Any = None + self._open = False + + @property + def process(self) -> Any: + return self._process + + @property + def exited(self) -> bool: + return self._process is not None and self._process.returncode is not None + + async def __aenter__(self) -> Any: + connection, self._process = await self._spawn.__aenter__() + self._open = True + await pause_at(self.starting) + return connection, self._process + + async def __aexit__(self, *exc_info: object) -> None: + await pause_at(self.closing) + if self.refuse_close: + # The process keeps running, still owned by its room. + raise RuntimeError(CLEANUP_FAILURE) + await self.close() + + async def close(self) -> None: + """Exit the spawn once, including one a refused exit left open.""" + if self._open: + self._open = False + await self._spawn.__aexit__(None, None, None) + + +class FailingExits: + """``spawn_agent_process`` whose every exit is refused, leaving the agent + running as a failed teardown does; leaving it closes them all.""" + + def __init__(self) -> None: + self.children: list[LifecycleChild] = [] + + def __call__(self, *args: Any, **kwargs: Any) -> LifecycleChild: + child = LifecycleChild(spawn_agent_process(*args, **kwargs)) + child.refuse_close = True + self.children.append(child) + return child + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *exc_info: object) -> None: + for child in self.children: + await child.close() + + +def install( + profile: ACPProfile, host: RetentionHost, monkeypatch: pytest.MonkeyPatch +) -> LifecycleBridge: + """Build ``profile``'s adapter on its production config, every room process + a :class:`LifecycleChild` handed to ``host``.""" + adapter = _adapter(profile, host) + + def spawn(client: Any, *command: str, cwd: str | None, **kwargs: Any) -> Any: + child = LifecycleChild(spawn_agent_process(client, *command, cwd=cwd, **kwargs)) + host.spawned(child, _workspace(command, cwd)) + return child + + kill_process = client_runtime._kill + + async def kill(process: asyncio.subprocess.Process) -> None: + child = next( + child + for child in host.children + if isinstance(child, LifecycleChild) and child.process is process + ) + await pause_at(child.closing) + await kill_process(process) + + monkeypatch.setattr(client_adapter, "spawn_agent_process", spawn) + monkeypatch.setattr(client_runtime, "_kill", kill) + return LifecycleBridge( + adapter=adapter, + residency=partial(_residency, adapter), + native_id=partial(_native_id, adapter), + ) + + +def _adapter(profile: ACPProfile, host: RetentionHost) -> ACPClientAdapter[Any]: + # The venv's launcher redirector would hide the peer's pid and exit on Windows. + command = (sys._base_executable, "-m", PEER_MODULE, *host.peer_arguments) + env = {"PYTHONPATH": str(REPO_ROOT), "__PYVENV_LAUNCHER__": sys.executable} + match profile: + case "omp_acp": + return OmpACPAdapter( + OmpACPAdapterConfig(command=(*command, OMP_ACP_SUBCOMMAND), env=env), + workspace_for_room=host.workspace, + ) + case "cursor_acp": + return CursorACPAdapter( + CursorACPAdapterConfig(command=command, env=env), + workspace_for_room=host.workspace, + ) + case "copilot_acp": + return CopilotACPAdapter( + CopilotACPAdapterConfig(command=command, env=env), + workspace_for_room=host.workspace, + ) + + +def _workspace(command: Sequence[str], cwd: str | None) -> str | None: + """The room workspace a process was started in.""" + if cwd is not None: + return cwd + flagged = (arg for arg in command if arg.startswith(OMP_CWD_PREFIX)) + return next((arg.removeprefix(OMP_CWD_PREFIX) for arg in flagged), None) + + +def _residency(adapter: ACPClientAdapter[Any], room_id: str) -> RetainedRoom | None: + room = adapter._rooms.get(room_id) + return None if room is None else room.retained + + +def _native_id(adapter: ACPClientAdapter[Any], room_id: str) -> str | None: + room = adapter._rooms.get(room_id) + return None if room is None or room.session is None else room.session.session_id diff --git a/tests/integrations/acp/acp_toolkit/peer.py b/tests/integrations/acp/acp_toolkit/peer.py index 3bb1df192..eacb5df5c 100644 --- a/tests/integrations/acp/acp_toolkit/peer.py +++ b/tests/integrations/acp/acp_toolkit/peer.py @@ -5,6 +5,7 @@ import argparse import asyncio import os +import subprocess import sys from enum import StrEnum from pathlib import Path @@ -26,6 +27,8 @@ INITIALIZE_PENDING_LINE = "initialization pending" PEER_EXIT_CODE = 3 CRASH_MARKER_ARG = "--crash-marker" +ORPHAN_PID_FILE_ARG = "--orphan-pid-file" +ORPHAN_LIFETIME_S = 120 RECOVERED_REPLY = "Recovered after the connection failed." @@ -42,7 +45,12 @@ class ExitStage(StrEnum): def stdio_peer_config( - stage: ExitStage, lines: list[str], *, crash_marker: Path | None = None + stage: ExitStage, + lines: list[str], + *, + crash_marker: Path | None = None, + orphan_pid_file: Path | None = None, + **settings: Any, ) -> ACPClientAdapterConfig: # Windows' venv redirector keeps stdout open until the peer exits. command = [ @@ -55,13 +63,30 @@ def stdio_peer_config( ] if crash_marker is not None: command.extend((CRASH_MARKER_ARG, str(crash_marker))) + if orphan_pid_file is not None: + command.extend((ORPHAN_PID_FILE_ARG, str(orphan_pid_file))) return ACPClientAdapterConfig( command=command, env={"__PYVENV_LAUNCHER__": sys.executable}, inject_band_tools=crash_marker is not None, + **settings, ) +def spawn_pipe_holder(pid_file: Path) -> None: + """Start a descendant that inherits this peer's stdout and stderr, as a + background job does, and record its pid.""" + holder = subprocess.Popen( + # Not sys.executable: under __PYVENV_LAUNCHER__ that is Windows' venv + # redirector, whose pid is not the child holding the pipes. + [sys._base_executable, "-c", f"import time; time.sleep({ORPHAN_LIFETIME_S})"], + stdin=subprocess.DEVNULL, + stdout=sys.stdout, + stderr=sys.stderr, + ) + pid_file.write_text(str(holder.pid)) + + class StdioPeer(FakeACPAgent): def __init__( self, stage: ExitStage, code: int, lines: list[str], crash_marker: Path | None @@ -149,7 +174,10 @@ async def main() -> None: parser.add_argument("code", type=int) parser.add_argument("lines", nargs="*") parser.add_argument(CRASH_MARKER_ARG, type=Path) + parser.add_argument(ORPHAN_PID_FILE_ARG, type=Path) args = parser.parse_args() + if args.orphan_pid_file is not None: + spawn_pipe_holder(args.orphan_pid_file) peer = StdioPeer(args.stage, args.code, args.lines, args.crash_marker) await run_agent(peer) peer.exit_process() diff --git a/tests/integrations/acp/acp_toolkit/workspacepeer.py b/tests/integrations/acp/acp_toolkit/workspacepeer.py new file mode 100644 index 000000000..66ca2a467 --- /dev/null +++ b/tests/integrations/acp/acp_toolkit/workspacepeer.py @@ -0,0 +1,197 @@ +"""A real stdio ACP agent that answers the common Room lifecycle suite's +commands in its workspace.""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from enum import StrEnum +from pathlib import Path +from typing import Any + +from acp import RequestError, run_agent +from acp.schema import ( + AllowedOutcome, + InitializeResponse, + NewSessionResponse, + ToolCallUpdate, +) +from pydantic import BaseModel + +from band.integrations.omp import OMP_CWD_FLAG +from band.runtime.tools import BandTool +from band.runtime.tools.registry import BAND_MCP_SERVER_NAME +from tests.adapters.roompeer import ( + FAIL_STARTUP_ARG, + HOLD_POLL_S, + HOLD_STARTUP_ARG, + STARTUP_FAILURE, + WORKSPACE_NOTE, + PeerCommand, + begin_startup, + file_request, + perform, + reply_text, + startup_held, + startup_refused, +) +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent, allow_or_reject + +CURSOR_WORKFLOW_PREFIX = "CURSOR_WORKFLOW=" +CURSOR_QUESTION_ID = "proceed" +CURSOR_ALLOW_OPTION = "allow-workspace-write" +CURSOR_JOURNAL = "cursor-journal.jsonl" +CURSOR_SETUP_HOLD = "hold-session-setup" +CURSOR_SETUP_READY = "session-allocated" + + +class CursorChoice(StrEnum): + CONTINUE = "continue" + CANCEL = "cancel" + + +class CursorPeerEvent(StrEnum): + SESSION_CREATED = "session-created" + PROMPT_STARTED = "prompt-started" + QUESTION_ANSWERED = "question-answered" + FILE_WRITTEN = "file-written" + + +class CursorPeerRecord(BaseModel): + event: CursorPeerEvent + value: str + + +def record_cursor(event: CursorPeerEvent, value: str) -> None: + with Path(CURSOR_JOURNAL).open("a") as stream: + stream.write( + CursorPeerRecord(event=event, value=value).model_dump_json() + "\n" + ) + + +def cursor_workflow(marker: str) -> str: + return CURSOR_WORKFLOW_PREFIX + marker + + +async def wait_for_release(hold: Path | None) -> None: + while startup_held(hold): + await asyncio.sleep(HOLD_POLL_S) + + +class WorkspacePeer(FakeACPAgent): + """``--hold-startup`` holds ``initialize`` while its file exists and + ``--fail-startup`` refuses it, as the suite's other peers do.""" + + def __init__(self, failure: Path | None, hold: Path | None) -> None: + super().__init__(supports_session_load=True) + self.failure = failure + self.hold = hold + self.on_prompt(self.answer) + + async def initialize( + self, protocol_version: int, client_capabilities: Any = None, **kwargs: Any + ) -> InitializeResponse: + begin_startup() + await wait_for_release(self.hold) + if startup_refused(self.failure): + raise RequestError(-32000, STARTUP_FAILURE) + return await super().initialize(protocol_version, client_capabilities, **kwargs) + + async def new_session( + self, cwd: str, mcp_servers: Any = None, **kwargs: Any + ) -> NewSessionResponse: + session = await super().new_session(cwd, mcp_servers, **kwargs) + record_cursor(CursorPeerEvent.SESSION_CREATED, session.session_id) + hold = Path(CURSOR_SETUP_HOLD) + if hold.exists(): + Path(CURSOR_SETUP_READY).touch() + await wait_for_release(hold) + return session + + async def answer(self, peer: FakeACPAgent, session_id: str) -> None: + content = peer.prompt_texts()[-1] + record_cursor(CursorPeerEvent.PROMPT_STARTED, content) + if PeerCommand.EXIT_PROCESS in content: + os._exit(0) + if PeerCommand.LOSE_TRANSPORT in content: + # A frame that is not JSON-RPC faults the client's reader while + # this process stays alive. + sys.stdout.write("[]\n") + sys.stdout.flush() + await asyncio.Future[None]() + if CURSOR_WORKFLOW_PREFIX in content: + marker = content.split(CURSOR_WORKFLOW_PREFIX, 1)[1] + await self.cursor_workflow(session_id, marker) + return + request = file_request(content) + result = perform(request["tool_name"], request["input"]) + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={ + "content": reply_text(request["tool_name"], result), + "mentions": ["@alice"], + }, + ) + + async def cursor_workflow(self, session_id: str, marker: str) -> None: + """A native edit waits for both a Cursor question and shell permission.""" + await self.update_cursor_todos( + session_id, + f"{marker}: waiting", + sessionless=True, + merge=True, + todo_prefix=marker, + ) + result = await self.ask_cursor_question( + session_id, CURSOR_QUESTION_ID, *CursorChoice + ) + answers = result.get("outcome", {}).get("answers", []) + selected = [ + option for answer in answers for option in answer["selectedOptionIds"] + ] + if selected != [CursorChoice.CONTINUE]: + return + record_cursor(CursorPeerEvent.QUESTION_ANSWERED, selected[0]) + permission = await self.ask_permission( + session_id, + ToolCallUpdate(tool_call_id="workspace-write", title="shell"), + allow_or_reject(CURSOR_ALLOW_OPTION), + ) + if not isinstance(permission.outcome, AllowedOutcome): + return + Path(WORKSPACE_NOTE).write_text(marker) + record_cursor(CursorPeerEvent.FILE_WRITTEN, marker) + await self.update_cursor_todos( + session_id, + f"{marker}: completed", + sessionless=True, + merge=True, + todo_prefix=marker, + ) + await self.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": marker, "mentions": ["@alice"]}, + ) + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument(FAIL_STARTUP_ARG, type=Path) + parser.add_argument(HOLD_STARTUP_ARG, type=Path) + # OMP passes its room's workspace as a flag instead of the launch cwd. + parser.add_argument(OMP_CWD_FLAG) + # Each profile adds its own launch flags and subcommand. + options, _ = parser.parse_known_args() + if options.cwd is not None: + os.chdir(options.cwd) + await run_agent(WorkspacePeer(options.fail_startup, options.hold_startup)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integrations/acp/clientadapter/__init__.py b/tests/integrations/acp/clientadapter/__init__.py new file mode 100644 index 000000000..9a58fd212 --- /dev/null +++ b/tests/integrations/acp/clientadapter/__init__.py @@ -0,0 +1 @@ +"""Tests for ACPClientAdapter, one concern per module.""" diff --git a/tests/integrations/acp/clientadapter/conftest.py b/tests/integrations/acp/clientadapter/conftest.py new file mode 100644 index 000000000..ba574a41c --- /dev/null +++ b/tests/integrations/acp/clientadapter/conftest.py @@ -0,0 +1,46 @@ +"""The scripted room process most ACPClientAdapter tests run on.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +from band.integrations.acp.client_adapter import ACPClientAdapter +from tests.integrations.acp.acp_toolkit.harness import ( + FakeSpawn, + scripted_session_spawn, +) +from tests.integrations.acp.clientadapter import kit + + +@pytest.fixture +def mock_spawn() -> FakeSpawn: + """The scripted connection each of the room's processes starts with.""" + return scripted_session_spawn() + + +@pytest_asyncio.fixture(name="adapter_on", loop_scope="function") +async def torn_down_adapters() -> AsyncIterator[kit.AdapterOn]: + """Builds adapters as ``kit.adapter_on`` does; every one is torn down + after the test.""" + async with AsyncExitStack() as stack: + + def build(conn: AsyncMock, **settings: object) -> ACPClientAdapter: + adapter = kit.adapter_on(conn, **settings) + stack.push_async_callback(adapter.cleanup_all) + return adapter + + yield build + + +@pytest.fixture +def adapter_with_mocks( + mock_spawn: FakeSpawn, adapter_on: kit.AdapterOn +) -> ACPClientAdapter: + """An adapter whose room processes run on ``mock_spawn``'s connection, + its rooms torn down after the test.""" + return adapter_on(mock_spawn.conn) diff --git a/tests/integrations/acp/clientadapter/kit.py b/tests/integrations/acp/clientadapter/kit.py new file mode 100644 index 000000000..4fa8e97f3 --- /dev/null +++ b/tests/integrations/acp/clientadapter/kit.py @@ -0,0 +1,79 @@ +"""Shared connections, adapters and event projections for ACPClientAdapter tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path +from unittest.mock import AsyncMock + +from acp.schema import NewSessionResponse + +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from band.integrations.acp.client_runtime import ACPRuntime +from band.testing import FakeAgentTools +from tests.integrations.acp.acp_toolkit.harness import ( + FakeSpawn, + inject_acp_spawn, + make_acp_connection, +) + +MOCK_ROOM = "room-123" +# Builds an adapter as ``adapter_on`` does; the conftest fixture of that name +# tears each one down. +AdapterOn = Callable[..., ACPClientAdapter] +CODEX = ACPClientAdapterConfig(command="codex") + + +def advertise_session_load(spawn: FakeSpawn) -> None: + """Make the scripted connection advertise ``loadSession`` at initialize.""" + spawn.conn.initialize.return_value.agent_capabilities.load_session = True + + +def permission_events(tools: FakeAgentTools) -> list[dict[str, object]]: + """The permission tool_call/tool_result events the handler posted to the room.""" + return [ + event + for event in tools.events_sent + if (event.get("metadata") or {}).get("permission_request") + ] + + +def event_types(events: list[dict[str, object]]) -> list[object]: + """The ordered ``message_type`` of each event — for asserting a pair's shape.""" + return [event["message_type"] for event in events] + + +def metadata_values(events: list[dict[str, object]], key: str) -> list[object]: + """The ordered value of one metadata field across a set of events.""" + return [event["metadata"][key] for event in events] + + +def scripted_conn(sessions: dict[str, str]) -> AsyncMock: + """A connection whose ``new_session`` names each room's session from + ``sessions``, keyed by the room id its workspace ends in.""" + conn = make_acp_connection() + conn.new_session = AsyncMock( + side_effect=lambda *, cwd, mcp_servers: NewSessionResponse( + session_id=sessions[Path(cwd).name] + ) + ) + return conn + + +def adapter_on(conn: AsyncMock, **settings: object) -> ACPClientAdapter: + """An adapter whose room processes all run on ``conn``.""" + adapter = ACPClientAdapter( + CODEX.model_copy(update={"inject_band_tools": False, **settings}) + ) + inject_acp_spawn(adapter, FakeSpawn(conn=conn)) + return adapter + + +def room_runtime(adapter: ACPClientAdapter, room: str = MOCK_ROOM) -> ACPRuntime: + """The process ``adapter`` currently runs for ``room``.""" + runtime = adapter._rooms[room].runtime + assert runtime is not None + return runtime diff --git a/tests/integrations/acp/clientadapter/test_cleanup.py b/tests/integrations/acp/clientadapter/test_cleanup.py new file mode 100644 index 000000000..e15cf8333 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_cleanup.py @@ -0,0 +1,167 @@ +"""Tests for ACPClientAdapter cleanup.""" + +from __future__ import annotations + +import asyncio +import logging +from unittest.mock import AsyncMock + +import pytest +from acp.schema import ( + NewSessionResponse, +) + +from band.integrations.acp import client_adapter +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, +) +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import ( + acp_adapter, + fake_agent_config, + room_holder, +) +from tests.integrations.acp.clientadapter.kit import CODEX +from tests.mcpbackends import backends_created_by + + +class TestACPClientAdapterCleanup: + """Tests for ACPClientAdapter cleanup.""" + + @pytest.mark.asyncio + async def test_on_cleanup_disposes_the_room_but_not_the_shared_backend( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + with backends_created_by(): + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + await session.send("hello", room="room-123") + runtime = session.runtime("room-123") + backend = session.adapter._mcp.current + + await session.adapter.on_cleanup("room-123") + + assert runtime.closed + assert "room-123" not in session.adapter._rooms + assert session.adapter._workspaces.rooms == () + assert session.adapter._mcp.current is backend + + @pytest.mark.asyncio + async def test_on_cleanup_idempotent(self) -> None: + """Should handle cleanup of non-existent room.""" + adapter = ACPClientAdapter(CODEX) + + await adapter.on_cleanup("nonexistent-room") + + @pytest.mark.asyncio + async def test_on_cleanup_twice(self) -> None: + """Should handle cleanup called twice.""" + async with acp_adapter(FakeACPAgent().will_say("ok")) as session: + await session.send("hello", room="room-123") + + await session.adapter.on_cleanup("room-123") + await session.adapter.on_cleanup("room-123") + + assert "room-123" not in session.adapter._rooms + + @pytest.mark.asyncio + async def test_fresh_session_cleanup_times_out( + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + adapter = ACPClientAdapter(CODEX) + runtime = adapter._build_runtime(room_holder(adapter, "room-1"), 1) + blocked_close = asyncio.Event() + + async def wait_to_close(_: str) -> None: + await blocked_close.wait() + + runtime.close_session = AsyncMock(wraps=wait_to_close) + monkeypatch.setattr(client_adapter, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.01) + + with caplog.at_level(logging.WARNING): + await adapter._close_session( + runtime, + "session-1", + reason=client_adapter.SessionCloseReason.UNCONFIGURED, + ) + + runtime.close_session.assert_awaited_once_with("session-1") + assert caplog.messages == [ + "Timed out closing ACP session session-1 (unconfigured) after 0.01 seconds" + ] + + @pytest.mark.asyncio + async def test_cancelled_fresh_session_does_not_wait_to_close(self) -> None: + adapter = ACPClientAdapter(CODEX) + room = room_holder(adapter, "room-1") + runtime = adapter._build_runtime(room, 1) + initialization_started = asyncio.Event() + close_started = asyncio.Event() + release_close = asyncio.Event() + close_finished = asyncio.Event() + runtime.create_session_response = AsyncMock( + return_value=NewSessionResponse(session_id="session-1") + ) + + async def wait_to_close(_: str) -> None: + close_started.set() + await release_close.wait() + close_finished.set() + + runtime.close_session = AsyncMock(wraps=wait_to_close) + + async def initialize() -> None: + async with adapter._fresh_session(room, runtime, []): + initialization_started.set() + await asyncio.Event().wait() + + initializing = asyncio.create_task(initialize()) + await initialization_started.wait() + initializing.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(initializing, timeout=0.1) + + await close_started.wait() + release_close.set() + await close_finished.wait() + + @pytest.mark.asyncio + async def test_cancelled_fresh_session_close_is_held_by_its_room(self) -> None: + """The background close task is held by the room until it finishes, so + the room's release can wait for it.""" + adapter = ACPClientAdapter(CODEX) + room = room_holder(adapter, "room-1") + runtime = adapter._build_runtime(room, 1) + initialization_started = asyncio.Event() + release_close = asyncio.Event() + runtime.create_session_response = AsyncMock( + return_value=NewSessionResponse(session_id="session-1") + ) + + async def wait_to_close(_: str) -> None: + await release_close.wait() + + runtime.close_session = AsyncMock(wraps=wait_to_close) + + async def initialize() -> None: + async with adapter._fresh_session(room, runtime, []): + initialization_started.set() + await asyncio.Event().wait() + + initializing = asyncio.create_task(initialize()) + await initialization_started.wait() + initializing.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(initializing, timeout=0.1) + + [closing] = room.session_closes + + release_close.set() + await closing + + assert room.session_closes == set() diff --git a/tests/integrations/acp/clientadapter/test_config.py b/tests/integrations/acp/clientadapter/test_config.py new file mode 100644 index 000000000..0df184e69 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_config.py @@ -0,0 +1,133 @@ +"""The settings ``ACPClientAdapterConfig`` validates and the launch they produce.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from acp.schema import ( + McpServerStdio, + SseMcpServer, +) +from pydantic import ValidationError + +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from tests.integrations.acp.acp_toolkit.harness import ( + Launch, + launch_for, +) +from tests.integrations.acp.clientadapter.kit import CODEX + + +class TestACPClientAdapterConfig: + """The settings ``ACPClientAdapterConfig`` validates and the launch they produce.""" + + @pytest.mark.asyncio + async def test_a_string_command_launches_as_one_argument( + self, tmp_path: Path + ) -> None: + adapter = ACPClientAdapter(CODEX, workspace_for_room=lambda _: str(tmp_path)) + + launch = await launch_for(adapter) + + assert launch.command == ("codex",) + + @pytest.mark.asyncio + async def test_settings_reach_the_launched_agent(self, tmp_path: Path) -> None: + config = ACPClientAdapterConfig( + command=["gemini", "cli"], + env={"API_KEY": "test"}, + auth_method="api_key", + use_unstable_protocol=True, + ) + adapter = ACPClientAdapter(config, workspace_for_room=lambda _: str(tmp_path)) + + launch = await launch_for(adapter) + + assert launch == Launch( + command=("gemini", "cli"), + env={"API_KEY": "test"}, + cwd=str(tmp_path), + use_unstable_protocol=True, + auth_method="api_key", + client_capabilities=None, + ) + + def test_command_is_required(self) -> None: + with pytest.raises(ValidationError, match="command\n Field required"): + ACPClientAdapterConfig.model_validate({}) + + @pytest.mark.parametrize("command", [[], ""]) + def test_an_empty_command_is_rejected(self, command: list[str] | str) -> None: + with pytest.raises(ValueError, match="ACP stdio transport requires a command"): + ACPClientAdapterConfig(command=command) + + @pytest.mark.parametrize( + "setting", + [{"host": "10.0.0.5", "port": 8080}, {"host": "10.0.0.5"}, {"port": 8080}], + ids=["host-and-port", "host", "port"], + ) + def test_a_shared_tcp_process_is_rejected(self, setting: dict[str, object]) -> None: + with pytest.raises(ValueError, match="TCP ACP transport cannot guarantee"): + ACPClientAdapterConfig.model_validate({"command": "codex", **setting}) + + def test_host_mcp_servers_load_as_acp_servers(self) -> None: + config = ACPClientAdapterConfig.model_validate( + { + "command": "codex", + "mcp_servers": [ + {"name": "fs", "command": "npx", "args": ["fs"], "env": []}, + { + "type": "sse", + "name": "band", + "url": "http://h/sse", + "headers": [], + }, + ], + } + ) + + assert config.mcp_servers == ( + McpServerStdio(name="fs", command="npx", args=["fs"], env=[]), + SseMcpServer(type="sse", name="band", url="http://h/sse", headers=[]), + ) + + def test_a_malformed_mcp_server_fails_at_load_not_at_session_start(self) -> None: + with pytest.raises(ValidationError, match="mcp_servers.0"): + ACPClientAdapterConfig.model_validate( + {"command": "codex", "mcp_servers": [{"name": "fs"}]} + ) + + @pytest.mark.parametrize("turn_timeout_s", [0, -1.0]) + def test_turn_timeout_must_be_positive(self, turn_timeout_s: float) -> None: + with pytest.raises(ValueError, match="greater than 0"): + ACPClientAdapterConfig(command="codex", turn_timeout_s=turn_timeout_s) + + def test_a_typed_selection_and_a_resolver_are_exclusive(self) -> None: + config = ACPClientAdapterConfig(command="codex", reasoning_effort="high") + + with pytest.raises(ValueError, match="not both"): + ACPClientAdapter(config, resolve_session_config=AsyncMock()) + + def test_a_custom_transport_is_rejected(self) -> None: + with pytest.raises( + ValueError, + match="custom ACP transports cannot guarantee room process isolation", + ): + ACPClientAdapter(CODEX, spawn_process=object()) + + def test_starts_with_no_room_state(self) -> None: + adapter = ACPClientAdapter(CODEX) + + assert adapter._rooms == {} + assert adapter._workspaces.rooms == () + + def test_retired_acp_delivery_mode_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="Extra inputs"): + ACPClientAdapterConfig.model_validate( + {"command": "codex", "assistant_text_mode": "thought"} + ) diff --git a/tests/integrations/acp/clientadapter/test_cursor_profile_extensions.py b/tests/integrations/acp/clientadapter/test_cursor_profile_extensions.py new file mode 100644 index 000000000..17fe495d2 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_cursor_profile_extensions.py @@ -0,0 +1,319 @@ +"""Tests for Cursor-specific extension handling via ACP client profiles.""" + +from __future__ import annotations + +import pytest + +from band.integrations.acp.client_profiles import CursorACPClientProfile +from band.integrations.acp.client_runtime import ACPCollectingClient + + +class TestACPCollectingClientCursorProfileExtensions: + """Tests for Cursor-specific extension handling via ACP client profiles.""" + + @pytest.mark.asyncio + async def test_ext_method_cursor_ask_question(self) -> None: + """Forwards Cursor's complete question payload to the decision bridge.""" + received: dict[str, object] = {} + + async def resolve(method: str, params: dict[str, object]) -> dict[str, object]: + received.update(method=method, params=params) + return { + "outcome": { + "outcome": "answered", + "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], + } + } + + client = ACPCollectingClient(profile=CursorACPClientProfile(resolve)) + + result = await client.ext_method( + "cursor/ask_question", + { + "questions": [ + { + "id": "q1", + "prompt": "Choose", + "options": [{"id": "a", "label": "A"}], + } + ], + }, + ) + + assert received["method"] == "cursor/ask_question" + assert result["outcome"]["outcome"] == "answered" + + @pytest.mark.asyncio + async def test_ext_method_without_decision_bridge_cancels_when_unanswerable( + self, + ) -> None: + """A bare profile (e.g. the generic ACP bridge) has nothing to pick + from an empty question list, and must still cancel rather than + fabricate an answer.""" + client = ACPCollectingClient(profile=CursorACPClientProfile()) + + result = await client.ext_method("cursor/ask_question", {"questions": []}) + + assert result == {"outcome": {"outcome": "cancelled"}} + + @pytest.mark.asyncio + async def test_ext_method_without_decision_bridge_auto_answers_unattended( + self, + ) -> None: + """Regression: resolve_acp_client_profile("cursor") (the generic ACP + bridge's factory) has no room to relay a decision to and no resolver + -- it must answer unattended (main's prior behavior) rather than + cancelling every real question and plan outright.""" + client = ACPCollectingClient(profile=CursorACPClientProfile()) + + ask_result = await client.ext_method( + "cursor/ask_question", + { + "questions": [ + { + "id": "q1", + "prompt": "Choose", + "options": [ + {"id": "a", "label": "A"}, + {"id": "b", "label": "B"}, + ], + } + ], + }, + ) + plan_result = await client.ext_method("cursor/create_plan", {"plan": "stuff"}) + + assert ask_result == { + "outcome": { + "outcome": "answered", + "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], + } + } + assert plan_result == {"outcome": {"outcome": "accepted"}} + + @pytest.mark.asyncio + async def test_ext_method_cursor_create_plan(self) -> None: + """Forwards plan approval to the decision bridge.""" + + async def resolve(method: str, params: dict[str, object]) -> dict[str, object]: + del method, params + return {"outcome": {"outcome": "accepted"}} + + client = ACPCollectingClient(profile=CursorACPClientProfile(resolve)) + + result = await client.ext_method("cursor/create_plan", {"plan": "stuff"}) + + assert result == {"outcome": {"outcome": "accepted"}} + + @pytest.mark.asyncio + async def test_ext_method_unknown_returns_empty(self) -> None: + """Should return empty dict for unknown extension methods.""" + client = ACPCollectingClient(profile=CursorACPClientProfile()) + + result = await client.ext_method("unknown/method", {}) + + assert result == {} + + @pytest.mark.asyncio + async def test_ext_notification_cursor_update_todos(self) -> None: + """Cursor's merge flag preserves prior todo state.""" + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/update_todos", + { + "todos": [ + {"id": "read", "content": "Read code", "status": "completed"} + ], + "merge": False, + }, + ) + await client.ext_notification( + "cursor/update_todos", + { + "todos": [ + {"id": "test", "content": "Write tests", "status": "pending"} + ], + "merge": True, + }, + ) + + chunks = client.get_collected_chunks("sess-1") + assert "[x] Read code" in chunks[-1].content + assert "[ ] Write tests" in chunks[-1].content + + @pytest.mark.asyncio + async def test_ext_notification_cursor_update_todos_clearing_the_list_still_renders( + self, + ) -> None: + """Regression: a `todos: []` update that legitimately clears the list + used to emit no chunk at all, leaving the room showing the stale + checklist from before the clear.""" + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/update_todos", + { + "todos": [ + {"id": "read", "content": "Read code", "status": "completed"} + ], + "merge": False, + }, + ) + await client.ext_notification( + "cursor/update_todos", + {"todos": [], "merge": False}, + ) + + chunks = client.get_collected_chunks("sess-1") + assert len(chunks) == 2 + assert profile._todos_by_session["sess-1"] == {} + assert chunks[-1].content != chunks[0].content + + @pytest.mark.asyncio + async def test_ext_notification_cursor_todos_do_not_cross_sessions(self) -> None: + active = "first" + profile = CursorACPClientProfile(active_session=lambda: active) + client = ACPCollectingClient(profile=profile) + await client.ext_notification( + "cursor/update_todos", + { + "todos": [{"id": "old", "content": "Old task", "status": "pending"}], + "merge": False, + }, + ) + active = "second" + await client.ext_notification( + "cursor/update_todos", + { + "todos": [{"id": "new", "content": "New task", "status": "pending"}], + "merge": True, + }, + ) + + chunks = client.get_collected_chunks("second") + assert chunks[-1].content == "- [ ] New task" + + @pytest.mark.asyncio + async def test_ext_notification_cursor_task(self) -> None: + """Renders documented task metadata without inventing a result field.""" + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/task", + { + "description": "Explore authentication", + "prompt": "Find the auth module", + "subagentType": "explore", + "model": "Auto", + }, + ) + + chunks = client.get_collected_chunks("sess-1") + assert len(chunks) == 1 + assert chunks[0].chunk_type == "plan" + assert "Explore authentication" in chunks[0].content + + @pytest.mark.asyncio + async def test_ext_notification_cursor_task_without_a_description_is_a_noop( + self, + ) -> None: + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification("cursor/task", {"description": ""}) + + assert client.get_collected_chunks("sess-1") == [] + + @pytest.mark.asyncio + async def test_ext_notification_cursor_generate_image_without_a_description_is_a_noop( + self, + ) -> None: + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/generate_image", {"filePath": "/tmp/logo.png"} + ) + + assert client.get_collected_chunks("sess-1") == [] + + @pytest.mark.asyncio + async def test_ext_notification_cursor_update_todos_marks_in_progress_and_cancelled( + self, + ) -> None: + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/update_todos", + { + "todos": [ + {"id": "a", "content": "Working", "status": "in_progress"}, + {"id": "b", "content": "Dropped", "status": "cancelled"}, + ], + "merge": False, + }, + ) + + chunks = client.get_collected_chunks("sess-1") + assert "[~] Working" in chunks[-1].content + assert "[-] Dropped" in chunks[-1].content + + @pytest.mark.asyncio + async def test_ext_notification_uses_serialized_profile_session(self) -> None: + """Cursor notifications omit session ids, so the profile names the turn's.""" + profile = CursorACPClientProfile(active_session=lambda: "sess-1") + client = ACPCollectingClient(profile=profile) + + await client.ext_notification( + "cursor/generate_image", + {"description": "A logo", "filePath": "/tmp/logo.png"}, + ) + + chunks = client.get_collected_chunks("sess-1") + assert chunks[0].content == "[Cursor generated image] A logo → /tmp/logo.png" + + @pytest.mark.asyncio + async def test_ext_notification_without_bound_session_or_own_id_is_noop( + self, + ) -> None: + """Nothing identifies which session's todos these are.""" + client = ACPCollectingClient(profile=CursorACPClientProfile()) + + await client.ext_notification( + "cursor/update_todos", + { + "todos": [{"id": "test", "content": "Test", "status": "pending"}], + "merge": False, + }, + ) + + assert client.get_collected_chunks() == [] + + @pytest.mark.asyncio + async def test_ext_notification_todos_use_their_own_session_id_unbound( + self, + ) -> None: + """Regression: the bridge path (resolve_acp_client_profile("cursor")) + sets no active session, so a notification that carries its own + sessionId must still render -- not fall silent because self._session_id + is None. ACPCollectingClient.ext_notification already resolves the + SAME precedence for chunk routing; the profile's own todo state must + match it.""" + client = ACPCollectingClient(profile=CursorACPClientProfile()) + + await client.ext_notification( + "cursor/update_todos", + { + "sessionId": "bridge-session", + "todos": [{"id": "test", "content": "Test", "status": "pending"}], + "merge": False, + }, + ) + + chunks = client.get_collected_chunks("bridge-session") + assert chunks[-1].content == "- [ ] Test" diff --git a/tests/integrations/acp/clientadapter/test_dead_connection_recovery.py b/tests/integrations/acp/clientadapter/test_dead_connection_recovery.py new file mode 100644 index 000000000..045d2f75c --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_dead_connection_recovery.py @@ -0,0 +1,128 @@ +"""A provider failure recovers the room's process through its owner.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock + +import pytest +from acp.helpers import update_agent_message_text + +from band.core.protocols import FAILURE_CODE_TIMEOUT +from band.core.retention import Residency +from band.testing import FakeAgentTools, reported_failures +from tests.integrations.acp.acp_toolkit.harness import ( + released, + send_to, +) +from tests.integrations.acp.clientadapter.kit import AdapterOn, scripted_conn + + +class TestACPClientAdapterDeadConnectionRecovery: + """A provider failure recovers the room's process through its owner.""" + + @pytest.mark.asyncio + async def test_prompt_error_recovers_the_process_and_keeps_the_session( + self, adapter_on: AdapterOn + ) -> None: + conn = scripted_conn({"room-1": "sess-1"}) + conn.prompt = AsyncMock(side_effect=RuntimeError("Process died")) + adapter = adapter_on(conn) + tools = FakeAgentTools() + + with pytest.raises(RuntimeError, match="Process died"): + await send_to(adapter, "room-1", tools) + room = adapter._rooms["room-1"] + await released(room) + + assert room.runtime is None + assert room.session is not None + assert (room.session.session_id, room.session.live) == ("sess-1", False) + assert len(reported_failures(tools)) == 1 + + @pytest.mark.asyncio + async def test_closing_thought_delivery_failure_leaves_connection_up( + self, adapter_on: AdapterOn + ) -> None: + """The agent answered fine; posting its reply to the room is what + failed. That must not recover a healthy process, nor be reported as + an ACP provider failure.""" + conn = scripted_conn({"room-1": "sess-1"}) + adapter = adapter_on(conn) + + async def prompt_with_reply(**kwargs: object) -> None: + client = adapter._rooms["room-1"].runtime.client + await client.session_update( + kwargs["session_id"], update_agent_message_text("Here's the answer") + ) + + conn.prompt = AsyncMock(side_effect=prompt_with_reply) + tools = FakeAgentTools() + + async def _raise(*args: object, **kwargs: object) -> None: + raise RuntimeError("platform rejected the message") + + tools.send_event = _raise # type: ignore[method-assign] + + await send_to(adapter, "room-1", tools) + + room = adapter._rooms["room-1"] + assert room.retained.snapshot.state == Residency.RESIDENT + assert room.runtime is not None and not room.runtime.closed + assert not reported_failures(tools) + + @pytest.mark.asyncio + async def test_session_bookkeeping_failure_leaves_connection_up( + self, adapter_on: AdapterOn + ) -> None: + """A failed session task event must not turn a completed prompt into an ACP failure.""" + adapter = adapter_on(scripted_conn({"room-1": "sess-1"})) + tools = FakeAgentTools() + tools.send_event_error = RuntimeError("platform rejected the task event") + + await send_to(adapter, "room-1", tools) + + room = adapter._rooms["room-1"] + assert room.retained.snapshot.state == Residency.RESIDENT + assert not reported_failures(tools) + + @pytest.mark.asyncio + async def test_turn_timeout_preserves_other_room_connection( + self, adapter_on: AdapterOn + ) -> None: + """A timed-out room must not interrupt another room's prompt.""" + conn = scripted_conn({"room-a": "sess-a", "room-b": "sess-b"}) + adapter = adapter_on(conn, turn_timeout_s=1) + b_started = asyncio.Event() + release_b = asyncio.Event() + + async def prompt(*, session_id: str, **kwargs: object) -> None: + if session_id == "sess-b": + b_started.set() + await release_b.wait() + else: + await asyncio.sleep(10) + + conn.prompt = AsyncMock(side_effect=prompt) + b_turn = asyncio.create_task(send_to(adapter, "room-b")) + await b_started.wait() + adapter.config = adapter.config.model_copy(update={"turn_timeout_s": 0.01}) + tools_a = FakeAgentTools() + + with pytest.raises(TimeoutError): + await send_to(adapter, "room-a", tools_a) + room_a, room_b = adapter._rooms["room-a"], adapter._rooms["room-b"] + await released(room_a) + + assert not b_turn.done() + assert room_a.runtime is None + assert room_b.live_session is not None + assert room_b.live_session[0] == "sess-b" + conn.cancel.assert_awaited_once_with("sess-a") + failures = reported_failures(tools_a) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert failures[0]["code"] == FAILURE_CODE_TIMEOUT + + release_b.set() + await b_turn diff --git a/tests/integrations/acp/clientadapter/test_emit_support.py b/tests/integrations/acp/clientadapter/test_emit_support.py new file mode 100644 index 000000000..6188311fb --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_emit_support.py @@ -0,0 +1,43 @@ +"""Which ``Emit`` kinds the ACP client adapter declares.""" + +from __future__ import annotations + +import pytest + +from band.core.exceptions import BandConfigError +from band.core.types import Emit +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) + + +class TestACPClientAdapterEmitSupport: + """Which ``Emit`` kinds the ACP client adapter declares. + + All three ACP adapters (OMP / Copilot / Cursor) inherit this: room + narration is gated by the caller's ``emit=`` (see ``room_emitter``), + and kinds ACP cannot observe (``Emit.USAGE``) are rejected up front + instead of silently ignored. + """ + + def test_supported_emit_kinds_are_accepted(self) -> None: + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command=["omp", "acp"]), + emit=Emit.TOOL_CALLS | Emit.THOUGHTS | Emit.TASK_EVENTS, + ) + assert adapter.features.emit == frozenset( + {Emit.TOOL_CALLS, Emit.THOUGHTS, Emit.TASK_EVENTS} + ) + + def test_silence_is_accepted(self) -> None: + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command=["omp", "acp"]), emit=() + ) + assert adapter.features.emit == frozenset() + + def test_an_unsupported_emit_kind_is_rejected(self) -> None: + with pytest.raises(BandConfigError): + ACPClientAdapter( + ACPClientAdapterConfig(command=["omp", "acp"]), emit=Emit.USAGE + ) diff --git a/tests/integrations/acp/clientadapter/test_local_mcp_config.py b/tests/integrations/acp/clientadapter/test_local_mcp_config.py new file mode 100644 index 000000000..e13a67c87 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_local_mcp_config.py @@ -0,0 +1,142 @@ +"""Tests for local Band MCP injection.""" + +from __future__ import annotations + +from urllib.parse import urlsplit + +import pytest + +from band.core.types import Capability +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from band.integrations.mcp import BandMCPTransport +from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS +from tests.integrations.acp.clientadapter.kit import CODEX +from tests.integrations.acp.conftest import make_platform_message +from tests.mcpbackends import backends_created_by +from tests.mcpclient import endpoint_path + + +class TestACPClientAdapterLocalMcpConfig: + """Tests for local Band MCP injection.""" + + @pytest.mark.asyncio + async def test_get_or_start_band_mcp_server_returns_http_config(self) -> None: + """Should expose the room's endpoint on the shared HTTP MCP server.""" + adapter = ACPClientAdapter(CODEX) + + try: + server = await adapter._get_or_start_band_mcp_server( + "room-1", BandMCPTransport.HTTP + ) + finally: + await adapter.cleanup_all() + + assert server.name == "band" + assert urlsplit(server.url).path == endpoint_path(room_id="room-1") + assert server.headers == [] + assert server.type == "http" + + @pytest.mark.asyncio + async def test_get_or_start_band_mcp_server_returns_sse_config(self) -> None: + """Should expose shared SSE when the ACP agent only supports SSE MCP.""" + adapter = ACPClientAdapter(CODEX) + + try: + server = await adapter._get_or_start_band_mcp_server( + "room-1", BandMCPTransport.SSE + ) + finally: + await adapter.cleanup_all() + + assert server.name == "band" + assert urlsplit(server.url).path == endpoint_path( + BandMCPTransport.SSE, room_id="room-1" + ) + assert server.headers == [] + assert server.type == "sse" + + @pytest.mark.asyncio + async def test_get_or_start_band_mcp_server_reuses_shared_server(self) -> None: + """Should start the shared Band MCP server only once.""" + adapter = ACPClientAdapter(CODEX) + + with backends_created_by() as starts: + first = await adapter._get_or_start_band_mcp_server( + "room-1", BandMCPTransport.HTTP + ) + second = await adapter._get_or_start_band_mcp_server( + "room-1", BandMCPTransport.HTTP + ) + + assert first.url == second.url + assert len(starts.requested) == 1 + + async def _registered_tool_names(self, adapter: ACPClientAdapter) -> set[str]: + """The tool names the adapter asks its Band MCP backend to serve.""" + with backends_created_by() as starts: + await adapter._get_or_start_band_mcp_server("room-1", BandMCPTransport.HTTP) + return {d.name for d in starts.requested[0].tool_definitions} + + @pytest.mark.asyncio + async def test_memory_tools_registered_when_declared(self) -> None: + """Declared MEMORY capability puts its tool group on the loopback server.""" + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), capabilities=Capability.MEMORY + ) + assert "band_store_memory" in await self._registered_tool_names(adapter) + + @pytest.mark.asyncio + async def test_memory_tools_absent_without_declaration(self) -> None: + """Undeclared MEMORY keeps its tool group off the server (an + enterprise feature the adapter must opt into).""" + registered = await self._registered_tool_names(ACPClientAdapter(CODEX)) + assert "band_store_memory" not in registered + assert "band_send_message" in registered + + @pytest.mark.asyncio + async def test_contact_tools_registered_regardless_of_declaration(self) -> None: + """Contact tools stay unconditionally registered — the pre-existing + default every caller without ``features=`` (every ACP example) relies + on. Only memory is capability-gated.""" + registered = await self._registered_tool_names(ACPClientAdapter(CODEX)) + assert "band_list_contacts" in registered + + def test_build_system_context_mentions_band_tools(self) -> None: + """Should keep ACP system context minimal and room-aware.""" + adapter = ACPClientAdapter(CODEX) + adapter.agent_name = "ACP Bridge" + adapter.agent_description = "Bridge to ACP agents" + msg = make_platform_message( + "Hello", + room_id="room-123", + sender_id="user-123", + sender_name="Pat", + ) + + system_context = adapter._build_system_context("room-123", msg) + + assert "Band tools" in system_context + assert COMMUNICATION_INSTRUCTIONS in system_context + assert "reply exactly once" not in system_context + assert "Never both" not in system_context + assert "chat_id" not in system_context + assert "Current requester name: Pat" in system_context + assert "Use each MCP tool's schema" in system_context + + def test_build_system_context_defers_to_external_mcp_tool_schema(self) -> None: + """The room value is supplied without assuming a remote tool's field name.""" + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command="codex", inject_band_tools=False) + ) + adapter.agent_name = "ACP Bridge" + adapter.agent_description = "Bridge to ACP agents" + msg = make_platform_message("Hello", room_id="room-123") + + system_context = adapter._build_system_context("room-123", msg) + + assert "Use each MCP tool's schema" in system_context + assert "Current chat_id: room-123" in system_context + assert "must include room_id" not in system_context diff --git a/tests/integrations/acp/clientadapter/test_on_message.py b/tests/integrations/acp/clientadapter/test_on_message.py new file mode 100644 index 000000000..21aec7c96 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_on_message.py @@ -0,0 +1,546 @@ +"""Tests for ACPClientAdapter.on_message().""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from acp.exceptions import RequestError +from acp.helpers import update_agent_message_text +from acp.schema import ( + NewSessionResponse, + SessionConfigOptionSelect, + SessionConfigSelectOption, + SetSessionConfigOptionResponse, +) + +from band.core.protocols import FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, +) +from band.integrations.acp.client_types import ACPClientSessionState +from band.testing import FakeAgentTools, events_of_type, reported_failures +from tests.integrations.acp.acp_toolkit.harness import ( + FakeSpawn, + released, + room_holder, +) +from tests.integrations.acp.clientadapter.kit import ( + CODEX, + MOCK_ROOM, + advertise_session_load, + room_runtime, +) +from tests.integrations.acp.conftest import make_platform_message + + +class TestACPClientAdapterOnMessage: + """Tests for ACPClientAdapter.on_message().""" + + @pytest.mark.asyncio + async def test_on_message_creates_session( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should create ACP session for new room.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + mock_spawn.conn.new_session.assert_called_once() + assert ( + adapter_with_mocks._rooms["room-123"].session.session_id + == "acp-session-123" + ) + + @pytest.mark.asyncio + async def test_on_message_applies_selected_session_configuration( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + effort = SessionConfigOptionSelect( + id="reasoning_effort", + name="Reasoning effort", + type="select", + current_value="medium", + options=[ + SessionConfigSelectOption(value="medium", name="Medium"), + SessionConfigSelectOption(value="high", name="High"), + ], + ) + mock_spawn.conn.new_session = AsyncMock( + return_value=NewSessionResponse( + session_id="acp-session-123", config_options=[effort] + ) + ) + mock_spawn.conn.set_config_option = AsyncMock( + return_value=SetSessionConfigOptionResponse( + config_options=[effort.model_copy(update={"current_value": "high"})] + ) + ) + resolver = AsyncMock(return_value={"reasoning_effort": "high"}) + adapter_with_mocks._resolve_session_config = resolver + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + resolver.assert_awaited_once() + request = resolver.await_args.args[0] + assert request.config_options == (effort,) + mock_spawn.conn.set_config_option.assert_awaited_once_with( + session_id="acp-session-123", + config_id="reasoning_effort", + value="high", + ) + + @pytest.mark.asyncio + async def test_on_message_reuses_session( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should reuse existing session for same room.""" + for content in ("Hello", "Again"): + await adapter_with_mocks.on_message( + make_platform_message(content, room_id="room-123"), + FakeAgentTools(), + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + mock_spawn.conn.new_session.assert_awaited_once() + assert len(mock_spawn.conn.prompt.await_args_list) == 2 + + @pytest.mark.asyncio + async def test_on_message_sends_prompt( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should send prompt to remote ACP agent.""" + tools = FakeAgentTools() + msg = make_platform_message("What is the weather?", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + mock_spawn.conn.prompt.assert_called_once() + call_kwargs = mock_spawn.conn.prompt.call_args.kwargs + assert call_kwargs["session_id"] == "acp-session-123" + + @pytest.mark.asyncio + async def test_on_message_emits_task_event( + self, adapter_with_mocks: ACPClientAdapter + ) -> None: + """Should emit task event for session rehydration.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + # Should have sent task event + task_events = events_of_type(tools, "task") + assert len(task_events) == 1 + assert task_events[0]["metadata"]["acp_client_session_id"] == "acp-session-123" + + @pytest.mark.asyncio + async def test_on_message_bootstrap_rehydrates( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should rehydrate room -> session mappings on bootstrap.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + advertise_session_load(mock_spawn) + mock_spawn.conn.load_session = AsyncMock(return_value=object()) + history = ACPClientSessionState(room_to_session={"room-123": "session-abc"}) + + await adapter_with_mocks.on_message( + msg, + tools, + history, + None, + None, + is_session_bootstrap=True, + room_id="room-123", + ) + + assert adapter_with_mocks._rooms["room-123"].session.session_id == "session-abc" + mock_spawn.conn.load_session.assert_awaited_once() + + @pytest.mark.asyncio + async def test_on_message_creates_new_session_when_persisted_session_cannot_load( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A rebooted ephemeral ACP agent creates a session before prompting.""" + stale_session = "stale-session" + fresh_session = MagicMock(session_id="fresh-session") + mock_spawn.conn.new_session = AsyncMock(return_value=fresh_session) + advertise_session_load(mock_spawn) + mock_spawn.conn.load_session = AsyncMock(return_value=None) + + async def prompt_new_session(**kwargs): + session_id = kwargs["session_id"] + # Stream the reply through the live sink the adapter registers for the + # turn (as a real agent would), not a direct buffer poke. + await room_runtime(adapter_with_mocks)._client.session_update( + session_id, update_agent_message_text("Recovered reply") + ) + + mock_spawn.conn.prompt = AsyncMock(side_effect=prompt_new_session) + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(room_to_session={"room-123": stale_session}), + None, + None, + is_session_bootstrap=True, + room_id="room-123", + ) + + assert ( + adapter_with_mocks._rooms["room-123"].session.session_id == "fresh-session" + ) + mock_spawn.conn.new_session.assert_awaited_once() + mock_spawn.conn.load_session.assert_awaited_once() + prompt_calls = mock_spawn.conn.prompt.call_args_list + assert [call.kwargs["session_id"] for call in prompt_calls] == ["fresh-session"] + assert "[System Context]" in prompt_calls[0].kwargs["prompt"][0].text + assert tools.messages_sent == [] + + @pytest.mark.asyncio + async def test_on_message_error_sends_error_event( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should report an AgentFailure when the ACP agent fails.""" + mock_spawn.conn.prompt = AsyncMock(side_effect=RuntimeError("Agent crashed")) + + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + with pytest.raises(RuntimeError, match="Agent crashed"): + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + + @pytest.mark.asyncio + async def test_prompt_timeout_error_is_not_reported_as_adapter_timeout( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A provider-raised TimeoutError is not the adapter's deadline.""" + mock_spawn.conn.prompt = AsyncMock( + side_effect=TimeoutError("provider socket timeout") + ) + + tools = FakeAgentTools() + + with pytest.raises(TimeoutError, match="provider socket timeout"): + await adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE + assert failures[0]["code"] is None + + @pytest.mark.asyncio + async def test_adapter_deadline_raises_already_reported_failure( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """The adapter's own deadline reports once and remains retryable.""" + adapter_with_mocks.config = adapter_with_mocks.config.model_copy( + update={"turn_timeout_s": 0.01} + ) + + async def slow_prompt(**_: object) -> None: + await asyncio.sleep(1) + + mock_spawn.conn.prompt = AsyncMock(side_effect=slow_prompt) + + tools = FakeAgentTools() + + with pytest.raises(TimeoutError): + await adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == FAILURE_CODE_TIMEOUT + + @pytest.mark.asyncio + async def test_cancelling_the_turn_cancels_its_prompt( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A cancelled turn stops its prompt on both sides of the ACP connection.""" + prompt_started = asyncio.Event() + prompt_cancelled = asyncio.Event() + + async def endless_prompt(**_: object) -> None: + prompt_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + prompt_cancelled.set() + raise + + conn = mock_spawn.conn + conn.prompt = AsyncMock(side_effect=endless_prompt) + turn = asyncio.create_task( + adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + FakeAgentTools(), + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + ) + await prompt_started.wait() + + turn.cancel() + with pytest.raises(asyncio.CancelledError): + await turn + + assert prompt_cancelled.is_set() + conn.cancel.assert_awaited_once_with("acp-session-123") + + @pytest.mark.asyncio + async def test_cancelling_the_turn_survives_repeated_cancel( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A second STOP during session/cancel still finishes that cancel.""" + prompt_started = asyncio.Event() + cancel_started = asyncio.Event() + release_cancel = asyncio.Event() + + async def endless_prompt(**_: object) -> None: + prompt_started.set() + await asyncio.Event().wait() + + async def slow_cancel(session_id: str) -> None: + cancel_started.set() + await release_cancel.wait() + + conn = mock_spawn.conn + conn.prompt = AsyncMock(side_effect=endless_prompt) + conn.cancel = AsyncMock(side_effect=slow_cancel) + turn = asyncio.create_task( + adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + FakeAgentTools(), + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + ) + await prompt_started.wait() + turn.cancel() + await cancel_started.wait() + turn.cancel() + await asyncio.sleep(0) + assert not turn.done() + release_cancel.set() + with pytest.raises(asyncio.CancelledError): + await turn + + conn.cancel.assert_awaited_once_with("acp-session-123") + + @pytest.mark.asyncio + async def test_timeout_cleanup_survives_outer_cancel( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """STOP during timeout cleanup still finishes session/cancel and failure.""" + adapter_with_mocks.config = adapter_with_mocks.config.model_copy( + update={"turn_timeout_s": 0.05} + ) + cancel_started = asyncio.Event() + release_cancel = asyncio.Event() + + async def slow_prompt(**_: object) -> None: + await asyncio.Event().wait() + + async def slow_cancel(session_id: str) -> None: + cancel_started.set() + await release_cancel.wait() + + conn = mock_spawn.conn + conn.prompt = AsyncMock(side_effect=slow_prompt) + conn.cancel = AsyncMock(side_effect=slow_cancel) + tools = FakeAgentTools() + turn = asyncio.create_task( + adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + ) + await cancel_started.wait() + turn.cancel() + await asyncio.sleep(0) + # Bare shield would finish the outer task here while cleanup orphans. + assert not turn.done() + release_cancel.set() + with pytest.raises(asyncio.CancelledError): + await turn + + conn.cancel.assert_awaited_once_with("acp-session-123") + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == FAILURE_CODE_TIMEOUT + await released(adapter_with_mocks._rooms[MOCK_ROOM]) + assert adapter_with_mocks._rooms[MOCK_ROOM].runtime is None + + @pytest.mark.asyncio + async def test_timeout_cleanup_survives_repeated_cancel( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A second STOP during drain still finishes session/cancel and failure.""" + adapter_with_mocks.config = adapter_with_mocks.config.model_copy( + update={"turn_timeout_s": 0.05} + ) + cancel_started = asyncio.Event() + release_cancel = asyncio.Event() + + async def slow_prompt(**_: object) -> None: + await asyncio.Event().wait() + + async def slow_cancel(session_id: str) -> None: + cancel_started.set() + await release_cancel.wait() + + conn = mock_spawn.conn + conn.prompt = AsyncMock(side_effect=slow_prompt) + conn.cancel = AsyncMock(side_effect=slow_cancel) + tools = FakeAgentTools() + turn = asyncio.create_task( + adapter_with_mocks.on_message( + make_platform_message("Hello", room_id="room-123"), + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + ) + await cancel_started.wait() + turn.cancel() + await asyncio.sleep(0) + turn.cancel() + await asyncio.sleep(0) + assert not turn.done() + release_cancel.set() + with pytest.raises(asyncio.CancelledError): + await turn + + conn.cancel.assert_awaited_once_with("acp-session-123") + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["code"] == FAILURE_CODE_TIMEOUT + await released(adapter_with_mocks._rooms[MOCK_ROOM]) + assert adapter_with_mocks._rooms[MOCK_ROOM].runtime is None + + @pytest.mark.asyncio + async def test_on_message_request_error_captures_code_and_data( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A JSON-RPC RequestError's code/data survive into the AgentFailure.""" + mock_spawn.conn.prompt = AsyncMock( + side_effect=RequestError(-32603, "Internal error", {"detail": "oom"}) + ) + + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + with pytest.raises(RequestError): + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "acp" + assert failures[0]["code"] == "-32603" + assert failures[0]["detail"] == {"detail": "oom"} + + @pytest.mark.asyncio + async def test_an_unstarted_runtime_rejects_calls_without_spawning(self) -> None: + """Only the room's acquisition starts a process, never a session call.""" + adapter = ACPClientAdapter(CODEX) + runtime = adapter._build_runtime(room_holder(adapter, "room-123"), 1) + + with pytest.raises(RuntimeError, match="connection is not open"): + await runtime.ensure_connection() + assert runtime.closed diff --git a/tests/integrations/acp/clientadapter/test_on_started.py b/tests/integrations/acp/clientadapter/test_on_started.py new file mode 100644 index 000000000..6dd97993c --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_on_started.py @@ -0,0 +1,119 @@ +"""Tests for ACPClientAdapter.on_started() and lazy room runtime connection.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from band.integrations.mcp import BandMCPTransport +from tests.integrations.acp.acp_toolkit.harness import ( + inject_acp_spawn, + started_runtime, +) +from tests.integrations.acp.clientadapter.kit import CODEX + + +class TestACPClientAdapterOnStarted: + """Tests for ACPClientAdapter.on_started() and lazy room runtime connection. + + Spawn/transport tests inject :class:`FakeSpawn` via ``inject_acp_spawn`` and + start the room runtime explicitly — ``on_started`` no longer spawns. + """ + + @pytest.mark.asyncio + async def test_room_runtime_start_spawns_and_initializes( + self, make_acp_transport + ) -> None: + """A room runtime's own start() spawns the process and initializes it.""" + transport = make_acp_transport() + adapter = ACPClientAdapter(CODEX) + inject_acp_spawn(adapter, transport) + await adapter.on_started("Codex Bridge", "Bridge to Codex") + runtime = await started_runtime(adapter) + + assert runtime._conn is transport.conn + transport.conn.initialize.assert_awaited_once_with(protocol_version=1) + + @pytest.mark.asyncio + async def test_on_started_skips_builtin_transport_options_for_injected_spawn( + self, make_acp_transport + ) -> None: + """Injected ``spawn_process`` factories must not receive stdio transport knobs. + + ``transport_kwargs`` / ``use_unstable_protocol`` are only meaningful for + the built-in stdio/TCP constructors; an injected factory owns its own + connection options. + """ + transport = make_acp_transport() + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command=["npx", "@zed-industries/codex-acp"]) + ) + inject_acp_spawn(adapter, transport) + await adapter.on_started("Codex Bridge", "Bridge to Codex") + await started_runtime(adapter) + + assert "transport_kwargs" not in transport.last_kwargs + assert "use_unstable_protocol" not in transport.last_kwargs + + @pytest.mark.asyncio + async def test_on_started_forwards_command_positionally( + self, make_acp_transport + ) -> None: + """Should forward the stdio command (executable + args) to the transport.""" + transport = make_acp_transport() + # Pin the launcher pass-through: with no PATH resolution (which happens at + # construction, via _resolve_launcher) the command reaches the transport + # verbatim, so this asserts the positional splat, not _resolve_launcher + # (covered by TestResolveLauncher) or whether `npx` happens to be installed here. + with patch( + "band.integrations.acp.client_adapter.shutil.which", return_value=None + ): + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command=["npx", "@zed-industries/codex-acp"]) + ) + inject_acp_spawn(adapter, transport) + await adapter.on_started("Codex Bridge", "Bridge to Codex") + await started_runtime(adapter) + + # spawn(client, *command, ...) — command splatted as positional args. + args, _ = transport.last_call + assert args == ("npx", "@zed-industries/codex-acp") + + @pytest.mark.asyncio + async def test_on_started_stores_agent_info(self, make_acp_transport) -> None: + """Should store agent name and description.""" + adapter = ACPClientAdapter(CODEX) + + await adapter.on_started("Test Agent", "A test agent") + + assert adapter.agent_name == "Test Agent" + assert adapter.agent_description == "A test agent" + + @pytest.mark.asyncio + async def test_on_started_prefers_http_mcp_when_supported( + self, make_acp_transport + ) -> None: + """Should select HTTP MCP when the ACP agent advertises it.""" + adapter = ACPClientAdapter(CODEX) + inject_acp_spawn(adapter, make_acp_transport(http=True, sse=True)) + await adapter.on_started("Test Agent", "A test agent") + runtime = await started_runtime(adapter) + + assert runtime._agent_mcp_transport is BandMCPTransport.HTTP + + @pytest.mark.asyncio + async def test_on_started_uses_sse_mcp_when_http_missing( + self, make_acp_transport + ) -> None: + """Should fall back to SSE MCP when that's all the ACP agent supports.""" + adapter = ACPClientAdapter(CODEX) + inject_acp_spawn(adapter, make_acp_transport(http=False, sse=True)) + await adapter.on_started("Test Agent", "A test agent") + runtime = await started_runtime(adapter) + + assert runtime._agent_mcp_transport is BandMCPTransport.SSE diff --git a/tests/integrations/acp/clientadapter/test_permission_handler.py b/tests/integrations/acp/clientadapter/test_permission_handler.py new file mode 100644 index 000000000..832ce55b8 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_permission_handler.py @@ -0,0 +1,406 @@ +"""Tests for bidirectional permission proxying.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from acp.schema import ( + PermissionOption, +) + +from band.converters.parsing import parse_tool_call, parse_tool_result +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, + ACPPermissionRequest, +) +from band.integrations.acp.client_types import ACPClientSessionState +from band.integrations.acp.types import ACPToolCall +from band.testing import FakeAgentTools +from tests.integrations.acp.acp_toolkit.harness import ( + FakeSpawn, +) +from tests.integrations.acp.clientadapter.kit import ( + event_types, + metadata_values, + permission_events, + room_runtime, +) +from tests.integrations.acp.conftest import make_platform_message + + +class TestACPClientAdapterPermissionHandler: + """Tests for bidirectional permission proxying.""" + + @pytest.mark.asyncio + async def test_permission_handler_wired_on_message( + self, adapter_with_mocks: ACPClientAdapter + ) -> None: + """Should set permission handler on client before sending prompt.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + # Permission handler should have been set for this session + assert len(room_runtime(adapter_with_mocks)._client._permission_handlers) > 0 + + @pytest.mark.asyncio + async def test_permission_resolver_receives_only_advertised_choices(self) -> None: + received: list[ACPPermissionRequest] = [] + + async def resolve(request: ACPPermissionRequest) -> str: + received.append(request) + return "reject" + + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), resolve_permission=resolve + ) + option_id = await adapter._resolve_permission_option( + call=ACPToolCall("call-1", "write_file", {}), + options=( + PermissionOption(optionId="allow", name="Allow", kind="allow_once"), + PermissionOption(optionId="reject", name="Reject", kind="reject_once"), + ), + room_id="room-1", + session_id="session-1", + ) + + assert option_id == "reject" + assert received[0].room_id == "room-1" + assert [option.option_id for option in received[0].options] == [ + "allow", + "reject", + ] + + @pytest.mark.asyncio + async def test_permission_resolver_invalid_option_raises(self) -> None: + async def resolve(_request: ACPPermissionRequest) -> str: + return "missing" + + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), resolve_permission=resolve + ) + with pytest.raises(ValueError, match="unavailable option"): + await adapter._resolve_permission_option( + call=ACPToolCall("call-1", "write_file", {}), + options=( + PermissionOption(optionId="allow", name="Allow", kind="allow_once"), + ), + room_id="room-1", + session_id="session-1", + ) + + @pytest.mark.asyncio + async def test_permission_resolver_deny_cancels_via_request_permission( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A wired PermissionResolver deny must cancel through request_permission.""" + + async def deny(_request: ACPPermissionRequest) -> None: + return None + + adapter_with_mocks._resolve_permission = deny + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + captured: dict[str, object] = {} + + async def mock_prompt(**kwargs: object) -> None: + tool_call = MagicMock() + tool_call.title = "write_file" + tool_call.tool_call_id = "tc-deny" + tool_call.raw_input = {"path": "/tmp/x"} + result = await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"}, + {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + captured.update(result) + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert captured == {"outcome": {"outcome": "cancelled"}} + perm_events = permission_events(tools) + assert event_types(perm_events) == ["tool_call", "tool_result"] + assert perm_events[1]["metadata"]["permission_outcome"] == "cancelled" + + @pytest.mark.asyncio + async def test_permission_handler_skips_pair_for_approved_band_send_message( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """An approved band_send_message grants silently, like any other tool. + + Regression guard: band_send_message/band_send_event were formerly + special-cased ("self-reporting") to post a synthetic permission pair + since their execution events were suppressed. Now nothing is suppressed — + if the tool executes, its own real tool_call/tool_result narrate it, so no + pair should be posted here either. + """ + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + async def mock_prompt(**kwargs): + tool_call = MagicMock() + tool_call.title = "band_send_message" + tool_call.tool_call_id = "tc-perm-1" + + result = await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"} + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + assert result == { + "outcome": {"outcome": "selected", "optionId": "allow-once"} + } + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert permission_events(tools) == [] + + @pytest.mark.asyncio + async def test_permission_handler_skips_pair_for_approved_ordinary_tool( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """An approved ordinary tool grants without posting a permission pair. + + The tool's own tool_call/tool_result already show the call, so a pair + would duplicate it in the room. The grant is still returned to the agent. + """ + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + async def mock_prompt(**kwargs): + tool_call = MagicMock() + tool_call.title = "write_file" + tool_call.tool_call_id = "tc-perm-1" + + result = await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"} + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + # The grant is still returned even though no pair is posted. + assert result == { + "outcome": {"outcome": "selected", "optionId": "allow-once"} + } + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert permission_events(tools) == [] + + @pytest.mark.asyncio + async def test_permission_handler_selects_allow_option( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should auto-approve by selecting an offered allow option (not "allowed").""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + captured_result = {} + + async def mock_prompt(**kwargs): + tool_call = MagicMock() + tool_call.title = "read_file" + tool_call.tool_call_id = "tc-read" + + result = await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "p-once", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + captured_result.update(result) + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert captured_result == { + "outcome": {"outcome": "selected", "optionId": "p-once"} + } + + @pytest.mark.asyncio + async def test_permission_handler_cancels_without_allow_option( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should cancel (not guess) when the agent offers no allow option.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + captured_result = {} + + async def mock_prompt(**kwargs): + tool_call = MagicMock() + tool_call.title = "rm_rf" + tool_call.tool_call_id = "tc-danger" + tool_call.raw_input = {"path": "/tmp/important"} + + result = await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + captured_result.update(result) + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + assert captured_result == {"outcome": {"outcome": "cancelled"}} + perm_events = permission_events(tools) + assert event_types(perm_events) == ["tool_call", "tool_result"] + assert metadata_values(perm_events, "tool_call_id") == [ + "tc-danger", + "tc-danger", + ] + call = parse_tool_call(str(perm_events[0]["content"])) + assert call is not None + assert call.args == {"path": "/tmp/important"} + result = parse_tool_result(str(perm_events[1]["content"])) + assert result is not None + assert result.output == "Permission cancelled" + assert result.is_error + assert perm_events[1]["metadata"]["permission_outcome"] == "cancelled" + + @pytest.mark.asyncio + async def test_denied_permission_pair_carries_the_canonical_tool_name( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """A denied ask naming a band tool under its MCP spelling must post its + synthetic pair under the canonical name — the pair is the only record + of the call, so it must speak the same vocabulary as real narration.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + async def mock_prompt(**kwargs): + tool_call = MagicMock() + tool_call.title = "band-band_send_event" + tool_call.tool_call_id = "tc-band" + await room_runtime(adapter_with_mocks)._client.request_permission( + options=[ + {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, + ], + session_id="acp-session-123", + tool_call=tool_call, + ) + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + perm_events = permission_events(tools) + assert event_types(perm_events) == ["tool_call", "tool_result"] + assert metadata_values(perm_events, "tool_name") == [ + "band_send_event", + "band_send_event", + ] + call = parse_tool_call(str(perm_events[0]["content"])) + assert call is not None and call.name == "band_send_event" + + @pytest.mark.asyncio + async def test_permission_handler_uses_name_fallback( + self, adapter_with_mocks: ACPClientAdapter, mock_spawn: FakeSpawn + ) -> None: + """Should fall back to 'name' attr if 'title' is not available.""" + tools = FakeAgentTools() + msg = make_platform_message("Hello", room_id="room-123") + + async def mock_prompt(**kwargs): + tool_call = MagicMock(spec=[]) # No attributes by default + tool_call.name = "bash" + tool_call.tool_call_id = "tc-bash" + + await room_runtime(adapter_with_mocks)._client.request_permission( + options={}, + session_id="acp-session-123", + tool_call=tool_call, + ) + + mock_spawn.conn.prompt = AsyncMock(side_effect=mock_prompt) + + await adapter_with_mocks.on_message( + msg, + tools, + ACPClientSessionState(), + None, + None, + is_session_bootstrap=False, + room_id="room-123", + ) + + perm_events = permission_events(tools) + assert event_types(perm_events) == ["tool_call", "tool_result"] + assert metadata_values(perm_events, "tool_name") == ["bash", "bash"] diff --git a/tests/integrations/acp/clientadapter/test_resolve_launcher.py b/tests/integrations/acp/clientadapter/test_resolve_launcher.py new file mode 100644 index 000000000..6f157510e --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_resolve_launcher.py @@ -0,0 +1,32 @@ +"""The ACP launcher resolves to a full path, so it spawns on Windows too.""" + +from __future__ import annotations + +from unittest.mock import patch + +from band.integrations.acp.client_adapter import ( + _resolve_launcher, +) + + +class TestResolveLauncher: + """The launcher is resolved to a full path so the subprocess spawns on Windows, + where an npm launcher (``npx``) is ``npx.cmd`` and bare-name exec lookup fails.""" + + def test_resolves_launcher_and_preserves_args(self) -> None: + """The launcher becomes its resolved path; the arguments are untouched.""" + with patch( + "band.integrations.acp.client_adapter.shutil.which", + return_value="/opt/node/bin/npx", + ): + assert _resolve_launcher(["npx", "@zed-industries/codex-acp"]) == [ + "/opt/node/bin/npx", + "@zed-industries/codex-acp", + ] + + def test_unresolved_name_is_left_as_is(self) -> None: + """An unresolvable launcher is passed through so spawn fails loudly, not here.""" + with patch( + "band.integrations.acp.client_adapter.shutil.which", return_value=None + ): + assert _resolve_launcher(["mystery-bin", "arg"]) == ["mystery-bin", "arg"] diff --git a/tests/integrations/acp/clientadapter/test_shutdown.py b/tests/integrations/acp/clientadapter/test_shutdown.py new file mode 100644 index 000000000..302f6770d --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_shutdown.py @@ -0,0 +1,52 @@ +"""Graceful shutdown must release the adapter-wide subprocess/TCP connection.""" + +from __future__ import annotations + +import pytest + +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import ( + acp_adapter, +) +from tests.mcpbackends import backends_created_by + + +class TestACPClientAdapterShutdown: + """Graceful shutdown must release the adapter-wide subprocess/TCP connection. + + ``Agent.stop()`` invokes ``cleanup_all()`` (not ``stop()``), so the teardown has + to hang off ``cleanup_all`` or the transport spawned in ``on_started`` leaks. + """ + + @pytest.mark.asyncio + async def test_cleanup_all_tears_down_the_transport(self) -> None: + agent = FakeACPAgent().will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + runtime = session.runtime() + + await session.adapter.cleanup_all() # what Agent.stop() calls + + assert runtime.closed + assert session.adapter._rooms == {} + + @pytest.mark.asyncio + async def test_restart_after_a_full_stop_allows_backend_creation(self) -> None: + """Agent.start() reuses the same adapter instance across a + stop()-then-start() restart (and across a retry after a failed + start). Rooms and + the MCP backend must serve again, or a perfectly healthy restarted + adapter can never answer or call a Band tool again.""" + agent = FakeACPAgent().will_say("ok") + async with acp_adapter(agent) as session: + await session.send("before the restart") + + await session.adapter.cleanup_all() # Agent.stop() + await session.adapter.on_started("Codex", "bridge") # Agent.start() + + with backends_created_by() as starts: + await session.adapter._mcp.ensure() + await session.send("after the restart") + + assert len(starts.requested) == 1 + assert agent.session_ids() == ["fake-session-1", "fake-session-2"] diff --git a/tests/integrations/acp/clientadapter/test_stop.py b/tests/integrations/acp/clientadapter/test_stop.py new file mode 100644 index 000000000..1eabfde57 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_stop.py @@ -0,0 +1,48 @@ +"""Tests for ACPClientAdapter.stop().""" + +from __future__ import annotations + +import pytest + +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, +) +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import ( + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.clientadapter.kit import CODEX, MOCK_ROOM +from tests.mcpbackends import backends_created_by, hold_backend + + +class TestACPClientAdapterStop: + """Tests for ACPClientAdapter.stop().""" + + @pytest.mark.asyncio + async def test_stop_closes_connection(self) -> None: + """Should close every room's ACP process and the MCP backend.""" + agent = FakeACPAgent().will_say("ok") + with backends_created_by(): + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + await session.send("hello", room=MOCK_ROOM) + runtime = session.runtime(MOCK_ROOM) + + await session.adapter.stop() + + assert runtime.closed + assert runtime.client is None + assert session.adapter._rooms == {} + assert session.adapter._mcp.current is None + + @pytest.mark.asyncio + async def test_stop_no_connection(self) -> None: + """Should handle stop when not connected.""" + adapter = ACPClientAdapter(CODEX) + backend = await hold_backend(adapter._mcp) + + await adapter.stop() + + assert backend.stop_calls == 1 diff --git a/tests/integrations/acp/clientadapter/test_transport.py b/tests/integrations/acp/clientadapter/test_transport.py new file mode 100644 index 000000000..3fb42ef65 --- /dev/null +++ b/tests/integrations/acp/clientadapter/test_transport.py @@ -0,0 +1,55 @@ +"""Tests for the injected stdio transport seam.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from tests.integrations.acp.acp_toolkit.harness import ( + inject_acp_spawn, + started_runtime, +) +from tests.integrations.acp.clientadapter.kit import CODEX + + +class TestACPClientAdapterTransport: + """Tests for the injected stdio transport seam.""" + + @pytest.mark.asyncio + async def test_injected_spawn_used_on_connection_start( + self, make_acp_transport + ) -> None: + """FakeSpawn patched onto _build_runtime is used when a room connects.""" + transport = make_acp_transport() + with patch( + "band.integrations.acp.client_adapter.shutil.which", return_value=None + ): + adapter = ACPClientAdapter(CODEX) + inject_acp_spawn(adapter, transport) + await adapter.on_started("Codex", "Codex bridge") + runtime = await started_runtime(adapter) + assert runtime._conn is transport.conn + args, _ = transport.last_call + assert args == ("codex",) + + @pytest.mark.asyncio + async def test_room_workspace_is_used_for_initial_spawn( + self, make_acp_transport, tmp_path: Path + ) -> None: + transport = make_acp_transport() + workspace = tmp_path / "acp-room" + adapter = ACPClientAdapter( + ACPClientAdapterConfig(command="codex"), + workspace_for_room=lambda _room_id: str(workspace), + ) + inject_acp_spawn(adapter, transport) + await adapter.on_started("", "") + await started_runtime(adapter) + + assert transport.last_kwargs["cwd"] == str(workspace) diff --git a/tests/integrations/acp/lifecycleprofile.py b/tests/integrations/acp/lifecycleprofile.py new file mode 100644 index 000000000..9ae57db27 --- /dev/null +++ b/tests/integrations/acp/lifecycleprofile.py @@ -0,0 +1,24 @@ +"""Registers the OMP, Cursor and Copilot ACP adapters with the common Room +lifecycle suite.""" + +from __future__ import annotations + +from functools import partial +from typing import get_args + +from acp import RequestError + +from tests.adapters.lifecycle.kit import ExpectedFailure, LifecycleProfile +from tests.adapters.roompeer import STARTUP_FAILURE +from tests.integrations.acp.acp_toolkit.lifecycle import ACPProfile, install + +PROFILES = [ + LifecycleProfile( + name=profile, + install=partial(install, profile), + # The turn reports the lost connection, then re-raises it. + lost_turn=ConnectionError, + failed_start=ExpectedFailure(RequestError, STARTUP_FAILURE), + ) + for profile in get_args(ACPProfile) +] diff --git a/tests/integrations/acp/roomownership/__init__.py b/tests/integrations/acp/roomownership/__init__.py new file mode 100644 index 000000000..fa62be413 --- /dev/null +++ b/tests/integrations/acp/roomownership/__init__.py @@ -0,0 +1,7 @@ +"""An ACP room owns its process, session and work until a release confirms it. + +Each test drives a real adapter over real ACP JSON-RPC: an in-process fake +agent, or the stdio peer subprocess where the process boundary itself is under +test. Barriers hold the exact step a race needs; only a transition's own +time bound is ever waited out. +""" diff --git a/tests/integrations/acp/roomownership/cursorflow.py b/tests/integrations/acp/roomownership/cursorflow.py new file mode 100644 index 000000000..1111702bd --- /dev/null +++ b/tests/integrations/acp/roomownership/cursorflow.py @@ -0,0 +1,38 @@ +"""Observable Cursor decision commands and native permission selections.""" + +from __future__ import annotations + +from acp.schema import AllowedOutcome, PermissionOption + +from band.integrations.acp.cursor import CursorCommandWord +from band.integrations.acp.permissions import ALLOW_ALWAYS_KIND, ALLOW_ONCE_KIND +from tests.integrations.acp.acp_toolkit import FakeACPAgent, Reply, decision_command + +ALLOW_ONCE = "allow-once" +ALLOW_ALWAYS = "allow-always" + + +def permission_menu() -> list[PermissionOption]: + return [ + PermissionOption(kind=ALLOW_ONCE_KIND, optionId=ALLOW_ONCE, name="Allow once"), + PermissionOption( + kind=ALLOW_ALWAYS_KIND, optionId=ALLOW_ALWAYS, name="Allow always" + ), + ] + + +def permission_selections(agent: FakeACPAgent) -> list[str | None]: + return [ + response.outcome.option_id + if isinstance(response.outcome, AllowedOutcome) + else None + for response in agent.permission_responses + ] + + +def advertised_decisions(reply: Reply) -> dict[CursorCommandWord, str]: + return { + command.word: command.token + for text in reply.texts + if (command := decision_command(text)) is not None + } diff --git a/tests/integrations/acp/roomownership/kit.py b/tests/integrations/acp/roomownership/kit.py new file mode 100644 index 000000000..2678202d6 --- /dev/null +++ b/tests/integrations/acp/roomownership/kit.py @@ -0,0 +1,283 @@ +"""Shared agents, sessions and helpers for the ACP room-ownership tests.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import signal +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass +from typing import Any + +import pytest +from acp.schema import SessionConfigOptionSelect, ToolCallUpdate + +from band.adapters.cursor_acp import CursorACPAdapter, CursorACPAdapterConfig +from band.client.streaming import ControlMode +from band.core.model_catalog import ModelSelection +from band.core.retention import ReleaseCause, RoomRetentionConfig +from band.integrations.acp import client_runtime +from band.integrations.acp.client_adapter import ( + ACPClientAdapter, + ACPClientAdapterConfig, +) +from band.integrations.acp.client_types import ACPRoom +from band.integrations.acp.cursor import ROOM_COMMAND, CursorCommandWord +from band.integrations.acp.model_selection import MODEL_CATEGORY +from tests.adapters.waiting import spin, until +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + AcpSession, + FakeACPAgent, + Reply, + acp_adapter, + decision_command, + decision_token, + fake_agent_config, + released, + select_option, + started_acp_adapter, +) +from tests.integrations.acp.acp_toolkit.agent import ( + MODEL_OPTION_ID, + Gate, + allow_or_reject, +) + +CANCELLED = {"outcome": {"outcome": "cancelled"}} +# A transition bound short enough that waiting it out costs nothing. +SHORT_TRANSITION_S = 0.05 +# A guard on a wait that should finish at once, so a hang fails clearly. +TEST_GUARD_S = 5 + + +def models(current: str, *offered: str) -> FakeACPAgent: + """An agent with session load whose model select offers ``offered``.""" + return FakeACPAgent( + supports_session_load=True, + config_options=[model_select(current, *offered)], + ) + + +def model_select(current: str, *offered: str) -> SessionConfigOptionSelect: + return select_option(MODEL_OPTION_ID, current, offered, category=MODEL_CATEGORY) + + +async def recover(session: AcpSession, room: str = DEFAULT_ROOM) -> ACPRoom: + """Retire the room's process through its owner; the session is kept.""" + holder = session.room(room) + assert holder.retained.recover(generation=holder.generation, reason="test") + await released(holder) + return holder + + +@asynccontextmanager +async def woken(adapter: ACPClientAdapter, room: ACPRoom) -> AsyncIterator[None]: + """Acquire ``room`` as a deliberate wake does, then close that process. + + Idle release is disabled for every ACP profile, so the wake path is driven + through the adapter's acquisition callback directly. + """ + try: + await adapter._acquire_room(room, room.generation + 1, True) + yield + finally: + await adapter._release_room(room, ReleaseCause.RECOVERY) + + +def decisions(reply: Reply) -> list[str]: + """Every decision prompt ``reply`` has posted: each tells the room how to reply.""" + return [text for text in reply.texts if decision_command(text) is not None] + + +def questions(reply: Reply) -> list[str]: + """The Cursor question decisions ``reply`` has posted to the room.""" + return [ + text + for text in reply.texts + if (command := decision_command(text)) is not None + and command.word == CursorCommandWord.ANSWER + ] + + +async def question_asked(reply: Reply) -> None: + """Let a running turn post its question decision to ``reply``'s room.""" + await until(lambda: bool(questions(reply)), failure="no question was asked") + + +def question_token(reply: Reply) -> str: + """The token of the one question decision ``reply`` asked the room.""" + [prompt] = questions(reply) + return decision_token(prompt) + + +def stop_budget() -> ACPClientAdapterConfig: + """Band tools on, with a transition bound short enough to outlast.""" + return fake_agent_config( + inject_band_tools=True, + retention=RoomRetentionConfig(transition_timeout_s=SHORT_TRANSITION_S), + ) + + +def holds_prompt(agent: FakeACPAgent, number: int) -> Gate: + """Hold the agent's ``number``-th prompt until release; others answer.""" + prompting = Gate() + + @agent.on_prompt + async def hold(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == number: + await prompting.hold() + + return prompting + + +def fails_prompt(agent: FakeACPAgent, number: int, error: Exception) -> None: + """Fail the agent's ``number``-th prompt with ``error``; others answer.""" + + @agent.on_prompt + async def fail(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == number: + raise error + + +@dataclass(frozen=True) +class CursorQuestion: + id: str + options: tuple[str, ...] + + +# The one Cursor question these tests ask, and the options it offers. +MODE_QUESTION = CursorQuestion(id="mode", options=("agent", "plan")) + + +async def ask_mode(agent: FakeACPAgent, session_id: str) -> dict[str, object]: + """Ask ``MODE_QUESTION`` from ``session_id`` and return the agent's answer.""" + return await agent.ask_cursor_question( + session_id, MODE_QUESTION.id, *MODE_QUESTION.options + ) + + +async def ask_shell_permission( + agent: FakeACPAgent, session_id: str +) -> dict[str, object]: + """Ask permission to run a shell tool from ``session_id``; return the answer.""" + answer = await agent.ask_permission( + session_id, + ToolCallUpdate(tool_call_id="tc-shell", title="shell"), + allow_or_reject(), + ) + return answer.model_dump(by_alias=True, exclude_none=True) + + +def answer(token: str, option: str) -> str: + """The room command answering ``MODE_QUESTION`` decision ``token`` with ``option``.""" + return ( + f"{ROOM_COMMAND} {CursorCommandWord.ANSWER} {token} {MODE_QUESTION.id}={option}" + ) + + +class QuestioningAgent(FakeACPAgent): + """Asks ``MODE_QUESTION`` per prompt and records each session's answer.""" + + def __init__(self) -> None: + super().__init__() + self.answers: dict[str, dict[str, object]] = {} + self.on_prompt(self.ask_mode) + + async def ask_mode(self, peer: FakeACPAgent, session_id: str) -> None: + self.answers[session_id] = await ask_mode(peer, session_id) + + +@asynccontextmanager +async def cursor(agent: FakeACPAgent, **settings: Any) -> AsyncIterator[AcpSession]: + adapter = CursorACPAdapter(CursorACPAdapterConfig(command="fake-agent", **settings)) + async with started_acp_adapter(adapter, agent) as session: + yield session + + +@asynccontextmanager +async def inline(agent: FakeACPAgent, **settings: Any) -> AsyncIterator[AcpSession]: + """An adapter whose turns run inside their message handler (OMP, Copilot).""" + async with acp_adapter(agent, fake_agent_config(**settings)) as session: + yield session + + +async def stop(session: AcpSession, handler: asyncio.Task[Reply]) -> None: + """A room ``/stop``: the runtime cancels the handler and interrupts the room.""" + handler.cancel() + await session.adapter.on_interrupt(DEFAULT_ROOM, ControlMode.STOP) + with suppress(asyncio.CancelledError): + await handler + + +async def restart(session: AcpSession) -> None: + """Start the adapter again, as ``Agent.start()`` does after a stop.""" + await session.adapter.on_started("Fake Agent", "in-process fake") + + +def answered(option: str) -> dict[str, object]: + return { + "outcome": { + "outcome": "answered", + "answers": [ + {"questionId": MODE_QUESTION.id, "selectedOptionIds": [option]} + ], + } + } + + +@dataclass(frozen=True) +class HeldSwitch: + """A model switch to ``SWITCHED_MODEL`` queued behind the room's held prompt.""" + + agent: FakeACPAgent + session: AcpSession + session_id: str + prompting: Gate + held: asyncio.Task[Reply] + switch: asyncio.Task[None] + + +SWITCHED_MODEL = "b" + + +@asynccontextmanager +async def switch_behind_held_prompt() -> AsyncIterator[HeldSwitch]: + """A bootstrapped room whose second prompt is held, with a model switch + queued behind it.""" + agent = models("a", "a", SWITCHED_MODEL) + prompting = holds_prompt(agent, 2) + async with acp_adapter(agent) as session: + await session.send("bootstrap") + held = asyncio.create_task(session.send("held")) + await prompting.received.wait() + switch = asyncio.create_task( + session.adapter.apply_model_selection( + ModelSelection(model=SWITCHED_MODEL), room_id=DEFAULT_ROOM + ) + ) + # The switch exposes no state while it queues on the room. + await spin() + yield HeldSwitch( + agent=agent, + session=session, + session_id=session.session_id(DEFAULT_ROOM), + prompting=prompting, + held=held, + switch=switch, + ) + + +def runtime_warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.name == client_runtime.__name__ and record.levelno >= logging.WARNING + ] + + +def terminate_pid(pid: int) -> None: + with suppress(ProcessLookupError, OSError): + os.kill(pid, signal.SIGTERM) diff --git a/tests/integrations/acp/roomownership/test_cursor_mixed_decisions.py b/tests/integrations/acp/roomownership/test_cursor_mixed_decisions.py new file mode 100644 index 000000000..b77ddc53a --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_mixed_decisions.py @@ -0,0 +1,125 @@ +"""Concurrent native decisions remain scoped to their kind and originating room.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable + +from band.integrations.acp.cursor import ( + DECISION_NOT_PENDING_TEMPLATE, + DECISION_RESOLVED_TEMPLATE, + ROOM_COMMAND, + CursorCommandWord, +) +from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool +from tests.adapters.waiting import until +from tests.integrations.acp.acp_toolkit import ( + FakeACPAgent, + TranscriptTools, + accepted_work_finished, +) +from tests.integrations.acp.roomownership.cursorflow import ( + ALLOW_ONCE, + advertised_decisions, +) +from tests.integrations.acp.roomownership.kit import ( + CANCELLED, + TEST_GUARD_S, + answer, + answered, + ask_mode, + ask_shell_permission, + cursor, + decisions, +) + + +class TestCursorMixedDecisions: + async def test_wrong_kind_room_and_duplicate_answers_cannot_claim_other_decisions( + self, + ) -> None: + peer = FakeACPAgent() + outcomes: dict[str, object] = {} + + async def record(kind: str, request: Awaitable[object]) -> None: + outcomes[kind] = await request + + @peer.on_prompt + async def ask_every_kind(agent: FakeACPAgent, session_id: str) -> None: + await asyncio.gather( + record("permission", ask_shell_permission(agent, session_id)), + record("question", ask_mode(agent, session_id)), + record("plan", agent.ask_cursor_plan(session_id, "Repair the project")), + ) + await agent.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.NO_REPLY, + arguments={}, + ) + + async with ( + cursor(peer, inject_band_tools=True) as session, + asyncio.timeout(TEST_GUARD_S), + ): + original_tools = TranscriptTools() + original = await session.send_judged( + "prepare repairs", tools=original_tools + ) + await until( + lambda: len(decisions(original)) == 3, + failure="Cursor did not post all three native decisions", + ) + tokens = advertised_decisions(original) + permission = tokens[CursorCommandWord.SELECT] + question = tokens[CursorCommandWord.ANSWER] + plan = tokens[CursorCommandWord.ACCEPT] + + wrong_room = await session.send_judged( + answer(question, "agent"), room="other-room" + ) + assert wrong_room.texts == [ + DECISION_NOT_PENDING_TEMPLATE.format(token=question) + ] + for command in ( + f"{ROOM_COMMAND} {CursorCommandWord.ACCEPT} {permission}", + f"{ROOM_COMMAND} {CursorCommandWord.SELECT} {plan} {ALLOW_ONCE}", + answer(question, "missing-option"), + ): + refused = await session.send_judged(command) + assert refused.errors == [] + assert outcomes == {} + assert not original_tools.turn.complete + + accept_plan = f"{ROOM_COMMAND} {CursorCommandWord.ACCEPT} {plan}" + resolved = await session.send_judged(accept_plan) + assert resolved.texts == [ + DECISION_RESOLVED_TEMPLATE.format(kind="plan", token=plan) + ] + await until( + lambda: "plan" in outcomes, failure="Native plan request stayed waiting" + ) + duplicate = await session.send_judged(accept_plan) + assert duplicate.texts == [DECISION_NOT_PENDING_TEMPLATE.format(token=plan)] + await session.send_judged(answer(question, "agent")) + await until( + lambda: "question" in outcomes, + failure="Native question request stayed waiting", + ) + await session.send_judged( + f"{ROOM_COMMAND} {CursorCommandWord.DENY} {permission}" + ) + await until( + lambda: "permission" in outcomes, + failure="Native permission request stayed waiting", + ) + await accepted_work_finished(session.room()) + + assert outcomes == { + "permission": CANCELLED, + "question": answered("agent"), + "plan": {"outcome": {"outcome": "accepted"}}, + } + assert original_tools.turn.complete + assert original.errors == [] + assert len(peer.prompts) == 1 diff --git a/tests/integrations/acp/roomownership/test_cursor_origin.py b/tests/integrations/acp/roomownership/test_cursor_origin.py new file mode 100644 index 000000000..ae90961b9 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_origin.py @@ -0,0 +1,109 @@ +"""Cursor's sessionless requests resolve against the process that sent them.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.integrations.acp.client_profiles import CURSOR_ASK_QUESTION_METHOD +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + accepted_work_finished, + pending_decisions, +) +from tests.integrations.acp.roomownership.kit import ( + CANCELLED, + MODE_QUESTION, + TEST_GUARD_S, + QuestioningAgent, + answer, + answered, + cursor, + holds_prompt, + question_token, + recover, +) + + +class TestCursorOrigin: + """Cursor's sessionless requests resolve against the process that sent them.""" + + @pytest.mark.asyncio + async def test_two_rooms_park_and_resolve_their_own_questions(self) -> None: + agent = QuestioningAgent() + async with cursor(agent) as session: + room_a = await session.send("ask", room="room-a") + room_b = await session.send("ask", room="room-b") + + await session.send(answer(question_token(room_b), "plan"), room="room-b") + await session.send(answer(question_token(room_a), "agent"), room="room-a") + for room in ("room-a", "room-b"): + await accepted_work_finished(session.room(room)) + + assert agent.answers == { + session.session_id("room-a"): answered("agent"), + session.session_id("room-b"): answered("plan"), + } + + @pytest.mark.asyncio + async def test_a_parked_question_pins_its_process_and_stays_answerable( + self, + ) -> None: + agent = QuestioningAgent() + async with cursor(agent) as session: + asked = await session.send("ask") + room = session.room() + + assert room.retained.snapshot.leases == 1 + assert room.retained.snapshot.accepted == 1 + assert not session.runtime().closed + + await session.send(answer(question_token(asked), "plan")) + await accepted_work_finished(room) + + assert room.retained.snapshot.leases == 0 + assert agent.answers == {session.session_id(DEFAULT_ROOM): answered("plan")} + + @pytest.mark.asyncio + async def test_an_old_process_cannot_open_a_decision_on_its_successor( + self, + ) -> None: + agent = FakeACPAgent() + parked = holds_prompt(agent, 2) + + async with cursor(agent) as session: + await session.send("hello") + old_client = session.runtime().client + assert old_client is not None + await recover(session) + successor = asyncio.create_task(session.send("again")) + await parked.received.wait() + + async with asyncio.timeout(TEST_GUARD_S): + stale = await old_client.ext_method( + CURSOR_ASK_QUESTION_METHOD, + { + "questions": [ + { + "id": MODE_QUESTION.id, + "options": [{"id": MODE_QUESTION.options[0]}], + } + ] + }, + ) + + assert stale == CANCELLED + assert pending_decisions(session.adapter) == {} + parked.release.set() + await successor + assert session.last_reply.texts == [] + + @pytest.mark.asyncio + async def test_a_sessionless_update_lands_in_its_rooms_open_turn(self) -> None: + agent = FakeACPAgent().will_update_cursor_todos("ship it", sessionless=True) + async with cursor(agent) as session: + reply = await session.send("hello") + + assert reply.plans == ["- [ ] ship it"] diff --git a/tests/integrations/acp/roomownership/test_cursor_permission_scope.py b/tests/integrations/acp/roomownership/test_cursor_permission_scope.py new file mode 100644 index 000000000..b19810339 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_permission_scope.py @@ -0,0 +1,98 @@ +"""An always grant applies to one Cursor turn, never to its successor.""" + +from __future__ import annotations + +import asyncio + +import pytest +from acp.schema import ToolCallUpdate + +from band.integrations.acp.cursor import ( + DECISION_NOT_PENDING_TEMPLATE, + ROOM_COMMAND, + CursorCommandWord, +) +from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + TranscriptTools, + accepted_work_finished, +) +from tests.integrations.acp.roomownership.cursorflow import ( + ALLOW_ALWAYS, + advertised_decisions, + permission_menu, + permission_selections, +) +from tests.integrations.acp.roomownership.kit import ( + TEST_GUARD_S, + cursor, + decisions, + restart, +) + + +class TestCursorPermissionScope: + @pytest.mark.parametrize("successor", ["next-turn", "leave-rejoin", "stop-restart"]) + async def test_an_always_grant_and_its_old_answer_do_not_authorize_a_successor( + self, successor: str + ) -> None: + peer = FakeACPAgent() + + @peer.on_prompt + async def request_shell(agent: FakeACPAgent, session_id: str) -> None: + count = 2 if len(agent.prompts) == 1 else 1 + for number in range(count): + await agent.ask_permission( + session_id, + ToolCallUpdate(tool_call_id=f"shell-{number}", title="shell"), + permission_menu(), + ) + await agent.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.NO_REPLY, + arguments={}, + ) + + async with ( + cursor(peer, inject_band_tools=True) as session, + asyncio.timeout(TEST_GUARD_S), + ): + original_tools = TranscriptTools() + original = await session.send_judged("first turn", tools=original_tools) + first_token = advertised_decisions(original)[CursorCommandWord.SELECT] + await session.send_judged( + f"{ROOM_COMMAND} {CursorCommandWord.SELECT} {first_token} {ALLOW_ALWAYS}" + ) + await accepted_work_finished(session.room()) + assert permission_selections(peer) == [ALLOW_ALWAYS, ALLOW_ALWAYS] + assert len(decisions(original)) == 1 + assert original_tools.turn.complete + + match successor: + case "leave-rejoin": + await session.adapter.on_cleanup(DEFAULT_ROOM) + case "stop-restart": + await session.adapter.cleanup_all() + await restart(session) + + successor_tools = TranscriptTools() + current = await session.send_judged("next turn", tools=successor_tools) + current_token = advertised_decisions(current)[CursorCommandWord.SELECT] + old_answer = await session.send_judged( + f"{ROOM_COMMAND} {CursorCommandWord.SELECT} {first_token} {ALLOW_ALWAYS}" + ) + assert old_answer.texts == [ + DECISION_NOT_PENDING_TEMPLATE.format(token=first_token) + ] + assert permission_selections(peer) == [ALLOW_ALWAYS, ALLOW_ALWAYS] + await session.send_judged( + f"{ROOM_COMMAND} {CursorCommandWord.DENY} {current_token}" + ) + await accepted_work_finished(session.room()) + + assert permission_selections(peer) == [ALLOW_ALWAYS, ALLOW_ALWAYS, None] + assert successor_tools.turn.complete + assert original.errors == current.errors == [] diff --git a/tests/integrations/acp/roomownership/test_cursor_recovered_turn.py b/tests/integrations/acp/roomownership/test_cursor_recovered_turn.py new file mode 100644 index 000000000..78dd96e5b --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_recovered_turn.py @@ -0,0 +1,51 @@ +"""A process recovery retired never reaches the room, even while the turn +it served is still reporting its failure.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest + +from tests.integrations.acp.acp_toolkit import DEFAULT_ROOM, FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import SlowFailureReports +from tests.integrations.acp.roomownership.kit import ( + CANCELLED, + ask_mode, + ask_shell_permission, + cursor, + decisions, + holds_prompt, +) + +Ask = Callable[[FakeACPAgent, str], Awaitable[dict[str, object]]] + + +# Each decision the retired process asks for, answered as the agent sees it. +ASKS: dict[str, Ask] = {"question": ask_mode, "permission": ask_shell_permission} + + +class TestCursorRecoveredTurn: + @pytest.mark.asyncio + @pytest.mark.parametrize("ask", ASKS.values(), ids=ASKS.keys()) + async def test_a_decision_from_a_timed_out_turns_process_is_never_posted( + self, ask: Ask + ) -> None: + agent = FakeACPAgent() + prompting = holds_prompt(agent, 1) + async with cursor(agent, turn_timeout_s=0.2, decision_timeout_s=0.1) as session: + failing = SlowFailureReports(lost=0) + sending = asyncio.create_task(session.send("work", tools=failing)) + await prompting.received.wait() + session_id = session.session_id(DEFAULT_ROOM) + # The timeout retired the process; its failure post is in flight. + await failing.reporting.received.wait() + + stale = await ask(agent, session_id) + failing.reporting.release.set() + prompting.release.set() + await asyncio.gather(sending, return_exceptions=True) + + assert stale == CANCELLED + assert decisions(failing.reply) == [] diff --git a/tests/integrations/acp/roomownership/test_cursor_recovery_sequences.py b/tests/integrations/acp/roomownership/test_cursor_recovery_sequences.py new file mode 100644 index 000000000..ff65aab12 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_recovery_sequences.py @@ -0,0 +1,173 @@ +"""Cursor turn ownership survives repeated recovery, settings queues and room leave.""" + +from __future__ import annotations + +import asyncio + +import pytest +from acp import RequestError + +from band.client.streaming import ControlMode +from band.core.exceptions import InputNotAccepted +from band.core.model_catalog import ModelSelection +from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool +from tests.adapters.waiting import spin +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + accepted_work_finished, +) +from tests.integrations.acp.acp_toolkit.agent import MODEL_OPTION_ID, Gate +from tests.integrations.acp.acp_toolkit.harness import SlowFailureReports +from tests.integrations.acp.roomownership.kit import ( + CANCELLED, + TEST_GUARD_S, + ask_mode, + cursor, + models, +) + + +class TestCursorRecoverySequences: + @pytest.mark.asyncio + async def test_repeated_failures_recover_the_conversation_then_serve_a_new_turn( + self, + ) -> None: + agent = FakeACPAgent(supports_session_load=True) + failure = RequestError.internal_error() + + @agent.on_prompt + async def fail_twice(peer: FakeACPAgent, session_id: str) -> None: + peer.knows_session(session_id) + if len(peer.prompts) <= 2: + raise failure + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": "recovered reply", "mentions": ["@peer-1"]}, + ) + + async with cursor(agent, inject_band_tools=True) as session: + failures = [] + for message in ("first failure", "second failure"): + with pytest.raises(RequestError): + await session.send_judged(message) + failures.append(session.last_reply) + retained = session.session_id(DEFAULT_ROOM) + served = await session.send_judged("healthy turn") + await accepted_work_finished(session.room()) + + assert served.texts == ["recovered reply"] + assert served.errors == [] + assert [reply.errors for reply in failures] == [[str(failure)]] * 2 + assert agent.session_load_requests == [retained, retained] + assert agent.session_ids() == [retained] + + @pytest.mark.asyncio + async def test_a_switch_waiting_behind_a_parked_turn_survives_stop( + self, + ) -> None: + agent = models("a", "a", "b") + served_models: list[str | None] = [] + native_finishing = Gate() + native_finished = asyncio.Event() + cancelled_outcomes: list[dict[str, object]] = [] + + @agent.on_prompt + async def ask_then_reply(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + cancelled_outcomes.append(await ask_mode(peer, session_id)) + await native_finishing.hold() + native_finished.set() + return + served_models.append(peer.current_value(MODEL_OPTION_ID)) + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": "selected model served", "mentions": ["@peer-1"]}, + ) + + async with cursor(agent, inject_band_tools=True) as session: + parked = await session.send_judged("ask") + switching = asyncio.create_task( + session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + ) + try: + async with asyncio.timeout(TEST_GUARD_S): + await spin() + assert not switching.done() + assert agent.config_selections() == [] + await session.adapter.on_interrupt(DEFAULT_ROOM, ControlMode.STOP) + await switching + await native_finishing.received.wait() + native_finishing.release.set() + await native_finished.wait() + await accepted_work_finished(session.room()) + served = await session.send_judged("next turn") + await accepted_work_finished(session.room()) + finally: + native_finishing.release.set() + await asyncio.gather(switching, return_exceptions=True) + + assert parked.errors == [] + assert cancelled_outcomes == [CANCELLED] + assert served_models == ["b"] + assert served.texts == ["selected model served"] + assert served.errors == [] + assert agent.config_selections() == [(MODEL_OPTION_ID, "b")] + assert agent.cancelled_sessions == [session.session_id(DEFAULT_ROOM)] + + @pytest.mark.asyncio + async def test_leave_joins_a_failure_report_before_rejoin_while_another_room_serves( + self, + ) -> None: + agent = FakeACPAgent() + failure = RequestError.internal_error() + + @agent.on_prompt + async def fail_first(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + raise failure + await peer.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": session_id, "mentions": ["@peer-1"]}, + ) + + async with cursor(agent, inject_band_tools=True) as session: + old_tools = SlowFailureReports(lost=0) + failing = asyncio.create_task(session.send_judged("fail", tools=old_tools)) + leaving = None + try: + async with asyncio.timeout(TEST_GUARD_S): + await old_tools.reporting.received.wait() + old_session = session.session_id(DEFAULT_ROOM) + leaving = asyncio.create_task( + session.adapter.on_cleanup(DEFAULT_ROOM) + ) + other = await session.send_judged("other room", room="other-room") + with pytest.raises(InputNotAccepted): + await session.send("too early") + assert not leaving.done() + old_tools.reporting.release.set() + with pytest.raises(RequestError): + await failing + await leaving + rejoined = await session.send_judged("rejoin") + await accepted_work_finished(session.room()) + finally: + old_tools.reporting.release.set() + pending = [failing] if leaving is None else [failing, leaving] + await asyncio.gather(*pending, return_exceptions=True) + + assert old_tools.reply.errors == [str(failure)] + assert old_tools.reply.texts == [] + assert other.errors == rejoined.errors == [] + assert other.texts == [session.session_id("other-room")] + assert rejoined.texts == [session.session_id(DEFAULT_ROOM)] + assert old_session != session.session_id(DEFAULT_ROOM) diff --git a/tests/integrations/acp/roomownership/test_cursor_session_retirement.py b/tests/integrations/acp/roomownership/test_cursor_session_retirement.py new file mode 100644 index 000000000..b54379ea1 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_session_retirement.py @@ -0,0 +1,67 @@ +"""A Cursor room forgets a retired session's state and settles its turn +before its process closes.""" + +from __future__ import annotations + +import asyncio +from typing import Any, cast + +import pytest + +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent +from tests.integrations.acp.acp_toolkit.harness import ( + DEFAULT_ROOM, + AcpSession, + accepted_work, +) +from tests.integrations.acp.roomownership.kit import cursor +from tests.mcpclient import crash_backend + + +def todo_sessions(session: AcpSession) -> set[str]: + """The sessions whose Cursor todos the room's live process still tracks.""" + client = session.runtime().client + assert client is not None + return set(cast(Any, client)._profile._todos_by_session) + + +class TestCursorSessionRetirement: + @pytest.mark.asyncio + async def test_a_retired_sessions_todos_are_forgotten(self) -> None: + agent = FakeACPAgent().will_update_cursor_todos("ship it").will_say("ok") + async with cursor(agent, inject_band_tools=True) as session: + await session.send("plan it") + retired = session.session_id(DEFAULT_ROOM) + await crash_backend(session.adapter._mcp) + await session.send("after the crash") + await session.retired_sessions_closed() + + assert todo_sessions(session) == {session.session_id(DEFAULT_ROOM)} + assert retired != session.session_id(DEFAULT_ROOM) + + @pytest.mark.asyncio + async def test_a_turn_still_running_at_cleanup_is_settled_before_its_process_closes( + self, + ) -> None: + """A turn left running detached keeps updating todos; leaving the room + cancels it once its settle grace is over and joins it before the room's + process, and its todos, are gone.""" + agent = FakeACPAgent() + updating = agent.keeps_updating_cursor_todos() + async with cursor(agent, turn_settle_timeout_s=0) as session: + turn = asyncio.create_task(session.send("plan it")) + await updating.wait() + runtime = session.runtime() + [detached] = accepted_work(session.room()) + exiting = agent.exits_slowly() + + cleanup = asyncio.create_task(session.adapter.on_cleanup(DEFAULT_ROOM)) + await exiting.received.wait() + settled_before_exit = detached.done() + exiting.release.set() + await cleanup + + assert settled_before_exit + assert runtime.closed and runtime.client is None + assert DEFAULT_ROOM not in session.adapter._rooms + await asyncio.gather(turn, return_exceptions=True) diff --git a/tests/integrations/acp/roomownership/test_cursor_turns.py b/tests/integrations/acp/roomownership/test_cursor_turns.py new file mode 100644 index 000000000..fc8ad5abb --- /dev/null +++ b/tests/integrations/acp/roomownership/test_cursor_turns.py @@ -0,0 +1,223 @@ +"""Every accepted Cursor turn is reachable by its room's controls.""" + +from __future__ import annotations + +import asyncio + +import pytest +from acp import RequestError + +from band.client.streaming import ControlMode +from band.core.exceptions import InputNotAccepted +from band.integrations.acp.client_types import session_busy_error +from tests.conftest import eager_tasks, requires_eager_tasks +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + accepted_work_finished, +) +from tests.integrations.acp.acp_toolkit.harness import ( + SlowFailureReports, + TranscriptTools, +) +from tests.integrations.acp.roomownership.kit import ( + answer, + answered, + ask_mode, + cursor, + holds_prompt, + question_asked, + question_token, +) + + +class TestCursorTurns: + """Every accepted Cursor turn is reachable by its room's controls.""" + + @pytest.mark.asyncio + async def test_an_interrupt_during_startup_ends_the_turn_and_frees_the_room( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + starting = agent.holds_next_initialize() + async with cursor(agent) as session: + turn = asyncio.create_task(session.send("hello")) + await starting.received.wait() + + await session.adapter.on_interrupt(DEFAULT_ROOM, ControlMode.STOP) + starting.release.set() + + with pytest.raises(asyncio.CancelledError): + await turn + assert agent.prompts == [] + + served = await session.send("again") + await accepted_work_finished(session.room()) + + assert served.errors == [] + assert len(agent.prompts) == 1 + + @pytest.mark.asyncio + async def test_an_interrupt_before_any_decision_cancels_the_prompt( + self, + ) -> None: + agent = FakeACPAgent() + prompting = holds_prompt(agent, 1) + + async with cursor(agent) as session: + turn = asyncio.create_task(session.send("hello")) + await prompting.received.wait() + + await session.adapter.on_interrupt(DEFAULT_ROOM, ControlMode.INTERRUPT) + + with pytest.raises(asyncio.CancelledError): + await turn + assert agent.cancelled_sessions == [session.session_id(DEFAULT_ROOM)] + prompting.release.set() + + @pytest.mark.asyncio + async def test_a_message_during_a_parked_turn_is_left_for_a_retry( + self, + ) -> None: + """The host keeps it, so the parked turn's answer still gets through + and the message runs once the room is free.""" + agent = FakeACPAgent() + answers: list[dict[str, object]] = [] + + @agent.on_prompt + async def ask_first(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + answers.append(await ask_mode(peer, session_id)) + + async with cursor(agent) as session: + asked = await session.send("ask") + refused = TranscriptTools() + with pytest.raises(InputNotAccepted): + await session.send("next", tools=refused) + await session.send(answer(question_token(asked), "plan")) + await accepted_work_finished(session.room()) + retried = await session.send("next") + + assert refused.reply.texts == [] and refused.reply.errors == [] + assert answers == [answered("plan")] + assert len(agent.prompts) == 2 and retried.errors == [] + + @pytest.mark.asyncio + async def test_a_message_during_an_interrupted_turns_unwind_is_not_failed( + self, + ) -> None: + """Cursor may still be finishing a cancelled prompt when the next one + arrives; its ``session_busy`` refusal leaves the message for a retry.""" + agent = FakeACPAgent() + cancelled = asyncio.Event() + + @agent.on_prompt + async def busy_after_cancel(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + await ask_mode(peer, session_id) + await cancelled.wait() + return + raise session_busy_error() + + async with cursor(agent) as session: + await session.send("long task") + await session.adapter.on_interrupt(DEFAULT_ROOM, ControlMode.INTERRUPT) + unwinding = TranscriptTools() + with pytest.raises(InputNotAccepted): + await session.send("next", tools=unwinding) + cancelled.set() + await accepted_work_finished(session.room()) + busy = TranscriptTools() + with pytest.raises(InputNotAccepted): + await session.send("next", tools=busy) + + assert unwinding.reply.errors == [] and busy.reply.errors == [] + assert len(agent.prompts) == 2 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("failure", "lost"), + [ + # The agent's error is reported, then judged once that post is lost. + (RequestError.internal_error(), 1), + (session_busy_error(), 0), + ], + ids=["agent-error", "session-busy"], + ) + async def test_a_slow_failure_report_keeps_the_room_until_it_lands( + self, failure: RequestError, lost: int + ) -> None: + agent = FakeACPAgent() + answers: list[dict[str, object]] = [] + + @agent.on_prompt + async def fail_then_ask(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + await ask_mode(peer, session_id) + raise failure + answers.append(await ask_mode(peer, session_id)) + + async with cursor(agent) as session: + failing = SlowFailureReports(lost=lost) + asked = await session.send("ask", tools=failing) + await session.send(answer(question_token(asked), "plan")) + await failing.reporting.received.wait() + + with pytest.raises(InputNotAccepted): + await session.send("next") + failing.reporting.release.set() + await accepted_work_finished(session.room()) + retried = await session.send("next") + await question_asked(retried) + await session.send(answer(question_token(retried), "agent")) + await accepted_work_finished(session.room()) + + assert answers == [answered("agent")] + + @pytest.mark.asyncio + async def test_a_report_lost_in_the_lease_is_not_retried_after_it( + self, + ) -> None: + agent = FakeACPAgent() + answers: list[dict[str, object]] = [] + + @agent.on_prompt + async def busy_then_ask(peer: FakeACPAgent, session_id: str) -> None: + if len(peer.prompts) == 1: + await ask_mode(peer, session_id) + raise session_busy_error() + answers.append(await ask_mode(peer, session_id)) + + async with cursor(agent) as session: + failing = SlowFailureReports(lost=1) + asked = await session.send("ask", tools=failing) + await session.send(answer(question_token(asked), "plan")) + await accepted_work_finished(session.room()) + retried = await session.send("next") + await question_asked(retried) + await session.send(answer(question_token(retried), "agent")) + await accepted_work_finished(session.room()) + + assert not failing.reporting.received.is_set() + assert answers == [answered("agent")] + + @requires_eager_tasks + @pytest.mark.asyncio + async def test_a_closing_room_refuses_a_message_under_an_eager_task_factory( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + async with cursor(agent) as session: + await session.send("hello") + exiting = agent.exits_slowly() + leaving = asyncio.create_task(session.adapter.on_cleanup(DEFAULT_ROOM)) + await exiting.received.wait() + tools = TranscriptTools() + + with eager_tasks(), pytest.raises(InputNotAccepted): + await session.send("late", tools=tools) + exiting.release.set() + await leaving + + assert tools.transcript == [] + assert len(agent.prompts) == 1 diff --git a/tests/integrations/acp/roomownership/test_decision_retirement.py b/tests/integrations/acp/roomownership/test_decision_retirement.py new file mode 100644 index 000000000..279841e77 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_decision_retirement.py @@ -0,0 +1,84 @@ +"""A slow answer acknowledgment cannot resolve a successor's decision.""" + +from __future__ import annotations + +import asyncio + +from band.integrations.acp.client_profiles import CURSOR_ASK_QUESTION_METHOD +from band.integrations.acp.cursor import DECISION_NOT_PENDING_TEMPLATE +from tests.integrations.acp.acp_toolkit import DEFAULT_ROOM, accepted_work_finished +from tests.integrations.acp.acp_toolkit.harness import TranscriptTools +from tests.integrations.acp.roomownership.kit import ( + CANCELLED, + MODE_QUESTION, + QuestioningAgent, + answer, + answered, + cursor, + question_token, +) + + +class TestDecisionRetirement: + async def test_a_held_answer_acknowledgment_cannot_settle_a_rejoined_room( + self, + ) -> None: + peer = QuestioningAgent() + async with cursor(peer) as session: + old_question = await session.send("old question") + old_room = session.room() + old_session = session.session_id(DEFAULT_ROOM) + old_client = session.runtime().client + assert old_client is not None + other_question = await session.send("other question", room="other-room") + other_session = session.session_id("other-room") + answer_tools = TranscriptTools() + acknowledgment = answer_tools.hold_message("resolved") + answering = asyncio.create_task( + session.send( + answer(question_token(old_question), "agent"), tools=answer_tools + ) + ) + + async with acknowledgment: + await accepted_work_finished(old_room) + await session.adapter.on_cleanup(DEFAULT_ROOM) + new_question = await session.send("new question") + new_room = session.room() + new_session = session.session_id(DEFAULT_ROOM) + assert new_room is not old_room + assert new_room.generation == old_room.generation + stale = await old_client.ext_method( + CURSOR_ASK_QUESTION_METHOD, + { + "questions": [ + { + "id": MODE_QUESTION.id, + "options": [{"id": MODE_QUESTION.options[0]}], + } + ] + }, + ) + late_answer = await session.send( + answer(question_token(old_question), "plan") + ) + + await answering + assert stale == CANCELLED + assert late_answer.texts == [ + DECISION_NOT_PENDING_TEMPLATE.format(token=question_token(old_question)) + ] + assert new_session not in peer.answers + assert other_session not in peer.answers + await session.send( + answer(question_token(other_question), "plan"), room="other-room" + ) + await accepted_work_finished(session.room("other-room")) + await session.send(answer(question_token(new_question), "plan")) + await accepted_work_finished(new_room) + + assert peer.answers == { + old_session: answered("agent"), + other_session: answered("plan"), + new_session: answered("plan"), + } diff --git a/tests/integrations/acp/roomownership/test_deliberate_wake.py b/tests/integrations/acp/roomownership/test_deliberate_wake.py new file mode 100644 index 000000000..dc0077d68 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_deliberate_wake.py @@ -0,0 +1,122 @@ +"""A wake restores the retained session exactly or fails keeping it.""" + +from __future__ import annotations + +import pytest + +from band.core.model_catalog import ModelSelection +from band.integrations.acp import sessions +from band.integrations.acp.session_config import ACPConfigError +from band.integrations.acp.sessions import ACPSessionRestoreError +from band.runtime.tools import BAND_MCP_SERVER_NAME +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.acp_toolkit.agent import MODEL_OPTION_ID +from tests.integrations.acp.roomownership.kit import ( + model_select, + models, + recover, + woken, +) +from tests.mcpclient import crash_backend + + +class TestDeliberateWake: + """A wake restores the retained session exactly or fails keeping it.""" + + @pytest.mark.asyncio + async def test_reloads_the_exact_session_with_fresh_mcp_and_selection( + self, + ) -> None: + agent = models("a", "a", "b").will_say("ok") + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + await session.send("hello") + retained = session.session_id(DEFAULT_ROOM) + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + stale_url = agent.mcp_server(retained, BAND_MCP_SERVER_NAME).url + room = await recover(session) + await crash_backend(session.adapter._mcp) + agent.knows_session(retained).advertises(model_select("a", "a", "b")) + switched = len(agent.config_selections(retained)) + + async with woken(session.adapter, room): + assert room.live_session is not None + assert room.live_session[0] == retained + live_url = agent.mcp_server(retained, BAND_MCP_SERVER_NAME).url + + assert agent.session_load_requests == [retained] + assert agent.session_ids() == [retained] + assert agent.config_selections(retained)[switched:] == [(MODEL_OPTION_ID, "b")] + assert live_url != stale_url + + @pytest.mark.asyncio + @pytest.mark.parametrize("loadable", [True, False], ids=["refused", "no-load"]) + async def test_a_refused_load_keeps_the_identity_and_never_falls_back( + self, loadable: bool + ) -> None: + agent = FakeACPAgent(supports_session_load=loadable).will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + retained = session.session_id(DEFAULT_ROOM) + room = await recover(session) + + with pytest.raises(ACPSessionRestoreError, match=retained): + async with woken(session.adapter, room): + pass + + assert room.session is not None + assert (room.session.session_id, room.session.live) == (retained, False) + assert agent.session_ids() == [retained] + assert len(agent.prompts) == 1 + + @pytest.mark.asyncio + async def test_an_unanswered_load_fails_at_its_bound_keeping_the_identity( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(sessions, "ACP_SESSION_LOAD_TIMEOUT_SECONDS", 0.05) + agent = FakeACPAgent(supports_session_load=True).will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + retained = session.session_id(DEFAULT_ROOM) + room = await recover(session) + loading = agent.knows_session(retained).holds_next_session_load() + try: + with pytest.raises(ACPSessionRestoreError, match="no answer"): + async with woken(session.adapter, room): + pass + finally: + loading.release.set() + + assert room.session is not None + assert (room.session.session_id, room.session.live) == (retained, False) + assert agent.session_ids() == [retained] + + @pytest.mark.asyncio + async def test_a_refused_selection_is_kept_for_the_room(self) -> None: + agent = models("a", "a", "b").will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + retained = session.session_id(DEFAULT_ROOM) + room = await recover(session) + agent.knows_session(retained).advertises(model_select("a", "a")) + + with pytest.raises(ACPConfigError): + async with woken(session.adapter, room): + pass + + assert session.adapter._room_selection(DEFAULT_ROOM) == ModelSelection( + model="b" + ) + assert room.session is not None + assert room.session.session_id == retained diff --git a/tests/integrations/acp/roomownership/test_detached_turn_on_close.py b/tests/integrations/acp/roomownership/test_detached_turn_on_close.py new file mode 100644 index 000000000..119838b27 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_detached_turn_on_close.py @@ -0,0 +1,70 @@ +"""A Cursor turn released on a question still answers for its message when +its room closes: woken, it reports its own outcome within the settle grace; +still running past that grace, it is cancelled and reported once.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +import pytest +from acp import RequestError + +from band.core.turn import MISSING_REPLY +from band.integrations.acp.client_adapter import ACPClientAdapter +from tests.integrations.acp.acp_toolkit import DEFAULT_ROOM, FakeACPAgent +from tests.integrations.acp.roomownership.kit import ( + TEST_GUARD_S, + ask_mode, + cursor, +) + + +async def shut_down(adapter: ACPClientAdapter) -> None: + await adapter.cleanup_all() + + +async def leave(adapter: ACPClientAdapter) -> None: + await adapter.on_cleanup(DEFAULT_ROOM) + + +CLOSINGS = pytest.mark.parametrize("closing", [shut_down, leave]) + + +class TestDetachedTurnOnClose: + @CLOSINGS + async def test_a_woken_turn_reports_its_own_failure( + self, closing: Callable[[ACPClientAdapter], Awaitable[None]] + ) -> None: + agent = FakeACPAgent() + failure = RequestError.internal_error() + + @agent.on_prompt + async def ask_then_fail(peer: FakeACPAgent, session_id: str) -> None: + await ask_mode(peer, session_id) + raise failure + + async with cursor(agent) as session: + asked = await session.send_judged("ask") + async with asyncio.timeout(TEST_GUARD_S): + await closing(session.adapter) + + assert asked.errors == [str(failure)] + + @CLOSINGS + async def test_a_turn_still_running_after_the_grace_is_reported_once( + self, closing: Callable[[ACPClientAdapter], Awaitable[None]] + ) -> None: + agent = FakeACPAgent() + + @agent.on_prompt + async def ask_then_hang(peer: FakeACPAgent, session_id: str) -> None: + await ask_mode(peer, session_id) + await asyncio.Event().wait() + + async with cursor(agent, turn_settle_timeout_s=0) as session: + asked = await session.send_judged("ask") + async with asyncio.timeout(TEST_GUARD_S): + await closing(session.adapter) + + assert asked.errors == [MISSING_REPLY.message] diff --git a/tests/integrations/acp/roomownership/test_direct_stop.py b/tests/integrations/acp/roomownership/test_direct_stop.py new file mode 100644 index 000000000..3ef4e55af --- /dev/null +++ b/tests/integrations/acp/roomownership/test_direct_stop.py @@ -0,0 +1,131 @@ +"""A direct stop owns the MCP server close until it is confirmed.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from tests.integrations.acp.acp_toolkit import ( + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.roomownership.kit import ( + TEST_GUARD_S, + restart, + stop_budget, +) +from tests.mcpbackends import FakeBandMCPBackend, backends_created_by + + +class TestDirectStop: + """A direct stop owns the MCP server's close until it is confirmed; only + a restart serves turns again.""" + + @pytest.mark.asyncio + async def test_a_cancelled_stop_keeps_closing_the_server_until_a_retry( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + stopped, closing = asyncio.Event(), asyncio.Event() + backend = FakeBandMCPBackend(stop_started=closing, stop_release=stopped) + with backends_created_by(backend) as starts: + async with acp_adapter(agent, stop_budget()) as session: + await session.send("hello") + stopping = asyncio.create_task(session.adapter.stop()) + await closing.wait() + stopping.cancel() + with pytest.raises(asyncio.CancelledError): + await stopping + + with pytest.raises(CleanupIncompleteError, match="MCP backend"): + await session.adapter.stop() + assert len(starts.requested) == 1 + + stopped.set() + await session.adapter.stop() + await restart(session) + reply = await session.send("again") + + assert backend.stop_calls == 1 + assert len(starts.requested) == 2 + assert reply.errors == [] + + @pytest.mark.asyncio + async def test_a_failed_close_is_retried_by_the_next_stop(self) -> None: + agent = FakeACPAgent().will_say("ok") + backend = FakeBandMCPBackend() + backend.fail_stops = 1 + with backends_created_by(backend) as starts: + async with acp_adapter(agent, stop_budget()) as session: + await session.send("hello") + with pytest.raises(OSError, match="stop failed"): + await session.adapter.stop() + + await session.adapter.stop() + await restart(session) + reply = await session.send("again") + + assert backend.stop_calls == 2 + assert len(starts.requested) == 2 + assert reply.errors == [] + + @pytest.mark.asyncio + async def test_concurrent_stops_share_one_close(self) -> None: + agent = FakeACPAgent().will_say("ok") + stopped, closing = asyncio.Event(), asyncio.Event() + backend = FakeBandMCPBackend(stop_started=closing, stop_release=stopped) + config = fake_agent_config(inject_band_tools=True) + with backends_created_by(backend): + async with acp_adapter(agent, config) as session: + await session.send("hello") + stops = [asyncio.create_task(session.adapter.stop()) for _ in range(2)] + await closing.wait() + stopped.set() + async with asyncio.timeout(TEST_GUARD_S): + await asyncio.gather(*stops) + + assert backend.stop_calls == 1 + + @pytest.mark.asyncio + async def test_a_turn_after_a_stop_waits_for_a_restart(self) -> None: + agent = FakeACPAgent().will_say("ok") + with backends_created_by() as starts: + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + await session.send("hello") + await session.adapter.stop() + + with pytest.raises(RoomClosingError): + await session.send("while stopped") + assert len(starts.requested) == 1 + + await restart(session) + reply = await session.send("again") + + assert len(starts.requested) == 2 + assert reply.errors == [] + + @pytest.mark.asyncio + async def test_a_stop_racing_shutdown_never_reopens_the_server(self) -> None: + agent = FakeACPAgent().will_say("ok") + stopped, closing = asyncio.Event(), asyncio.Event() + backend = FakeBandMCPBackend(stop_started=closing, stop_release=stopped) + config = fake_agent_config(inject_band_tools=True) + with backends_created_by(backend) as starts: + async with acp_adapter(agent, config) as session: + await session.send("hello") + shutdown = asyncio.create_task(session.adapter.cleanup_all()) + await closing.wait() + stopping = asyncio.create_task(session.adapter.stop()) + stopped.set() + async with asyncio.timeout(TEST_GUARD_S): + await asyncio.gather(shutdown, stopping) + + with pytest.raises(RoomClosingError): + await session.send("after shutdown") + + assert len(starts.requested) == 1 diff --git a/tests/integrations/acp/roomownership/test_final_cleanup.py b/tests/integrations/acp/roomownership/test_final_cleanup.py new file mode 100644 index 000000000..245aca0e8 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_final_cleanup.py @@ -0,0 +1,177 @@ +"""Leaving keeps the room owned until its process is confirmed closed.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from tests.adapters.waiting import spin +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.acp_toolkit.agent import AGENT_EXIT_FAILURE +from tests.integrations.acp.roomownership.kit import stop_budget +from tests.mcpbackends import FakeBandMCPBackend, backends_created_by +from tests.mcpclient import crash_backend + + +class TestFinalCleanup: + """Leaving keeps the room owned until its process is confirmed closed.""" + + @pytest.mark.asyncio + async def test_a_failed_exit_keeps_the_room_owned_until_a_retry_succeeds( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + runtime = session.runtime() + agent.fails_next_exit() + + with pytest.raises(RuntimeError, match=AGENT_EXIT_FAILURE): + await session.adapter.on_cleanup(DEFAULT_ROOM) + + room = session.room() + assert room.runtime is runtime + assert room.retained.snapshot.pending_cleanup + with pytest.raises(RoomClosingError): + await session.send("rejoin") + assert agent.session_ids() == ["fake-session-1"] + + await session.adapter.on_cleanup(DEFAULT_ROOM) + + assert runtime.closed + assert DEFAULT_ROOM not in session.adapter._rooms + await session.send("rejoined") + assert agent.session_ids() == ["fake-session-1", "fake-session-2"] + + @pytest.mark.asyncio + async def test_a_cancelled_cleanup_keeps_closing_and_refuses_a_rejoin( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + exiting = agent.exits_slowly() + async with acp_adapter(agent) as session: + await session.send("hello") + runtime = session.runtime() + cleanup = asyncio.create_task(session.adapter.on_cleanup(DEFAULT_ROOM)) + await exiting.received.wait() + + with pytest.raises(RoomClosingError): + await session.send("rejoin") + cleanup.cancel() + await asyncio.gather(cleanup, return_exceptions=True) + assert not runtime.closed + assert DEFAULT_ROOM in session.adapter._rooms + + exiting.release.set() + await session.adapter.on_cleanup(DEFAULT_ROOM) + + assert runtime.closed + assert DEFAULT_ROOM not in session.adapter._rooms + + @pytest.mark.asyncio + async def test_a_held_room_keeps_the_shared_mcp_server_until_a_retry( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + config = stop_budget() + with backends_created_by(): + async with acp_adapter(agent, config) as session: + await session.send("hello") + runtime = session.runtime() + exiting = agent.exits_slowly() + + with pytest.raises(CleanupIncompleteError): + await session.adapter.cleanup_all() + assert session.adapter._mcp.current is not None + assert not runtime.closed + # A restart retries the unfinished stop; it is still held. + with pytest.raises(CleanupIncompleteError, match="still running"): + await session.adapter.on_started("Fake Agent", "too early") + assert DEFAULT_ROOM in session.adapter._rooms + assert not runtime.closed + + exiting.release.set() + await session.adapter.cleanup_all() + + assert runtime.closed + assert session.adapter._mcp.current is None + await session.adapter.on_started("Fake Agent", "restarted") + + @pytest.mark.asyncio + async def test_an_mcp_stop_past_the_budget_is_left_for_a_retry(self) -> None: + agent = FakeACPAgent().will_say("ok") + stopping = asyncio.Event() + backend = FakeBandMCPBackend(stop_release=stopping) + config = stop_budget() + with backends_created_by(backend): + async with acp_adapter(agent, config) as session: + await session.send("hello") + runtime = session.runtime() + + with pytest.raises(CleanupIncompleteError, match="MCP backend"): + await session.adapter.cleanup_all() + assert runtime.closed + + stopping.set() + await session.adapter.cleanup_all() + + assert session.adapter._mcp.current is None + assert backend.stop_calls == 1 + + @pytest.mark.asyncio + async def test_a_failed_room_exit_fails_shutdown_and_keeps_the_mcp_server( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + config = fake_agent_config(inject_band_tools=True) + with backends_created_by(): + async with acp_adapter(agent, config) as session: + await session.send("hello") + runtime = session.runtime() + agent.fails_next_exit() + + with pytest.RaisesGroup( + pytest.RaisesExc(RuntimeError, match=AGENT_EXIT_FAILURE) + ): + await session.adapter.cleanup_all() + assert session.adapter._mcp.current is not None + assert not runtime.closed + + await session.adapter.cleanup_all() + + assert runtime.closed + assert session.adapter._mcp.current is None + + @pytest.mark.asyncio + async def test_retired_session_closes_finish_before_the_process_exits( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + await session.send("before the crash") + retired = session.session_id(DEFAULT_ROOM) + closing = agent.holds_session_closes() + exiting = agent.exits_slowly() + await crash_backend(session.adapter._mcp) + await session.send("after the crash") + await closing.received.wait() + + cleanup = asyncio.create_task(session.adapter.on_cleanup(DEFAULT_ROOM)) + await spin() + exited_under_a_held_close = exiting.received.is_set() + closing.release.set() + await exiting.received.wait() + + assert not exited_under_a_held_close + assert agent.closed_sessions == [retired] + exiting.release.set() + await cleanup diff --git a/tests/integrations/acp/roomownership/test_inline_prompt_cleanup.py b/tests/integrations/acp/roomownership/test_inline_prompt_cleanup.py new file mode 100644 index 000000000..2f0e999ff --- /dev/null +++ b/tests/integrations/acp/roomownership/test_inline_prompt_cleanup.py @@ -0,0 +1,90 @@ +"""Final cleanup settles and cancels ordinary ACP prompts through their owner.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.retention import RoomRetentionConfig +from tests.adapters.waiting import until +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.roomownership.kit import TEST_GUARD_S, holds_prompt + + +class TestInlinePromptCleanup: + @pytest.mark.parametrize("cleanup", ["stop", "leave"]) + async def test_cleanup_cancels_a_prompt_after_its_settle_grace( + self, cleanup: str + ) -> None: + agent = FakeACPAgent() + held = holds_prompt(agent, 2) + config = fake_agent_config( + turn_settle_timeout_s=0.01, + retention=RoomRetentionConfig(transition_timeout_s=TEST_GUARD_S), + ) + async with acp_adapter(agent, config) as session: + await session.send("bootstrap") + runtime = session.runtime() + native_id = session.session_id(DEFAULT_ROOM) + prompt = asyncio.create_task(session.send("held")) + await asyncio.wait_for(held.received.wait(), TEST_GUARD_S) + try: + match cleanup: + case "stop": + await session.adapter.stop() + case "leave": + await session.adapter.on_cleanup(DEFAULT_ROOM) + + with pytest.raises(asyncio.CancelledError): + await prompt + assert agent.cancelled_sessions == [native_id] + assert runtime.closed + finally: + held.release.set() + await asyncio.gather(prompt, return_exceptions=True) + + @pytest.mark.parametrize("cleanup", ["stop", "leave"]) + async def test_cleanup_allows_a_prompt_to_finish_within_its_grace( + self, cleanup: str + ) -> None: + agent = FakeACPAgent() + held = holds_prompt(agent, 2) + config = fake_agent_config( + turn_settle_timeout_s=TEST_GUARD_S, + retention=RoomRetentionConfig(transition_timeout_s=TEST_GUARD_S), + ) + async with acp_adapter(agent, config) as session: + await session.send("bootstrap") + runtime = session.runtime() + room = session.room() + prompt = asyncio.create_task(session.send("held")) + await asyncio.wait_for(held.received.wait(), TEST_GUARD_S) + match cleanup: + case "stop": + closing = asyncio.create_task(session.adapter.stop()) + case "leave": + closing = asyncio.create_task( + session.adapter.on_cleanup(DEFAULT_ROOM) + ) + try: + await until( + lambda: room.retained.closing, + failure="cleanup never closed room admission", + ) + held.release.set() + async with asyncio.timeout(TEST_GUARD_S): + reply = await prompt + await closing + + assert reply.errors == [] + assert agent.cancelled_sessions == [] + assert runtime.closed + finally: + held.release.set() + await asyncio.gather(prompt, closing, return_exceptions=True) diff --git a/tests/integrations/acp/roomownership/test_interrupted_settings.py b/tests/integrations/acp/roomownership/test_interrupted_settings.py new file mode 100644 index 000000000..b58efe76a --- /dev/null +++ b/tests/integrations/acp/roomownership/test_interrupted_settings.py @@ -0,0 +1,77 @@ +"""Interrupted partial switches affect the live session but are never remembered.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from band.core.exceptions import BandConfigError +from band.core.model_catalog import ModelSelection +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.acp_toolkit.agent import EFFORT_OPTION_ID, MODEL_OPTION_ID +from tests.integrations.acp.roomownership.kit import TEST_GUARD_S, recover + + +class TestInterruptedSettings: + @pytest.mark.asyncio + @pytest.mark.parametrize("interruption", ["cancel", "disconnect"]) + async def test_a_partially_applied_switch_is_not_restored( + self, interruption: str + ) -> None: + configured = ModelSelection(model="a", reasoning_effort="low") + agent = ( + FakeACPAgent(supports_session_load=True) + .advertises_models( + {"a": ("low", "high"), "b": ("low", "high")}, current="a" + ) + .will_say("still serving") + ) + async with acp_adapter( + agent, fake_agent_config(model="a", reasoning_effort="low") + ) as session: + await session.send("bootstrap") + retained = session.session_id(DEFAULT_ROOM) + agent.knows_session(retained) + effort_reply = agent.holds_config_replies(EFFORT_OPTION_ID) + switch = asyncio.create_task( + session.adapter.apply_model_selection( + ModelSelection(model="b", reasoning_effort="high"), + room_id=DEFAULT_ROOM, + ) + ) + try: + async with asyncio.timeout(TEST_GUARD_S): + await effort_reply.received.wait() + assert agent.current_value(MODEL_OPTION_ID) == "b" + assert agent.current_value(EFFORT_OPTION_ID) == "high" + assert session.adapter._room_selection(DEFAULT_ROOM) == configured + if interruption == "cancel": + switch.cancel() + with pytest.raises(asyncio.CancelledError): + await switch + else: + agent.hang_up() + with pytest.raises(BandConfigError): + await switch + finally: + effort_reply.release.set() + await asyncio.gather(switch, return_exceptions=True) + + await recover(session) + restored = await session.send("recover") + + assert restored.errors == [] + assert session.adapter._room_selection(DEFAULT_ROOM) == configured + assert agent.current_value(MODEL_OPTION_ID) == "a" + assert agent.current_value(EFFORT_OPTION_ID) == "low" + assert agent.session_load_requests == [retained] + assert agent.config_selections(retained)[-2:] == [ + (MODEL_OPTION_ID, "a"), + (EFFORT_OPTION_ID, "low"), + ] diff --git a/tests/integrations/acp/roomownership/test_process_closure.py b/tests/integrations/acp/roomownership/test_process_closure.py new file mode 100644 index 000000000..76cde47e9 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_process_closure.py @@ -0,0 +1,121 @@ +"""A room keeps its agent process until that process is confirmed closed.""" + +from __future__ import annotations + +import asyncio +from contextlib import nullcontext +from pathlib import Path + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from band.core.retention import Residency, RoomRetentionConfig +from band.integrations.acp import client_adapter, client_runtime +from band.integrations.acp.client_adapter import ACPClientAdapter, ACPTurnTimeoutError +from tests.adapters.roompeer import CLEANUP_FAILURE +from tests.integrations.acp.acp_toolkit import DEFAULT_ROOM +from tests.integrations.acp.acp_toolkit.harness import send_to +from tests.integrations.acp.acp_toolkit.lifecycle import FailingExits +from tests.integrations.acp.acp_toolkit.peer import ExitStage, stdio_peer_config +from tests.integrations.acp.roomownership.kit import runtime_warnings, terminate_pid +from tests.paths import REPO_ROOT + + +class TestProcessClosure: + """A room keeps its agent process until that process is confirmed closed.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("stage", "turn_error"), + [(ExitStage.EOF, None), (ExitStage.PROMPT, ACPTurnTimeoutError)], + ids=["exits-at-close", "exited-before-close"], + ) + async def test_a_descendant_holding_the_agents_pipes_keeps_its_room_owned( + self, + tmp_path: Path, + stage: ExitStage, + turn_error: type[Exception] | None, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + """The agent exits, at close or before it, but a background descendant + still holds its stdout, so closure is unconfirmed: the room keeps its + process until the descendant is gone and a retry confirms the exit.""" + monkeypatch.setattr(client_runtime, "STDOUT_HELD_WARNING_SECONDS", 0.1) + holder_pid_file = tmp_path / "holder.pid" + adapter = ACPClientAdapter( + stdio_peer_config( + stage, + [], + orphan_pid_file=holder_pid_file, + retention=RoomRetentionConfig(transition_timeout_s=2.0), + # A held stdout hides the agent's death, so its prompt only ends + # at the turn deadline, whose recovery then waits on the pipes. + turn_timeout_s=1.0, + ), + workspace_for_room=lambda room_id: str(REPO_ROOT), + ) + await adapter.on_started("Stdio peer", "Real subprocess") + try: + with nullcontext() if turn_error is None else pytest.raises(turn_error): + await send_to(adapter, DEFAULT_ROOM) + room = adapter._rooms[DEFAULT_ROOM] + runtime = room.runtime + assert runtime is not None + + with pytest.raises(CleanupIncompleteError): + await adapter.on_cleanup(DEFAULT_ROOM) + + assert room.runtime is runtime and not runtime.closed + assert room.retained.snapshot.state == Residency.SUSPENDING + process = runtime._transport + assert isinstance(process, asyncio.subprocess.Process) + held = f"ACP agent subprocess {process.pid} (exit code" + assert any(warning.startswith(held) for warning in runtime_warnings(caplog)) + + terminate_pid(int(holder_pid_file.read_text())) + await adapter.on_cleanup(DEFAULT_ROOM) + + assert runtime.closed + assert DEFAULT_ROOM not in adapter._rooms + finally: + if holder_pid_file.exists(): + terminate_pid(int(holder_pid_file.read_text())) + await adapter.cleanup_all() + + @pytest.mark.asyncio + async def test_a_retry_after_a_failed_exit_kills_the_running_agent( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """The agent's exit fails and leaves it running; a retry kills that same + process and confirms its closure before the room is released.""" + adapter = ACPClientAdapter( + stdio_peer_config(ExitStage.EOF, []), + workspace_for_room=lambda room_id: str(REPO_ROOT), + ) + async with FailingExits() as spawns: + monkeypatch.setattr(client_adapter, "spawn_agent_process", spawns) + await adapter.on_started("Stdio peer", "Real subprocess") + try: + await send_to(adapter, DEFAULT_ROOM) + room = adapter._rooms[DEFAULT_ROOM] + runtime = room.runtime + [child] = spawns.children + process = child.process + + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await adapter.on_cleanup(DEFAULT_ROOM) + + assert process.returncode is None + assert room.runtime is runtime + + await adapter.on_cleanup(DEFAULT_ROOM) + + assert process.returncode is not None + assert DEFAULT_ROOM not in adapter._rooms + assert runtime_warnings(caplog) == [ + f"ACP agent subprocess {process.pid} failed to exit", + f"Killing ACP agent subprocess {process.pid} after its exit failed", + ] + finally: + await adapter.cleanup_all() diff --git a/tests/integrations/acp/roomownership/test_recovery.py b/tests/integrations/acp/roomownership/test_recovery.py new file mode 100644 index 000000000..c38003e6f --- /dev/null +++ b/tests/integrations/acp/roomownership/test_recovery.py @@ -0,0 +1,161 @@ +"""A failed turn retires its process through the owner and keeps the session.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from acp import RequestError +from acp.schema import LoadSessionResponse + +from band.core.model_catalog import ModelSelection +from band.core.retention import Residency +from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + FakeACPAgent, + acp_adapter, + fake_agent_config, +) +from tests.integrations.acp.roomownership.kit import ( + fails_prompt, + model_select, + models, + recover, +) + + +class TestRecovery: + """A failed turn retires its process through the owner and keeps the session.""" + + @pytest.mark.asyncio + async def test_the_next_turn_reloads_the_recovered_session(self) -> None: + agent = FakeACPAgent(supports_session_load=True) + fails_prompt( + agent, 1, RequestError.internal_error({"detail": "process wedged"}) + ) + + async with acp_adapter(agent) as session: + with pytest.raises(RequestError): + await session.send("hello") + retained = session.session_id(DEFAULT_ROOM) + first_runtime = session.room().runtime + agent.knows_session(retained) + + await session.send("again") + + assert session.runtime() is not first_runtime + assert agent.session_load_requests == [retained] + assert agent.session_ids() == [retained] + + @pytest.mark.asyncio + async def test_a_refused_selection_after_a_crash_fails_one_turn_and_reverts( + self, + ) -> None: + agent = models("a", "a", "b").will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + retained = session.session_id(DEFAULT_ROOM) + await recover(session) + agent.knows_session(retained).advertises(model_select("a", "a")) + + refused = await session.send("again") + refused_on = session.room().runtime + served = await session.send("once more") + + assert refused.errors == [ + ( + 'ACP session configuration failed: model "b" is not advertised; ' + "available: a" + ) + ] + assert served.errors == [] + # Loaded but unconfigured, the session is reloaded on a new process. + assert session.runtime() is not refused_on + assert ( + session.adapter._room_selection(DEFAULT_ROOM) + == session.adapter.model_selection + ) + assert [prompt["session_id"] for prompt in agent.prompts] == [retained] * 2 + assert retained not in agent.closed_sessions + + @pytest.mark.asyncio + async def test_a_failed_session_setup_reports_and_recovers_the_room( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok").fails_next_new_session() + async with acp_adapter(agent) as session: + with pytest.raises(RequestError): + await session.send("hello") + failed = session.last_reply + first_runtime = session.room().runtime + + await session.send("again") + + assert failed.errors == ["Internal error"] + assert session.runtime() is not first_runtime + assert len(agent.prompts) == 1 + + @pytest.mark.asyncio + async def test_a_superseded_generation_cannot_recover_its_successor( + self, + ) -> None: + agent = FakeACPAgent(supports_session_load=True).will_say("ok") + async with acp_adapter(agent) as session: + await session.send("hello") + room = session.room() + superseded = room.generation + agent.knows_session(session.session_id(DEFAULT_ROOM)) + await recover(session) + await session.send("again") + successor = session.runtime() + + assert not room.retained.recover(generation=superseded, reason="late") + assert room.retained.snapshot.state == Residency.RESIDENT + assert session.runtime() is successor and not successor.closed + + @pytest.mark.asyncio + async def test_tools_are_bound_before_the_recovered_session_restores( + self, + ) -> None: + """A Band tool the agent calls while restoring is served with the + restoring turn's tools, not the failed turn's.""" + reply = "restored" + + class RestoringAgent(FakeACPAgent): + async def load_session( + self, + cwd: str, + session_id: str, + mcp_servers: Any = None, + **kwargs: Any, + ) -> LoadSessionResponse: + loaded = await super().load_session( + cwd, session_id, mcp_servers, **kwargs + ) + await self.call_mcp_tool( + session_id=session_id, + server=BAND_MCP_SERVER_NAME, + tool_name=BandTool.SEND_MESSAGE, + arguments={"content": reply, "mentions": ["@peer-1"]}, + ) + return loaded + + agent = RestoringAgent(supports_session_load=True) + fails_prompt(agent, 1, RequestError.internal_error()) + + async with acp_adapter( + agent, fake_agent_config(inject_band_tools=True) + ) as session: + with pytest.raises(RequestError): + await session.send("hello") + failed = session.last_reply + agent.knows_session(session.session_id(DEFAULT_ROOM)) + + restoring = await session.send("again") + + assert restoring.texts == [reply] + assert reply not in failed.texts diff --git a/tests/integrations/acp/roomownership/test_settings.py b/tests/integrations/acp/roomownership/test_settings.py new file mode 100644 index 000000000..f01fd68bb --- /dev/null +++ b/tests/integrations/acp/roomownership/test_settings.py @@ -0,0 +1,160 @@ +"""A model switch takes its room in turn, like a prompt.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest + +from band.core.exceptions import BandConfigError, RoomClosingError +from band.core.model_catalog import ModelSelection +from tests.adapters.waiting import until +from tests.integrations.acp.acp_toolkit import DEFAULT_ROOM, acp_adapter +from tests.integrations.acp.acp_toolkit.agent import MODEL_OPTION_ID +from tests.integrations.acp.roomownership.kit import ( + SWITCHED_MODEL, + TEST_GUARD_S, + holds_prompt, + models, + recover, + switch_behind_held_prompt, +) + + +class TestSettings: + """A model switch takes its room in turn, like a prompt.""" + + @pytest.mark.asyncio + async def test_a_switch_on_a_recovered_room_starts_no_process(self) -> None: + agent = models("a", "a", "b") + async with acp_adapter(agent) as session: + await session.send("bootstrap") + session_id = session.session_id(DEFAULT_ROOM) + room = await recover(session) + + with pytest.raises(BandConfigError, match="no live ACP session"): + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + + assert room.runtime is None + assert agent.config_selections(session_id) == [] + + @pytest.mark.asyncio + async def test_a_cancelled_switch_caller_stays_cancelled_while_its_room_leaves( + self, + ) -> None: + async with switch_behind_held_prompt() as queued: + room = queued.session.room() + async with asyncio.timeout(TEST_GUARD_S): + queued.held.cancel() + leaving = asyncio.create_task( + queued.session.adapter.on_cleanup(DEFAULT_ROOM) + ) + # The caller gives up as the room starts closing, in the one + # loop turn before the cancelled prompt frees the switch to + # be refused by the closing room. + await asyncio.sleep(0) + assert room.retained.closing + queued.switch.cancel() + queued.prompting.release.set() + await leaving + with pytest.raises(asyncio.CancelledError): + await queued.switch + await asyncio.gather(queued.held, return_exceptions=True) + + assert queued.agent.config_selections(queued.session_id) == [] + + @pytest.mark.asyncio + async def test_a_switch_waits_for_the_rooms_running_prompt(self) -> None: + async with switch_behind_held_prompt() as queued: + assert not queued.switch.done() + assert queued.agent.config_selections(queued.session_id) == [] + + queued.prompting.release.set() + await queued.held + await queued.switch + + assert queued.agent.config_selections(queued.session_id) == [ + (MODEL_OPTION_ID, SWITCHED_MODEL) + ] + + @pytest.mark.asyncio + async def test_a_switch_waiting_for_a_prompt_never_holds_the_rooms_leave( + self, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.WARNING) + async with switch_behind_held_prompt() as queued: + async with asyncio.timeout(TEST_GUARD_S): + # The runtime cancels the room's handler, then the room leaves. + queued.held.cancel() + leaving = asyncio.create_task( + queued.session.adapter.on_cleanup(DEFAULT_ROOM) + ) + await until( + lambda: queued.session_id in queued.agent.cancelled_sessions, + failure="the held prompt was never cancelled", + ) + # The agent honours the cancel, ending its prompt. + queued.prompting.release.set() + await leaving + with pytest.raises(BandConfigError, match="ended mid-switch") as ended: + await queued.switch + # The failure the closing room caused stays on the record, for + # the caller to report. + assert isinstance(ended.value.__cause__, RoomClosingError) + assert caplog.records == [] + await asyncio.gather(queued.held, return_exceptions=True) + + assert queued.agent.config_selections(queued.session_id) == [] + + @pytest.mark.asyncio + async def test_a_switch_on_a_leaving_room_is_refused_before_it_runs( + self, + ) -> None: + agent = models("a", "a", "b") + prompting = holds_prompt(agent, 2) + async with acp_adapter(agent) as session: + await session.send("bootstrap") + session_id = session.session_id(DEFAULT_ROOM) + held = asyncio.create_task(session.send("held")) + await prompting.received.wait() + async with asyncio.timeout(TEST_GUARD_S): + leaving = asyncio.create_task(session.adapter.on_cleanup(DEFAULT_ROOM)) + await until( + lambda: session.room().retained.closing, + failure="the room never started leaving", + ) + try: + with pytest.raises(RoomClosingError): + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id=DEFAULT_ROOM + ) + finally: + prompting.release.set() + await asyncio.gather(leaving, held, return_exceptions=True) + + assert agent.config_selections(session_id) == [] + + @pytest.mark.asyncio + async def test_a_prompt_in_one_room_never_holds_another_rooms_switch( + self, + ) -> None: + agent = models("a", "a", "b") + prompting = holds_prompt(agent, 3) + async with acp_adapter(agent) as session: + await session.send("bootstrap", room="room-a") + await session.send("bootstrap", room="room-b") + other = session.session_id("room-b") + held = asyncio.create_task(session.send("held", room="room-a")) + await prompting.received.wait() + + async with asyncio.timeout(TEST_GUARD_S): + await session.adapter.apply_model_selection( + ModelSelection(model="b"), room_id="room-b" + ) + + assert agent.config_selections(other) == [(MODEL_OPTION_ID, "b")] + prompting.release.set() + await held diff --git a/tests/integrations/acp/roomownership/test_startup.py b/tests/integrations/acp/roomownership/test_startup.py new file mode 100644 index 000000000..1478e5774 --- /dev/null +++ b/tests/integrations/acp/roomownership/test_startup.py @@ -0,0 +1,109 @@ +"""A process starts within the room's bounded acquisition.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from contextlib import AbstractAsyncContextManager + +import pytest + +from band.core.retention import RoomRetentionConfig +from band.integrations.acp import client_adapter +from band.integrations.acp.client_adapter import PROCESS_NOT_READY_ERROR +from tests.integrations.acp.acp_toolkit import ( + DEFAULT_ROOM, + AcpSession, + FakeACPAgent, + accepted_work_finished, + acp_adapter, + fake_agent_config, + released, +) +from tests.integrations.acp.roomownership.kit import ( + SHORT_TRANSITION_S, + TEST_GUARD_S, + cursor, + inline, + stop, +) + + +def adapter_warnings(caplog: pytest.LogCaptureFixture) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.name == client_adapter.__name__ and record.levelno == logging.WARNING + ] + + +class TestStartup: + """A process starts within the room's bounded acquisition.""" + + @pytest.mark.asyncio + async def test_a_start_that_never_answers_fails_at_its_bound_and_recovers( + self, caplog: pytest.LogCaptureFixture + ) -> None: + agent = FakeACPAgent().will_say("ok") + agent.holds_next_initialize() + config = fake_agent_config( + retention=RoomRetentionConfig(transition_timeout_s=SHORT_TRANSITION_S) + ) + async with acp_adapter(agent, config) as session: + with pytest.raises(TimeoutError): + await session.send("hello") + assert session.last_reply.errors == [PROCESS_NOT_READY_ERROR] + assert adapter_warnings(caplog) == [ + ( + f"Room {DEFAULT_ROOM}: ACP agent process not ready: " + f"Room {DEFAULT_ROOM} start abandoned after {SHORT_TRANSITION_S}s" + ) + ] + + served = await session.send("again") + + assert served.errors == [] + assert session.room().generation == 2 + assert len(agent.prompts) == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("opened", [inline, cursor], ids=["inline", "cursor"]) + async def test_a_stop_during_a_start_that_never_answers_frees_the_room( + self, opened: Callable[..., AbstractAsyncContextManager[AcpSession]] + ) -> None: + agent = FakeACPAgent().will_say("ok") + starting = agent.holds_next_initialize() + # The default start bound is far away: the stop, not the bound, frees it. + async with opened(agent) as session: + first = asyncio.create_task(session.send("hello")) + await starting.received.wait() + + await stop(session, first) + async with asyncio.timeout(TEST_GUARD_S): + served = await session.send("again") + await accepted_work_finished(session.room()) + + assert served.errors == [] + assert len(agent.prompts) == 1 + + @pytest.mark.asyncio + async def test_a_start_outlasting_the_transition_timeout_fails_and_recovers( + self, + ) -> None: + agent = FakeACPAgent().will_say("ok") + starting = agent.holds_next_initialize() + config = fake_agent_config( + retention=RoomRetentionConfig(transition_timeout_s=SHORT_TRANSITION_S) + ) + async with acp_adapter(agent, config) as session: + with pytest.raises(TimeoutError): + await session.send("hello") + starting.release.set() + await released(session.room()) + + served = await session.send("again") + + assert served.errors == [] + assert session.room().generation == 2 + assert len(agent.prompts) == 1 diff --git a/tests/integrations/acp/test_client_adapter.py b/tests/integrations/acp/test_client_adapter.py deleted file mode 100644 index 14cee3051..000000000 --- a/tests/integrations/acp/test_client_adapter.py +++ /dev/null @@ -1,2314 +0,0 @@ -"""Tests for ACPClientAdapter.""" - -from __future__ import annotations - -import asyncio -import logging -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch -from urllib.parse import urlsplit - -import pytest -from acp.exceptions import RequestError -from acp.helpers import update_agent_message_text -from acp.schema import ( - McpServerStdio, - NewSessionResponse, - PermissionOption, - SessionConfigOptionSelect, - SessionConfigSelectOption, - SetSessionConfigOptionResponse, - SseMcpServer, -) -from pydantic import ValidationError - -from band.converters.parsing import parse_tool_call, parse_tool_result -from band.core.exceptions import BandConfigError -from band.core.protocols import FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE -from band.core.types import Capability, Emit -from band.integrations.acp import client_adapter -from band.integrations.acp.client_adapter import ( - ACPClientAdapter, - ACPClientAdapterConfig, - ACPPermissionRequest, - RoomSession, - _resolve_launcher, -) -from band.integrations.acp.client_profiles import CursorACPClientProfile -from band.integrations.acp.client_runtime import ACPCollectingClient -from band.integrations.acp.client_types import ( - ACPClientSessionState, - BandACPClient, -) -from band.integrations.acp.types import ACPToolCall -from band.integrations.mcp import BandMCPTransport -from band.runtime.prompts import COMMUNICATION_INSTRUCTIONS -from band.testing import FakeAgentTools, events_of_type, reported_failures -from tests.integrations.acp.acp_toolkit.harness import ( - Launch, - inject_acp_spawn, - launch_for, -) -from tests.integrations.acp.conftest import make_platform_message -from tests.mcpbackends import backends_created_by, hold_backend -from tests.mcpclient import endpoint_path - -_MOCK_ROOM = "room-123" -CODEX = ACPClientAdapterConfig(command="codex") - - -def permission_events(tools: FakeAgentTools) -> list[dict[str, object]]: - """The permission tool_call/tool_result events the handler posted to the room.""" - return [ - event - for event in tools.events_sent - if (event.get("metadata") or {}).get("permission_request") - ] - - -def event_types(events: list[dict[str, object]]) -> list[object]: - """The ordered ``message_type`` of each event — for asserting a pair's shape.""" - return [event["message_type"] for event in events] - - -def metadata_values(events: list[dict[str, object]], key: str) -> list[object]: - """The ordered value of one metadata field across a set of events.""" - return [event["metadata"][key] for event in events] - - -class TestACPClientAdapterConfig: - """The settings ``ACPClientAdapterConfig`` validates and the launch they produce.""" - - @pytest.mark.asyncio - async def test_a_string_command_launches_as_one_argument( - self, tmp_path: Path - ) -> None: - adapter = ACPClientAdapter(CODEX, workspace_for_room=lambda _: str(tmp_path)) - - launch = await launch_for(adapter) - - assert launch.command == ("codex",) - - @pytest.mark.asyncio - async def test_settings_reach_the_launched_agent(self, tmp_path: Path) -> None: - config = ACPClientAdapterConfig( - command=["gemini", "cli"], - env={"API_KEY": "test"}, - auth_method="api_key", - use_unstable_protocol=True, - ) - adapter = ACPClientAdapter(config, workspace_for_room=lambda _: str(tmp_path)) - - launch = await launch_for(adapter) - - assert launch == Launch( - command=("gemini", "cli"), - env={"API_KEY": "test"}, - cwd=str(tmp_path), - use_unstable_protocol=True, - auth_method="api_key", - client_capabilities=None, - ) - - def test_command_is_required(self) -> None: - with pytest.raises(ValidationError, match="command\n Field required"): - ACPClientAdapterConfig.model_validate({}) - - @pytest.mark.parametrize("command", [[], ""]) - def test_an_empty_command_is_rejected(self, command: list[str] | str) -> None: - with pytest.raises(ValueError, match="ACP stdio transport requires a command"): - ACPClientAdapterConfig(command=command) - - @pytest.mark.parametrize( - "setting", - [{"host": "10.0.0.5", "port": 8080}, {"host": "10.0.0.5"}, {"port": 8080}], - ids=["host-and-port", "host", "port"], - ) - def test_a_shared_tcp_process_is_rejected(self, setting: dict[str, object]) -> None: - with pytest.raises(ValueError, match="TCP ACP transport cannot guarantee"): - ACPClientAdapterConfig.model_validate({"command": "codex", **setting}) - - def test_host_mcp_servers_load_as_acp_servers(self) -> None: - config = ACPClientAdapterConfig.model_validate( - { - "command": "codex", - "mcp_servers": [ - {"name": "fs", "command": "npx", "args": ["fs"], "env": []}, - { - "type": "sse", - "name": "band", - "url": "http://h/sse", - "headers": [], - }, - ], - } - ) - - assert config.mcp_servers == ( - McpServerStdio(name="fs", command="npx", args=["fs"], env=[]), - SseMcpServer(type="sse", name="band", url="http://h/sse", headers=[]), - ) - - def test_a_malformed_mcp_server_fails_at_load_not_at_session_start(self) -> None: - with pytest.raises(ValidationError, match="mcp_servers.0"): - ACPClientAdapterConfig.model_validate( - {"command": "codex", "mcp_servers": [{"name": "fs"}]} - ) - - @pytest.mark.parametrize("turn_timeout_s", [0, -1.0]) - def test_turn_timeout_must_be_positive(self, turn_timeout_s: float) -> None: - with pytest.raises(ValueError, match="greater than 0"): - ACPClientAdapterConfig(command="codex", turn_timeout_s=turn_timeout_s) - - def test_a_typed_selection_and_a_resolver_are_exclusive(self) -> None: - config = ACPClientAdapterConfig(command="codex", reasoning_effort="high") - - with pytest.raises(ValueError, match="not both"): - ACPClientAdapter(config, resolve_session_config=AsyncMock()) - - def test_a_custom_transport_is_rejected(self) -> None: - with pytest.raises( - ValueError, - match="custom ACP transports cannot guarantee room process isolation", - ): - ACPClientAdapter(CODEX, spawn_process=object()) - - def test_starts_with_no_room_state(self) -> None: - adapter = ACPClientAdapter(CODEX) - - assert adapter._runtimes == {} - assert adapter._workspaces.rooms == () - assert adapter._room_to_session == {} - assert adapter._room_tools == {} - - -class TestACPClientAdapterTransport: - """Tests for the injected stdio transport seam.""" - - @pytest.mark.asyncio - async def test_injected_spawn_used_on_connection_start( - self, make_acp_transport - ) -> None: - """FakeSpawn patched onto _build_runtime is used when a room connects.""" - transport = make_acp_transport() - with patch( - "band.integrations.acp.client_adapter.shutil.which", return_value=None - ): - adapter = ACPClientAdapter(CODEX) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex", "Codex bridge") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - assert runtime._conn is transport.conn - args, _ = transport.last_call - assert args == ("codex",) - - @pytest.mark.asyncio - async def test_room_workspace_is_used_for_initial_spawn( - self, make_acp_transport, tmp_path: Path - ) -> None: - transport = make_acp_transport() - workspace = tmp_path / "acp-room" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), - workspace_for_room=lambda _room_id: str(workspace), - ) - inject_acp_spawn(adapter, transport) - await adapter.on_started("", "") - runtime = await adapter._runtime_for("room-1") - await adapter._ensure_connection(runtime) - - assert transport.last_kwargs["cwd"] == str(workspace) - - -class TestACPClientAdapterShutdown: - """Graceful shutdown must release the adapter-wide subprocess/TCP connection. - - ``Agent.stop()`` invokes ``cleanup_all()`` (not ``stop()``), so the teardown has - to hang off ``cleanup_all`` or the transport spawned in ``on_started`` leaks. - """ - - @pytest.mark.asyncio - async def test_cleanup_all_tears_down_the_transport( - self, make_acp_transport - ) -> None: - transport = make_acp_transport() - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex", "bridge") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - assert runtime._ctx is not None # transport is up - - await adapter.cleanup_all() # the hook Agent.stop() calls on graceful shutdown - - assert runtime._ctx is None # ...and released - assert runtime._conn is None - - @pytest.mark.asyncio - async def test_stale_room_cleanup_preserves_a_replacement_runtime(self) -> None: - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - failed_runtime = adapter._build_runtime() - replacement_runtime = adapter._build_runtime() - adapter._runtimes["room-1"] = replacement_runtime - - await adapter.on_cleanup("room-1", expected_runtime=failed_runtime) - - assert adapter._runtimes["room-1"] is replacement_runtime - - @pytest.mark.asyncio - async def test_restart_after_a_full_stop_allows_backend_creation( - self, make_acp_transport - ) -> None: - """Agent.start() reuses the same adapter instance across a - stop()-then-start() restart (and across a retry after a failed - start -- both go through cleanup_all's final=True default). The ACP - connection self-heals unconditionally; the MCP backend must too, or a - perfectly healthy restarted adapter can never call a Band tool again.""" - transport = make_acp_transport() - adapter = ACPClientAdapter(CODEX) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex", "bridge") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - await adapter.cleanup_all() # Agent.stop(), final=True - - await adapter.on_started("Codex", "bridge") # Agent.start() again - - with backends_created_by() as starts: - await adapter._mcp.ensure() - - assert len(starts.requested) == 1 - - -class TestACPClientAdapterLocalMcpConfig: - """Tests for local Band MCP injection.""" - - @pytest.mark.asyncio - async def test_get_or_start_band_mcp_server_returns_http_config(self) -> None: - """Should expose the room's endpoint on the shared HTTP MCP server.""" - adapter = ACPClientAdapter(CODEX) - - try: - server = await adapter._get_or_start_band_mcp_server("room-1") - finally: - await adapter.cleanup_all() - - assert server.name == "band" - assert urlsplit(server.url).path == endpoint_path(room_id="room-1") - assert server.headers == [] - assert server.type == "http" - - @pytest.mark.asyncio - async def test_get_or_start_band_mcp_server_returns_sse_config(self) -> None: - """Should expose shared SSE when the ACP agent only supports SSE MCP.""" - adapter = ACPClientAdapter(CODEX) - runtime = adapter._build_runtime() - runtime._agent_mcp_transport = BandMCPTransport.SSE - adapter._runtimes["room-1"] = runtime - adapter._workspaces.claim("room-1", "/tmp/room-1") - - try: - server = await adapter._get_or_start_band_mcp_server("room-1") - finally: - await adapter.cleanup_all() - - assert server.name == "band" - assert urlsplit(server.url).path == endpoint_path( - BandMCPTransport.SSE, room_id="room-1" - ) - assert server.headers == [] - assert server.type == "sse" - - @pytest.mark.asyncio - async def test_get_or_start_band_mcp_server_reuses_shared_server(self) -> None: - """Should start the shared Band MCP server only once.""" - adapter = ACPClientAdapter(CODEX) - - with backends_created_by() as starts: - first = await adapter._get_or_start_band_mcp_server("room-1") - second = await adapter._get_or_start_band_mcp_server("room-1") - - assert first.url == second.url - assert len(starts.requested) == 1 - - @pytest.mark.asyncio - async def test_turn_recovery_stop_allows_backend_recreation(self) -> None: - """The on_message error path's ``stop()`` tears down to recover a wedged - turn, not to end the adapter -- a later turn on any room must still be - able to self-heal by starting a fresh backend.""" - adapter = ACPClientAdapter(CODEX) - stopped = await hold_backend(adapter._mcp) - - await adapter.stop() # the on_message except-handler's call, not shutdown - - with backends_created_by() as starts: - recreated = await adapter._mcp.ensure() - - assert recreated is not stopped - assert len(starts.requested) == 1 - - async def _registered_tool_names(self, adapter: ACPClientAdapter) -> set[str]: - """The tool names the adapter asks its Band MCP backend to serve.""" - with backends_created_by() as starts: - await adapter._get_or_start_band_mcp_server("room-1") - return {d.name for d in starts.requested[0].tool_definitions} - - @pytest.mark.asyncio - async def test_memory_tools_registered_when_declared(self) -> None: - """Declared MEMORY capability puts its tool group on the loopback server.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), capabilities=Capability.MEMORY - ) - assert "band_store_memory" in await self._registered_tool_names(adapter) - - @pytest.mark.asyncio - async def test_memory_tools_absent_without_declaration(self) -> None: - """Undeclared MEMORY keeps its tool group off the server (an - enterprise feature the adapter must opt into).""" - registered = await self._registered_tool_names(ACPClientAdapter(CODEX)) - assert "band_store_memory" not in registered - assert "band_send_message" in registered - - @pytest.mark.asyncio - async def test_contact_tools_registered_regardless_of_declaration(self) -> None: - """Contact tools stay unconditionally registered — the pre-existing - default every caller without ``features=`` (every ACP example) relies - on. Only memory is capability-gated.""" - registered = await self._registered_tool_names(ACPClientAdapter(CODEX)) - assert "band_list_contacts" in registered - - def test_build_system_context_mentions_band_tools(self) -> None: - """Should keep ACP system context minimal and room-aware.""" - adapter = ACPClientAdapter(CODEX) - adapter.agent_name = "ACP Bridge" - adapter.agent_description = "Bridge to ACP agents" - msg = make_platform_message( - "Hello", - room_id="room-123", - sender_id="user-123", - sender_name="Pat", - ) - - system_context = adapter._build_system_context("room-123", msg) - - assert "Band tools" in system_context - assert COMMUNICATION_INSTRUCTIONS in system_context - assert "reply exactly once" not in system_context - assert "Never both" not in system_context - assert "chat_id" not in system_context - assert "Current requester name: Pat" in system_context - assert "Use each MCP tool's schema" in system_context - - def test_build_system_context_defers_to_external_mcp_tool_schema(self) -> None: - """The room value is supplied without assuming a remote tool's field name.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - adapter.agent_name = "ACP Bridge" - adapter.agent_description = "Bridge to ACP agents" - msg = make_platform_message("Hello", room_id="room-123") - - system_context = adapter._build_system_context("room-123", msg) - - assert "Use each MCP tool's schema" in system_context - assert "Current chat_id: room-123" in system_context - assert "must include room_id" not in system_context - - -class TestACPClientAdapterOnStarted: - """Tests for ACPClientAdapter.on_started() and lazy room runtime connection. - - Spawn/transport tests inject :class:`FakeSpawn` via ``inject_acp_spawn`` and - start the room runtime explicitly — ``on_started`` no longer spawns. - """ - - @pytest.mark.asyncio - async def test_room_runtime_start_spawns_and_initializes( - self, make_acp_transport - ) -> None: - """A room runtime's own start() spawns the process and initializes it.""" - transport = make_acp_transport() - adapter = ACPClientAdapter(CODEX) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex Bridge", "Bridge to Codex") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - assert runtime._conn is transport.conn - transport.conn.initialize.assert_awaited_once_with(protocol_version=1) - - @pytest.mark.asyncio - async def test_on_started_skips_builtin_transport_options_for_injected_spawn( - self, make_acp_transport - ) -> None: - """Injected ``spawn_process`` factories must not receive stdio transport knobs. - - ``transport_kwargs`` / ``use_unstable_protocol`` are only meaningful for - the built-in stdio/TCP constructors; an injected factory owns its own - connection options. - """ - transport = make_acp_transport() - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command=["npx", "@zed-industries/codex-acp"]) - ) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex Bridge", "Bridge to Codex") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - assert "transport_kwargs" not in transport.last_kwargs - assert "use_unstable_protocol" not in transport.last_kwargs - - @pytest.mark.asyncio - async def test_on_started_forwards_command_positionally( - self, make_acp_transport - ) -> None: - """Should forward the stdio command (executable + args) to the transport.""" - transport = make_acp_transport() - # Pin the launcher pass-through: with no PATH resolution (which happens at - # construction, via _resolve_launcher) the command reaches the transport - # verbatim, so this asserts the positional splat, not _resolve_launcher - # (covered by TestResolveLauncher) or whether `npx` happens to be installed here. - with patch( - "band.integrations.acp.client_adapter.shutil.which", return_value=None - ): - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command=["npx", "@zed-industries/codex-acp"]) - ) - inject_acp_spawn(adapter, transport) - await adapter.on_started("Codex Bridge", "Bridge to Codex") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - # spawn(client, *command, ...) — command splatted as positional args. - args, _ = transport.last_call - assert args == ("npx", "@zed-industries/codex-acp") - - @pytest.mark.asyncio - async def test_on_started_stores_agent_info(self, make_acp_transport) -> None: - """Should store agent name and description.""" - adapter = ACPClientAdapter(CODEX) - - await adapter.on_started("Test Agent", "A test agent") - - assert adapter.agent_name == "Test Agent" - assert adapter.agent_description == "A test agent" - - @pytest.mark.asyncio - async def test_on_started_prefers_http_mcp_when_supported( - self, make_acp_transport - ) -> None: - """Should select HTTP MCP when the ACP agent advertises it.""" - adapter = ACPClientAdapter(CODEX) - inject_acp_spawn(adapter, make_acp_transport(http=True, sse=True)) - await adapter.on_started("Test Agent", "A test agent") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - assert runtime._agent_mcp_transport is BandMCPTransport.HTTP - - @pytest.mark.asyncio - async def test_on_started_uses_sse_mcp_when_http_missing( - self, make_acp_transport - ) -> None: - """Should fall back to SSE MCP when that's all the ACP agent supports.""" - adapter = ACPClientAdapter(CODEX) - inject_acp_spawn(adapter, make_acp_transport(http=False, sse=True)) - await adapter.on_started("Test Agent", "A test agent") - runtime = await adapter._runtime_for("room-1") - await runtime.start() - - assert runtime._agent_mcp_transport is BandMCPTransport.SSE - - -class TestACPClientAdapterOnMessage: - """Tests for ACPClientAdapter.on_message().""" - - @pytest.fixture - async def adapter_with_mocks(self) -> ACPClientAdapter: - """Create adapter with mocked ACP connection for one room.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - runtime = await adapter._runtime_for(_MOCK_ROOM) - - runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "acp-session-123" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._conn.prompt = AsyncMock() - runtime._client = BandACPClient() - - return adapter - - def _runtime(self, adapter: ACPClientAdapter): - return adapter._runtimes[_MOCK_ROOM] - - @pytest.mark.asyncio - async def test_on_message_creates_session( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should create ACP session for new room.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.new_session.assert_called_once() - assert ( - adapter_with_mocks._room_to_session["room-123"].session_id - == "acp-session-123" - ) - - @pytest.mark.asyncio - async def test_on_message_applies_selected_session_configuration( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - effort = SessionConfigOptionSelect( - id="reasoning_effort", - name="Reasoning effort", - type="select", - current_value="medium", - options=[ - SessionConfigSelectOption(value="medium", name="Medium"), - SessionConfigSelectOption(value="high", name="High"), - ], - ) - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.new_session = AsyncMock( - return_value=NewSessionResponse( - session_id="acp-session-123", config_options=[effort] - ) - ) - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.set_config_option = AsyncMock( - return_value=SetSessionConfigOptionResponse( - config_options=[effort.model_copy(update={"current_value": "high"})] - ) - ) - resolver = AsyncMock(return_value={"reasoning_effort": "high"}) - adapter_with_mocks._resolve_session_config = resolver - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - resolver.assert_awaited_once() - request = resolver.await_args.args[0] - assert request.config_options == (effort,) - adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._conn.set_config_option.assert_awaited_once_with( - session_id="acp-session-123", - config_id="reasoning_effort", - value="high", - ) - - @pytest.mark.asyncio - async def test_on_message_reuses_session( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should reuse existing session for same room.""" - adapter_with_mocks._room_to_session["room-123"] = RoomSession( - "existing-session", band_url=None - ) - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.new_session.assert_not_called() - - @pytest.mark.asyncio - async def test_on_message_sends_prompt( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should send prompt to remote ACP agent.""" - tools = FakeAgentTools() - msg = make_platform_message("What is the weather?", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt.assert_called_once() - call_kwargs = adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._conn.prompt.call_args.kwargs - assert call_kwargs["session_id"] == "acp-session-123" - - @pytest.mark.asyncio - async def test_on_message_emits_task_event( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should emit task event for session rehydration.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - # Should have sent task event - task_events = events_of_type(tools, "task") - assert len(task_events) == 1 - assert task_events[0]["metadata"]["acp_client_session_id"] == "acp-session-123" - - @pytest.mark.asyncio - async def test_on_message_bootstrap_rehydrates( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should rehydrate room -> session mappings on bootstrap.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - adapter_with_mocks._runtimes[_MOCK_ROOM]._agent_supports_session_load = True - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.load_session = AsyncMock( - return_value=object() - ) - history = ACPClientSessionState(room_to_session={"room-123": "session-abc"}) - - await adapter_with_mocks.on_message( - msg, - tools, - history, - None, - None, - is_session_bootstrap=True, - room_id="room-123", - ) - - assert ( - adapter_with_mocks._room_to_session["room-123"].session_id == "session-abc" - ) - adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._conn.load_session.assert_awaited_once() - - @pytest.mark.asyncio - async def test_on_message_creates_new_session_when_persisted_session_cannot_load( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A rebooted ephemeral ACP agent creates a session before prompting.""" - stale_session = "stale-session" - fresh_session = MagicMock(session_id="fresh-session") - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.new_session = AsyncMock( - return_value=fresh_session - ) - adapter_with_mocks._runtimes[_MOCK_ROOM]._agent_supports_session_load = True - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.load_session = AsyncMock( - return_value=None - ) - - async def prompt_new_session(**kwargs): - session_id = kwargs["session_id"] - # Stream the reply through the live sink the adapter registers for the - # turn (as a real agent would), not a direct buffer poke. - await adapter_with_mocks._runtimes[_MOCK_ROOM]._client.session_update( - session_id, update_agent_message_text("Recovered reply") - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=prompt_new_session - ) - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(room_to_session={"room-123": stale_session}), - None, - None, - is_session_bootstrap=True, - room_id="room-123", - ) - - assert ( - adapter_with_mocks._room_to_session["room-123"].session_id - == "fresh-session" - ) - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.new_session.assert_awaited_once() - adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._conn.load_session.assert_awaited_once() - prompt_calls = adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._conn.prompt.call_args_list - assert [call.kwargs["session_id"] for call in prompt_calls] == ["fresh-session"] - assert "[System Context]" in prompt_calls[0].kwargs["prompt"][0].text - assert tools.messages_sent == [] - - @pytest.mark.asyncio - async def test_on_message_error_sends_error_event( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should report an AgentFailure when the ACP agent fails.""" - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=RuntimeError("Agent crashed") - ) - - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - with pytest.raises(RuntimeError, match="Agent crashed"): - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "acp" - assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE - - @pytest.mark.asyncio - async def test_prompt_timeout_error_is_not_reported_as_adapter_timeout( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A provider-raised TimeoutError is not the adapter's deadline.""" - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=TimeoutError("provider socket timeout") - ) - - tools = FakeAgentTools() - - with pytest.raises(TimeoutError, match="provider socket timeout"): - await adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["message"] == GENERIC_PROVIDER_FAILURE_MESSAGE - assert failures[0]["code"] is None - - @pytest.mark.asyncio - async def test_adapter_deadline_raises_already_reported_failure( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """The adapter's own deadline reports once and remains retryable.""" - adapter_with_mocks.config = adapter_with_mocks.config.model_copy( - update={"turn_timeout_s": 0.01} - ) - - async def slow_prompt(**_: object) -> None: - await asyncio.sleep(1) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=slow_prompt - ) - - tools = FakeAgentTools() - - with pytest.raises(TimeoutError): - await adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["code"] == FAILURE_CODE_TIMEOUT - - @pytest.mark.asyncio - async def test_cancelling_the_turn_cancels_its_prompt( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A cancelled turn stops its prompt on both sides of the ACP connection.""" - prompt_started = asyncio.Event() - prompt_cancelled = asyncio.Event() - - async def endless_prompt(**_: object) -> None: - prompt_started.set() - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - prompt_cancelled.set() - raise - - conn = self._runtime(adapter_with_mocks)._conn - conn.prompt = AsyncMock(side_effect=endless_prompt) - turn = asyncio.create_task( - adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - FakeAgentTools(), - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - ) - await prompt_started.wait() - - turn.cancel() - with pytest.raises(asyncio.CancelledError): - await turn - - assert prompt_cancelled.is_set() - conn.cancel.assert_awaited_once_with("acp-session-123") - - @pytest.mark.asyncio - async def test_cancelling_the_turn_survives_repeated_cancel( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A second STOP during session/cancel still finishes that cancel.""" - prompt_started = asyncio.Event() - cancel_started = asyncio.Event() - release_cancel = asyncio.Event() - - async def endless_prompt(**_: object) -> None: - prompt_started.set() - await asyncio.Event().wait() - - async def slow_cancel(session_id: str) -> None: - cancel_started.set() - await release_cancel.wait() - - conn = self._runtime(adapter_with_mocks)._conn - conn.prompt = AsyncMock(side_effect=endless_prompt) - conn.cancel = AsyncMock(side_effect=slow_cancel) - turn = asyncio.create_task( - adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - FakeAgentTools(), - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - ) - await prompt_started.wait() - turn.cancel() - await cancel_started.wait() - turn.cancel() - await asyncio.sleep(0) - assert not turn.done() - release_cancel.set() - with pytest.raises(asyncio.CancelledError): - await turn - - conn.cancel.assert_awaited_once_with("acp-session-123") - - @pytest.mark.asyncio - async def test_timeout_cleanup_survives_outer_cancel( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """STOP during timeout cleanup still finishes session/cancel and failure.""" - adapter_with_mocks.config = adapter_with_mocks.config.model_copy( - update={"turn_timeout_s": 0.05} - ) - cancel_started = asyncio.Event() - release_cancel = asyncio.Event() - - async def slow_prompt(**_: object) -> None: - await asyncio.Event().wait() - - async def slow_cancel(session_id: str) -> None: - cancel_started.set() - await release_cancel.wait() - - conn = self._runtime(adapter_with_mocks)._conn - conn.prompt = AsyncMock(side_effect=slow_prompt) - conn.cancel = AsyncMock(side_effect=slow_cancel) - tools = FakeAgentTools() - turn = asyncio.create_task( - adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - ) - await cancel_started.wait() - turn.cancel() - await asyncio.sleep(0) - # Bare shield would finish the outer task here while cleanup orphans. - assert not turn.done() - release_cancel.set() - with pytest.raises(asyncio.CancelledError): - await turn - - conn.cancel.assert_awaited_once_with("acp-session-123") - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["code"] == FAILURE_CODE_TIMEOUT - assert _MOCK_ROOM not in adapter_with_mocks._runtimes - - @pytest.mark.asyncio - async def test_timeout_cleanup_survives_repeated_cancel( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A second STOP during drain still finishes session/cancel and failure.""" - adapter_with_mocks.config = adapter_with_mocks.config.model_copy( - update={"turn_timeout_s": 0.05} - ) - cancel_started = asyncio.Event() - release_cancel = asyncio.Event() - - async def slow_prompt(**_: object) -> None: - await asyncio.Event().wait() - - async def slow_cancel(session_id: str) -> None: - cancel_started.set() - await release_cancel.wait() - - conn = self._runtime(adapter_with_mocks)._conn - conn.prompt = AsyncMock(side_effect=slow_prompt) - conn.cancel = AsyncMock(side_effect=slow_cancel) - tools = FakeAgentTools() - turn = asyncio.create_task( - adapter_with_mocks.on_message( - make_platform_message("Hello", room_id="room-123"), - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - ) - await cancel_started.wait() - turn.cancel() - await asyncio.sleep(0) - turn.cancel() - await asyncio.sleep(0) - assert not turn.done() - release_cancel.set() - with pytest.raises(asyncio.CancelledError): - await turn - - conn.cancel.assert_awaited_once_with("acp-session-123") - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["code"] == FAILURE_CODE_TIMEOUT - assert _MOCK_ROOM not in adapter_with_mocks._runtimes - - @pytest.mark.asyncio - async def test_on_message_request_error_captures_code_and_data( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A JSON-RPC RequestError's code/data survive into the AgentFailure.""" - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=RequestError(-32603, "Internal error", {"detail": "oom"}) - ) - - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - with pytest.raises(RequestError): - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - failures = reported_failures(tools) - assert len(failures) == 1 - assert failures[0]["provider"] == "acp" - assert failures[0]["code"] == "-32603" - assert failures[0]["detail"] == {"detail": "oom"} - - @pytest.mark.asyncio - async def test_runtime_rejects_calls_when_respawn_is_disabled(self) -> None: - """A runtime only rejects an unstarted connection when respawn is disabled.""" - adapter = ACPClientAdapter(CODEX) - runtime = await adapter._runtime_for("room-123") - - with pytest.raises(RuntimeError, match="ACP client not initialized"): - await runtime.ensure_connection(can_respawn=False) - - -class TestACPClientAdapterPermissionHandler: - """Tests for bidirectional permission proxying.""" - - @pytest.fixture - async def adapter_with_mocks(self) -> ACPClientAdapter: - """Create adapter with mocked ACP connection for one room.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - runtime = await adapter._runtime_for(_MOCK_ROOM) - - runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "acp-session-123" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._conn.prompt = AsyncMock() - runtime._client = BandACPClient() - - return adapter - - def _runtime(self, adapter: ACPClientAdapter): - return adapter._runtimes[_MOCK_ROOM] - - @pytest.mark.asyncio - async def test_permission_handler_wired_on_message( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should set permission handler on client before sending prompt.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - # Permission handler should have been set for this session - assert ( - len(adapter_with_mocks._runtimes[_MOCK_ROOM]._client._permission_handlers) - > 0 - ) - - @pytest.mark.asyncio - async def test_permission_resolver_receives_only_advertised_choices(self) -> None: - received: list[ACPPermissionRequest] = [] - - async def resolve(request: ACPPermissionRequest) -> str: - received.append(request) - return "reject" - - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), resolve_permission=resolve - ) - option_id = await adapter._resolve_permission_option( - call=ACPToolCall("call-1", "write_file", {}), - options=( - PermissionOption(optionId="allow", name="Allow", kind="allow_once"), - PermissionOption(optionId="reject", name="Reject", kind="reject_once"), - ), - room_id="room-1", - session_id="session-1", - ) - - assert option_id == "reject" - assert received[0].room_id == "room-1" - assert [option.option_id for option in received[0].options] == [ - "allow", - "reject", - ] - - @pytest.mark.asyncio - async def test_permission_resolver_invalid_option_raises(self) -> None: - async def resolve(_request: ACPPermissionRequest) -> str: - return "missing" - - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex"), resolve_permission=resolve - ) - with pytest.raises(ValueError, match="unavailable option"): - await adapter._resolve_permission_option( - call=ACPToolCall("call-1", "write_file", {}), - options=( - PermissionOption(optionId="allow", name="Allow", kind="allow_once"), - ), - room_id="room-1", - session_id="session-1", - ) - - @pytest.mark.asyncio - async def test_permission_resolver_deny_cancels_via_request_permission( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A wired PermissionResolver deny must cancel through request_permission.""" - - async def deny(_request: ACPPermissionRequest) -> None: - return None - - adapter_with_mocks._resolve_permission = deny - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - captured: dict[str, object] = {} - - async def mock_prompt(**kwargs: object) -> None: - tool_call = MagicMock() - tool_call.title = "write_file" - tool_call.tool_call_id = "tc-deny" - tool_call.raw_input = {"path": "/tmp/x"} - result = await adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._client.request_permission( - options=[ - {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"}, - {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - captured.update(result) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - assert captured == {"outcome": {"outcome": "cancelled"}} - perm_events = permission_events(tools) - assert event_types(perm_events) == ["tool_call", "tool_result"] - assert perm_events[1]["metadata"]["permission_outcome"] == "cancelled" - - @pytest.mark.asyncio - async def test_permission_handler_skips_pair_for_approved_band_send_message( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """An approved band_send_message grants silently, like any other tool. - - Regression guard: band_send_message/band_send_event were formerly - special-cased ("self-reporting") to post a synthetic permission pair - since their execution events were suppressed. Now nothing is suppressed — - if the tool executes, its own real tool_call/tool_result narrate it, so no - pair should be posted here either. - """ - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - async def mock_prompt(**kwargs): - tool_call = MagicMock() - tool_call.title = "band_send_message" - tool_call.tool_call_id = "tc-perm-1" - - result = await adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._client.request_permission( - options=[ - {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"} - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - assert result == { - "outcome": {"outcome": "selected", "optionId": "allow-once"} - } - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - assert permission_events(tools) == [] - - @pytest.mark.asyncio - async def test_permission_handler_skips_pair_for_approved_ordinary_tool( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """An approved ordinary tool grants without posting a permission pair. - - The tool's own tool_call/tool_result already show the call, so a pair - would duplicate it in the room. The grant is still returned to the agent. - """ - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - async def mock_prompt(**kwargs): - tool_call = MagicMock() - tool_call.title = "write_file" - tool_call.tool_call_id = "tc-perm-1" - - result = await adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._client.request_permission( - options=[ - {"optionId": "allow-once", "name": "Allow", "kind": "allow_once"} - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - # The grant is still returned even though no pair is posted. - assert result == { - "outcome": {"outcome": "selected", "optionId": "allow-once"} - } - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - assert permission_events(tools) == [] - - @pytest.mark.asyncio - async def test_permission_handler_selects_allow_option( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should auto-approve by selecting an offered allow option (not "allowed").""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - captured_result = {} - - async def mock_prompt(**kwargs): - tool_call = MagicMock() - tool_call.title = "read_file" - tool_call.tool_call_id = "tc-read" - - result = await adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._client.request_permission( - options=[ - {"optionId": "p-once", "name": "Allow once", "kind": "allow_once"}, - {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - captured_result.update(result) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - assert captured_result == { - "outcome": {"outcome": "selected", "optionId": "p-once"} - } - - @pytest.mark.asyncio - async def test_permission_handler_cancels_without_allow_option( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should cancel (not guess) when the agent offers no allow option.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - captured_result = {} - - async def mock_prompt(**kwargs): - tool_call = MagicMock() - tool_call.title = "rm_rf" - tool_call.tool_call_id = "tc-danger" - tool_call.raw_input = {"path": "/tmp/important"} - - result = await adapter_with_mocks._runtimes[ - _MOCK_ROOM - ]._client.request_permission( - options=[ - {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - captured_result.update(result) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - assert captured_result == {"outcome": {"outcome": "cancelled"}} - perm_events = permission_events(tools) - assert event_types(perm_events) == ["tool_call", "tool_result"] - assert metadata_values(perm_events, "tool_call_id") == [ - "tc-danger", - "tc-danger", - ] - call = parse_tool_call(str(perm_events[0]["content"])) - assert call is not None - assert call.args == {"path": "/tmp/important"} - result = parse_tool_result(str(perm_events[1]["content"])) - assert result is not None - assert result.output == "Permission cancelled" - assert result.is_error - assert perm_events[1]["metadata"]["permission_outcome"] == "cancelled" - - @pytest.mark.asyncio - async def test_denied_permission_pair_carries_the_canonical_tool_name( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """A denied ask naming a band tool under its MCP spelling must post its - synthetic pair under the canonical name — the pair is the only record - of the call, so it must speak the same vocabulary as real narration.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - async def mock_prompt(**kwargs): - tool_call = MagicMock() - tool_call.title = "band-band_send_event" - tool_call.tool_call_id = "tc-band" - await adapter_with_mocks._runtimes[_MOCK_ROOM]._client.request_permission( - options=[ - {"optionId": "p-rej", "name": "Reject", "kind": "reject_once"}, - ], - session_id="acp-session-123", - tool_call=tool_call, - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - perm_events = permission_events(tools) - assert event_types(perm_events) == ["tool_call", "tool_result"] - assert metadata_values(perm_events, "tool_name") == [ - "band_send_event", - "band_send_event", - ] - call = parse_tool_call(str(perm_events[0]["content"])) - assert call is not None and call.name == "band_send_event" - - @pytest.mark.asyncio - async def test_permission_handler_uses_name_fallback( - self, adapter_with_mocks: ACPClientAdapter - ) -> None: - """Should fall back to 'name' attr if 'title' is not available.""" - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-123") - - async def mock_prompt(**kwargs): - tool_call = MagicMock(spec=[]) # No attributes by default - tool_call.name = "bash" - tool_call.tool_call_id = "tc-bash" - - await adapter_with_mocks._runtimes[_MOCK_ROOM]._client.request_permission( - options={}, - session_id="acp-session-123", - tool_call=tool_call, - ) - - adapter_with_mocks._runtimes[_MOCK_ROOM]._conn.prompt = AsyncMock( - side_effect=mock_prompt - ) - - await adapter_with_mocks.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-123", - ) - - perm_events = permission_events(tools) - assert event_types(perm_events) == ["tool_call", "tool_result"] - assert metadata_values(perm_events, "tool_name") == ["bash", "bash"] - - -class TestACPClientAdapterCleanup: - """Tests for ACPClientAdapter cleanup.""" - - @pytest.mark.asyncio - async def test_on_cleanup_removes_mapping(self) -> None: - """Should remove room -> session mapping.""" - adapter = ACPClientAdapter(CODEX) - adapter._room_to_session["room-123"] = RoomSession("session-123", band_url=None) - adapter._room_tools["room-123"] = MagicMock() - backend = await hold_backend(adapter._mcp) - - await adapter.on_cleanup("room-123") - - assert "room-123" not in adapter._room_to_session - assert "room-123" not in adapter._room_tools - assert backend.stop_calls == 0 - - @pytest.mark.asyncio - async def test_on_cleanup_idempotent(self) -> None: - """Should handle cleanup of non-existent room.""" - adapter = ACPClientAdapter(CODEX) - - await adapter.on_cleanup("nonexistent-room") - - @pytest.mark.asyncio - async def test_on_cleanup_twice(self) -> None: - """Should handle cleanup called twice.""" - adapter = ACPClientAdapter(CODEX) - adapter._room_to_session["room-123"] = RoomSession("session-123", band_url=None) - - await adapter.on_cleanup("room-123") - await adapter.on_cleanup("room-123") - - assert "room-123" not in adapter._room_to_session - - @pytest.mark.asyncio - async def test_fresh_session_cleanup_times_out( - self, - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - ) -> None: - adapter = ACPClientAdapter(CODEX) - runtime = await adapter._runtime_for("room-1") - blocked_close = asyncio.Event() - - async def wait_to_close(_: str) -> None: - await blocked_close.wait() - - runtime.close_session = AsyncMock(wraps=wait_to_close) - monkeypatch.setattr(client_adapter, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.01) - - with caplog.at_level(logging.WARNING): - await adapter._close_session( - runtime, - "session-1", - reason=client_adapter.SessionCloseReason.UNCONFIGURED, - ) - - runtime.close_session.assert_awaited_once_with("session-1") - assert caplog.messages == [ - "Timed out closing ACP session session-1 (unconfigured) after 0.01 seconds" - ] - - @pytest.mark.asyncio - async def test_cancelled_fresh_session_does_not_wait_to_close(self) -> None: - adapter = ACPClientAdapter(CODEX) - runtime = await adapter._runtime_for("room-1") - initialization_started = asyncio.Event() - close_started = asyncio.Event() - release_close = asyncio.Event() - close_finished = asyncio.Event() - runtime.create_session_response = AsyncMock( - return_value=NewSessionResponse(session_id="session-1") - ) - - async def wait_to_close(_: str) -> None: - close_started.set() - await release_close.wait() - close_finished.set() - - runtime.close_session = AsyncMock(wraps=wait_to_close) - - async def initialize() -> None: - async with adapter._fresh_session(runtime, "room-1", []): - initialization_started.set() - await asyncio.Event().wait() - - initializing = asyncio.create_task(initialize()) - await initialization_started.wait() - initializing.cancel() - - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(initializing, timeout=0.1) - - await close_started.wait() - release_close.set() - await close_finished.wait() - - @pytest.mark.asyncio - async def test_cancelled_fresh_session_close_is_tracked_not_lost(self) -> None: - """The background close task is retained, not a bare, unreferenced task.""" - adapter = ACPClientAdapter(CODEX) - runtime = await adapter._runtime_for("room-1") - initialization_started = asyncio.Event() - release_close = asyncio.Event() - runtime.create_session_response = AsyncMock( - return_value=NewSessionResponse(session_id="session-1") - ) - - async def wait_to_close(_: str) -> None: - await release_close.wait() - - runtime.close_session = AsyncMock(wraps=wait_to_close) - - async def initialize() -> None: - async with adapter._fresh_session(runtime, "room-1", []): - initialization_started.set() - await asyncio.Event().wait() - - initializing = asyncio.create_task(initialize()) - await initialization_started.wait() - initializing.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(initializing, timeout=0.1) - - assert len(adapter._background_tasks) == 1 - - release_close.set() - await asyncio.gather(*adapter._background_tasks) - - assert adapter._background_tasks == set() - - @pytest.mark.asyncio - async def test_cleanup_all_waits_for_background_close_tasks(self) -> None: - adapter = ACPClientAdapter(CODEX) - runtime = await adapter._runtime_for("room-1") - initialization_started = asyncio.Event() - close_finished = asyncio.Event() - runtime.create_session_response = AsyncMock( - return_value=NewSessionResponse(session_id="session-1") - ) - - async def wait_to_close(_: str) -> None: - close_finished.set() - - runtime.close_session = AsyncMock(wraps=wait_to_close) - - async def initialize() -> None: - async with adapter._fresh_session(runtime, "room-1", []): - initialization_started.set() - await asyncio.Event().wait() - - initializing = asyncio.create_task(initialize()) - await initialization_started.wait() - initializing.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(initializing, timeout=0.1) - - await adapter.cleanup_all(final=False) - - assert close_finished.is_set() - assert adapter._background_tasks == set() - - -class TestACPClientAdapterStop: - """Tests for ACPClientAdapter.stop().""" - - @pytest.mark.asyncio - async def test_stop_closes_connection(self) -> None: - """Should close ACP connection gracefully.""" - adapter = ACPClientAdapter(CODEX) - runtime = adapter._build_runtime() - mock_ctx = MagicMock() - mock_ctx.__aexit__ = AsyncMock(return_value=None) - runtime._ctx = mock_ctx - runtime._conn = AsyncMock() - runtime._client = BandACPClient() - adapter._runtimes[_MOCK_ROOM] = runtime - adapter._workspaces.claim(_MOCK_ROOM, "/tmp/room-123") - adapter._room_to_session[_MOCK_ROOM] = RoomSession("session-123", band_url=None) - adapter._room_tools[_MOCK_ROOM] = MagicMock() - backend = await hold_backend(adapter._mcp) - - await adapter.stop() - - mock_ctx.__aexit__.assert_called_once() - assert backend.stop_calls == 1 - assert runtime._ctx is None - assert runtime._conn is None - assert runtime._client is None - assert adapter._room_to_session == {} - assert adapter._room_tools == {} - assert adapter._mcp.current is None - - @pytest.mark.asyncio - async def test_stop_no_connection(self) -> None: - """Should handle stop when not connected.""" - adapter = ACPClientAdapter(CODEX) - backend = await hold_backend(adapter._mcp) - - await adapter.stop() - - assert backend.stop_calls == 1 - - @pytest.mark.asyncio - async def test_stop_handles_exit_error(self) -> None: - """Should handle errors during shutdown.""" - adapter = ACPClientAdapter(CODEX) - runtime = adapter._build_runtime() - runtime._ctx = AsyncMock() - runtime._ctx.__aexit__ = AsyncMock(side_effect=RuntimeError("Cleanup error")) - adapter._runtimes[_MOCK_ROOM] = runtime - adapter._workspaces.claim(_MOCK_ROOM, "/tmp/room-123") - - # Should not raise - await adapter.stop() - assert runtime._ctx is None - - -class TestACPCollectingClientCursorProfileExtensions: - """Tests for Cursor-specific extension handling via ACP client profiles.""" - - @pytest.mark.asyncio - async def test_ext_method_cursor_ask_question(self) -> None: - """Forwards Cursor's complete question payload to the decision bridge.""" - received: dict[str, object] = {} - - async def resolve(method: str, params: dict[str, object]) -> dict[str, object]: - received.update(method=method, params=params) - return { - "outcome": { - "outcome": "answered", - "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], - } - } - - client = ACPCollectingClient(profile=CursorACPClientProfile(resolve)) - - result = await client.ext_method( - "cursor/ask_question", - { - "questions": [ - { - "id": "q1", - "prompt": "Choose", - "options": [{"id": "a", "label": "A"}], - } - ], - }, - ) - - assert received["method"] == "cursor/ask_question" - assert result["outcome"]["outcome"] == "answered" - - @pytest.mark.asyncio - async def test_ext_method_without_decision_bridge_cancels_when_unanswerable( - self, - ) -> None: - """A bare profile (e.g. the generic ACP bridge) has nothing to pick - from an empty question list, and must still cancel rather than - fabricate an answer.""" - client = ACPCollectingClient(profile=CursorACPClientProfile()) - - result = await client.ext_method("cursor/ask_question", {"questions": []}) - - assert result == {"outcome": {"outcome": "cancelled"}} - - @pytest.mark.asyncio - async def test_ext_method_without_decision_bridge_auto_answers_unattended( - self, - ) -> None: - """Regression: resolve_acp_client_profile("cursor") (the generic ACP - bridge's factory) has no room to relay a decision to and no resolver - -- it must answer unattended (main's prior behavior) rather than - cancelling every real question and plan outright.""" - client = ACPCollectingClient(profile=CursorACPClientProfile()) - - ask_result = await client.ext_method( - "cursor/ask_question", - { - "questions": [ - { - "id": "q1", - "prompt": "Choose", - "options": [ - {"id": "a", "label": "A"}, - {"id": "b", "label": "B"}, - ], - } - ], - }, - ) - plan_result = await client.ext_method("cursor/create_plan", {"plan": "stuff"}) - - assert ask_result == { - "outcome": { - "outcome": "answered", - "answers": [{"questionId": "q1", "selectedOptionIds": ["a"]}], - } - } - assert plan_result == {"outcome": {"outcome": "accepted"}} - - @pytest.mark.asyncio - async def test_ext_method_cursor_create_plan(self) -> None: - """Forwards plan approval to the decision bridge.""" - - async def resolve(method: str, params: dict[str, object]) -> dict[str, object]: - del method, params - return {"outcome": {"outcome": "accepted"}} - - client = ACPCollectingClient(profile=CursorACPClientProfile(resolve)) - - result = await client.ext_method("cursor/create_plan", {"plan": "stuff"}) - - assert result == {"outcome": {"outcome": "accepted"}} - - @pytest.mark.asyncio - async def test_ext_method_unknown_returns_empty(self) -> None: - """Should return empty dict for unknown extension methods.""" - client = ACPCollectingClient(profile=CursorACPClientProfile()) - - result = await client.ext_method("unknown/method", {}) - - assert result == {} - - @pytest.mark.asyncio - async def test_ext_notification_cursor_update_todos(self) -> None: - """Cursor's merge flag preserves prior todo state.""" - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/update_todos", - { - "todos": [ - {"id": "read", "content": "Read code", "status": "completed"} - ], - "merge": False, - }, - ) - await client.ext_notification( - "cursor/update_todos", - { - "todos": [ - {"id": "test", "content": "Write tests", "status": "pending"} - ], - "merge": True, - }, - ) - - chunks = client.get_collected_chunks("sess-1") - assert "[x] Read code" in chunks[-1].content - assert "[ ] Write tests" in chunks[-1].content - - @pytest.mark.asyncio - async def test_ext_notification_cursor_update_todos_clearing_the_list_still_renders( - self, - ) -> None: - """Regression: a `todos: []` update that legitimately clears the list - used to emit no chunk at all, leaving the room showing the stale - checklist from before the clear.""" - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/update_todos", - { - "todos": [ - {"id": "read", "content": "Read code", "status": "completed"} - ], - "merge": False, - }, - ) - await client.ext_notification( - "cursor/update_todos", - {"todos": [], "merge": False}, - ) - - chunks = client.get_collected_chunks("sess-1") - assert len(chunks) == 2 - assert profile._todos_by_session["sess-1"] == {} - assert chunks[-1].content != chunks[0].content - - @pytest.mark.asyncio - async def test_ext_notification_cursor_todos_do_not_cross_sessions(self) -> None: - profile = CursorACPClientProfile() - client = ACPCollectingClient(profile=profile) - profile.bind_session("first") - await client.ext_notification( - "cursor/update_todos", - { - "todos": [{"id": "old", "content": "Old task", "status": "pending"}], - "merge": False, - }, - ) - profile.bind_session("second") - await client.ext_notification( - "cursor/update_todos", - { - "todos": [{"id": "new", "content": "New task", "status": "pending"}], - "merge": True, - }, - ) - - chunks = client.get_collected_chunks("second") - assert chunks[-1].content == "- [ ] New task" - - @pytest.mark.asyncio - async def test_ext_notification_cursor_task(self) -> None: - """Renders documented task metadata without inventing a result field.""" - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/task", - { - "description": "Explore authentication", - "prompt": "Find the auth module", - "subagentType": "explore", - "model": "Auto", - }, - ) - - chunks = client.get_collected_chunks("sess-1") - assert len(chunks) == 1 - assert chunks[0].chunk_type == "plan" - assert "Explore authentication" in chunks[0].content - - @pytest.mark.asyncio - async def test_ext_notification_cursor_task_without_a_description_is_a_noop( - self, - ) -> None: - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification("cursor/task", {"description": ""}) - - assert client.get_collected_chunks("sess-1") == [] - - @pytest.mark.asyncio - async def test_ext_notification_cursor_generate_image_without_a_description_is_a_noop( - self, - ) -> None: - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/generate_image", {"filePath": "/tmp/logo.png"} - ) - - assert client.get_collected_chunks("sess-1") == [] - - @pytest.mark.asyncio - async def test_ext_notification_cursor_update_todos_marks_in_progress_and_cancelled( - self, - ) -> None: - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/update_todos", - { - "todos": [ - {"id": "a", "content": "Working", "status": "in_progress"}, - {"id": "b", "content": "Dropped", "status": "cancelled"}, - ], - "merge": False, - }, - ) - - chunks = client.get_collected_chunks("sess-1") - assert "[~] Working" in chunks[-1].content - assert "[-] Dropped" in chunks[-1].content - - @pytest.mark.asyncio - async def test_ext_notification_uses_serialized_profile_session(self) -> None: - """Cursor notifications omit session ids, so the profile binds the turn.""" - profile = CursorACPClientProfile() - profile.bind_session("sess-1") - client = ACPCollectingClient(profile=profile) - - await client.ext_notification( - "cursor/generate_image", - {"description": "A logo", "filePath": "/tmp/logo.png"}, - ) - - chunks = client.get_collected_chunks("sess-1") - assert chunks[0].content == "[Cursor generated image] A logo → /tmp/logo.png" - - @pytest.mark.asyncio - async def test_ext_notification_without_bound_session_or_own_id_is_noop( - self, - ) -> None: - """Nothing identifies which session's todos these are.""" - client = ACPCollectingClient(profile=CursorACPClientProfile()) - - await client.ext_notification( - "cursor/update_todos", - { - "todos": [{"id": "test", "content": "Test", "status": "pending"}], - "merge": False, - }, - ) - - assert client.get_collected_chunks() == [] - - @pytest.mark.asyncio - async def test_ext_notification_todos_use_their_own_session_id_unbound( - self, - ) -> None: - """Regression: the bridge path (resolve_acp_client_profile("cursor")) - never calls bind_session, so a notification that carries its own - sessionId must still render -- not fall silent because self._session_id - is None. ACPCollectingClient.ext_notification already resolves the - SAME precedence for chunk routing; the profile's own todo state must - match it.""" - client = ACPCollectingClient(profile=CursorACPClientProfile()) - - await client.ext_notification( - "cursor/update_todos", - { - "sessionId": "bridge-session", - "todos": [{"id": "test", "content": "Test", "status": "pending"}], - "merge": False, - }, - ) - - chunks = client.get_collected_chunks("bridge-session") - assert chunks[-1].content == "- [ ] Test" - - -class TestACPClientAdapterDeadConnectionRecovery: - """Tests for dead connection recovery after subprocess crash.""" - - @pytest.mark.asyncio - async def test_prompt_error_clears_connection(self) -> None: - """Should stop connection on prompt error so next message respawns.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - runtime = await adapter._runtime_for("room-1") - runtime._conn = AsyncMock() - runtime._conn.prompt = AsyncMock(side_effect=RuntimeError("Process died")) - mock_session = MagicMock() - mock_session.session_id = "sess-1" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._client = BandACPClient() - - mock_ctx = MagicMock() - mock_ctx.__aexit__ = AsyncMock(return_value=None) - runtime._ctx = mock_ctx - - tools = FakeAgentTools() - msg = make_platform_message("Hello", room_id="room-1") - - with pytest.raises(RuntimeError, match="Process died"): - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - # Connection should be cleared after error - assert runtime._conn is None - assert runtime._ctx is None - - # AgentFailure should be reported - assert len(reported_failures(tools)) == 1 - - @pytest.mark.asyncio - async def test_closing_thought_delivery_failure_leaves_connection_up(self) -> None: - """The agent answered fine; posting its reply to the room is what - failed. That must not tear down and respawn a healthy connection, - nor be reported as an ACP provider failure.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - runtime = await adapter._runtime_for("room-1") - runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "sess-1" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._client = BandACPClient() - - mock_ctx = MagicMock() - mock_ctx.__aexit__ = AsyncMock(return_value=None) - runtime._ctx = mock_ctx - - async def prompt_with_reply(**kwargs): - session_id = kwargs["session_id"] - await runtime._client.session_update( - session_id, update_agent_message_text("Here's the answer") - ) - - runtime._conn.prompt = AsyncMock(side_effect=prompt_with_reply) - - tools = FakeAgentTools() - - async def _raise(*args: object, **kwargs: object) -> None: - raise RuntimeError("platform rejected the message") - - tools.send_event = _raise # type: ignore[method-assign] - - msg = make_platform_message("Hello", room_id="room-1") - - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - assert runtime._conn is not None - assert runtime._ctx is not None - assert not reported_failures(tools) - - @pytest.mark.asyncio - async def test_session_bookkeeping_failure_leaves_connection_up(self) -> None: - """A failed session task event must not turn a completed prompt into an ACP failure.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command="codex", inject_band_tools=False) - ) - runtime = await adapter._runtime_for("room-1") - runtime._conn = AsyncMock() - mock_session = MagicMock() - mock_session.session_id = "sess-1" - runtime._conn.new_session = AsyncMock(return_value=mock_session) - runtime._client = BandACPClient() - - mock_ctx = MagicMock() - mock_ctx.__aexit__ = AsyncMock(return_value=None) - runtime._ctx = mock_ctx - - runtime._conn.prompt = AsyncMock() - tools = FakeAgentTools() - tools.send_event_error = RuntimeError("platform rejected the task event") - msg = make_platform_message("Hello", room_id="room-1") - - await adapter.on_message( - msg, - tools, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-1", - ) - - assert runtime._conn is not None - assert runtime._ctx is not None - assert not reported_failures(tools) - - @pytest.mark.asyncio - async def test_turn_timeout_preserves_other_room_connection(self) -> None: - """A timed-out room must not interrupt another room's prompt.""" - adapter = ACPClientAdapter( - ACPClientAdapterConfig( - command="codex", inject_band_tools=False, turn_timeout_s=1 - ) - ) - runtime_a = await adapter._runtime_for("room-a") - conn_a = AsyncMock() - runtime_a._conn = conn_a - conn_a.new_session = AsyncMock(return_value=MagicMock(session_id="sess-a")) - runtime_a._client = BandACPClient() - mock_ctx_a = MagicMock() - mock_ctx_a.__aexit__ = AsyncMock(return_value=None) - runtime_a._ctx = mock_ctx_a - - runtime_b = await adapter._runtime_for("room-b") - conn_b = AsyncMock() - runtime_b._conn = conn_b - conn_b.new_session = AsyncMock(return_value=MagicMock(session_id="sess-b")) - runtime_b._client = BandACPClient() - mock_ctx_b = MagicMock() - mock_ctx_b.__aexit__ = AsyncMock(return_value=None) - runtime_b._ctx = mock_ctx_b - - b_started = asyncio.Event() - release_b = asyncio.Event() - - async def prompt_b(*, session_id: str, **kwargs: object) -> None: - b_started.set() - await release_b.wait() - - async def prompt_a(*, session_id: str, **kwargs: object) -> None: - await asyncio.sleep(10) - - conn_b.prompt = AsyncMock(side_effect=prompt_b) - conn_a.prompt = AsyncMock(side_effect=prompt_a) - - tools_b = FakeAgentTools() - b_turn = asyncio.create_task( - adapter.on_message( - make_platform_message("Hello", room_id="room-b"), - tools_b, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-b", - ) - ) - await b_started.wait() - adapter.config = adapter.config.model_copy(update={"turn_timeout_s": 0.01}) - - tools_a = FakeAgentTools() - - with pytest.raises(TimeoutError): - await adapter.on_message( - make_platform_message("Hello", room_id="room-a"), - tools_a, - ACPClientSessionState(), - None, - None, - is_session_bootstrap=False, - room_id="room-a", - ) - - assert not b_turn.done() - assert "room-a" not in adapter._room_to_session - assert adapter._room_to_session["room-b"].session_id == "sess-b" - conn_a.cancel.assert_awaited_once_with("sess-a") - conn_b.cancel.assert_not_called() - failures = reported_failures(tools_a) - assert len(failures) == 1 - assert failures[0]["provider"] == "acp" - assert failures[0]["code"] == FAILURE_CODE_TIMEOUT - - release_b.set() - await b_turn - - -class TestResolveLauncher: - """The launcher is resolved to a full path so the subprocess spawns on Windows, - where an npm launcher (``npx``) is ``npx.cmd`` and bare-name exec lookup fails.""" - - def test_resolves_launcher_and_preserves_args(self) -> None: - """The launcher becomes its resolved path; the arguments are untouched.""" - with patch( - "band.integrations.acp.client_adapter.shutil.which", - return_value="/opt/node/bin/npx", - ): - assert _resolve_launcher(["npx", "@zed-industries/codex-acp"]) == [ - "/opt/node/bin/npx", - "@zed-industries/codex-acp", - ] - - def test_unresolved_name_is_left_as_is(self) -> None: - """An unresolvable launcher is passed through so spawn fails loudly, not here.""" - with patch( - "band.integrations.acp.client_adapter.shutil.which", return_value=None - ): - assert _resolve_launcher(["mystery-bin", "arg"]) == ["mystery-bin", "arg"] - - -class TestACPClientAdapterEmitSupport: - """Which ``Emit`` kinds the ACP client adapter declares. - - All three ACP adapters (OMP / Copilot / Cursor) inherit this: room - narration is gated by the caller's ``emit=`` (see ``room_emitter``), - and kinds ACP cannot observe (``Emit.USAGE``) are rejected up front - instead of silently ignored. - """ - - def test_supported_emit_kinds_are_accepted(self) -> None: - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command=["omp", "acp"]), - emit=Emit.TOOL_CALLS | Emit.THOUGHTS | Emit.TASK_EVENTS, - ) - assert adapter.features.emit == frozenset( - {Emit.TOOL_CALLS, Emit.THOUGHTS, Emit.TASK_EVENTS} - ) - - def test_silence_is_accepted(self) -> None: - adapter = ACPClientAdapter( - ACPClientAdapterConfig(command=["omp", "acp"]), emit=() - ) - assert adapter.features.emit == frozenset() - - def test_an_unsupported_emit_kind_is_rejected(self) -> None: - with pytest.raises(BandConfigError): - ACPClientAdapter( - ACPClientAdapterConfig(command=["omp", "acp"]), emit=Emit.USAGE - ) - - -def test_retired_acp_delivery_mode_is_rejected() -> None: - with pytest.raises(ValidationError, match="Extra inputs"): - ACPClientAdapterConfig.model_validate( - {"command": "codex", "assistant_text_mode": "thought"} - ) diff --git a/tests/integrations/acp/test_client_adapter_behavior.py b/tests/integrations/acp/test_client_adapter_behavior.py index b08cc1b62..6248de66b 100644 --- a/tests/integrations/acp/test_client_adapter_behavior.py +++ b/tests/integrations/acp/test_client_adapter_behavior.py @@ -14,7 +14,6 @@ from __future__ import annotations -import asyncio import re from typing import Any from urllib.parse import urlsplit @@ -33,10 +32,13 @@ HISTORY_REPLAY_HEADER, NEW_MESSAGE_MARKER_PREFIX, ACPClientAdapter, - SessionInitializer, ) -from band.integrations.acp.client_types import ACPClientSessionState -from band.integrations.mcp import BandMCPBackendStoppedError +from band.integrations.acp.client_types import ( + SESSION_BUSY_CODE, + SESSION_BUSY_REASON, + ACPClientSessionState, + session_busy_error, +) from band.runtime.formatters import build_participants_message from band.runtime.prompts import ( COMMUNICATION_INSTRUCTIONS, @@ -727,9 +729,9 @@ async def test_band_tool_call_without_chat_id_lands_in_its_own_room( @pytest.mark.asyncio async def test_a_turn_after_shutdown_is_refused(fake_agent) -> None: - """``cleanup_all()`` (as ``Agent.stop()`` calls it) closes the Band MCP - backend for good: a late turn fails instead of starting a server nothing - would stop.""" + """``cleanup_all()`` (as ``Agent.stop()`` calls it) stops room residency + and the Band MCP backend for good: a late turn is refused before it starts + a process or a server nothing would stop.""" fake_agent.will_say("ok") with backends_created_by() as starts: async with acp_adapter( @@ -738,10 +740,11 @@ async def test_a_turn_after_shutdown_is_refused(fake_agent) -> None: await session.send("before shutdown", room="room-1") await session.adapter.cleanup_all() - with pytest.raises(BandMCPBackendStoppedError): + with pytest.raises(InputNotAccepted, match="retention is stopping"): await session.send("after shutdown", room="room-2") assert len(starts.requested) == 1 + assert fake_agent.session_ids() == ["fake-session-1"] @pytest.mark.asyncio @@ -779,7 +782,7 @@ async def test_an_open_rooms_band_reply_survives_a_crash(fake_agent) -> None: reply = await session.send("after the crash", room="room-1") live_url = band_mcp_url(fake_agent, session.session_id("room-1")) - await session.adapter._drain_background_tasks() + await session.retired_sessions_closed("room-1") assert fake_agent.closed_sessions == [stale_session] assert not session.runtime_keeps_output_of(stale_session, room="room-1") @@ -789,32 +792,6 @@ async def test_an_open_rooms_band_reply_survives_a_crash(fake_agent) -> None: assert urlsplit(live_url).port != urlsplit(crashed_url).port -@pytest.mark.asyncio -async def test_a_retired_sessions_unreleased_setup_is_never_reused(fake_agent) -> None: - """A turn that finished creating the room's session may not have released - its setup yet; the next turn, after the Band URL went stale, must not be - handed that retired session.""" - async with acp_adapter( - fake_agent, fake_agent_config(inject_band_tools=True) - ) as session: - await session.send("before the crash", room="room-1") - retired = session.session_id("room-1") - unreleased = asyncio.create_task(_finished_setup(retired)) - await unreleased - session.adapter._session_initializers["room-1"] = SessionInitializer( - task=unreleased, waiters=1 - ) - await crash_backend(session.adapter._mcp) - - await session.send("after the crash", room="room-1") - - assert session.session_id("room-1") != retired - - -async def _finished_setup(session_id: str) -> tuple[str, bool]: - return session_id, True - - @pytest.mark.asyncio async def test_reloaded_session_gets_its_rooms_band_mcp_endpoint() -> None: agent = ( @@ -852,7 +829,7 @@ async def test_a_bootstrap_after_a_crash_never_restores_the_retired_session() -> await session.send("before the crash", bootstrap=True, history=history) await crash_backend(session.adapter._mcp) await session.send("after the crash", bootstrap=True, history=history) - await session.adapter._drain_background_tasks() + await session.retired_sessions_closed("room-1") assert session.session_id("room-1") != "persisted" @@ -1354,7 +1331,7 @@ async def test_busy_first_prompt_preserves_session_context(restored: bool) -> No @agent.on_prompt async def reject_once(peer: FakeACPAgent, sid: str) -> None: if len(peer.prompts) == 1: - raise RequestError(-32003, "Session is busy", {"reason": "session_busy"}) + raise session_busy_error() await peer.say(sid, "Blue.") async with acp_adapter(agent) as session: @@ -1385,9 +1362,9 @@ async def reject_once(peer: FakeACPAgent, sid: str) -> None: @pytest.mark.parametrize( ("code", "data"), [ - (-32003, None), - (-32003, {"reason": "other"}), - (-32603, {"reason": "session_busy"}), + (SESSION_BUSY_CODE, None), + (SESSION_BUSY_CODE, {"reason": "other"}), + (-32603, {"reason": SESSION_BUSY_REASON}), ], ) async def test_only_exact_typed_busy_response_preserves_session( @@ -1421,7 +1398,7 @@ async def autonomous_activity(peer: FakeACPAgent, sid: str) -> None: sid, update_tool_call("other-work", raw_output="Done", status="completed") ) await peer.say(sid, "Autonomous final text") - raise RequestError(-32003, "Session is busy", {"reason": "session_busy"}) + raise session_busy_error() tools = FakeAgentTools() async with acp_adapter(agent) as session: diff --git a/tests/integrations/acp/test_client_runtime.py b/tests/integrations/acp/test_client_runtime.py index 00aa0f2c9..885f61823 100644 --- a/tests/integrations/acp/test_client_runtime.py +++ b/tests/integrations/acp/test_client_runtime.py @@ -276,8 +276,7 @@ async def resolve(method: str, params: dict[str, object]) -> dict[str, object]: } } - profile = CursorACPClientProfile(resolve) - profile.bind_session("sess-1") + profile = CursorACPClientProfile(resolve, active_session=lambda: "sess-1") client = ACPCollectingClient(profile=profile) ask_result = await client.ext_method( @@ -1197,22 +1196,6 @@ async def test_start_cleans_up_failed_authenticate(self) -> None: assert runtime._ctx is None mock_ctx.__aexit__.assert_awaited_once_with(None, None, None) - @pytest.mark.asyncio - async def test_ensure_connection_respawns_when_allowed(self) -> None: - mock_conn = AsyncMock() - mock_conn.initialize = AsyncMock(return_value=MagicMock()) - mock_ctx = AsyncMock() - mock_ctx.__aenter__ = AsyncMock(return_value=(mock_conn, MagicMock())) - runtime = ACPRuntime( - command=["codex"], - spawn_process=lambda *args, **kwargs: mock_ctx, - ) - - conn = await runtime.ensure_connection(can_respawn=True) - - assert conn is mock_conn - mock_conn.initialize.assert_awaited_once_with(protocol_version=1) - @pytest.mark.asyncio async def test_set_permission_handler_delegates_to_client(self) -> None: runtime = ACPRuntime(command=["codex"]) diff --git a/tests/integrations/acp/test_cursor_identity.py b/tests/integrations/acp/test_cursor_identity.py index 7c5668e85..c5375f65e 100644 --- a/tests/integrations/acp/test_cursor_identity.py +++ b/tests/integrations/acp/test_cursor_identity.py @@ -12,77 +12,79 @@ from band.integrations.acp.types import ToolCallRoomEvent, ToolResultRoomEvent from band.runtime.tools import BAND_MCP_SERVER_NAME, BandTool, mcp_tool_spelling from band.testing import FakeAgentTools, events_of_type +from tests.integrations.acp.acp_toolkit import room_holder -class AliasArguments(BaseModel): - """An accepted custom name that overlaps the MCP reply spelling.""" +async def alias_reply(arguments: BaseModel) -> str: + return arguments.model_dump()["content"] - content: str - -async def alias_reply(arguments: AliasArguments) -> str: - return arguments.content - - -@pytest.mark.asyncio -@pytest.mark.parametrize("with_alias", [False, True], ids=["plain", "custom-alias"]) -@pytest.mark.parametrize( - ("title", "is_reply"), - [ - ("band-band_send_message: band_send_message", True), - ("other-band_send_message: band_send_message", False), - ("band-band_send_message: band_no_reply", False), - ], - ids=["band-title", "foreign-server", "mismatched-halves"], -) -async def test_cursor_title_preserves_reply_identity_in_room_events( - with_alias: bool, - title: str, - is_reply: bool, -) -> None: - alias_model = create_model( - f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, BandTool.SEND_MESSAGE)}Input", - __base__=AliasArguments, +class TestCursorIdentity: + @pytest.mark.asyncio + @pytest.mark.parametrize("with_alias", [False, True], ids=["plain", "custom-alias"]) + @pytest.mark.parametrize( + ("title", "is_reply"), + [ + ("band-band_send_message: band_send_message", True), + ("other-band_send_message: band_send_message", False), + ("band-band_send_message: band_no_reply", False), + ], + ids=["band-title", "foreign-server", "mismatched-halves"], ) - adapter = CursorACPAdapter( - additional_tools=[(alias_model, alias_reply)] if with_alias else None, - ) - client = adapter._runtime_client_factory() - tools = FakeAgentTools() - async with RoomTurnEmitter( - tools, session_id="session", room_id="room", records_tool_effects=True - ) as emitter: - client.set_sink("session", emitter.emit) - await client.session_update( - "session", - ToolCallStart( - session_update="tool_call", - tool_call_id="reply", - title=title, - status="in_progress", - raw_input={"content": "Reply"}, - ), + async def test_cursor_title_preserves_reply_identity_in_room_events( + self, + with_alias: bool, + title: str, + is_reply: bool, + ) -> None: + # A custom tool whose name overlaps the MCP reply spelling. + alias_model = create_model( + f"{mcp_tool_spelling(BAND_MCP_SERVER_NAME, BandTool.SEND_MESSAGE)}Input", + content=(str, ...), ) - await client.session_update( - "session", - ToolCallProgress( - session_update="tool_call_update", - tool_call_id="reply", - status="completed", - raw_output={"id": "message"}, - ), + adapter = CursorACPAdapter( + additional_tools=[(alias_model, alias_reply)] if with_alias else None, ) - await client.session_update("session", update_agent_message_text("Posted it.")) - await client.flush("session") + client = adapter._runtime_client_factory(room_holder(adapter), 1) + tools = FakeAgentTools() + async with RoomTurnEmitter( + tools, session_id="session", room_id="room", records_tool_effects=True + ) as emitter: + client.set_sink("session", emitter.emit) + await client.session_update( + "session", + ToolCallStart( + session_update="tool_call", + tool_call_id="reply", + title=title, + status="in_progress", + raw_input={"content": "Reply"}, + ), + ) + await client.session_update( + "session", + ToolCallProgress( + session_update="tool_call_update", + tool_call_id="reply", + status="completed", + raw_output={"id": "message"}, + ), + ) + await client.session_update( + "session", update_agent_message_text("Posted it.") + ) + await client.flush("session") - call = ToolCallRoomEvent.model_validate_json( - events_of_type(tools, "tool_call")[0]["content"] - ) - result = ToolResultRoomEvent.model_validate_json( - events_of_type(tools, "tool_result")[0]["content"] - ) - assert tools.turn.replied is is_reply - assert call.name == result.name == (BandTool.SEND_MESSAGE if is_reply else title) - assert [event["content"] for event in events_of_type(tools, "thought")] == ( - [] if is_reply else ["Posted it."] - ) + call = ToolCallRoomEvent.model_validate_json( + events_of_type(tools, "tool_call")[0]["content"] + ) + result = ToolResultRoomEvent.model_validate_json( + events_of_type(tools, "tool_result")[0]["content"] + ) + assert tools.turn.replied is is_reply + assert ( + call.name == result.name == (BandTool.SEND_MESSAGE if is_reply else title) + ) + assert [event["content"] for event in events_of_type(tools, "thought")] == ( + [] if is_reply else ["Posted it."] + ) diff --git a/tests/integrations/acp/test_cursor_message_delivery.py b/tests/integrations/acp/test_cursor_message_delivery.py new file mode 100644 index 000000000..ae93f13a0 --- /dev/null +++ b/tests/integrations/acp/test_cursor_message_delivery.py @@ -0,0 +1,63 @@ +"""A Cursor message that arrives while the room's turn is parked stays +actionable: the host keeps it unacknowledged instead of the adapter queueing +it behind the turn.""" + +from __future__ import annotations + +import pytest + +from band.adapters.cursor_acp import CursorACPAdapter, CursorACPAdapterConfig +from band.client.streaming import ControlMode +from band.platform.link import BandLink +from band.runtime.types import SessionConfig +from tests.conftest import make_message_event +from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent, allow_or_reject +from tests.integrations.acp.acp_toolkit.harness import pair_in_process +from tests.runtime.helpers import ( + AGENT_ID, + ALICE, + ROOM_ID, + PostingPlatform, + rest_client_over, + run_through, +) + + +class TestCursorMessageDelivery: + @pytest.mark.asyncio + async def test_a_message_behind_a_parked_turn_is_never_acknowledged_unrun( + self, + ) -> None: + agent = FakeACPAgent().will_ask_permission( + title="shell", options=allow_or_reject("allow-once") + ) + adapter = CursorACPAdapter(CursorACPAdapterConfig(command="fake-agent")) + pair_in_process(adapter, agent) + await adapter.on_started("Cursor", "under test") + peer = PostingPlatform() + try: + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id=AGENT_ID, api_key="test-key") + link.rest = rest + ctx = run_through( + adapter, + link, + config=SessionConfig( + enable_context_hydration=False, enable_working_state=False + ), + ) + ctx.add_participant(ALICE) + await ctx._process_event( + make_message_event(room_id=ROOM_ID, msg_id="A", content="run it") + ) + await ctx._process_event( + make_message_event(room_id=ROOM_ID, msg_id="B", content="then this") + ) + await adapter.on_interrupt(ROOM_ID, ControlMode.STOP) + await ctx.stop() + finally: + await adapter.stop() + + assert len(agent.prompts) == 1 + assert peer.marked("processed") == ["A"] + assert peer.marked("failed") == [] diff --git a/tests/integrations/acp/test_runtime_close_reporting.py b/tests/integrations/acp/test_runtime_close_reporting.py new file mode 100644 index 000000000..3d6afa484 --- /dev/null +++ b/tests/integrations/acp/test_runtime_close_reporting.py @@ -0,0 +1,79 @@ +"""A failed close of an ACP agent process has exactly one reporter.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager + +import pytest + +from band.integrations.acp.client_runtime import ACPRuntime +from tests.adapters.roompeer import CLEANUP_FAILURE +from tests.integrations.acp.acp_toolkit.lifecycle import FailingExits +from tests.integrations.acp.acp_toolkit.peer import ExitStage, stdio_peer_config +from tests.paths import REPO_ROOT + +RefusingRuntime = Callable[[ExitStage], AbstractAsyncContextManager[ACPRuntime]] + + +@pytest.fixture +def refusing_runtime() -> RefusingRuntime: + """A runtime over a real peer that exits at ``stage`` but whose first exit + is refused; a retried stop kills it.""" + + @asynccontextmanager + async def build(stage: ExitStage) -> AsyncIterator[ACPRuntime]: + config = stdio_peer_config(stage, []) + async with FailingExits() as spawns: + runtime = ACPRuntime( + command=list(config.command), + cwd=str(REPO_ROOT), + env=config.env, + spawn_process=spawns, + ) + try: + yield runtime + finally: + await runtime.stop() + + return build + + +def tracebacks(caplog: pytest.LogCaptureFixture) -> list[str]: + """Every logged message that carries a traceback.""" + return [record.getMessage() for record in caplog.records if record.exc_info] + + +class TestRuntimeCloseReporting: + @pytest.mark.asyncio + async def test_a_failed_stop_is_left_to_its_caller( + self, refusing_runtime: RefusingRuntime, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.WARNING) + async with refusing_runtime(ExitStage.EOF) as runtime: + await runtime.start() + with pytest.raises(RuntimeError, match=CLEANUP_FAILURE): + await runtime.stop() + + assert tracebacks(caplog) == [] + + @pytest.mark.asyncio + async def test_a_failed_close_after_a_failed_start_is_logged_once( + self, refusing_runtime: RefusingRuntime, caplog: pytest.LogCaptureFixture + ) -> None: + """The start raises its own error, so the close failure is logged.""" + caplog.set_level(logging.WARNING) + async with refusing_runtime(ExitStage.INITIALIZE) as runtime: + with pytest.raises(ConnectionError, match="Connection closed"): + await runtime.start() + process = runtime._transport + assert isinstance(process, asyncio.subprocess.Process) + + assert tracebacks(caplog) == [ + ( + f"Closing ACP agent subprocess {process.pid} after a failed start " + "failed; stop retries it" + ) + ] diff --git a/tests/integrations/claude_sdk/conftest.py b/tests/integrations/claude_sdk/conftest.py new file mode 100644 index 000000000..39440aaf7 --- /dev/null +++ b/tests/integrations/claude_sdk/conftest.py @@ -0,0 +1,51 @@ +"""Claude session managers whose CLI children are real processes.""" + +from __future__ import annotations + +import sys +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +import pytest_asyncio +from claude_agent_sdk import ClaudeAgentOptions + +from band.integrations.claude_sdk.session_manager import ClaudeSessionManager +from band.workspaces import RoomWorkspaces +from tests.adapters.claude_sdk.process import WorkspaceProcess, launch_children + + +@dataclass +class Launches: + """The real CLI children a manager started, each failing its startup.""" + + manager: ClaudeSessionManager + children: list[WorkspaceProcess] = field(default_factory=list) + # Whether each child launched from now on refuses every close. + refuse_close: bool = False + + +@pytest_asyncio.fixture(loop_scope="function") +async def launches( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Launches]: + manager = ClaudeSessionManager( + ClaudeAgentOptions(cli_path=sys.executable, env={"PROBE_MARKER": "startup"}), + workspaces=RoomWorkspaces(lambda _: str(tmp_path / "shared")), + ) + launched = Launches(manager=manager) + + def inherit_refusal(child: WorkspaceProcess) -> None: + child.refuse_close = launched.refuse_close + + launched.children = launch_children(monkeypatch, WorkspaceProcess, inherit_refusal) + # The injected failure: the SDK's int() of this fails after the child spawns + # and before it builds the Query. + monkeypatch.setenv("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "invalid") + try: + yield launched + finally: + for child in launched.children: + child.refuse_close = False + await manager.stop() diff --git a/tests/integrations/claude_sdk/sessionmanager/__init__.py b/tests/integrations/claude_sdk/sessionmanager/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integrations/claude_sdk/sessionmanager/conftest.py b/tests/integrations/claude_sdk/sessionmanager/conftest.py new file mode 100644 index 000000000..f64e8806c --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/conftest.py @@ -0,0 +1,15 @@ +"""Fixtures for the ClaudeSessionManager tests.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from tests.adapters.claude_sdk.fakecli import FakeClaude, scripted_claude + + +@pytest.fixture +def claude() -> Iterator[FakeClaude]: + with scripted_claude() as claude: + yield claude diff --git a/tests/integrations/claude_sdk/sessionmanager/kit.py b/tests/integrations/claude_sdk/sessionmanager/kit.py new file mode 100644 index 000000000..9d2343772 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/kit.py @@ -0,0 +1,97 @@ +"""Shared doubles and helpers for the ClaudeSessionManager tests.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from claude_agent_sdk import ( + ClaudeAgentOptions, + ThinkingBlock, +) +from claude_agent_sdk.types import ( + CanUseTool, + PermissionResult, + PermissionResultAllow, + ToolPermissionContext, +) + +from band.integrations.claude_sdk.session_manager import ( + ClaudeRoomSession, + ClaudeSessionManager, +) +from tests.adapters.claude_sdk.fakecli import MODEL, Thinking +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET +from tests.adapters.claude_sdk.process import WorkspacePeer +from tests.adapters.waiting import until + +# A result for work the CLI finished after the turn that started it ended. +STRAY_RESULT = { + "type": "result", + "subtype": "success", + "duration_ms": 1, + "duration_api_ms": 1, + "is_error": False, + "num_turns": 1, + "session_id": "background", + "result": "background work finished", +} + + +def thinking(text: str) -> list[Thinking]: + """A turn that only thinks, so it needs no Band MCP server.""" + return [Thinking(text)] + + +def thoughts(messages: list[Any]) -> list[str]: + return [ + block.thinking + for message in messages + for block in getattr(message, "content", []) + if isinstance(block, ThinkingBlock) + ] + + +def new_manager(**options: Any) -> ClaudeSessionManager: + return ClaudeSessionManager( + ClaudeAgentOptions(model=MODEL, mcp_servers={}), + retention=REPORTING_BUDGET, + **options, + ) + + +class TaskRecordingPeer(WorkspacePeer): + """A real CLI child that notes which task drives each transport call.""" + + tasks: dict[str, asyncio.Task[Any] | None] + + async def connect(self) -> None: + self.tasks = {"connect": asyncio.current_task()} + await super().connect() + + async def write(self, data: str) -> None: + if json.loads(data)["type"] == "user": + self.tasks["query"] = asyncio.current_task() + await super().write(data) + + async def close(self) -> None: + self.tasks.setdefault("close", asyncio.current_task()) + await super().close() + + +def allow_everything(session: ClaudeRoomSession, generation: int) -> CanUseTool: + async def decide( + tool_name: str, arguments: dict[str, Any], context: ToolPermissionContext + ) -> PermissionResult: + return PermissionResultAllow(updated_input=arguments) + + return decide + + +async def retired(session: ClaudeRoomSession, generation: int) -> None: + """Wait until recovery retires the room's residency ``generation``.""" + await until( + lambda: not session.speaks_for(generation), + failure=f"residency {generation} was never retired", + ) diff --git a/tests/integrations/claude_sdk/sessionmanager/test_disposal.py b/tests/integrations/claude_sdk/sessionmanager/test_disposal.py new file mode 100644 index 000000000..c4cf11f46 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_disposal.py @@ -0,0 +1,67 @@ +"""Room disposal and manager stop keep unfinished or failed cleanup owned.""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from band.integrations.claude_sdk.session_manager import ( + ClaudeSessionManagerStoppedError, +) +from tests.adapters.claude_sdk.fakecli import CLEANUP_FAILED, FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, + thinking, +) + + +class TestDisposal: + async def test_an_unfinished_disposal_stays_owned_and_a_retry_joins_it( + self, + claude: FakeClaude, + ) -> None: + manager = new_manager() + session = manager.room("room-a") + claude.script(thinking("hi")) + await exchange(session, "hi") + closing = claude.closing = Hold() + + leaving = asyncio.create_task(manager.cleanup_session("room-a")) + await closing.arrived() + leaving.cancel() + with pytest.raises(CleanupIncompleteError): + await manager.cleanup_session("room-a") + [cli] = claude.sessions + assert (cli.close_calls, manager.has_session("room-a")) == (1, True) + + closing.released.set() + await manager.cleanup_session("room-a") + await manager.stop() + + assert not cli.alive + assert manager.session("room-a") is None + + async def test_a_stopped_manager_keeps_failed_rooms_and_refuses_new_ones( + self, + claude: FakeClaude, + ) -> None: + manager = new_manager() + claude.script(thinking("hi")) + await exchange(manager.room("room-a"), "hi") + claude.refuse_close = True + + with pytest.raises(ExceptionGroup) as failed: + await manager.stop() + assert failed.group_contains(RuntimeError, match=re.escape(CLEANUP_FAILED)) + with pytest.raises(ClaudeSessionManagerStoppedError): + manager.room("room-b") + claude.refuse_close = False + await manager.stop() + + [cli] = claude.sessions + assert not cli.alive + assert manager.get_active_rooms() == [] diff --git a/tests/integrations/claude_sdk/sessionmanager/test_release_callbacks.py b/tests/integrations/claude_sdk/sessionmanager/test_release_callbacks.py new file mode 100644 index 000000000..46d1afc32 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_release_callbacks.py @@ -0,0 +1,38 @@ +"""The release callback keeps a CLI it failed to close for a retry.""" + +from __future__ import annotations + +import re + +import pytest + +from band.core.retention import ReleaseCause, ReleaseOutcome +from tests.adapters.claude_sdk.fakecli import CLEANUP_FAILED, FakeClaude +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, +) + + +class TestReleaseCallbacks: + @pytest.mark.parametrize("cause", [ReleaseCause.RECOVERY, ReleaseCause.FINAL]) + async def test_recovery_and_final_releases_close_or_keep_the_cli_for_a_retry( + self, claude: FakeClaude, cause: ReleaseCause + ) -> None: + manager = new_manager() + session = manager.room("room-a") + await session.acquire(1, False) + claude.refuse_close = True + + with pytest.raises(RuntimeError, match=re.escape(CLEANUP_FAILED)): + await session.release(cause) + held = session.holds_cli + claude.refuse_close = False + outcome = await session.release(cause) + await manager.stop() + + assert (held, outcome, session.holds_cli) == ( + True, + ReleaseOutcome.RELEASED, + False, + ) + assert not any(cli.alive for cli in claude.sessions) diff --git a/tests/integrations/claude_sdk/sessionmanager/test_residency_authority.py b/tests/integrations/claude_sdk/sessionmanager/test_residency_authority.py new file mode 100644 index 000000000..9154a0d87 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_residency_authority.py @@ -0,0 +1,38 @@ +"""Only the current residency of the current room session may act.""" + +from __future__ import annotations + +from tests.adapters.claude_sdk.fakecli import FakeClaude +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, + thinking, +) + + +class TestResidencyAuthority: + async def test_a_retired_or_replaced_residency_loses_authority( + self, + claude: FakeClaude, + ) -> None: + manager = new_manager() + claude.script(thinking("one"), thinking("two"), thinking("three")) + first = manager.room("room-a") + await exchange(first, "one") + assert first.speaks_for(1) + + assert first.recover("crashed") + assert not first.speaks_for(1) + await exchange(first, "two") + assert first.speaks_for(2) + + await manager.cleanup_session("room-a") + rejoined = manager.room("room-a") + await exchange(rejoined, "three") + # The rejoined room counts generations from the start again. + authority = [first.speaks_for(1), rejoined.speaks_for(1)] + await manager.stop() + + assert rejoined is not first + assert authority == [False, True] + assert len(claude.sessions) == 3 diff --git a/tests/integrations/claude_sdk/sessionmanager/test_resume_policy.py b/tests/integrations/claude_sdk/sessionmanager/test_resume_policy.py new file mode 100644 index 000000000..0848c8e4b --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_resume_policy.py @@ -0,0 +1,100 @@ +"""Which failures keep the room's conversation and which start a new one.""" + +from __future__ import annotations + +import re + +import pytest +from claude_agent_sdk import ( + CLIConnectionError, +) + +from band.core.retention import ReleaseCause +from tests.adapters.claude_sdk.fakecli import STARTUP_EXITED, FakeClaude +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, + thinking, +) + + +class TestResumePolicy: + async def test_a_strict_wake_keeps_an_unresumable_session( + self, + claude: FakeClaude, + ) -> None: + """A deliberate wake reports the failure and keeps the conversation's + identity.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-gone") + claude.unresumable.add("sess-gone") + + with pytest.raises(CLIConnectionError, match=re.escape(STARTUP_EXITED)): + await session.acquire(1, True) + await session.release(ReleaseCause.FINAL) + + assert claude.resumed == ["sess-gone"] + assert session.session_id == "sess-gone" + assert not any(cli.alive for cli in claude.sessions) + + async def test_bootstrap_starts_a_new_conversation_for_an_unresumable_session( + self, + claude: FakeClaude, + ) -> None: + """Bootstrap and crash recovery start a new conversation.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-gone") + claude.unresumable.add("sess-gone") + + await session.acquire(1, False) + await session.release(ReleaseCause.FINAL) + + assert claude.resumed == ["sess-gone", None] + assert session.session_id is None + assert not any(cli.alive for cli in claude.sessions) + + async def test_a_startup_that_fails_twice_keeps_the_conversation( + self, + claude: FakeClaude, + ) -> None: + """The fresh-session fallback drops the conversation only once a new one + runs, so a startup failure unrelated to the resume leaves the next + message resuming it.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-1") + claude.refuse_connect = True + + with pytest.raises(CLIConnectionError): + await exchange(session, "hi") + claude.refuse_connect = False + claude.script(thinking("back")) + await exchange(session, "hi") + await manager.stop() + + assert claude.resumed == ["sess-1", None, "sess-1"] + + async def test_a_failed_resume_that_could_not_close_is_never_replaced( + self, + claude: FakeClaude, + ) -> None: + """The bootstrap fallback only starts a new CLI once the failed one is + closed; otherwise the failed CLI stays held for recovery.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-gone") + claude.unresumable.add("sess-gone") + claude.refuse_close = True + + with pytest.raises(CLIConnectionError, match=re.escape(STARTUP_EXITED)): + await exchange(session, "hi") + [cli] = claude.sessions + held = (manager.has_session("room-a"), session.session_id) + claude.refuse_close = False + await manager.cleanup_session("room-a") + + assert held == (True, "sess-gone") + assert claude.resumed == ["sess-gone"] + assert not cli.alive diff --git a/tests/integrations/claude_sdk/sessionmanager/test_room_independence.py b/tests/integrations/claude_sdk/sessionmanager/test_room_independence.py new file mode 100644 index 000000000..a1dd0b878 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_room_independence.py @@ -0,0 +1,36 @@ +"""One room's held connect or close never holds up another room.""" + +from __future__ import annotations + +import asyncio + +from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, + thinking, + thoughts, +) + + +class TestRoomIndependence: + async def test_a_held_connect_or_close_never_holds_up_another_room( + self, + claude: FakeClaude, + ) -> None: + manager = new_manager() + claude.script(thinking("b first"), thinking("a"), thinking("b again")) + connecting = claude.connecting = Hold() + opening = asyncio.create_task(exchange(manager.room("room-a"), "hi")) + async with connecting: + assert thoughts(await exchange(manager.room("room-b"), "hi")) == ["b first"] + await opening + + closing = claude.closing = Hold() + leaving = asyncio.create_task(manager.cleanup_session("room-a")) + async with closing: + assert thoughts(await exchange(manager.room("room-b"), "hi")) == ["b again"] + await leaving + await manager.stop() + + assert [session.alive for session in claude.sessions] == [False, False] diff --git a/tests/integrations/claude_sdk/sessionmanager/test_room_options.py b/tests/integrations/claude_sdk/sessionmanager/test_room_options.py new file mode 100644 index 000000000..7e433d531 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_room_options.py @@ -0,0 +1,58 @@ +"""A room's CLI options bind the room, its workspace and its residency.""" + +from __future__ import annotations + +from pathlib import Path + +from claude_agent_sdk import ( + ClaudeAgentOptions, +) +from claude_agent_sdk.types import ( + CanUseTool, +) + +from band.integrations.claude_sdk.session_manager import ( + ClaudeRoomSession, + ClaudeSessionManager, +) +from band.workspaces import RoomWorkspaces, create_room_workspace_resolver +from tests.adapters.claude_sdk.fakecli import MODEL, FakeClaude +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + allow_everything, + thinking, +) + + +class TestRoomOptions: + async def test_room_options_bind_the_room_its_workspace_and_its_residency( + self, claude: FakeClaude, tmp_path: Path + ) -> None: + bindings: list[tuple[str, int]] = [] + + def bind(session: ClaudeRoomSession, generation: int) -> CanUseTool: + bindings.append((session.room_id, generation)) + return allow_everything(session, generation) + + manager = ClaudeSessionManager( + ClaudeAgentOptions(model=MODEL, mcp_servers={}), + can_use_tool_factory=bind, + mcp_servers_factory=lambda room_id: { + "band": {"type": "http", "url": room_id} + }, + workspaces=RoomWorkspaces(create_room_workspace_resolver(tmp_path)), + ) + base = manager.base_options + claude.script(thinking("hi")) + session = manager.room("room-a") + session.adopt("sess-earlier") + await exchange(session, "hi") + await manager.stop() + + [cli] = claude.sessions + assert cli.options.cwd == str(tmp_path / "room-a") + assert cli.options.resume == "sess-earlier" + assert cli.options.mcp_servers == {"band": {"type": "http", "url": "room-a"}} + assert cli.options.model == base.model + assert base.resume is None and base.mcp_servers == {} + assert bindings == [("room-a", 1)] diff --git a/tests/integrations/claude_sdk/sessionmanager/test_room_reader.py b/tests/integrations/claude_sdk/sessionmanager/test_room_reader.py new file mode 100644 index 000000000..6342e8c0c --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_room_reader.py @@ -0,0 +1,108 @@ +"""The room's single reader keeps stray output out of turns and retires a CLI whose output ends.""" + +from __future__ import annotations + +import asyncio +import logging + +import pytest +from claude_agent_sdk import ( + CLIJSONDecodeError, + ResultMessage, +) + +from band.integrations.claude_sdk import session_manager +from tests.adapters.claude_sdk.fakecli import AfterResult, FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import exchange +from tests.adapters.waiting import until +from tests.integrations.claude_sdk.sessionmanager.kit import ( + STRAY_RESULT, + new_manager, + retired, + thinking, + thoughts, +) + + +class TestRoomReader: + async def test_one_reader_keeps_output_between_turns_out_of_the_next( + self, claude: FakeClaude, caplog: pytest.LogCaptureFixture + ) -> None: + """The CLI reports background work after a turn's result. The room's + single reader drops what arrives while no turn listens, so it is never + buffered for the next turn, which ends at its own result.""" + manager = new_manager() + session = manager.room("room-a") + finished = asyncio.Event() + claude.script([AfterResult(STRAY_RESULT, gate=finished)], thinking("second")) + try: + with caplog.at_level(logging.DEBUG, logger=session_manager.__name__): + await exchange(session, "first") + finished.set() + await until( + lambda: "outside any turn" in caplog.text, + failure="the stray result was never dropped", + ) + second = await exchange(session, "second") + finally: + await manager.stop() + + assert thoughts(second) == ["second"] + assert [m.session_id for m in second if isinstance(m, ResultMessage)] == [ + "sess-1" + ] + assert len(claude.sessions) == 1 + + async def test_a_cli_that_exits_between_turns_is_retired_by_its_reader( + self, + claude: FakeClaude, + ) -> None: + """No turn is listening when the output ends, so the reader itself hands + the crashed CLI to the room's owner, which closes it; the next message + starts a new conversation, as a crash noticed inside a turn does.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-1") + claude.script(thinking("hi"), thinking("again")) + await exchange(session, "hi") + closing = claude.closing = Hold() + + crashed, *_ = claude.sessions + crashed.die() + async with closing: + authority_at_close = session.speaks_for(1) + await exchange(session, "again") + await manager.stop() + + assert authority_at_close is False + assert claude.resumed == ["sess-1", None] + + async def test_a_read_failure_after_a_result_retires_the_cli( + self, + claude: FakeClaude, + ) -> None: + """The SDK ends its message stream on a malformed line while the CLI runs + on. The turn already has its result and reads no further, so the reader + retires the CLI instead of leaving the next turn on a stream nobody reads; + the process did not crash, so the next CLI resumes the conversation.""" + manager = new_manager() + session = manager.room("room-a") + session.adopt("sess-1") + malformed = asyncio.Event() + unreadable = CLIJSONDecodeError("{", ValueError("Buffer size exceeds limit")) + claude.script( + [*thinking("one"), AfterResult(unreadable, gate=malformed)], thinking("two") + ) + try: + async with session.using() as client: + with session.listening() as stream: + await client.query("one") + [_ async for _ in stream.until_result()] + malformed.set() + await retired(session, 1) + second = await exchange(session, "two") + finally: + await manager.stop() + + assert thoughts(second) == ["two"] + assert claude.resumed == ["sess-1", "sess-1"] diff --git a/tests/integrations/claude_sdk/sessionmanager/test_sdk_task_affinity.py b/tests/integrations/claude_sdk/sessionmanager/test_sdk_task_affinity.py new file mode 100644 index 000000000..9d9408d13 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_sdk_task_affinity.py @@ -0,0 +1,54 @@ +"""The installed SDK runs one room session across tasks.""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest +from claude_agent_sdk import ( + ClaudeAgentOptions, + ResultMessage, +) + +from band.integrations.claude_sdk.session_manager import ( + ClaudeSessionManager, +) +from tests.adapters.claude_sdk.process import launch_children +from tests.integrations.claude_sdk.sessionmanager.kit import ( + TaskRecordingPeer, + allow_everything, +) + + +class TestSdkTaskAffinity: + async def test_the_installed_sdk_runs_one_session_across_tasks( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Connect, query and close each run in a different task on one loop, + against a real child process: the SDK floor needs no task-affine actor.""" + children = launch_children(monkeypatch, TaskRecordingPeer) + manager = ClaudeSessionManager( + ClaudeAgentOptions(cli_path=sys.executable, cwd=tmp_path), + can_use_tool_factory=allow_everything, + ) + session = manager.room("room-a") + request = json.dumps( + {"tool_name": "Write", "input": {"file_path": "notes.txt", "content": "x"}} + ) + try: + async with session.using() as client: + with session.listening() as stream: + await asyncio.create_task(client.query(request)) + [*_, result] = [message async for message in stream.until_result()] + await asyncio.create_task(manager.cleanup_session("room-a")) + finally: + await manager.stop() + + [child] = children + assert isinstance(result, ResultMessage) + assert (result.is_error, result.result) == (False, "x") + assert child.exited + assert len({*child.tasks.values(), asyncio.current_task()}) == 4 diff --git a/tests/integrations/claude_sdk/sessionmanager/test_startup_cleanup.py b/tests/integrations/claude_sdk/sessionmanager/test_startup_cleanup.py new file mode 100644 index 000000000..364986a62 --- /dev/null +++ b/tests/integrations/claude_sdk/sessionmanager/test_startup_cleanup.py @@ -0,0 +1,42 @@ +"""A failed start whose close is refused keeps its process, and the next +start retries closing that same process before starting another.""" + +from __future__ import annotations + +import re + +import pytest +from claude_agent_sdk import CLIConnectionError + +from tests.adapters.claude_sdk.fakecli import CLEANUP_FAILED, STARTUP_EXITED, FakeClaude +from tests.adapters.claude_sdk.helpers import exchange +from tests.integrations.claude_sdk.sessionmanager.kit import ( + new_manager, + thinking, + thoughts, +) + + +class TestStartupCleanup: + async def test_failed_startup_cleanup_is_retried_on_the_same_process( + self, + claude: FakeClaude, + ) -> None: + manager = new_manager() + session = manager.room("room-a") + claude.refuse_connect = claude.refuse_close = True + + with pytest.raises(CLIConnectionError, match=re.escape(STARTUP_EXITED)): + await exchange(session, "hi") + claude.refuse_connect = False + with pytest.raises(RuntimeError, match=re.escape(CLEANUP_FAILED)): + await exchange(session, "hi") + assert manager.has_session("room-a") + assert len(claude.sessions) == 1 + + claude.refuse_close = False + claude.script(thinking("started")) + assert thoughts(await exchange(session, "hi")) == ["started"] + await manager.stop() + + assert [cli.alive for cli in claude.sessions] == [False, False] diff --git a/tests/integrations/claude_sdk/test_session_manager.py b/tests/integrations/claude_sdk/test_session_manager.py deleted file mode 100644 index d60aa7c56..000000000 --- a/tests/integrations/claude_sdk/test_session_manager.py +++ /dev/null @@ -1,367 +0,0 @@ -"""Tests for ClaudeSessionManager.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from band.adapters.claude_sdk import _CLAUDE_SDK_AVAILABLE as _HAS_CLAUDE_SDK -from band.integrations.claude_sdk.session_manager import ( - ClaudeSessionManager, - ClaudeSessionManagerStoppedError, -) -from band.runtime.tools import BAND_MCP_SERVER_NAME - -if _HAS_CLAUDE_SDK: - from claude_agent_sdk import ClaudeAgentOptions - -pytestmark = pytest.mark.skipif( - not _HAS_CLAUDE_SDK, - reason="claude-agent-sdk not installed (pip install band-sdk[claude_sdk])", -) - - -@pytest.fixture -def mock_options() -> ClaudeAgentOptions: - """Create real ClaudeAgentOptions for tests that go through _build_options.""" - return ClaudeAgentOptions( - model="claude-sonnet-4-5-20250929", - system_prompt="You are a test bot.", - mcp_servers={}, - allowed_tools=[], - permission_mode="acceptEdits", - ) - - -@pytest.fixture -def real_options() -> ClaudeAgentOptions: - """Create a real ClaudeAgentOptions dataclass for _build_options tests.""" - return ClaudeAgentOptions( - model="claude-sonnet-4-5-20250929", - system_prompt="You are a test bot.", - mcp_servers={"band": MagicMock()}, - allowed_tools=["mcp__band__band_send_message"], - permission_mode="acceptEdits", - ) - - -class TestInvalidateSession: - """Tests for invalidate_session() — evicts dead clients without disconnect.""" - - @pytest.mark.asyncio - async def test_invalidate_removes_session_without_disconnect( - self, mock_options: ClaudeAgentOptions - ) -> None: - """invalidate_session should remove the client without calling disconnect().""" - - manager = ClaudeSessionManager(mock_options) - await manager.start() - - # Inject a mock dead client directly into _sessions - dead_client = MagicMock() - dead_client.disconnect = AsyncMock() - manager._sessions["room-dead"] = dead_client - - await manager.invalidate_session("room-dead") - - assert "room-dead" not in manager._sessions - dead_client.disconnect.assert_not_awaited() - - await manager.stop() - - @pytest.mark.asyncio - async def test_invalidate_nonexistent_room_is_noop( - self, mock_options: ClaudeAgentOptions - ) -> None: - """invalidate_session on a room that doesn't exist should be a safe no-op.""" - - manager = ClaudeSessionManager(mock_options) - await manager.start() - - # Should not raise - await manager.invalidate_session("room-nonexistent") - - assert manager.get_session_count() == 0 - - await manager.stop() - - @pytest.mark.asyncio - async def test_get_or_create_after_invalidate_creates_fresh_client( - self, mock_options: ClaudeAgentOptions - ) -> None: - """After invalidation, get_or_create_session should create a new client.""" - - manager = ClaudeSessionManager(mock_options) - await manager.start() - - # Inject a mock dead client - dead_client = MagicMock() - dead_client.disconnect = AsyncMock() - manager._sessions["room-1"] = dead_client - - await manager.invalidate_session("room-1") - assert "room-1" not in manager._sessions - - # Now create a fresh session - fresh_client = MagicMock() - fresh_client.connect = AsyncMock() - fresh_client.disconnect = AsyncMock() - - with patch( - "band.integrations.claude_sdk.session_manager.ClaudeSDKClient", - return_value=fresh_client, - ): - client = await manager.get_or_create_session("room-1") - - assert client is fresh_client - fresh_client.connect.assert_awaited_once() - - await manager.stop() - - @pytest.mark.asyncio - async def test_invalidate_does_not_affect_other_rooms( - self, mock_options: ClaudeAgentOptions - ) -> None: - """Invalidating one room should leave other rooms' sessions intact.""" - - manager = ClaudeSessionManager(mock_options) - await manager.start() - - client_a = MagicMock() - client_b = MagicMock(disconnect=AsyncMock()) - manager._sessions["room-a"] = client_a - manager._sessions["room-b"] = client_b - - await manager.invalidate_session("room-a") - - assert "room-a" not in manager._sessions - assert manager._sessions["room-b"] is client_b - - await manager.stop() - - -async def never_started(manager: ClaudeSessionManager) -> None: - pass - - -async def stopped(manager: ClaudeSessionManager) -> None: - await manager.start() - await manager.stop() - - -@pytest.mark.parametrize("state", [never_started, stopped]) -@pytest.mark.parametrize( - "command", - [ - pytest.param(lambda m: m.cleanup_session("room-1"), id="cleanup_session"), - pytest.param(lambda m: m.invalidate_session("room-1"), id="invalidate_session"), - pytest.param(lambda m: m.cleanup_all(), id="cleanup_all"), - ], -) -async def test_a_command_without_a_running_loop_returns_at_once( - mock_options: ClaudeAgentOptions, - state: Callable[[ClaudeSessionManager], Awaitable[None]], - command: Callable[[ClaudeSessionManager], Awaitable[None]], -) -> None: - """An agent can leave a room before any message started the loop, or - after it stopped; nothing would ever answer a queued command.""" - manager = ClaudeSessionManager(mock_options) - await state(manager) - - async with asyncio.timeout(1): - await command(manager) - - -@pytest.mark.parametrize( - "teardown", - [ - pytest.param(lambda m: m.cleanup_session("room-1"), id="cleanup_session"), - pytest.param(lambda m: m.invalidate_session("room-1"), id="invalidate_session"), - pytest.param(lambda m: m.cleanup_all(), id="cleanup_all"), - ], -) -async def test_a_teardown_racing_stop_succeeds( - mock_options: ClaudeAgentOptions, - teardown: Callable[[ClaudeSessionManager], Awaitable[None]], -) -> None: - """Stop already tears every session down, so a teardown queued behind it - has nothing left to fail on and must not raise during shutdown.""" - manager = ClaudeSessionManager(mock_options) - await manager.start() - - async with asyncio.timeout(1): - stopped, torn_down = await asyncio.gather( - manager.stop(), teardown(manager), return_exceptions=True - ) - - assert (stopped, torn_down) == (None, None) - - -async def test_a_session_request_racing_stop_fails_instead_of_hanging( - mock_options: ClaudeAgentOptions, -) -> None: - """A new session can't start on a stopping manager, and its caller must - not wait forever on a future nothing will resolve.""" - manager = ClaudeSessionManager(mock_options) - await manager.start() - - async with asyncio.timeout(1): - stopped, requested = await asyncio.gather( - manager.stop(), - manager.get_or_create_session("room-1"), - return_exceptions=True, - ) - - assert stopped is None - assert isinstance(requested, ClaudeSessionManagerStoppedError) - - -async def start_slow_to_stop(options: ClaudeAgentOptions) -> ClaudeSessionManager: - """A running manager whose shutdown yields while disconnecting a session. - - A helper, not a fixture: async fixtures run on the session loop, and the - manager's loop task must live on the test's. - """ - - async def slow_disconnect() -> None: - await asyncio.sleep(0) - - manager = ClaudeSessionManager(options) - await manager.start() - manager._sessions["room-1"] = MagicMock(disconnect=slow_disconnect) - return manager - - -async def test_a_manager_without_an_mcp_factory_reuses_the_rooms_session( - mock_options: ClaudeAgentOptions, -) -> None: - """Only a factory's changed servers recycle a session; without one, the - room keeps its client instead of reconnecting the CLI every message.""" - manager = ClaudeSessionManager(mock_options) - client = MagicMock(connect=AsyncMock(), disconnect=AsyncMock()) - - with patch( - "band.integrations.claude_sdk.session_manager.ClaudeSDKClient", - return_value=client, - ): - first = await manager.get_or_create_session("room-1") - second = await manager.get_or_create_session("room-1") - await manager.stop() - - assert first is second - client.connect.assert_awaited_once() - - -async def test_overlapping_stops_share_one_shutdown( - mock_options: ClaudeAgentOptions, -) -> None: - slow_to_stop = await start_slow_to_stop(mock_options) - async with asyncio.timeout(1): - results = await asyncio.gather( - slow_to_stop.stop(), slow_to_stop.stop(), return_exceptions=True - ) - - assert results == [None, None] - - -async def test_a_cancelled_stop_still_finishes_the_shutdown( - mock_options: ClaudeAgentOptions, -) -> None: - slow_to_stop = await start_slow_to_stop(mock_options) - first = asyncio.ensure_future(slow_to_stop.stop()) - await asyncio.sleep(0) - first.cancel() - - async with asyncio.timeout(1): - await slow_to_stop.stop() - - assert not slow_to_stop.has_session("room-1") - - -async def test_a_stopped_manager_refuses_new_sessions( - mock_options: ClaudeAgentOptions, -) -> None: - """``stop()`` is final: the adapter builds a fresh manager to start again, - so a stopped one must never quietly restart its loop.""" - manager = ClaudeSessionManager(mock_options) - await manager.start() - await manager.stop() - - with pytest.raises(ClaudeSessionManagerStoppedError): - async with asyncio.timeout(1): - await manager.get_or_create_session("room-1") - - -class TestBuildOptions: - """Tests for _build_options() using dataclasses.replace().""" - - def test_preserves_all_base_fields(self, real_options: ClaudeAgentOptions) -> None: - """_build_options should preserve all base_options fields.""" - - manager = ClaudeSessionManager(real_options) - result = manager._build_options("room-1") - - assert result.model == real_options.model - assert result.system_prompt == real_options.system_prompt - assert result.mcp_servers == real_options.mcp_servers - assert result.allowed_tools == real_options.allowed_tools - assert result.permission_mode == real_options.permission_mode - - def test_always_returns_copy(self, real_options: ClaudeAgentOptions) -> None: - """_build_options should return a copy even with no overrides.""" - - manager = ClaudeSessionManager(real_options) - result = manager._build_options("room-1") - - assert result is not real_options - - def test_applies_resume_override(self, real_options: ClaudeAgentOptions) -> None: - """_build_options should set resume when session_id provided.""" - - manager = ClaudeSessionManager(real_options) - result = manager._build_options("room-1", resume_session_id="sess-abc") - - assert result.resume == "sess-abc" - - def test_applies_can_use_tool_factory( - self, real_options: ClaudeAgentOptions - ) -> None: - """_build_options should bind can_use_tool from factory.""" - - mock_callback = MagicMock() - factory = MagicMock(return_value=mock_callback) - - manager = ClaudeSessionManager(real_options, can_use_tool_factory=factory) - result = manager._build_options("room-1") - - factory.assert_called_once_with("room-1") - assert result.can_use_tool is mock_callback - - def test_applies_mcp_servers_factory( - self, real_options: ClaudeAgentOptions - ) -> None: - """_build_options should give each room the factory's MCP servers.""" - - def room_servers(room_id: str) -> dict[str, Any]: - return {BAND_MCP_SERVER_NAME: {"type": "http", "url": room_id}} - - manager = ClaudeSessionManager(real_options, mcp_servers_factory=room_servers) - - servers = [manager._build_options(room).mcp_servers for room in ("a", "b")] - - assert servers == [room_servers("a"), room_servers("b")] - - def test_does_not_mutate_base_options( - self, real_options: ClaudeAgentOptions - ) -> None: - """_build_options should not mutate the original base_options.""" - - manager = ClaudeSessionManager(real_options) - manager._build_options("room-1", resume_session_id="sess-abc") - - # base_options should be unmodified - assert not hasattr(real_options, "resume") or real_options.resume is None diff --git a/tests/integrations/claude_sdk/test_startup.py b/tests/integrations/claude_sdk/test_startup.py index 6dadfe084..6be9d1ae8 100644 --- a/tests/integrations/claude_sdk/test_startup.py +++ b/tests/integrations/claude_sdk/test_startup.py @@ -2,54 +2,52 @@ from __future__ import annotations -import sys -from pathlib import Path +import re import pytest -from claude_agent_sdk import ClaudeAgentOptions -from band.integrations.claude_sdk import transport from band.integrations.claude_sdk.session_manager import ClaudeSessionManager -from band.workspaces import RoomWorkspaces -from tests.adapters.claude_sdk.process import WorkspaceProcess - - -@pytest.mark.parametrize("cleanup_fails", [False, True]) -async def test_pre_query_startup_failure_reaps_real_child( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cleanup_fails: bool -) -> None: - children: list[WorkspaceProcess] = [] - - def launch(*, prompt: str, options: ClaudeAgentOptions) -> WorkspaceProcess: - child = WorkspaceProcess(prompt=prompt, options=options) - child.refuse_close = cleanup_fails - children.append(child) - return child - - monkeypatch.setattr(transport, "SubprocessCLITransport", launch) - monkeypatch.setenv("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "invalid") - manager = ClaudeSessionManager( - ClaudeAgentOptions(cli_path=sys.executable, env={"PROBE_MARKER": "startup"}), - workspaces=RoomWorkspaces(lambda _: str(tmp_path / "shared")), - ) - try: - expected_error = RuntimeError if cleanup_fails else ValueError - with pytest.raises(expected_error): - await manager.get_or_create_session("room-a") - assert len(children) == 1 - if cleanup_fails: - assert not children[0].exited - assert manager.has_session("room-a") - with pytest.raises(ValueError, match="both"): - await manager.get_or_create_session("room-b") - with pytest.raises(RuntimeError, match="cleanup failed"): - await manager.get_or_create_session("room-a") - assert len(children) == 1 - children[0].refuse_close = False - await manager.cleanup_session("room-a") - assert children[0].exited +from tests.adapters.lifecycle.kit import workspace_still_claimed +from tests.adapters.roompeer import CLEANUP_FAILURE +from tests.integrations.claude_sdk.conftest import Launches + + +async def start(manager: ClaudeSessionManager, room_id: str) -> None: + async with manager.room(room_id).using(): + pass + + +class TestPreQueryStartup: + """A startup failing before the SDK builds its Query still reaps the child.""" + + async def test_a_failed_start_reaps_its_child(self, launches: Launches) -> None: + manager = launches.manager + + with pytest.raises(ValueError, match="invalid literal"): + await start(manager, "room-a") + + [child] = launches.children + assert child.exited + assert not manager.has_session("room-a") + + async def test_a_refused_close_keeps_the_child_and_its_claim_until_a_retry( + self, launches: Launches + ) -> None: + manager = launches.manager + launches.refuse_close = True + + with pytest.raises(ValueError, match="invalid literal"): + await start(manager, "room-a") + [child] = launches.children + assert not child.exited + assert manager.has_session("room-a") + with workspace_still_claimed(): + await start(manager, "room-b") + with pytest.raises(RuntimeError, match=re.escape(CLEANUP_FAILURE)): + await start(manager, "room-a") + assert launches.children == [child] + + child.refuse_close = False + await manager.cleanup_session("room-a") + assert child.exited assert not manager.has_session("room-a") - finally: - for child in children: - child.refuse_close = False - await manager.stop() diff --git a/tests/integrations/claude_sdk/test_workspaces.py b/tests/integrations/claude_sdk/test_workspaces.py index 20fce7a07..ce8220cd3 100644 --- a/tests/integrations/claude_sdk/test_workspaces.py +++ b/tests/integrations/claude_sdk/test_workspaces.py @@ -8,7 +8,7 @@ from typing import Any import pytest -from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage +from claude_agent_sdk import ClaudeAgentOptions, ResultMessage from claude_agent_sdk.types import ( CanUseTool, PermissionResult, @@ -17,91 +17,93 @@ ToolPermissionContext, ) -from band.integrations.claude_sdk import transport -from band.integrations.claude_sdk.session_manager import ClaudeSessionManager +from band.integrations.claude_sdk.session_manager import ( + ClaudeRoomSession, + ClaudeSessionManager, +) from band.workspaces import RoomWorkspaces, create_room_workspace_resolver -from tests.adapters.claude_sdk.process import WorkspacePeer +from tests.adapters.claude_sdk.helpers import exchange +from tests.adapters.claude_sdk.process import WorkspacePeer, launch_children async def file_operation( - client: ClaudeSDKClient, tool_name: str, **arguments: str + session: ClaudeRoomSession, tool_name: str, **arguments: str ) -> tuple[bool, str | None]: - await client.query(json.dumps({"tool_name": tool_name, "input": arguments})) - async for message in client.receive_response(): - if isinstance(message, ResultMessage): - return message.is_error, message.result + request = json.dumps({"tool_name": tool_name, "input": arguments}) + match await exchange(session, request): + case [*_, ResultMessage() as result]: + return result.is_error, result.result raise AssertionError("The child returned no result") -async def test_real_sdk_permissions_and_files_survive_room_restart( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - children: list[WorkspacePeer] = [] - requests: list[tuple[str, str]] = [] - - def launch(*, prompt: str, options: ClaudeAgentOptions) -> WorkspacePeer: - child = WorkspacePeer(prompt=prompt, options=options) - children.append(child) - return child +class TestWorkspaceRestarts: + async def test_real_sdk_permissions_and_files_survive_room_restart( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + children = launch_children(monkeypatch, WorkspacePeer) + requests: list[tuple[str, str]] = [] - def permissions(room_id: str) -> CanUseTool: - async def decide( - tool_name: str, arguments: dict[str, Any], context: ToolPermissionContext - ) -> PermissionResult: - requests.append((room_id, tool_name)) - if arguments.get("content") == "denied": - return PermissionResultDeny(message="Refused by the host") - updated = {**arguments} - if tool_name == "Write": - updated["content"] = arguments["content"].upper() - return PermissionResultAllow(updated_input=updated) + def permissions(session: ClaudeRoomSession, generation: int) -> CanUseTool: + async def decide( + tool_name: str, + arguments: dict[str, Any], + context: ToolPermissionContext, + ) -> PermissionResult: + requests.append((session.room_id, tool_name)) + if arguments.get("content") == "denied": + return PermissionResultDeny(message="Refused by the host") + updated = {**arguments} + if tool_name == "Write": + updated["content"] = arguments["content"].upper() + return PermissionResultAllow(updated_input=updated) - return decide + return decide - monkeypatch.setattr(transport, "SubprocessCLITransport", launch) - workspaces = RoomWorkspaces(create_room_workspace_resolver(tmp_path)) + workspaces = RoomWorkspaces(create_room_workspace_resolver(tmp_path)) - def new_manager() -> ClaudeSessionManager: - return ClaudeSessionManager( - ClaudeAgentOptions(cli_path=sys.executable), - workspaces=workspaces, - can_use_tool_factory=permissions, - ) + def new_manager() -> ClaudeSessionManager: + return ClaudeSessionManager( + ClaudeAgentOptions(cli_path=sys.executable), + workspaces=workspaces, + can_use_tool_factory=permissions, + ) - manager = new_manager() - try: - for room_id in ("room-a", "room-b"): - client = await manager.get_or_create_session(room_id) + manager = new_manager() + try: + for room_id in ("room-a", "room-b"): + assert await file_operation( + manager.room(room_id), + "Write", + file_path="notes.txt", + content=room_id, + ) == (False, room_id.upper()) assert await file_operation( - client, "Write", file_path="notes.txt", content=room_id - ) == (False, room_id.upper()) - client = await manager.get_or_create_session("room-a") - assert await file_operation( - client, "Write", file_path="notes.txt", content="denied" - ) == (True, "denied") - assert len(children) == 2 - assert all(not child.exited for child in children) - assert (tmp_path / "room-a" / "notes.txt").read_text() == "ROOM-A" - assert (tmp_path / "room-b" / "notes.txt").read_text() == "ROOM-B" - assert not (tmp_path / "notes.txt").exists() - await manager.stop() - assert all(child.exited for child in children) + manager.room("room-a"), "Write", file_path="notes.txt", content="denied" + ) == (True, "denied") + assert len(children) == 2 + assert all(not child.exited for child in children) + assert (tmp_path / "room-a" / "notes.txt").read_text() == "ROOM-A" + assert (tmp_path / "room-b" / "notes.txt").read_text() == "ROOM-B" + assert not (tmp_path / "notes.txt").exists() + await manager.stop() + assert all(child.exited for child in children) - manager = new_manager() - for room_id in ("room-a", "room-b"): - client = await manager.get_or_create_session(room_id) - assert await file_operation(client, "Read", file_path="notes.txt") == ( - False, - room_id.upper(), - ) - assert requests == [ - ("room-a", "Write"), - ("room-b", "Write"), - ("room-a", "Write"), - ("room-a", "Read"), - ("room-b", "Read"), - ] - assert len(children) == 4 - finally: - await manager.stop() - assert all(child.exited for child in children) + manager = new_manager() + for room_id in ("room-a", "room-b"): + assert await file_operation( + manager.room(room_id), "Read", file_path="notes.txt" + ) == ( + False, + room_id.upper(), + ) + assert requests == [ + ("room-a", "Write"), + ("room-b", "Write"), + ("room-a", "Write"), + ("room-a", "Read"), + ("room-b", "Read"), + ] + assert len(children) == 4 + finally: + await manager.stop() + assert all(child.exited for child in children) diff --git a/tests/integrations/codex/__init__.py b/tests/integrations/codex/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integrations/codex/cleanup.py b/tests/integrations/codex/cleanup.py new file mode 100644 index 000000000..ec7c3b4a5 --- /dev/null +++ b/tests/integrations/codex/cleanup.py @@ -0,0 +1,58 @@ +"""Content-free snapshots of subprocess cleanup stalled at a test deadline.""" + +from __future__ import annotations + +import asyncio +import inspect +from typing import Any + +from band.integrations.codex import CodexStdioClient + + +def process_snapshot(process: asyncio.subprocess.Process) -> dict[str, object]: + native = getattr(process._transport, "_proc", None) + try: + native_exit: object = native.poll() if native is not None else "unavailable" + except (AttributeError, OSError) as error: + native_exit = type(error).__name__ + return { + "pid": process.pid, + "asyncio_exit": process.returncode, + "native_exit": native_exit, + } + + +def await_snapshot(task: asyncio.Task[Any]) -> dict[str, object]: + chain: list[str] = [] + helpers: list[dict[str, object]] = [] + waiting: object = task.get_coro() + while inspect.iscoroutine(waiting): + frame = waiting.cr_frame + chain.append( + f"{waiting.cr_code.co_name}:{frame.f_lineno}" + if frame + else waiting.cr_code.co_name + ) + if frame is not None: + helper = frame.f_locals.get("taskkill") + if isinstance(helper, asyncio.subprocess.Process): + helpers.append(process_snapshot(helper)) + waiting = waiting.cr_await + failure = task.exception() if task.done() and not task.cancelled() else None + return { + "done": task.done(), + "cancelled": task.cancelled(), + "failure": type(failure).__name__ if failure is not None else None, + "await_chain": chain, + "kill_helpers": helpers, + } + + +def cleanup_snapshot( + client: CodexStdioClient, process: asyncio.subprocess.Process +) -> dict[str, object]: + closing = client._close_task + return { + "child": process_snapshot(process), + "cleanup": await_snapshot(closing) if closing is not None else "not-started", + } diff --git a/tests/integrations/codex/test_adapter_e2e.py b/tests/integrations/codex/test_adapter_e2e.py deleted file mode 100644 index 7b9b1d706..000000000 --- a/tests/integrations/codex/test_adapter_e2e.py +++ /dev/null @@ -1,529 +0,0 @@ -"""Integration-level tests for CodexAdapter lifecycle behavior.""" - -from __future__ import annotations - -import asyncio -import json -import time -from collections import deque -from datetime import UTC, datetime -from typing import Any - -import pytest - -from band.adapters.codex import CodexAdapter, CodexAdapterConfig -from band.core.protocols import TurnResultAlreadyReported -from band.core.types import ( - AgentInput, - Capability, - Emit, - HistoryProvider, - PlatformMessage, -) -from band.integrations.codex import CodexJsonRpcError, CodexRequestMethod, RpcEvent -from band.testing import FakeAgentTools -from tests.adapters.codexturns import ( - RecordedRequests, - await_released_turn, - final_text, - tool_call_request, - turn_completed, -) -from tests.adapters.dispatch import deliver_event - - -def _platform_message(content: str, *, room_id: str = "room-1") -> PlatformMessage: - return PlatformMessage( - id=f"msg-{content[:8]}", - room_id=room_id, - content=content, - sender_id="user-1", - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ) - - -def _agent_input( - content: str, - tools: FakeAgentTools, - *, - raw_history: list[dict[str, Any]] | None = None, - room_id: str = "room-1", - is_session_bootstrap: bool = True, -) -> AgentInput: - return AgentInput( - msg=_platform_message(content, room_id=room_id), - tools=tools, - history=HistoryProvider(raw=raw_history or []), - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=is_session_bootstrap, - room_id=room_id, - ) - - -class _ToolSchemaFakeTools(FakeAgentTools): - def get_openai_tool_schemas( - self, - *, - capabilities: frozenset[Capability] | None = None, - ) -> list[dict[str, Any]]: - del capabilities - return [ - { - "type": "function", - "function": { - "name": "band_send_message", - "description": "Send a message", - "parameters": { - "type": "object", - "properties": {"content": {"type": "string"}}, - }, - }, - } - ] - - -class _FakeCodexClient(RecordedRequests): - def __init__( - self, - *, - events: list[RpcEvent] | None = None, - resume_error: Exception | None = None, - ) -> None: - self.requests: list[tuple[str, dict[str, Any]]] = [] - self.responses: list[tuple[int | str, dict[str, Any]]] = [] - self._events = deque(events or []) - self._resume_error = resume_error - self._thread_counter = 0 - self._turn_counter = 0 - - async def connect(self) -> None: - return None - - async def initialize( - self, - *, - client_name: str, - client_title: str, - client_version: str, - experimental_api: bool = False, - opt_out_notification_methods: list[str] | None = None, - ) -> dict[str, Any]: - return {"userAgent": f"{client_name}/{client_version}"} - - async def request( - self, - method: str, - params: dict[str, Any] | None = None, - *, - retry_on_overload: bool = True, - ) -> dict[str, Any]: - payload = params or {} - self.requests.append((method, payload)) - - if method == CodexRequestMethod.MODEL_LIST: - return {"data": [{"id": "gpt-5.5", "hidden": False}]} - if method == CodexRequestMethod.THREAD_RESUME: - if self._resume_error is not None: - raise self._resume_error - return {"thread": {"id": str(payload.get("threadId") or "thr-resumed")}} - if method == CodexRequestMethod.THREAD_START: - self._thread_counter += 1 - return {"thread": {"id": f"thr-{self._thread_counter}"}} - if method == CodexRequestMethod.TURN_START: - self._turn_counter += 1 - return { - "turn": { - "id": f"turn-{self._turn_counter}", - "status": "inProgress", - "items": [], - "error": None, - } - } - return {} - - async def recv_event(self, timeout_s: float | None = None) -> RpcEvent: - if not self._events: - raise TimeoutError() - return self._events.popleft() - - async def respond(self, request_id: int | str, result: dict[str, Any]) -> None: - self.responses.append((request_id, result)) - - async def respond_error( - self, - request_id: int | str, - *, - code: int, - message: str, - data: Any | None = None, - ) -> None: - self.responses.append( - (request_id, {"error": {"code": code, "message": message}}) - ) - - async def close(self) -> None: - return None - - -def patch_codex_client(adapter: CodexAdapter, client: _FakeCodexClient) -> None: - def _build(_config: CodexAdapterConfig) -> _FakeCodexClient: - return client - - adapter._build_client = _build # type: ignore[method-assign] - - -def make_codex_adapter( - client: _FakeCodexClient, - config: CodexAdapterConfig | None = None, - **kwargs: Any, -) -> CodexAdapter: - adapter = CodexAdapter(config=config or CodexAdapterConfig(), **kwargs) - patch_codex_client(adapter, client) - return adapter - - -def _notify(method: str, params: dict[str, Any]) -> RpcEvent: - return RpcEvent( - kind="notification", - method=method, - params=params, - id=None, - raw={"method": method, "params": params}, - ) - - -def _request(request_id: int, method: str, params: dict[str, Any]) -> RpcEvent: - return RpcEvent( - kind="request", - method=method, - params=params, - id=request_id, - raw={"id": request_id, "method": method, "params": params}, - ) - - -def _answered_turn() -> list[RpcEvent]: - """A turn explicitly completed through a Band decline tool.""" - return [ - tool_call_request(1, "band_no_reply"), - final_text("Done."), - turn_completed(), - ] - - -async def test_on_event_uses_converter_history_to_resume_thread() -> None: - tools = _ToolSchemaFakeTools() - fake_client = _FakeCodexClient(events=_answered_turn()) - adapter = make_codex_adapter(fake_client, config=CodexAdapterConfig()) - await adapter.on_started("Codex Agent", "Integration test agent") - - raw_history = [ - { - "message_type": "task", - "content": "historical mapping", - "metadata": { - "codex_thread_id": "thr-history", - "codex_room_id": "room-1", - "codex_created_at": datetime.now(UTC).isoformat(), - }, - } - ] - await adapter.on_event( - _agent_input( - "continue work", - tools, - raw_history=raw_history, - room_id="room-1", - is_session_bootstrap=True, - ) - ) - - methods = fake_client.request_methods - assert CodexRequestMethod.THREAD_RESUME in methods - assert CodexRequestMethod.THREAD_START not in methods - assert any("Status: resumed" in event["content"] for event in tools.events_sent) - - -@pytest.mark.asyncio -async def test_manual_approval_resolved_by_out_of_band_approve_command() -> None: - tools = _ToolSchemaFakeTools() - fake_client = _FakeCodexClient( - events=[ - _request( - 7, - "item/commandExecution/requestApproval", - {"approvalId": "ap-1", "command": ["git", "push"]}, - ), - _notify( - "turn/completed", - { - "turn": { - "id": "turn-1", - "status": "completed", - "items": [], - "error": None, - } - }, - ), - ] - ) - adapter = make_codex_adapter( - fake_client, - config=CodexAdapterConfig(approval_mode="manual", approval_wait_timeout_s=30.0), - ) - await adapter.on_started("Codex Agent", "Integration test agent") - - first_turn = asyncio.create_task( - deliver_event( - adapter, - _agent_input( - "run protected command", - tools, - room_id="room-1", - is_session_bootstrap=True, - ), - ) - ) - - start = time.monotonic() - while time.monotonic() - start < 2.0: - if any("Approval id: `ap-1`" in msg["content"] for msg in tools.messages_sent): - break - await asyncio.sleep(0.01) - else: - pytest.fail("Approval notification not sent within timeout") - - await deliver_event( - adapter, - _agent_input( - "/approve ap-1", - tools, - room_id="room-1", - is_session_bootstrap=False, - ), - ) - await asyncio.wait_for(first_turn, timeout=2.0) - # on_event returned once the room was asked; the turn itself runs on. - await await_released_turn(adapter, "room-1", timeout_s=2.0) - - assert (7, {"decision": "accept"}) in fake_client.responses - assert any( - "Approval `ap-1` resolved as `accept`." in msg["content"] - for msg in tools.messages_sent - ) - - -@pytest.mark.asyncio -async def test_restart_rehydrates_mapping_from_previous_task_events() -> None: - tools_first = _ToolSchemaFakeTools() - fake_client_first = _FakeCodexClient(events=_answered_turn()) - adapter_first = make_codex_adapter(fake_client_first, config=CodexAdapterConfig()) - await adapter_first.on_started("Codex Agent", "Integration test agent") - await adapter_first.on_event( - _agent_input( - "first turn", - tools_first, - room_id="room-9", - is_session_bootstrap=True, - ) - ) - - persisted_history = list(tools_first.events_sent) - - tools_second = _ToolSchemaFakeTools() - fake_client_second = _FakeCodexClient(events=_answered_turn()) - adapter_second = make_codex_adapter(fake_client_second, config=CodexAdapterConfig()) - await adapter_second.on_started("Codex Agent", "Integration test agent") - await adapter_second.on_event( - _agent_input( - "second turn after restart", - tools_second, - raw_history=persisted_history, - room_id="room-9", - is_session_bootstrap=True, - ) - ) - - methods_second = fake_client_second.request_methods - assert CodexRequestMethod.THREAD_RESUME in methods_second - assert CodexRequestMethod.THREAD_START not in methods_second - - -@pytest.mark.asyncio -async def test_resume_failure_injects_conversation_history() -> None: - """Full lifecycle: first session produces history, second session resume fails, - verify turn input contains history context.""" - # --- First session: produce some history --- - tools_first = _ToolSchemaFakeTools() - fake_client_first = _FakeCodexClient(events=_answered_turn()) - adapter_first = make_codex_adapter(fake_client_first, config=CodexAdapterConfig()) - await adapter_first.on_started("Codex Agent", "Integration test agent") - await adapter_first.on_event( - _agent_input( - "refactor the auth module", - tools_first, - room_id="room-hist", - is_session_bootstrap=True, - ) - ) - - # Simulate persisted history: task events + text messages - persisted_history = list(tools_first.events_sent) - persisted_history.append( - { - "message_type": "text", - "content": "refactor the auth module", - "sender_name": "Alice", - } - ) - persisted_history.append( - { - "message_type": "text", - "content": "Done — split into auth_handler.py and middleware.py", - "sender_name": "CodexAgent", - } - ) - - # --- Second session: resume fails, should inject history --- - tools_second = _ToolSchemaFakeTools() - fake_client_second = _FakeCodexClient( - events=_answered_turn(), - resume_error=CodexJsonRpcError(code=-32002, message="Thread expired"), - ) - adapter_second = make_codex_adapter(fake_client_second, config=CodexAdapterConfig()) - await adapter_second.on_started("Codex Agent", "Integration test agent") - await adapter_second.on_event( - _agent_input( - "now add rate limiting", - tools_second, - raw_history=persisted_history, - room_id="room-hist", - is_session_bootstrap=True, - ) - ) - - methods = fake_client_second.request_methods - assert CodexRequestMethod.THREAD_RESUME in methods - assert CodexRequestMethod.THREAD_START in methods - - turn_start = fake_client_second.params_of(CodexRequestMethod.TURN_START)[0] - turn_input = turn_start["input"] - history_items = [ - item for item in turn_input if "[Conversation History]" in item["text"] - ] - assert len(history_items) == 1 - assert "[Alice]: refactor the auth module" in history_items[0]["text"] - assert ( - "[CodexAgent]: Done — split into auth_handler.py and middleware.py" - in history_items[0]["text"] - ) - - -@pytest.mark.asyncio -async def test_item_completed_forwards_internal_operations() -> None: - """Full cycle: commandExecution + fileChange + agentMessage items all forwarded.""" - - tools = _ToolSchemaFakeTools() - fake_client = _FakeCodexClient( - events=[ - _notify( - "item/completed", - { - "item": { - "type": "commandExecution", - "id": "cmd-1", - "command": "pytest tests/", - "cwd": "/workspace", - "aggregated_output": "5 passed", - "exitCode": 0, - } - }, - ), - _notify( - "item/completed", - { - "item": { - "type": "fileChange", - "id": "fc-1", - "changes": [{"path": "src/app.py"}], - "status": "applied", - } - }, - ), - _notify( - "item/completed", - { - "item": { - "type": "reasoning", - "id": "reason-1", - "summary": ["Tests pass, applying fix"], - } - }, - ), - _notify( - "item/completed", - { - "item": { - "type": "agentMessage", - "id": "msg-1", - "text": "Fixed the bug and all tests pass.", - } - }, - ), - _notify( - "turn/completed", - { - "turn": { - "id": "turn-1", - "status": "completed", - "items": [], - "error": None, - } - }, - ), - ] - ) - adapter = make_codex_adapter( - fake_client, config=CodexAdapterConfig(), emit=Emit.TOOL_CALLS | Emit.THOUGHTS - ) - await adapter.on_started("Codex Agent", "Integration test agent") - with pytest.raises(TurnResultAlreadyReported): - await adapter.on_event( - _agent_input( - "fix the failing test", - tools, - room_id="room-ops", - is_session_bootstrap=True, - ) - ) - - # Verify tool events for commandExecution - tool_call_events = [ - e for e in tools.events_sent if e["message_type"] == "tool_call" - ] - tool_result_events = [ - e for e in tools.events_sent if e["message_type"] == "tool_result" - ] - # commandExecution + fileChange = 2 tool_call + 2 tool_result - assert len(tool_call_events) == 2 - assert len(tool_result_events) == 2 - - exec_call = json.loads(tool_call_events[0]["content"]) - assert exec_call["name"] == "exec" - assert exec_call["args"]["command"] == "pytest tests/" - - file_call = json.loads(tool_call_events[1]["content"]) - assert file_call["name"] == "file_edit" - - # Verify thought event for reasoning - thought_events = [e for e in tools.events_sent if e["message_type"] == "thought"] - assert len(thought_events) == 2 - assert "Tests pass" in thought_events[0]["content"] - - # Verify final text message from agentMessage - assert tools.messages_sent == [] diff --git a/tests/integrations/codex/test_cleanup.py b/tests/integrations/codex/test_cleanup.py index e8e41aa8d..ab9d0669c 100644 --- a/tests/integrations/codex/test_cleanup.py +++ b/tests/integrations/codex/test_cleanup.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio +import logging import sys import textwrap from collections.abc import AsyncIterator @@ -13,7 +14,10 @@ import pytest_asyncio from band.integrations.codex import CodexStdioClient, stdio_client -from tests.paths import REPO_ROOT +from tests.integrations.codex.cleanup import cleanup_snapshot +from tests.paths import ROOM_PEER + +logger = logging.getLogger(__name__) HEARTBEAT_S = 0.05 HEARTBEAT_FILE = "heartbeat" @@ -50,7 +54,7 @@ async def child( command=[ sys.executable, "-u", - str(REPO_ROOT / "tests/adapters/roompeer.py"), + str(ROOM_PEER), "--stay", ], cwd=str(tmp_path), @@ -92,7 +96,13 @@ async def test_interrupted_close_still_reaps_the_child( else: with pytest.raises(TimeoutError): await asyncio.wait_for(client.close(), timeout=0.05) - await asyncio.wait_for(process.wait(), timeout=3) + try: + await asyncio.wait_for(process.wait(), timeout=3) + except TimeoutError: + logger.error( + "Codex cleanup deadline: %s", cleanup_snapshot(client, process) + ) + raise await client.close() assert process.returncode is not None diff --git a/tests/integrations/mcpbackends/test_final_close_ownership.py b/tests/integrations/mcpbackends/test_final_close_ownership.py index 302d6827a..1535f60b2 100644 --- a/tests/integrations/mcpbackends/test_final_close_ownership.py +++ b/tests/integrations/mcpbackends/test_final_close_ownership.py @@ -19,7 +19,7 @@ class TestFinalCloseOwnership: """A final close owns its backend until the stop actually succeeds.""" async def test_failed_final_stop_retries_the_same_backend_and_blocks_reopen( - self, + self, caplog: pytest.LogCaptureFixture ) -> None: backend = FakeBandMCPBackend() backend.fail_stops = 1 @@ -28,6 +28,8 @@ async def test_failed_final_stop_retries_the_same_backend_and_blocks_reopen( with pytest.raises(OSError, match="stop failed"): await owner.close(final=True) + # The closing caller raised the failure, so nothing else logs it. + assert [record for record in caplog.records if record.exc_info] == [] with pytest.raises(CleanupIncompleteError): await owner.reopen() diff --git a/tests/integrations/slack/conftest.py b/tests/integrations/slack/conftest.py new file mode 100644 index 000000000..2e100c296 --- /dev/null +++ b/tests/integrations/slack/conftest.py @@ -0,0 +1,5 @@ +"""Fixtures for the Slack integration tests.""" + +from __future__ import annotations + +from tests.adapters.codex.kit import codex_room # noqa: F401 diff --git a/tests/integrations/slack/test_controls.py b/tests/integrations/slack/test_controls.py index 651a8ef71..b3ebf11a5 100644 --- a/tests/integrations/slack/test_controls.py +++ b/tests/integrations/slack/test_controls.py @@ -6,64 +6,65 @@ import pytest +from band.adapters.codex import CodexCommand from band.core.exceptions import InputNotAccepted from band.integrations.codex.types import CodexSessionState from band.testing import events_of_type -from tests.adapters import test_codex_adapter -from tests.adapters.codexturns import final_text, turn_completed -from tests.adapters.test_codex_adapter import ( +from tests.adapters.codex.kit import ( ToolSchemaFakeTools, make_platform_message, parked_on_approval, + requested_approval_token, ) +from tests.adapters.codexturns import final_text, turn_completed from tests.integrations.slack.test_wrapping import _make_adapter, _mention_event -codex_room = test_codex_adapter.codex_room - -@pytest.mark.parametrize("entry", ["band", "slack", "recovery"]) -async def test_wrapped_controls_release_original_turn_without_accepting_new_work( - entry: str, codex_room: Any -) -> None: - room = await codex_room( - *parked_on_approval(final_text("Tests pass."), turn_completed()) - ) - wrapper, _, _, _ = _make_adapter(inner=room.adapter) - await room.send("run tests") - original_tools = room.deliveries[0] +class TestSlackControls: + @pytest.mark.parametrize("entry", ["band", "slack", "recovery"]) + async def test_wrapped_controls_release_original_turn_without_accepting_new_work( + self, entry: str, codex_room: Any + ) -> None: + room = await codex_room( + *parked_on_approval(final_text("Tests pass."), turn_completed()) + ) + wrapper, _, _, _ = _make_adapter(inner=room.adapter) + await room.send("run tests") + original_tools = room.deliveries[0] - async def deliver(content: str) -> bool | None: - msg = make_platform_message(content=content) - tools = ToolSchemaFakeTools() - match entry: - case "band": - await wrapper.on_message( - msg, - tools, - CodexSessionState(), - None, - None, - is_session_bootstrap=False, - room_id=msg.room_id, - ) - case "slack": - await wrapper._invoke_brain_for_slack_event( - wrapper.config.apps[0], _mention_event(text=content)["event"] - ) - case "recovery": - return await wrapper.try_handle_control(msg, tools) - return None + async def deliver(content: str) -> bool | None: + msg = make_platform_message(content=content) + tools = ToolSchemaFakeTools() + match entry: + case "band": + await wrapper.on_message( + msg, + tools, + CodexSessionState(), + None, + None, + is_session_bootstrap=False, + room_id=msg.room_id, + ) + case "slack": + await wrapper._invoke_brain_for_slack_event( + wrapper.config.apps[0], _mention_event(text=content)["event"] + ) + case "recovery": + return await wrapper.try_handle_control(msg, tools) + return None - if entry == "recovery": - assert await deliver("another ordinary request") is False - else: - with pytest.raises(InputNotAccepted): - await deliver("another ordinary request") - assert room.client.responses == [] + if entry == "recovery": + assert await deliver("another ordinary request") is False + else: + with pytest.raises(InputNotAccepted): + await deliver("another ordinary request") + assert room.client.responses == [] - handled = await deliver("/approve req-10") - if entry == "recovery": - assert handled is True - await room.settled() - assert room.client.responses == [(10, {"decision": "accept"})] - assert events_of_type(original_tools, "thought")[-1]["content"] == "Tests pass." + token = requested_approval_token(original_tools) + handled = await deliver(f"/{CodexCommand.APPROVE} {token}") + if entry == "recovery": + assert handled is True + await room.settled() + assert room.client.responses == [(10, {"decision": "accept"})] + assert events_of_type(original_tools, "thought")[-1]["content"] == "Tests pass." diff --git a/tests/paths.py b/tests/paths.py index 08ac5043f..1dd4138fd 100644 --- a/tests/paths.py +++ b/tests/paths.py @@ -25,6 +25,8 @@ BAND_MCP_DIR = REPO_ROOT / "packages" / "band-mcp" # Every shipped source tree, for the repo-wide AST guards. SHIPPED_SOURCE_ROOTS = (SRC_ROOT, BAND_MCP_DIR / "src") +# The scripted provider child the room lifecycle tests run. +ROOM_PEER = REPO_ROOT / "tests" / "adapters" / "roompeer.py" ENV_TEST_FILE = REPO_ROOT / ".env.test" BUG_HUNTING_SCRIPTS = ( REPO_ROOT / ".claude" / "skills" / "bug-hunting-via-example" / "scripts" diff --git a/tests/runtime/decisionroom.py b/tests/runtime/decisionroom.py new file mode 100644 index 000000000..6df1c1186 --- /dev/null +++ b/tests/runtime/decisionroom.py @@ -0,0 +1,101 @@ +"""An adapter's side of a ``DecisionRegistry``, for room-level flow tests.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field + +from band.runtime.decisions import DecisionEntry, DecisionRegistry, Timeout + +Outcome = str | Timeout + +#: Virtual seconds; distinct deadlines fix which of two expiries is due first. +SHORT_DEADLINE_S = 30.0 +DEADLINE_S = 60.0 + + +@dataclass +class Ask: + name: str + future: asyncio.Future[str] = field( + default_factory=lambda: asyncio.get_running_loop().create_future() + ) + waiting: asyncio.Event = field(default_factory=asyncio.Event) + + +class Room: + """An adapter's side of the registry: each ask gets a waiting asker, and + every open ask the registry hands back is resolved with why it went.""" + + def __init__(self, registry: DecisionRegistry[Ask]) -> None: + self.registry = registry + self.askers: dict[str, asyncio.Task[Outcome]] = {} + + async def ask( + self, + name: str, + *, + key: str | None = None, + timeout_s: float = DEADLINE_S, + ) -> DecisionEntry[Ask] | None: + ask = Ask(name) + registration = ( + self.registry.register_minted(ask) + if key is None + else self.registry.register_keyed(ask, key=key) + ) + if registration is None: + return None + for removed in registration.removed: + why = "evicted" if removed is registration.evicted else "replaced" + removed.payload.future.set_result(why) + self.askers[name] = asyncio.create_task( + self._wait(registration.entry, ask, timeout_s) + ) + await ask.waiting.wait() + return registration.entry + + async def _wait( + self, entry: DecisionEntry[Ask], ask: Ask, timeout_s: float + ) -> Outcome: + # wait() runs straight into its first suspension once called, so a + # caller woken by ``waiting`` finds the asker already parked in it. + ask.waiting.set() + return await self.registry.wait(entry, ask.future, timeout_s=timeout_s) + + def reply(self, token: str, answer: str) -> bool: + """A room reply: claim, then resolve with no await in between.""" + if (entry := self.registry.try_claim(token)) is None: + return False + entry.payload.future.set_result(answer) + return True + + async def abandon(self, name: str) -> None: + """The asker's turn is cancelled; wait until it has fully unwound.""" + asker = self.askers.pop(name) + asker.cancel() + await asyncio.wait([asker]) + + def tear_down(self) -> None: + for entry in self.registry.cancel_all(): + entry.payload.future.set_result("cancelled") + + async def outcomes(self) -> dict[str, Outcome]: + return {name: await asker for name, asker in self.askers.items()} + + +class Expiries: + """An ``on_timeout`` callback that records which asks expired.""" + + def __init__(self) -> None: + self.names: list[str] = [] + self._expired = asyncio.Condition() + + async def __call__(self, entry: DecisionEntry[Ask]) -> None: + async with self._expired: + self.names.append(entry.payload.name) + self._expired.notify_all() + + async def until(self, name: str) -> None: + async with self._expired: + await self._expired.wait_for(lambda: name in self.names) diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 7f0840862..534c6c4d5 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -20,14 +20,14 @@ MemorySystem, MemoryType, ) -from band.core.protocols import TURN_FAILURE_PROVIDER -from band.core.simple_adapter import SimpleAdapter +from band.core.protocols import TURN_FAILURE_PROVIDER, FrameworkAdapter from band.core.types import ( SYNTHETIC_CONTACT_EVENTS_SENDER_ID, SYNTHETIC_SENDER_TYPE, PlatformMessage, ) from band.platform.event import MessageEvent +from band.platform.link import BandLink from band.runtime.execution import ExecutionContext from band.runtime.types import SessionConfig from tests.conftest import make_message_event @@ -75,8 +75,8 @@ def answer(request: httpx.Request) -> httpx.Response: def run_through( - adapter: SimpleAdapter[Any], - link: MagicMock, + adapter: FrameworkAdapter, + link: BandLink | MagicMock, *, config: SessionConfig | None = None, ) -> ExecutionContext: @@ -156,6 +156,13 @@ async def resist_cancellation_until(release: asyncio.Event) -> None: IDLE: ActivityReport = (False, False) +def page(data: list[dict[str, Any]]) -> httpx.Response: + """A listing that fits in one page.""" + return httpx.Response( + 200, json={"data": data, "metadata": {"has_more": False, "limit": 50}} + ) + + class LifecyclePlatform: """A controlled HTTP peer for the real Fern lifecycle and posting client.""" @@ -221,16 +228,16 @@ def answer(self, request: httpx.Request) -> httpx.Response: (i + 1 for i, m in enumerate(self.messages) if m["id"] == cursor), 0 ) limit = int(request.url.params.get("limit", "100")) - page = self.messages[start : start + limit] + window = self.messages[start : start + limit] has_more = start + limit < len(self.messages) return httpx.Response( 200, json={ - "data": page, + "data": window, "metadata": { "limit": limit, "has_more": has_more, - "next_cursor": page[-1]["id"] if has_more else None, + "next_cursor": window[-1]["id"] if has_more else None, }, }, ) @@ -270,9 +277,7 @@ def answer(self, request: httpx.Request) -> httpx.Response: } }, ) - return httpx.Response( - 200, json={"data": [], "metadata": {"has_more": False, "limit": 50}} - ) + return page([]) def marked(self, status: str) -> list[str]: return [mid for mid, mark in self.accepted_marks if mark == status] @@ -281,6 +286,26 @@ def requested(self, status: str) -> list[str]: return [mid for mid, mark in self.requested_marks if mark == status] +#: A room participant the platform lists. +ALICE = {"id": "user-456", "name": "Alice", "handle": "alice", "type": "User"} + + +class PostingPlatform(LifecyclePlatform): + """A running room whose participants are listed and whose posts land.""" + + def answer(self, request: httpx.Request) -> httpx.Response: + tail = request.url.path.rsplit("/", 1)[-1] + if tail == "participants": + return page([ALICE]) + if request.method == "POST" and tail == "events": + return httpx.Response( + 201, json={"data": {"id": "e-1", "message_type": "event"}} + ) + if request.method == "POST" and tail == "messages": + return httpx.Response(201, json={"data": {"id": "m-1", "recipients": []}}) + return super().answer(request) + + class ClaimGate: """Hold one real HTTP processing response across a control or shutdown.""" diff --git a/tests/runtime/test_decisions.py b/tests/runtime/test_decisions.py index ee37d0844..85e5e7620 100644 --- a/tests/runtime/test_decisions.py +++ b/tests/runtime/test_decisions.py @@ -13,108 +13,20 @@ import asyncio from collections.abc import AsyncIterator, Callable -from dataclasses import dataclass, field import pytest from band.runtime.decisions import DecisionEntry, DecisionRegistry, Timeout - -Outcome = str | Timeout +from tests.runtime.decisionroom import ( + DEADLINE_S, + SHORT_DEADLINE_S, + Ask, + Expiries, + Room, +) pytestmark = pytest.mark.looptime -#: Virtual seconds; distinct deadlines fix which of two expiries is due first. -SHORT_DEADLINE_S = 30.0 -DEADLINE_S = 60.0 - - -@dataclass -class Ask: - name: str - future: asyncio.Future[str] = field( - default_factory=lambda: asyncio.get_running_loop().create_future() - ) - waiting: asyncio.Event = field(default_factory=asyncio.Event) - - -class Room: - """An adapter's side of the registry: each ask gets a waiting asker, and - every open ask the registry hands back is resolved with why it went.""" - - def __init__(self, registry: DecisionRegistry[Ask]) -> None: - self.registry = registry - self.askers: dict[str, asyncio.Task[Outcome]] = {} - - async def ask( - self, - name: str, - *, - key: str | None = None, - room_id: str | None = None, - timeout_s: float = DEADLINE_S, - ) -> DecisionEntry[Ask] | None: - ask = Ask(name) - registration = ( - self.registry.register_minted(ask, room_id=room_id) - if key is None - else self.registry.register_keyed(ask, key=key) - ) - if registration is None: - return None - for removed in registration.removed: - why = "evicted" if removed is registration.evicted else "replaced" - removed.payload.future.set_result(why) - self.askers[name] = asyncio.create_task( - self._wait(registration.entry, ask, timeout_s) - ) - await ask.waiting.wait() - return registration.entry - - async def _wait( - self, entry: DecisionEntry[Ask], ask: Ask, timeout_s: float - ) -> Outcome: - # wait() runs straight into its first suspension once called, so a - # caller woken by ``waiting`` finds the asker already parked in it. - ask.waiting.set() - return await self.registry.wait(entry, ask.future, timeout_s=timeout_s) - - def reply(self, token: str, answer: str) -> bool: - """A room reply: claim, then resolve with no await in between.""" - if (entry := self.registry.try_claim(token)) is None: - return False - entry.payload.future.set_result(answer) - return True - - async def abandon(self, name: str) -> None: - """The asker's turn is cancelled; wait until it has fully unwound.""" - asker = self.askers.pop(name) - asker.cancel() - await asyncio.wait([asker]) - - def tear_down(self, room_id: str) -> None: - for entry in self.registry.cancel_room(room_id): - entry.payload.future.set_result("cancelled") - - async def outcomes(self) -> dict[str, Outcome]: - return {name: await asker for name, asker in self.askers.items()} - - -class Expiries: - """An ``on_timeout`` callback that records which asks expired.""" - - def __init__(self) -> None: - self.names: list[str] = [] - self._expired = asyncio.Condition() - - async def __call__(self, entry: DecisionEntry[Ask]) -> None: - async with self._expired: - self.names.append(entry.payload.name) - self._expired.notify_all() - - async def until(self, name: str) -> None: - async with self._expired: - await self._expired.wait_for(lambda: name in self.names) - @pytest.fixture async def open_room() -> AsyncIterator[Callable[..., Room]]: @@ -135,168 +47,162 @@ def expiries() -> Expiries: return Expiries() -async def test_a_busy_room_gives_every_asker_exactly_one_outcome( - open_room: Callable[..., Room], -) -> None: - """At capacity with a reply mid-flight: the oldest open ask is evicted - (never the claimed one), a redelivery supersedes its predecessor without - evicting, a redelivery of the claimed ask is refused, the claimant's - late answer still wins past the deadline, and the rest time out.""" - room = open_room(max_pending=2) - await room.ask("a", key="a", timeout_s=SHORT_DEADLINE_S) - await room.ask("b", key="b") - claimed = room.registry.try_claim("a") - assert claimed is not None - - await room.ask("c1", key="c") - await room.ask("c2", key="c") - assert await room.ask("a-again", key="a") is None - assert list(room.registry) == ["a", "c"] - - # c2's deadline is due after a's, so once c2 has expired a is past its own. - assert await room.askers["c2"] is Timeout.TIMED_OUT - claimed.payload.future.set_result("accept") - - assert await room.outcomes() == { - "a": "accept", - "b": "evicted", - "c1": "replaced", - "c2": Timeout.TIMED_OUT, - } - assert not room.registry - assert room.reply("c", "accept") is False - - -async def test_tearing_down_a_room_resolves_only_its_open_asks( - open_room: Callable[..., Room], -) -> None: - """Teardown resolves the room's open asks, leaves a claimed one to its - claimant, and never touches another room; an asker cancelled with its - turn drops its ask without cancelling the answer's future.""" - room = open_room() - await room.ask("open", room_id="room-1") - claimed_ask = await room.ask("claimed", room_id="room-1") - await room.ask("elsewhere", room_id="room-2") - abandoned = await room.ask("abandoned", room_id="room-2") - assert claimed_ask is not None and abandoned is not None - claimed = room.registry.try_claim(claimed_ask.token) - assert claimed is not None - - room.tear_down("room-1") - claimed.payload.future.set_result("accept") - await room.abandon("abandoned") - - [survivor] = room.registry.unclaimed_in_room("room-2") - assert survivor.payload.name == "elsewhere" - assert room.reply(survivor.token, "decline") - assert await room.outcomes() == { - "open": "cancelled", - "claimed": "accept", - "elsewhere": "decline", - } - assert not abandoned.payload.future.cancelled() - assert not room.registry - - -async def test_expiry_timers_race_replies_redeliveries_and_teardown( - expiries: Expiries, -) -> None: - """A reply before the deadline stops that ask's timer; a redelivery drops - its predecessor's timer and a stale handle can't arm a new one; and - teardown during an expiry that already claimed its ask lets it finish.""" - registry: DecisionRegistry[Ask] = DecisionRegistry() - replying, release = asyncio.Event(), asyncio.Event() - - async def slow_expiry(entry: DecisionEntry[Ask]) -> None: - replying.set() - await release.wait() - await expiries(entry) - - def ask(name: str, key: str) -> DecisionEntry[Ask]: - registration = registry.register_keyed(Ask(name), key=key) - assert registration is not None - return registration.entry - - registry.start_timeout(ask("answered", "p1"), SHORT_DEADLINE_S, expiries) - first = ask("first", "p2") - registry.start_timeout(first, SHORT_DEADLINE_S, expiries) - redelivery = ask("redelivery", "p2") - registry.start_timeout(first, SHORT_DEADLINE_S, expiries) - registry.start_timeout(redelivery, DEADLINE_S, expiries) - registry.start_timeout(ask("slow", "p3"), 0, slow_expiry) - assert registry.try_claim("p1") is not None - - await replying.wait() - # Every other timer was due before the redelivery's; only it may fire. - await expiries.until("redelivery") - assert expiries.names == ["redelivery"] - - assert registry.cancel_all() == [] - release.set() - await expiries.until("slow") - assert expiries.names == ["redelivery", "slow"] - - -async def test_stale_handles_never_act_on_a_newer_registration() -> None: - """A handle outlives its registration -- replaced by a redelivery, or - issued by a room registry teardown has since replaced -- and every - operation through it must leave the newer registration alone.""" - registry: DecisionRegistry[Ask] = DecisionRegistry() - old_ask = Ask("old") - old = registry.register_keyed(old_ask, key="k") - assert old is not None - replacement = registry.register_keyed(Ask("new"), key="k") - assert replacement is not None and replacement.replaced is old.entry - - registry.forget(old.entry) - assert registry.withdraw(old.entry) is False - registry.start_timeout(old.entry, 0, Expiries()) - assert await registry.wait(old.entry, old_ask.future, timeout_s=DEADLINE_S) is ( - Timeout.TIMED_OUT - ) - - fresh: DecisionRegistry[Ask] = DecisionRegistry() - assert fresh.register_keyed(Ask("fresh"), key="k") is not None - fresh.forget(old.entry) - assert fresh.withdraw(old.entry) is False - - assert [entry.payload.name for entry in registry.unclaimed()] == ["new"] - assert [entry.payload.name for entry in fresh.unclaimed()] == ["fresh"] - - -async def test_a_failed_prompt_withdraws_the_ask_unless_a_reply_claimed_it() -> None: - """The prompt send failed: an unanswered ask is withdrawn so nobody waits - on it, but a reply that already claimed one owns its answer.""" - registry: DecisionRegistry[Ask] = DecisionRegistry() - unanswered = registry.register_minted(Ask("unanswered")).entry - answered_ask = Ask("answered") - answered = registry.register_minted(answered_ask).entry - claimed = registry.try_claim(answered.token) - assert claimed is not None - - assert registry.withdraw(unanswered) is True - assert registry.withdraw(answered) is False - claimed.payload.future.set_result("accept") - - assert await registry.wait(answered, answered_ask.future, timeout_s=DEADLINE_S) == ( - "accept" - ) - assert not registry - - -async def test_a_turn_cancelled_as_its_ask_resolves_still_unwinds( - open_room: Callable[..., Room], -) -> None: - """Room cleanup resolves an open ask, then cancels the turn parked on it - in the same tick. The turn must still end cancelled: an answer that - swallows the cancel lets it carry on into a connection already closing, - and the shutdown awaiting it never returns.""" - room = open_room() - await room.ask("parked", room_id="room-1") - asker = room.askers.pop("parked") - - room.tear_down("room-1") - asker.cancel() - await asyncio.wait([asker]) - - assert asker.cancelled() +class TestDecisionRegistryRoomFlows: + async def test_a_busy_room_gives_every_asker_exactly_one_outcome( + self, + open_room: Callable[..., Room], + ) -> None: + """At capacity with a reply mid-flight: the oldest open ask is evicted + (never the claimed one), a redelivery supersedes its predecessor without + evicting, a redelivery of the claimed ask is refused, the claimant's + late answer still wins past the deadline, and the rest time out.""" + room = open_room(max_pending=2) + await room.ask("a", key="a", timeout_s=SHORT_DEADLINE_S) + await room.ask("b", key="b") + claimed = room.registry.try_claim("a") + assert claimed is not None + + await room.ask("c1", key="c") + await room.ask("c2", key="c") + assert await room.ask("a-again", key="a") is None + assert list(room.registry) == ["a", "c"] + + # c2's deadline is due after a's, so once c2 has expired a is past its own. + assert await room.askers["c2"] is Timeout.TIMED_OUT + claimed.payload.future.set_result("accept") + + assert await room.outcomes() == { + "a": "accept", + "b": "evicted", + "c1": "replaced", + "c2": Timeout.TIMED_OUT, + } + assert not room.registry + assert room.reply("c", "accept") is False + + async def test_tearing_down_a_room_resolves_its_open_asks( + self, + open_room: Callable[..., Room], + ) -> None: + """Teardown resolves the room's open asks and leaves a claimed one to its + claimant; an asker cancelled with its turn drops its ask without + cancelling the answer's future.""" + room = open_room() + await room.ask("open") + claimed_ask = await room.ask("claimed") + abandoned = await room.ask("abandoned") + assert claimed_ask is not None and abandoned is not None + claimed = room.registry.try_claim(claimed_ask.token) + assert claimed is not None + await room.abandon("abandoned") + + room.tear_down() + claimed.payload.future.set_result("accept") + + assert await room.outcomes() == {"open": "cancelled", "claimed": "accept"} + assert not abandoned.payload.future.cancelled() + assert not room.registry + + async def test_expiry_timers_race_replies_redeliveries_and_teardown( + self, + expiries: Expiries, + ) -> None: + """A reply before the deadline stops that ask's timer; a redelivery drops + its predecessor's timer and a stale handle can't arm a new one; and + teardown during an expiry that already claimed its ask lets it finish.""" + registry: DecisionRegistry[Ask] = DecisionRegistry() + replying, release = asyncio.Event(), asyncio.Event() + + async def slow_expiry(entry: DecisionEntry[Ask]) -> None: + replying.set() + await release.wait() + await expiries(entry) + + def ask(name: str, key: str) -> DecisionEntry[Ask]: + registration = registry.register_keyed(Ask(name), key=key) + assert registration is not None + return registration.entry + + registry.start_timeout(ask("answered", "p1"), SHORT_DEADLINE_S, expiries) + first = ask("first", "p2") + registry.start_timeout(first, SHORT_DEADLINE_S, expiries) + redelivery = ask("redelivery", "p2") + registry.start_timeout(first, SHORT_DEADLINE_S, expiries) + registry.start_timeout(redelivery, DEADLINE_S, expiries) + registry.start_timeout(ask("slow", "p3"), 0, slow_expiry) + assert registry.try_claim("p1") is not None + + await replying.wait() + # Every other timer was due before the redelivery's; only it may fire. + await expiries.until("redelivery") + assert expiries.names == ["redelivery"] + + assert registry.cancel_all() == [] + release.set() + await expiries.until("slow") + assert expiries.names == ["redelivery", "slow"] + + async def test_stale_handles_never_act_on_a_newer_registration(self) -> None: + """A handle outlives its registration -- replaced by a redelivery, or + issued by a room registry teardown has since replaced -- and every + operation through it must leave the newer registration alone.""" + registry: DecisionRegistry[Ask] = DecisionRegistry() + old_ask = Ask("old") + old = registry.register_keyed(old_ask, key="k") + assert old is not None + replacement = registry.register_keyed(Ask("new"), key="k") + assert replacement is not None and replacement.replaced is old.entry + + registry.forget(old.entry) + assert registry.withdraw(old.entry) is False + registry.start_timeout(old.entry, 0, Expiries()) + assert await registry.wait(old.entry, old_ask.future, timeout_s=DEADLINE_S) is ( + Timeout.TIMED_OUT + ) + + fresh: DecisionRegistry[Ask] = DecisionRegistry() + assert fresh.register_keyed(Ask("fresh"), key="k") is not None + fresh.forget(old.entry) + assert fresh.withdraw(old.entry) is False + + assert [entry.payload.name for entry in registry.unclaimed()] == ["new"] + assert [entry.payload.name for entry in fresh.unclaimed()] == ["fresh"] + + async def test_a_failed_prompt_withdraws_the_ask_unless_a_reply_claimed_it( + self, + ) -> None: + """The prompt send failed: an unanswered ask is withdrawn so nobody waits + on it, but a reply that already claimed one owns its answer.""" + registry: DecisionRegistry[Ask] = DecisionRegistry() + unanswered = registry.register_minted(Ask("unanswered")).entry + answered_ask = Ask("answered") + answered = registry.register_minted(answered_ask).entry + claimed = registry.try_claim(answered.token) + assert claimed is not None + + assert registry.withdraw(unanswered) is True + assert registry.withdraw(answered) is False + claimed.payload.future.set_result("accept") + + assert await registry.wait( + answered, answered_ask.future, timeout_s=DEADLINE_S + ) == ("accept") + assert not registry + + async def test_a_turn_cancelled_as_its_ask_resolves_still_unwinds( + self, + open_room: Callable[..., Room], + ) -> None: + """Room cleanup resolves an open ask, then cancels the turn parked on it + in the same tick. The turn must still end cancelled: an answer that + swallows the cancel lets it carry on into a connection already closing, + and the shutdown awaiting it never returns.""" + room = open_room() + await room.ask("parked") + asker = room.askers.pop("parked") + + room.tear_down() + asker.cancel() + await asyncio.wait([asker]) + + assert asker.cancelled() diff --git a/tests/toolkit/test_omp_form_policy.py b/tests/toolkit/test_omp_form_policy.py index 3b4486814..424c73bbf 100644 --- a/tests/toolkit/test_omp_form_policy.py +++ b/tests/toolkit/test_omp_form_policy.py @@ -71,124 +71,133 @@ def approval_message(tool: str) -> str: return f"{OMP_TOOL_APPROVAL_HEADER}mcp__band_{tool.removeprefix('band_')}\nArguments: {{}}" -@pytest.mark.asyncio -@pytest.mark.parametrize("tool", [BandTool.SEND_MESSAGE, BandTool.NO_REPLY]) -async def test_native_probe_denied_then_completion_accepted( - adapter: OmpACPAdapter, - emitter: RoomTurnEmitter, - tools: FakeAgentTools, - form_mode: ElicitationFormSessionMode, - tool: BandTool, -) -> None: - client = adapter._runtime_client_factory() - handler = adapter._make_elicitation_handler(emitter, "room", "session") - assert handler is not None - client.set_elicitation_handler("session", handler) - - denied = await client.create_elicitation(FORM_PROBE_TITLE, form_mode) - accepted = await client.create_elicitation(approval_message(tool), form_mode) - - assert isinstance(denied, DeclineElicitationResponse) - assert isinstance(accepted, AcceptElicitationResponse) - assert accepted.content == {"choice": OMP_FORM_APPROVE} - calls = events_of_type(tools, "tool_call") - results = events_of_type(tools, "tool_result") - assert len(calls) == len(results) == 1 - call = json.loads(calls[0]["content"]) - result = json.loads(results[0]["content"]) - assert call["args"] == {"message": FORM_PROBE_TITLE} - assert result["tool_call_id"] == call["tool_call_id"] - assert result["is_error"] - assert "cancelled" in result["output"].lower() - assert results[0]["metadata"]["permission_outcome"] == "cancelled" - assert not tools.turn.complete - assert tools.chat == [] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "message", - [ - FORM_PROBE_TITLE, - "Allow tool: mcp__other_band_send_message", - "Allow tool: mcp__band_send_event", - "Allow tool: bash", - "Allow tool: band_send_message", - " Allow tool: mcp__band_send_message", - "Allow tool: mcp__band_send_message suffix", - "Allow tool: mcp__band_send_message ", - "Allow tool: mcp__band_send_message\r\nArguments: {}", - "Description\nAllow tool: mcp__band_send_message", - ], -) -async def test_native_forms_cannot_broaden_approval( - adapter: OmpACPAdapter, - emitter: RoomTurnEmitter, - form_mode: ElicitationFormSessionMode, - message: str, -) -> None: - handler = adapter._make_elicitation_handler(emitter, "room", "session") - assert handler is not None - response = await handler(message=message, mode=form_mode) - assert isinstance(response, DeclineElicitationResponse) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("arguments", [{}, {"message": None}, {"message": 42}]) -async def test_malformed_synthetic_requests_are_denied( - arguments: dict[str, JsonValue], -) -> None: - request = ACPPermissionRequest( - room_id="room", - session_id="session", - tool_call=ACPToolCall( - tool_call_id="call", name=OMP_APPROVAL_FORM_TOOL_NAME, arguments=arguments - ), - options=(), +class TestOmpFormPolicy: + @pytest.mark.asyncio + @pytest.mark.parametrize("tool", [BandTool.SEND_MESSAGE, BandTool.NO_REPLY]) + async def test_native_probe_denied_then_completion_accepted( + self, + adapter: OmpACPAdapter, + emitter: RoomTurnEmitter, + tools: FakeAgentTools, + form_mode: ElicitationFormSessionMode, + tool: BandTool, + ) -> None: + room = adapter._room("room") + client = adapter._runtime_client_factory(room, room.generation) + handler = adapter._make_elicitation_handler(emitter, "room", "session") + assert handler is not None + client.set_elicitation_handler("session", handler) + + denied = await client.create_elicitation(FORM_PROBE_TITLE, form_mode) + accepted = await client.create_elicitation(approval_message(tool), form_mode) + + assert isinstance(denied, DeclineElicitationResponse) + assert isinstance(accepted, AcceptElicitationResponse) + assert accepted.content == {"choice": OMP_FORM_APPROVE} + calls = events_of_type(tools, "tool_call") + results = events_of_type(tools, "tool_result") + assert len(calls) == len(results) == 1 + call = json.loads(calls[0]["content"]) + result = json.loads(results[0]["content"]) + assert call["args"] == {"message": FORM_PROBE_TITLE} + assert result["tool_call_id"] == call["tool_call_id"] + assert result["is_error"] + assert "cancelled" in result["output"].lower() + assert results[0]["metadata"]["permission_outcome"] == "cancelled" + assert not tools.turn.complete + assert tools.chat == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "message", + [ + FORM_PROBE_TITLE, + "Allow tool: mcp__other_band_send_message", + "Allow tool: mcp__band_send_event", + "Allow tool: bash", + "Allow tool: band_send_message", + " Allow tool: mcp__band_send_message", + "Allow tool: mcp__band_send_message suffix", + "Allow tool: mcp__band_send_message ", + "Allow tool: mcp__band_send_message\r\nArguments: {}", + "Description\nAllow tool: mcp__band_send_message", + ], ) - assert await deny_probe_allow_completion(request) is None - - -@pytest.mark.asyncio -@pytest.mark.parametrize("one_shot", [True, False]) -async def test_policy_selects_only_the_offered_one_shot_option(one_shot: bool) -> None: - options = [ - PermissionOption(option_id="persistent", name="Always", kind="allow_always") - ] - if one_shot: - options.append( - PermissionOption(option_id="fixture-once", name="Once", kind="allow_once") + async def test_native_forms_cannot_broaden_approval( + self, + adapter: OmpACPAdapter, + emitter: RoomTurnEmitter, + form_mode: ElicitationFormSessionMode, + message: str, + ) -> None: + handler = adapter._make_elicitation_handler(emitter, "room", "session") + assert handler is not None + response = await handler(message=message, mode=form_mode) + assert isinstance(response, DeclineElicitationResponse) + + @pytest.mark.asyncio + @pytest.mark.parametrize("arguments", [{}, {"message": None}, {"message": 42}]) + async def test_malformed_synthetic_requests_are_denied( + self, + arguments: dict[str, JsonValue], + ) -> None: + request = ACPPermissionRequest( + room_id="room", + session_id="session", + tool_call=ACPToolCall( + tool_call_id="call", + name=OMP_APPROVAL_FORM_TOOL_NAME, + arguments=arguments, + ), + options=(), + ) + assert await deny_probe_allow_completion(request) is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("one_shot", [True, False]) + async def test_policy_selects_only_the_offered_one_shot_option( + self, one_shot: bool + ) -> None: + options = [ + PermissionOption(option_id="persistent", name="Always", kind="allow_always") + ] + if one_shot: + options.append( + PermissionOption( + option_id="fixture-once", name="Once", kind="allow_once" + ) + ) + request = ACPPermissionRequest( + room_id="room", + session_id="session", + tool_call=ACPToolCall( + tool_call_id="call", + name=OMP_APPROVAL_FORM_TOOL_NAME, + arguments={"message": approval_message(BandTool.SEND_MESSAGE)}, + ), + options=tuple(options), + ) + assert await deny_probe_allow_completion(request) == ( + "fixture-once" if one_shot else None ) - request = ACPPermissionRequest( - room_id="room", - session_id="session", - tool_call=ACPToolCall( - tool_call_id="call", - name=OMP_APPROVAL_FORM_TOOL_NAME, - arguments={"message": approval_message(BandTool.SEND_MESSAGE)}, - ), - options=tuple(options), - ) - assert await deny_probe_allow_completion(request) == ( - "fixture-once" if one_shot else None - ) - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "name", [BandTool.SEND_MESSAGE, "mcp__band_send_message", "bash"] -) -async def test_direct_native_permissions_remain_cancelled( - adapter: OmpACPAdapter, emitter: RoomTurnEmitter, name: str -) -> None: - handler = adapter._make_permission_handler(emitter, "room") - response = await handler( - options=[PermissionOption(option_id="once", name="Once", kind="allow_once")], - session_id="session", - tool_call={ - "toolCallId": "native", - "title": name, - "rawInput": {"message": approval_message(BandTool.SEND_MESSAGE)}, - }, + @pytest.mark.asyncio + @pytest.mark.parametrize( + "name", [BandTool.SEND_MESSAGE, "mcp__band_send_message", "bash"] ) - assert response == {"outcome": {"outcome": "cancelled"}} + async def test_direct_native_permissions_remain_cancelled( + self, adapter: OmpACPAdapter, emitter: RoomTurnEmitter, name: str + ) -> None: + handler = adapter._make_permission_handler(emitter, "room") + response = await handler( + options=[ + PermissionOption(option_id="once", name="Once", kind="allow_once") + ], + session_id="session", + tool_call={ + "toolCallId": "native", + "title": name, + "rawInput": {"message": approval_message(BandTool.SEND_MESSAGE)}, + }, + ) + assert response == {"outcome": {"outcome": "cancelled"}} From f807d627a247d846859f68c06fb0e8063c1a8df4 Mon Sep 17 00:00:00 2001 From: Natan Zalkin Date: Wed, 14 Oct 2026 16:34:56 +0000 Subject: [PATCH 07/13] feat: configure stopped-room polling and separate runtime types (#804) * feat: configure the stopped-room reconciliation interval A room paused by STOP probes /next at the base interval forever, because those probes are the only way to discover a missed PLAY. SessionConfig.stopped_resync_seconds lets operators trade PLAY-discovery latency for request volume on paused rooms. Unset keeps the base interval. * fix: validate session settings and exercise stopped polling through real wiring * refactor: split runtime types and import directly from their owners * fix: complete direct runtime owner imports on current main * refactor: clarify session timing invariants --------- Co-authored-by: Alexander Zaikman --- AGENTS.md | 9 +- README.md | 4 +- band-bridge/bridge_core/bridge.py | 2 +- docs/websocket-events.md | 20 +- examples/anthropic/05_contact_management.py | 2 +- .../05_contact_and_memory_agent.py | 2 +- .../crewai/07_contact_and_memory_agent.py | 2 +- examples/run_agent.py | 2 +- examples/sandbox/staging-smoke/agent.py | 2 +- src/band/agent.py | 11 +- src/band/cli/trigger.py | 2 +- src/band/config/settings.py | 2 +- src/band/core/types.py | 2 +- src/band/platform/link.py | 2 +- src/band/platform/message_lifecycle.py | 2 +- src/band/runtime/__init__.py | 33 +- src/band/runtime/agentconfig.py | 23 ++ src/band/runtime/callbacks.py | 22 ++ src/band/runtime/contact_handler.py | 7 +- src/band/runtime/contacts.py | 101 ++++++ src/band/runtime/context.py | 22 ++ src/band/runtime/execution.py | 24 +- src/band/runtime/handles.py | 10 + src/band/runtime/messages.py | 35 ++ src/band/runtime/oneshot.py | 8 +- src/band/runtime/platform_runtime.py | 12 +- src/band/runtime/resync.py | 23 +- src/band/runtime/runtime.py | 7 +- src/band/runtime/sessionconfig.py | 107 ++++++ src/band/runtime/types.py | 307 ------------------ .../lifecycle/test_runtime_closing.py | 2 +- tests/agent/test_create_factory.py | 3 +- tests/agent/test_startup_race_condition.py | 2 +- tests/agentcontacts/test_contact_broadcast.py | 2 +- .../test_contact_subscription.py | 2 +- .../test_create_contact_config.py | 2 +- .../test_platform_runtime_contact_setup.py | 2 +- tests/agentcontacts/test_startup_order.py | 2 +- tests/bridge/test_bridge.py | 2 +- tests/conftest.py | 2 +- .../test_next_actionable_semantics.py | 2 +- tests/e2e/baseline/toolkit/control.py | 2 +- tests/e2e/baseline/toolkit/provisioning.py | 2 +- tests/integration/test_agent_contacts.py | 2 +- tests/integration/test_callback_strategy.py | 2 +- tests/integration/test_hub_room_strategy.py | 2 +- .../acp/test_cursor_message_delivery.py | 2 +- tests/markdown_docs/globals.py | 2 +- tests/platformruntime/test_initialization.py | 3 +- .../test_interrupted_cleanup.py | 2 +- tests/platformruntime/test_single_instance.py | 2 +- tests/platformruntime/test_start.py | 3 +- tests/preprocessing/test_commitment.py | 2 +- tests/preprocessing/test_controls.py | 2 +- tests/preprocessing/test_default.py | 2 +- tests/runtime/conftest.py | 2 +- tests/runtime/executioninterrupt/kit.py | 3 +- .../test_backlog_interrupt.py | 2 +- .../executioninterrupt/test_cycle_watchdog.py | 2 +- .../test_late_rest_refusals.py | 2 +- tests/runtime/helpers.py | 2 +- tests/runtime/resync/conftest.py | 12 +- tests/runtime/resync/kit.py | 27 +- tests/runtime/resync/test_idle_timeout.py | 2 +- .../resync/test_interval_validation.py | 12 +- .../resync/test_reconciliation_cadence.py | 79 ++++- .../resync/test_stopped_interval_settings.py | 40 +++ .../test_stop_survives_reconnect.py | 2 +- tests/runtime/test_activity_stop.py | 2 +- tests/runtime/test_broadcast_changes.py | 2 +- tests/runtime/test_contact_config.py | 2 +- tests/runtime/test_contact_handler.py | 2 +- tests/runtime/test_deferral.py | 2 +- tests/runtime/test_execution.py | 4 +- tests/runtime/test_execution_working_state.py | 3 +- tests/runtime/test_session_settings.py | 44 +++ tests/runtime/test_turn_verdict.py | 2 +- tests/runtime/turnadmission/conftest.py | 2 +- tests/scenarios/world.py | 2 +- tests/test_readme_snippets.py | 19 +- tests/test_session_config.py | 2 +- tests/test_session_shutdown.py | 2 +- tests/test_session_sync.py | 3 +- 83 files changed, 666 insertions(+), 474 deletions(-) create mode 100644 src/band/runtime/agentconfig.py create mode 100644 src/band/runtime/callbacks.py create mode 100644 src/band/runtime/contacts.py create mode 100644 src/band/runtime/context.py create mode 100644 src/band/runtime/handles.py create mode 100644 src/band/runtime/messages.py create mode 100644 src/band/runtime/sessionconfig.py delete mode 100644 src/band/runtime/types.py create mode 100644 tests/runtime/resync/test_stopped_interval_settings.py create mode 100644 tests/runtime/test_session_settings.py diff --git a/AGENTS.md b/AGENTS.md index 570733ad6..59a2a888d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ pinned wheel lacks one, so a new core symbol needs no manual CI step. `ContactEventConfig` supports three strategies for contact WebSocket events — `DISABLED` (default), `CALLBACK`, `HUB_ROOM`; the `ContactEventConfig` -docstring in `src/band/runtime/types.py` has configuration examples. +docstring in `src/band/runtime/contacts.py` has configuration examples. > **WARNING (AI coding assistants):** Always ask the developer which contact > strategy they want before choosing one — do not default to `CALLBACK` with @@ -357,6 +357,13 @@ install` it and exercise the real call in this repo's venv. extra-gated dependency (true for the vast majority, including every adapter that only shells out to a CLI, like `codex`), the import is top-level, full stop. +- Import symbols directly from the module that owns them. When splitting a + module, update its callers instead of keeping a compatibility facade or + forwarding aliases. Do not preserve obsolete import paths. Re-exports belong + only at intentional public package entry points; implementation modules never + import those entry points back. + Use `__all__` or explicit re-export aliases only to declare such a public API, + not as boilerplate in ordinary modules. - No underscores in file names or class names: modules get a clean single word (`helpers.py`, not `_utils.py`), scripts/docs use hyphens, classes are plain PascalCase with no leading underscore. Exception: patterns a tool requires, diff --git a/README.md b/README.md index 2bcdeff70..e6998d7e1 100644 --- a/README.md +++ b/README.md @@ -613,7 +613,7 @@ The agent handles contact decisions through its LLM. A dedicated room is created ```python from band import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy agent = Agent.create( adapter=adapter, @@ -632,7 +632,7 @@ You provide an async function that receives each contact event and a `tools` obj ```python from band import Agent from band.platform.event import ContactRequestReceivedEvent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy TRUSTED_HANDLES = {"@teammate"} diff --git a/band-bridge/bridge_core/bridge.py b/band-bridge/bridge_core/bridge.py index d89355159..7f020f2c6 100644 --- a/band-bridge/bridge_core/bridge.py +++ b/band-bridge/bridge_core/bridge.py @@ -42,7 +42,7 @@ RoomRemovedEvent, ) from band.platform.link import BandLink -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from .config import AgentConfig, BridgeConfig, ReconnectConfig from .control import ControlSignalHandler diff --git a/docs/websocket-events.md b/docs/websocket-events.md index 54d081b19..f233da3fb 100644 --- a/docs/websocket-events.md +++ b/docs/websocket-events.md @@ -32,9 +32,16 @@ Both values must be finite and positive; the effective cap is `max(base, maximum so a maximum at or below the base disables growth. A received STOP suppresses local message triggers while `/next` probes continue -at the base interval. A current nonempty response permits discovery of a missed -PLAY, followed by normal acknowledgement recovery and an accepted claim. Newer -STOP, PLAY or INTERRUPT controls invalidate obsolete recovery results. A 204 +at a fixed interval: `SessionConfig.stopped_resync_seconds`, or the base interval +when unset. Stopped probes never grow, and a resumed room returns to the base +interval. A longer stopped interval lowers request volume for paused rooms but +delays discovering a missed PLAY by up to that long. `STOPPED_RESYNC_SECONDS` +provides an environment default through Pydantic settings; an explicit +`SessionConfig(stopped_resync_seconds=...)` takes precedence, including `None` +to use the base interval. Set values must be finite and positive; an empty +environment value is ignored. A current nonempty response permits discovery of +a missed PLAY, followed by normal acknowledgement recovery and an accepted claim. +Newer STOP, PLAY or INTERRUPT controls invalidate obsolete recovery results. A 204 response preserves the local pause because it cannot distinguish stopped from empty rooms. @@ -56,6 +63,13 @@ without spinning; retryable handler failures retain their normal attempt budget. These intervals bound scheduling, subject to active handlers, HTTP operations and recovery retries. +`SessionConfig` uses Pydantic settings for all fields. Environment names match +the field names without regard to case, explicit keyword arguments override +the environment, and empty environment values use defaults. For example, +`IDLE_RESYNC_SECONDS=30` sets the base interval. Construct it with keyword +arguments and serialize it with `model_dump()`; dataclass utilities and +positional construction are no longer supported. + ## Pending work and control recovery A provider attempt that raises `InputNotAccepted` leaves its delivery diff --git a/examples/anthropic/05_contact_management.py b/examples/anthropic/05_contact_management.py index bfe590259..6032d2b04 100644 --- a/examples/anthropic/05_contact_management.py +++ b/examples/anthropic/05_contact_management.py @@ -25,7 +25,7 @@ from band.core.types import Capability from band.platform.event import ContactEvent, ContactRequestReceivedEvent from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO, extra_loggers={"band_anthropic_agent": logging.INFO}) logger = logging.getLogger(__name__) diff --git a/examples/copilot_sdk/05_contact_and_memory_agent.py b/examples/copilot_sdk/05_contact_and_memory_agent.py index f09f9c425..5944cf0b6 100755 --- a/examples/copilot_sdk/05_contact_and_memory_agent.py +++ b/examples/copilot_sdk/05_contact_and_memory_agent.py @@ -40,7 +40,7 @@ from band import Agent, configure_logging from band.adapters import CopilotSDKAdapter, CopilotSDKAdapterConfig from band.core.types import Capability, Emit -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO) logger = logging.getLogger(__name__) diff --git a/examples/crewai/07_contact_and_memory_agent.py b/examples/crewai/07_contact_and_memory_agent.py index 162976383..055b0d652 100644 --- a/examples/crewai/07_contact_and_memory_agent.py +++ b/examples/crewai/07_contact_and_memory_agent.py @@ -30,7 +30,7 @@ from band import Agent, configure_logging from band.adapters import CrewAIAdapter, CrewAIAdapterConfig from band.core.types import Capability -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy configure_logging(logging.INFO, extra_loggers={"band_crewai_agent": logging.INFO}) logger = logging.getLogger(__name__) diff --git a/examples/run_agent.py b/examples/run_agent.py index c48404ebd..8c3abffa7 100755 --- a/examples/run_agent.py +++ b/examples/run_agent.py @@ -67,7 +67,7 @@ from band.core.types import Capability, Emit from band.platform.event import ContactEvent, ContactRequestReceivedEvent from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy # Load environment from .env load_dotenv() diff --git a/examples/sandbox/staging-smoke/agent.py b/examples/sandbox/staging-smoke/agent.py index 449d0a3f4..3b1f83191 100644 --- a/examples/sandbox/staging-smoke/agent.py +++ b/examples/sandbox/staging-smoke/agent.py @@ -56,7 +56,7 @@ from band import Agent from band.adapters import LangGraphAdapter -from band.runtime.types import normalize_handle +from band.runtime.handles import normalize_handle logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s" diff --git a/src/band/agent.py b/src/band/agent.py index 5c38f7e52..f7d0cf29b 100644 --- a/src/band/agent.py +++ b/src/band/agent.py @@ -16,15 +16,12 @@ from band.core.protocols import FrameworkAdapter, Preprocessor from band.core.simple_adapter import SimpleAdapter from band.preprocessing.default import DefaultPreprocessor +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback +from band.runtime.contacts import ContactEventConfig from band.runtime.platform_runtime import PlatformRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.startup import dispose_adapter, release_adapter, start_adapter -from band.runtime.types import ( - AgentConfig, - ContactEventConfig, - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) if TYPE_CHECKING: from band.core.types import AgentInput diff --git a/src/band/cli/trigger.py b/src/band/cli/trigger.py index fbffbdee2..9f46856a2 100644 --- a/src/band/cli/trigger.py +++ b/src/band/cli/trigger.py @@ -53,7 +53,7 @@ from band import LogSettings, LogStream from band.client.rest import aclose_rest_client -from band.runtime.types import normalize_handle +from band.runtime.handles import normalize_handle logger = logging.getLogger(__name__) diff --git a/src/band/config/settings.py b/src/band/config/settings.py index fb9311d44..27725e47e 100644 --- a/src/band/config/settings.py +++ b/src/band/config/settings.py @@ -29,7 +29,7 @@ class PlatformSettings(BaseSettings): class RuntimeSettings(BaseSettings): """Process-wide runtime tuning knobs, overridable via environment - variables. Distinct from ``SessionConfig`` (``band.runtime.types``), + variables. Distinct from ``SessionConfig`` (``band.runtime.sessionconfig``), which holds per-agent-instance tunables. """ diff --git a/src/band/core/types.py b/src/band/core/types.py index c11170939..976fa467f 100644 --- a/src/band/core/types.py +++ b/src/band/core/types.py @@ -137,7 +137,7 @@ class Capability(_FlagEnum): These control tool-schema inclusion only -- they do NOT affect runtime event routing (WebSocket subscriptions, contact-event strategies, hub-room creation). Those remain under - ContactEventConfig / ContactEventStrategy in runtime/types.py. + ContactEventConfig / ContactEventStrategy in runtime/contacts.py. """ MEMORY = "memory" diff --git a/src/band/platform/link.py b/src/band/platform/link.py index cbb96345f..524c3cd07 100644 --- a/src/band/platform/link.py +++ b/src/band/platform/link.py @@ -40,7 +40,7 @@ ) from band.platform.message_lifecycle import ActionablePage, MessageLifecycle from band.platform.subscriptions import SubscriptionManager -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage if TYPE_CHECKING: from band.client.streaming import ( diff --git a/src/band/platform/message_lifecycle.py b/src/band/platform/message_lifecycle.py index afe2aa493..2c6d1c8df 100644 --- a/src/band/platform/message_lifecycle.py +++ b/src/band/platform/message_lifecycle.py @@ -20,7 +20,7 @@ from band.client.rest import DEFAULT_REQUEST_OPTIONS, AsyncRestClient from band.core.exceptions import RoomExecutionStoppedError from band.core.types import metadata_to_dict -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage logger = logging.getLogger(__name__) diff --git a/src/band/runtime/__init__.py b/src/band/runtime/__init__.py index b857e0d70..1f16c7f5d 100644 --- a/src/band/runtime/__init__.py +++ b/src/band/runtime/__init__.py @@ -18,26 +18,34 @@ run_with_graceful_shutdown: Convenience function to run agent with signal handling """ +from __future__ import annotations + +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import MessageHandler +from band.runtime.context import ConversationContext + # Types -from .execution import Execution, ExecutionContext, ExecutionHandler +from band.runtime.execution import Execution, ExecutionContext, ExecutionHandler # Utilities -from .formatters import ( +from band.runtime.formatters import ( build_participants_message, format_history_for_llm, format_message_for_llm, ) -from .oneshot import OneShotEnvelopeError, OneShotInvoker, OneShotStatus +from band.runtime.messages import PlatformMessage +from band.runtime.oneshot import OneShotEnvelopeError, OneShotInvoker, OneShotStatus # Core runtime components -from .presence import RoomPresence -from .prompts import BASE_INSTRUCTIONS, TEMPLATES, render_system_prompt -from .retry_tracker import MessageRetryTracker -from .runtime import AgentRuntime -from .shutdown import GracefulShutdown, run_with_graceful_shutdown +from band.runtime.presence import RoomPresence +from band.runtime.prompts import BASE_INSTRUCTIONS, TEMPLATES, render_system_prompt +from band.runtime.retry_tracker import MessageRetryTracker +from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig +from band.runtime.shutdown import GracefulShutdown, run_with_graceful_shutdown # Tools -from .tools import ( +from band.runtime.tools import ( ALL_TOOL_NAMES, BASE_TOOL_NAMES, CHAT_TOOL_NAMES, @@ -50,13 +58,6 @@ HumanTools, mcp_tool_names, ) -from .types import ( - AgentConfig, - ConversationContext, - MessageHandler, - PlatformMessage, - SessionConfig, -) __all__ = [ "ALL_TOOL_NAMES", diff --git a/src/band/runtime/agentconfig.py b/src/band/runtime/agentconfig.py new file mode 100644 index 000000000..6fc9450e0 --- /dev/null +++ b/src/band/runtime/agentconfig.py @@ -0,0 +1,23 @@ +"""Agent connection and room subscription policy.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from band.core.types import ConflictPolicy + + +@dataclass +class AgentConfig: + """Configuration for agent runtime.""" + + auto_subscribe_existing_rooms: bool = True + # Refuse to start when another process on this host already runs the + # same agent id: duplicates steal each other's in-flight room messages + # (the recovery sweep has no liveness check) and stateful adapters + # resume the same on-disk sessions, splitting one conversation. + single_instance: bool = True + # Platform-side duplicate guard for the initial WebSocket connect (cross-host, + # cross-TMPDIR); complements ``single_instance`` on this host. See + # ``ConflictPolicy`` for semantics and limits. + conflict_policy: ConflictPolicy = ConflictPolicy.SUPERSEDE diff --git a/src/band/runtime/callbacks.py b/src/band/runtime/callbacks.py new file mode 100644 index 000000000..e6a0e0f06 --- /dev/null +++ b/src/band/runtime/callbacks.py @@ -0,0 +1,22 @@ +"""Callback contracts for runtime messages and events.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from band.platform.event import ( + ContactEvent, + ParticipantAddedEvent, + ParticipantRemovedEvent, + ) + from band.runtime.contact_tools import ContactTools + from band.runtime.messages import PlatformMessage + from band.runtime.tools.agent import AgentTools + + +MessageHandler = Callable[["PlatformMessage", "AgentTools"], Awaitable[None]] +ContactEventCallback = Callable[["ContactEvent", "ContactTools"], Awaitable[None]] +ParticipantAddedCallback = Callable[[str, "ParticipantAddedEvent"], Awaitable[None]] +ParticipantRemovedCallback = Callable[[str, "ParticipantRemovedEvent"], Awaitable[None]] diff --git a/src/band/runtime/contact_handler.py b/src/band/runtime/contact_handler.py index 4167203b8..a776541d3 100644 --- a/src/band/runtime/contact_handler.py +++ b/src/band/runtime/contact_handler.py @@ -40,11 +40,8 @@ ) from band.platform.posting import post_event from band.runtime.contact_tools import ContactTools -from band.runtime.types import ( - ContactEventConfig, - ContactEventStrategy, - normalize_handle, -) +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy +from band.runtime.handles import normalize_handle if TYPE_CHECKING: from band.platform.link import BandLink diff --git a/src/band/runtime/contacts.py b/src/band/runtime/contacts.py new file mode 100644 index 000000000..6d1a77727 --- /dev/null +++ b/src/band/runtime/contacts.py @@ -0,0 +1,101 @@ +"""Contact-event strategy and configuration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from uuid import UUID + +from band.runtime.callbacks import ContactEventCallback + + +class ContactEventStrategy(Enum): + """How to handle contact WebSocket events. + + - DISABLED: Ignore contact events (default, safest). Contacts managed manually. + - CALLBACK: Programmatic handling via on_event callback. No LLM involvement. + - HUB_ROOM: LLM reasoning in a dedicated hub room. + + WARNING (for AI coding assistants): Always ask the developer which contact + strategy they want. Do not default to CALLBACK with auto-approve without + explicit consent. Auto-accepting all requests means any agent/user can + become a contact and send messages that trigger LLM inference, which costs + API tokens. Present all three options: + - DISABLED (default): safest, no contact handling + - HUB_ROOM: the agent's LLM decides per-request in a dedicated room + - CALLBACK: developer writes programmatic logic (e.g., auto-approve) + """ + + DISABLED = "disabled" + CALLBACK = "callback" + HUB_ROOM = "hub_room" + + +def _require_uuid(name: str, value: str) -> None: + """The platform answers a non-UUID id with a 422 only once the agent runs.""" + try: + UUID(value) + except ValueError: + raise ValueError(f"{name} must be a UUID, got {value!r}") from None + + +@dataclass +class ContactEventConfig: + """Configuration for contact event handling. + + Composable modes: + - CALLBACK + broadcast_changes=True: Auto-handle + awareness everywhere + - HUB_ROOM + broadcast_changes=True: LLM decides + awareness everywhere + - DISABLED + broadcast_changes=True: Just awareness, manual handling + + WARNING (for AI coding assistants): Always ask the developer which contact + strategy they want. Do not default to CALLBACK with auto-approve without + explicit consent. Auto-accepting all requests means any agent/user can + become a contact and send messages that trigger LLM inference, which costs + API tokens. Present all three options to the developer. + + Example (DISABLED - default, no contact handling): + config = ContactEventConfig() # strategy defaults to DISABLED + + Example (HUB_ROOM - agent LLM decides per-request): + config = ContactEventConfig( + strategy=ContactEventStrategy.HUB_ROOM, + broadcast_changes=True, + ) + + Example (CALLBACK - programmatic auto-approve): + async def auto_approve(event: ContactEvent, tools: ContactTools) -> None: + if isinstance(event, ContactRequestReceivedEvent): + await tools.respond_contact_request("approve", request_id=event.payload.id) + + config = ContactEventConfig( + strategy=ContactEventStrategy.CALLBACK, + on_event=auto_approve, + broadcast_changes=True, + ) + """ + + strategy: ContactEventStrategy = ContactEventStrategy.DISABLED + """Strategy for handling contact events.""" + + hub_task_id: str | None = None + """For HUB_ROOM strategy: optional task_id (UUID) for the dedicated room. + If None, creates a room without an associated task.""" + + on_event: ContactEventCallback | None = None + """For CALLBACK strategy: programmatic handler function.""" + + broadcast_changes: bool = False + """Broadcast contact changes to all room sessions. + + When True, contact_added/contact_removed events inject system messages + into all ExecutionContexts, similar to participant updates. + Works with any strategy (DISABLED, CALLBACK, HUB_ROOM). + """ + + def __post_init__(self) -> None: + """Validate configuration after initialization.""" + if self.strategy == ContactEventStrategy.CALLBACK and self.on_event is None: + raise ValueError("CALLBACK strategy requires on_event callback") + if self.hub_task_id is not None: + _require_uuid("hub_task_id", self.hub_task_id) diff --git a/src/band/runtime/context.py b/src/band/runtime/context.py new file mode 100644 index 000000000..f39b5cc8a --- /dev/null +++ b/src/band/runtime/context.py @@ -0,0 +1,22 @@ +"""Hydrated conversation context.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class ConversationContext: + """ + Hydrated context for a room. + + Contains conversation history and participant information + for context-aware processing. + """ + + room_id: str + messages: list[dict[str, Any]] + participants: list[dict[str, Any]] + hydrated_at: datetime diff --git a/src/band/runtime/execution.py b/src/band/runtime/execution.py index ab9072e59..6948d1133 100644 --- a/src/band/runtime/execution.py +++ b/src/band/runtime/execution.py @@ -74,19 +74,16 @@ PlatformEvent, ReconnectedEvent, ) +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback +from band.runtime.context import ConversationContext from band.runtime.context_serialization import context_item_to_dict from band.runtime.cycle import TurnScope from band.runtime.formatters import build_participants_message, format_history_for_llm +from band.runtime.messages import PlatformMessage from band.runtime.participants import log_roster_call, log_roster_error from band.runtime.resync import ResyncSchedule +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import ( - ConversationContext, - ParticipantAddedCallback, - ParticipantRemovedCallback, - PlatformMessage, - SessionConfig, -) from band.runtime.working_state import WorkingStateReporter if TYPE_CHECKING: @@ -407,7 +404,9 @@ def __init__( self._pending_system_messages: list[str] = [] self._pending_resync: ResyncRequest | None = None self._resync_schedule = ResyncSchedule( - self.config.idle_resync_seconds, self.config.idle_resync_max_seconds + self.config.idle_resync_seconds, + self.config.idle_resync_max_seconds, + stopped=self.config.stopped_resync_seconds, ) self._resync_handled_work = False self._queue_wait: asyncio.Timeout | None = None @@ -423,7 +422,7 @@ def __init__( # Cleared in the per-message ``finally``. self._turn_failure_reported: bool = False - # Received STOP suppresses local triggers. Base-interval probes discover + # Received STOP suppresses local triggers. Periodic probes discover # a missed PLAY; platform claims remain authoritative. self._stopped: bool = False # The first stop's graceful result; a retry only settles retained work. @@ -761,7 +760,9 @@ def _resume_room(self) -> None: def _reset_resync_interval(self) -> None: if self._resync_schedule.due_at is None: return - self._resync_schedule.reset(asyncio.get_running_loop().time()) + self._resync_schedule.reset( + asyncio.get_running_loop().time(), stopped=self._stopped + ) if self._queue_wait is not None and not self._queue_wait.expired(): self._queue_wait.reschedule(self._resync_schedule.due_at) @@ -1418,7 +1419,8 @@ async def _reconcile(self) -> None: await self._scan_controls() self._resync_schedule.complete( asyncio.get_running_loop().time(), - empty=outcome is ResyncOutcome.EMPTY and not self._stopped, + empty=outcome is ResyncOutcome.EMPTY, + stopped=self._stopped, ) async def _wait_until_resync_complete(self) -> ResyncOutcome: diff --git a/src/band/runtime/handles.py b/src/band/runtime/handles.py new file mode 100644 index 000000000..c390fca9c --- /dev/null +++ b/src/band/runtime/handles.py @@ -0,0 +1,10 @@ +"""Canonical formatting for platform handles.""" + +from __future__ import annotations + + +def normalize_handle(handle: str | None) -> str | None: + """Return a nonempty handle with its @ prefix, or None.""" + if not handle: + return None + return handle if handle.startswith("@") else f"@{handle}" diff --git a/src/band/runtime/messages.py b/src/band/runtime/messages.py new file mode 100644 index 000000000..00b085a43 --- /dev/null +++ b/src/band/runtime/messages.py @@ -0,0 +1,35 @@ +"""Runtime projection of a platform message.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any + + +@dataclass +class PlatformMessage: + """ + Message from platform (normalized for adapters). + + This is the message format passed to MessageHandlers. + """ + + id: str + room_id: str + content: str + sender_id: str + sender_type: str # "User", "Agent", "System" + sender_name: str | None + message_type: str + metadata: dict[str, Any] + created_at: datetime + + def format_for_llm(self) -> str: + """ + Format message with sender prefix for LLM consumption. + + Returns string in format: [SENDER_NAME]: message content + """ + sender = self.sender_name or self.sender_type + return f"[{sender}]: {self.content}" diff --git a/src/band/runtime/oneshot.py b/src/band/runtime/oneshot.py index d43367eb1..bda78fed3 100644 --- a/src/band/runtime/oneshot.py +++ b/src/band/runtime/oneshot.py @@ -81,13 +81,13 @@ format_history_for_llm, replace_uuid_mentions, ) -from band.runtime.participants import participant_snapshot -from band.runtime.startup import dispose_adapter, release_adapter, start_adapter -from band.runtime.tools import AgentTools # BandLink.get_next_message returns this dataclass, not band.core.types' # same-named one that _build_platform_message below constructs. -from band.runtime.types import PlatformMessage as RestPlatformMessage +from band.runtime.messages import PlatformMessage as RestPlatformMessage +from band.runtime.participants import participant_snapshot +from band.runtime.startup import dispose_adapter, release_adapter, start_adapter +from band.runtime.tools import AgentTools logger = logging.getLogger(__name__) diff --git a/src/band/runtime/platform_runtime.py b/src/band/runtime/platform_runtime.py index 945101a1f..c5011411c 100644 --- a/src/band/runtime/platform_runtime.py +++ b/src/band/runtime/platform_runtime.py @@ -15,18 +15,14 @@ from band.core.types import PlatformConnection from band.platform.event import ContactEvent, MessageEvent, PlatformEvent from band.platform.link import BandLink +from band.runtime.agentconfig import AgentConfig +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback from band.runtime.contact_handler import ContactEventHandler +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.execution import ExecutionContext from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.single_instance import SingleInstanceGuard -from band.runtime.types import ( - AgentConfig, - ContactEventConfig, - ContactEventStrategy, - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) logger = logging.getLogger(__name__) diff --git a/src/band/runtime/resync.py b/src/band/runtime/resync.py index 4a912e2d6..7aec3bbfd 100644 --- a/src/band/runtime/resync.py +++ b/src/band/runtime/resync.py @@ -8,9 +8,12 @@ class ResyncSchedule: """Keep periodic recovery independent of queue activity.""" - def __init__(self, base: float, maximum: float) -> None: + def __init__( + self, base: float, maximum: float, *, stopped: float | None = None + ) -> None: self.base = base self.maximum = max(base, maximum) + self.stopped = base if stopped is None else stopped self.interval = base self.due_at: float | None = None @@ -18,12 +21,18 @@ def start(self, now: float) -> None: self.interval = self.base self.due_at = now + random.uniform(0, self.base) - def reset(self, now: float) -> None: - self.interval = self.base + def reset(self, now: float, *, stopped: bool) -> None: + self.interval = self.stopped if stopped else self.base if self.due_at is not None: - self.due_at = min(self.due_at, now + self.base) - - def complete(self, now: float, *, empty: bool) -> None: - self.interval = min(self.interval * 2, self.maximum) if empty else self.base + self.due_at = min(self.due_at, now + self.interval) + + def complete(self, now: float, *, empty: bool, stopped: bool) -> None: + if stopped: + # Stopped probes never grow: they are how a missed PLAY is found. + self.interval = self.stopped + elif empty: + self.interval = min(self.interval * 2, self.maximum) + else: + self.interval = self.base if self.due_at is not None and self.due_at <= now: self.due_at = now + self.interval diff --git a/src/band/runtime/runtime.py b/src/band/runtime/runtime.py index a5097cb6d..35cbdad7c 100644 --- a/src/band/runtime/runtime.py +++ b/src/band/runtime/runtime.py @@ -15,16 +15,13 @@ from band.client.streaming import ControlMode from band.platform.event import PlatformEvent +from band.runtime.callbacks import ParticipantAddedCallback, ParticipantRemovedCallback from band.runtime.control import ControlDeduplicator, apply_control, control_targets from band.runtime.cycle import TurnScope from band.runtime.execution import Execution, ExecutionContext, ExecutionHandler from band.runtime.presence import RoomPresence from band.runtime.rooms import RoomExecutions, request_resync -from band.runtime.types import ( - ParticipantAddedCallback, - ParticipantRemovedCallback, - SessionConfig, -) +from band.runtime.sessionconfig import SessionConfig if TYPE_CHECKING: from band.client.streaming import AgentControlPayload diff --git a/src/band/runtime/sessionconfig.py b/src/band/runtime/sessionconfig.py new file mode 100644 index 000000000..9c9b73171 --- /dev/null +++ b/src/band/runtime/sessionconfig.py @@ -0,0 +1,107 @@ +"""Per-session execution settings and timing constraints.""" + +from __future__ import annotations + +from typing import Annotated, Self + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +# The platform clears working state after this TTL if refreshes stop. +# Keep-alive validation leaves headroom for a missed or slow refresh. +PLATFORM_WORKING_STATE_TTL_SECONDS: float = 10.0 + + +def _require_positive_when_set(name: str, value: float | None) -> None: + """Shared guard for the optional-timeout fields below: unset (None) means + unbounded and is always valid; a set value must be strictly positive.""" + if value is not None and value <= 0: + raise ValueError(f"{name} must be > 0 when set (got {value})") + + +PositiveFiniteSeconds = Annotated[float, Field(gt=0, allow_inf_nan=False)] + + +class SessionConfig(BaseSettings): + """Execution settings; explicit values override case-insensitive environment values.""" + + model_config = SettingsConfigDict( + extra="ignore", + case_sensitive=False, + env_ignore_empty=True, + ) + + enable_context_cache: bool = True + context_cache_ttl_seconds: int = 300 + max_context_messages: int = 100 + # Max attempts per message before permanently failing. band_sdk_core.RetryTracker's + # constructor validates this range itself -- no second Python-side check. + max_message_retries: int = 1 + enable_context_hydration: bool = True # Whether to fetch history from platform API + # Base reconciliation interval, also used for retries and, unless + # stopped_resync_seconds is set, for stopped-room probes. + # Queue traffic cannot postpone a check; active handlers remain serial. + idle_resync_seconds: PositiveFiniteSeconds = 60.0 + + # --- Working-state (boolean "is the agent reasoning") reporting --- + # Kill-switch: disable all working-state reporting instantly if needed. + enable_working_state: bool = True + # Keep-alive cadence: how often working:true is refreshed while a cycle runs. + # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. + working_keep_alive_seconds: float = 3.0 + # Per-POST deadline for each activity report (must be < cadence so a slow + # POST can't pile onto the next keep-alive tick). + working_request_timeout_seconds: int = 2 + # Optional upper bound on how long we keep asserting working:true for one + # cycle. When exceeded we stop refreshing (platform TTL clears it); we never + # cancel the reasoning. None = unbounded. NOT a hang-killer. + max_working_state_seconds: float | None = None + + # Bound the handler so a stalled adapter cannot leave input processing forever. + # Expiry cancels reasoning and raises TimeoutError through normal failure/retry + # handling. Hydration and claim/ack calls are outside this bound; None is unbounded. + max_cycle_seconds: float | None = None + + # Post an `error` event when a message fails its final attempt and the + # adapter didn't report it; otherwise the room can't tell it from a message + # never received. + report_turn_failures_to_room: bool = True + + # Empty checks double the interval up to max(base, maximum). Only the first + # periodic check is spread within the base interval. Equal caps disable growth. + idle_resync_max_seconds: PositiveFiniteSeconds = 120.0 + + # Fixed interval for /next probes while a received STOP pauses the room. + # These probes are the only way to discover a missed PLAY, so a longer + # interval delays resuming by up to that long. None uses idle_resync_seconds. + stopped_resync_seconds: PositiveFiniteSeconds | None = None + + @model_validator(mode="after") + def validate_timing(self) -> Self: + # Working-state invariants only matter when reporting is enabled. + if self.enable_working_state: + ttl_half = PLATFORM_WORKING_STATE_TTL_SECONDS / 2 + if self.working_keep_alive_seconds <= 0: + raise ValueError( + f"working_keep_alive_seconds must be > 0 (got {self.working_keep_alive_seconds})" + ) + if self.working_keep_alive_seconds >= ttl_half: + raise ValueError( + f"working_keep_alive_seconds must be < TTL/2 ({ttl_half}) to keep TTL " + f"headroom (got {self.working_keep_alive_seconds})" + ) + if self.working_request_timeout_seconds <= 0: + raise ValueError( + f"working_request_timeout_seconds must be > 0 (got {self.working_request_timeout_seconds})" + ) + if self.working_request_timeout_seconds >= self.working_keep_alive_seconds: + raise ValueError( + f"working_request_timeout_seconds ({self.working_request_timeout_seconds}) must be < " + f"working_keep_alive_seconds ({self.working_keep_alive_seconds}) so a slow POST can't stack" + ) + _require_positive_when_set( + "max_working_state_seconds", self.max_working_state_seconds + ) + + _require_positive_when_set("max_cycle_seconds", self.max_cycle_seconds) + return self diff --git a/src/band/runtime/types.py b/src/band/runtime/types.py deleted file mode 100644 index 6e39dfb6e..000000000 --- a/src/band/runtime/types.py +++ /dev/null @@ -1,307 +0,0 @@ -""" -Runtime types for Band agent SDK. - -Extracted from core/types.py - data structures used across the runtime layer. -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from math import isfinite -from typing import TYPE_CHECKING, Any -from uuid import UUID - -from band.core.types import ConflictPolicy - - -def normalize_handle(handle: str | None) -> str | None: - """ - Normalize a handle to always include the @ prefix. - - Handles may or may not include the @ prefix depending on the source. - This function ensures consistent formatting. - - Args: - handle: The handle to normalize (may or may not have @ prefix) - - Returns: - Handle with @ prefix, or None if input is None/empty - """ - if not handle: - return None - return handle if handle.startswith("@") else f"@{handle}" - - -if TYPE_CHECKING: - from band.platform.event import ( - ContactEvent, - ParticipantAddedEvent, - ParticipantRemovedEvent, - ) - - from .contact_tools import ContactTools - from .tools import AgentTools - - -@dataclass -class AgentConfig: - """Configuration for agent runtime.""" - - auto_subscribe_existing_rooms: bool = True - # Refuse to start when another process on this host already runs the - # same agent id: duplicates steal each other's in-flight room messages - # (the recovery sweep has no liveness check) and stateful adapters - # resume the same on-disk sessions, splitting one conversation. - single_instance: bool = True - # Platform-side duplicate guard for the initial WebSocket connect (cross-host, - # cross-TMPDIR); complements ``single_instance`` on this host. See - # ``ConflictPolicy`` for semantics and limits. - conflict_policy: ConflictPolicy = ConflictPolicy.SUPERSEDE - - -# Platform-side TTL (seconds) for the boolean working-state indicator. The -# adapter must refresh well within this window; if refreshes stop (crash, hang, -# disconnect) the platform clears the indicator. Mirrors the Ticket B contract -# (TTL <= 10s). Used only to derive the keep-alive cadence guard below. -PLATFORM_WORKING_STATE_TTL_SECONDS: float = 10.0 - - -def _require_positive_when_set(name: str, value: float | None) -> None: - """Shared guard for the optional-timeout fields below: unset (None) means - unbounded and is always valid; a set value must be strictly positive.""" - if value is not None and value <= 0: - raise ValueError(f"{name} must be > 0 when set (got {value})") - - -@dataclass -class SessionConfig: - """Configuration for execution context.""" - - enable_context_cache: bool = True - context_cache_ttl_seconds: int = 300 - max_context_messages: int = 100 - # Max attempts per message before permanently failing. band_sdk_core.RetryTracker's - # constructor validates this range itself -- no second Python-side check. - max_message_retries: int = 1 - enable_context_hydration: bool = True # Whether to fetch history from platform API - # Base reconciliation interval, also used for stopped probes and retries. - # Queue traffic cannot postpone a check; active handlers remain serial. - idle_resync_seconds: float = 60.0 - - # --- Working-state (boolean "is the agent reasoning") reporting --- - # Kill-switch: disable all working-state reporting instantly if needed. - enable_working_state: bool = True - # Keep-alive cadence: how often working:true is refreshed while a cycle runs. - # Must be < TTL/2 so a single missed/slow ping still lands inside the TTL. - working_keep_alive_seconds: float = 3.0 - # Per-POST deadline for each activity report (must be < cadence so a slow - # POST can't pile onto the next keep-alive tick). - working_request_timeout_seconds: int = 2 - # Optional upper bound on how long we keep asserting working:true for one - # cycle. When exceeded we stop refreshing (platform TTL clears it); we never - # cancel the reasoning. None = unbounded. NOT a hang-killer. - max_working_state_seconds: float | None = None - - # Upper bound on one reasoning cycle (the handler invoked by _run_cycle). - # Unlike max_working_state_seconds, exceeding this DOES cancel the cycle. - # This covers the handler cycle, not context hydration or message claim/ack - # calls. A handler stuck awaiting an external call (e.g. a wedged adapter - # subprocess) would otherwise leave its message in 'processing' forever. - # On expiry the cycle is cancelled and TimeoutError propagates through the - # normal handler-exception path (mark_failed + retry), the same as any - # other handler error. None = unbounded (default — matches prior behavior - # for callers that never opt in). - max_cycle_seconds: float | None = None - - # Post an `error` event when a message fails its final attempt and the - # adapter didn't report it; otherwise the room can't tell it from a message - # never received. - report_turn_failures_to_room: bool = True - - # Empty checks double the interval up to max(base, maximum). Only the first - # periodic check is spread within the base interval. Equal caps disable growth. - idle_resync_max_seconds: float = 120.0 - - def __post_init__(self) -> None: - for name in ("idle_resync_seconds", "idle_resync_max_seconds"): - value = getattr(self, name) - if not isfinite(value) or value <= 0: - raise ValueError(f"{name} must be finite and > 0 (got {value})") - - # Working-state invariants only matter when reporting is enabled. - if self.enable_working_state: - ttl_half = PLATFORM_WORKING_STATE_TTL_SECONDS / 2 - if self.working_keep_alive_seconds <= 0: - raise ValueError( - f"working_keep_alive_seconds must be > 0 (got {self.working_keep_alive_seconds})" - ) - if self.working_keep_alive_seconds >= ttl_half: - raise ValueError( - f"working_keep_alive_seconds must be < TTL/2 ({ttl_half}) to keep TTL " - f"headroom (got {self.working_keep_alive_seconds})" - ) - if self.working_request_timeout_seconds <= 0: - raise ValueError( - f"working_request_timeout_seconds must be > 0 (got {self.working_request_timeout_seconds})" - ) - if self.working_request_timeout_seconds >= self.working_keep_alive_seconds: - raise ValueError( - f"working_request_timeout_seconds ({self.working_request_timeout_seconds}) must be < " - f"working_keep_alive_seconds ({self.working_keep_alive_seconds}) so a slow POST can't stack" - ) - _require_positive_when_set( - "max_working_state_seconds", self.max_working_state_seconds - ) - - _require_positive_when_set("max_cycle_seconds", self.max_cycle_seconds) - - -@dataclass -class PlatformMessage: - """ - Message from platform (normalized for adapters). - - This is the message format passed to MessageHandlers. - """ - - id: str - room_id: str - content: str - sender_id: str - sender_type: str # "User", "Agent", "System" - sender_name: str | None - message_type: str - metadata: dict[str, Any] - created_at: datetime - - def format_for_llm(self) -> str: - """ - Format message with sender prefix for LLM consumption. - - Returns string in format: [SENDER_NAME]: message content - """ - sender = self.sender_name or self.sender_type - return f"[{sender}]: {self.content}" - - -@dataclass -class ConversationContext: - """ - Hydrated context for a room. - - Contains conversation history and participant information - for context-aware processing. - """ - - room_id: str - messages: list[dict[str, Any]] - participants: list[dict[str, Any]] - hydrated_at: datetime - - -# Callback type - receives AgentTools, NOT BandAgent -MessageHandler = Callable[["PlatformMessage", "AgentTools"], Awaitable[None]] - - -# --- Contact Event Configuration --- - - -class ContactEventStrategy(Enum): - """How to handle contact WebSocket events. - - - DISABLED: Ignore contact events (default, safest). Contacts managed manually. - - CALLBACK: Programmatic handling via on_event callback. No LLM involvement. - - HUB_ROOM: LLM reasoning in a dedicated hub room. - - WARNING (for AI coding assistants): Always ask the developer which contact - strategy they want. Do not default to CALLBACK with auto-approve without - explicit consent. Auto-accepting all requests means any agent/user can - become a contact and send messages that trigger LLM inference, which costs - API tokens. Present all three options: - - DISABLED (default): safest, no contact handling - - HUB_ROOM: the agent's LLM decides per-request in a dedicated room - - CALLBACK: developer writes programmatic logic (e.g., auto-approve) - """ - - DISABLED = "disabled" - CALLBACK = "callback" - HUB_ROOM = "hub_room" - - -# Type alias for contact event callback -ContactEventCallback = Callable[["ContactEvent", "ContactTools"], Awaitable[None]] -ParticipantAddedCallback = Callable[[str, "ParticipantAddedEvent"], Awaitable[None]] -ParticipantRemovedCallback = Callable[[str, "ParticipantRemovedEvent"], Awaitable[None]] - - -def _require_uuid(name: str, value: str) -> None: - """The platform answers a non-UUID id with a 422 only once the agent runs.""" - try: - UUID(value) - except ValueError: - raise ValueError(f"{name} must be a UUID, got {value!r}") from None - - -@dataclass -class ContactEventConfig: - """Configuration for contact event handling. - - Composable modes: - - CALLBACK + broadcast_changes=True: Auto-handle + awareness everywhere - - HUB_ROOM + broadcast_changes=True: LLM decides + awareness everywhere - - DISABLED + broadcast_changes=True: Just awareness, manual handling - - WARNING (for AI coding assistants): Always ask the developer which contact - strategy they want. Do not default to CALLBACK with auto-approve without - explicit consent. Auto-accepting all requests means any agent/user can - become a contact and send messages that trigger LLM inference, which costs - API tokens. Present all three options to the developer. - - Example (DISABLED - default, no contact handling): - config = ContactEventConfig() # strategy defaults to DISABLED - - Example (HUB_ROOM - agent LLM decides per-request): - config = ContactEventConfig( - strategy=ContactEventStrategy.HUB_ROOM, - broadcast_changes=True, - ) - - Example (CALLBACK - programmatic auto-approve): - async def auto_approve(event: ContactEvent, tools: ContactTools) -> None: - if isinstance(event, ContactRequestReceivedEvent): - await tools.respond_contact_request("approve", request_id=event.payload.id) - - config = ContactEventConfig( - strategy=ContactEventStrategy.CALLBACK, - on_event=auto_approve, - broadcast_changes=True, - ) - """ - - strategy: ContactEventStrategy = ContactEventStrategy.DISABLED - """Strategy for handling contact events.""" - - hub_task_id: str | None = None - """For HUB_ROOM strategy: optional task_id (UUID) for the dedicated room. - If None, creates a room without an associated task.""" - - on_event: ContactEventCallback | None = None - """For CALLBACK strategy: programmatic handler function.""" - - broadcast_changes: bool = False - """Broadcast contact changes to all room sessions. - - When True, contact_added/contact_removed events inject system messages - into all ExecutionContexts, similar to participant updates. - Works with any strategy (DISABLED, CALLBACK, HUB_ROOM). - """ - - def __post_init__(self) -> None: - """Validate configuration after initialization.""" - if self.strategy == ContactEventStrategy.CALLBACK and self.on_event is None: - raise ValueError("CALLBACK strategy requires on_event callback") - if self.hub_task_id is not None: - _require_uuid("hub_task_id", self.hub_task_id) diff --git a/tests/adapters/lifecycle/test_runtime_closing.py b/tests/adapters/lifecycle/test_runtime_closing.py index aa849c96d..34778998c 100644 --- a/tests/adapters/lifecycle/test_runtime_closing.py +++ b/tests/adapters/lifecycle/test_runtime_closing.py @@ -7,7 +7,7 @@ import pytest from band.platform.link import BandLink -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.adapters.hold import Hold from tests.adapters.lifecycle.kit import RetentionHost from tests.adapters.lifecycle.runtime import ReplyingPlatform diff --git a/tests/agent/test_create_factory.py b/tests/agent/test_create_factory.py index 70a10283e..3fecc9265 100644 --- a/tests/agent/test_create_factory.py +++ b/tests/agent/test_create_factory.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from band.agent import Agent -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.agentconfig import AgentConfig +from band.runtime.sessionconfig import SessionConfig class TestCreateFactory: diff --git a/tests/agent/test_startup_race_condition.py b/tests/agent/test_startup_race_condition.py index b86777db7..36e01ff91 100644 --- a/tests/agent/test_startup_race_condition.py +++ b/tests/agent/test_startup_race_condition.py @@ -14,7 +14,7 @@ ) from band.core.simple_adapter import SimpleAdapter from band.platform.event import MessageEvent -from band.runtime.types import ConversationContext +from band.runtime.context import ConversationContext from band.testing.platform import platform_connection_stub diff --git a/tests/agentcontacts/test_contact_broadcast.py b/tests/agentcontacts/test_contact_broadcast.py index 524b6b03f..a47fd3a02 100644 --- a/tests/agentcontacts/test_contact_broadcast.py +++ b/tests/agentcontacts/test_contact_broadcast.py @@ -3,7 +3,7 @@ from __future__ import annotations from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentContactBroadcast: diff --git a/tests/agentcontacts/test_contact_subscription.py b/tests/agentcontacts/test_contact_subscription.py index d7288cfb3..fd06afb23 100644 --- a/tests/agentcontacts/test_contact_subscription.py +++ b/tests/agentcontacts/test_contact_subscription.py @@ -7,7 +7,7 @@ import pytest from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentContactSubscription: diff --git a/tests/agentcontacts/test_create_contact_config.py b/tests/agentcontacts/test_create_contact_config.py index 9fea68093..e9d453977 100644 --- a/tests/agentcontacts/test_create_contact_config.py +++ b/tests/agentcontacts/test_create_contact_config.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from band.agent import Agent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestAgentCreateContactConfig: diff --git a/tests/agentcontacts/test_platform_runtime_contact_setup.py b/tests/agentcontacts/test_platform_runtime_contact_setup.py index bc3b44594..6bb15f744 100644 --- a/tests/agentcontacts/test_platform_runtime_contact_setup.py +++ b/tests/agentcontacts/test_platform_runtime_contact_setup.py @@ -6,8 +6,8 @@ import pytest +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig, ContactEventStrategy class TestPlatformRuntimeContactSetup: diff --git a/tests/agentcontacts/test_startup_order.py b/tests/agentcontacts/test_startup_order.py index f77e4aa3c..43a3a25cf 100644 --- a/tests/agentcontacts/test_startup_order.py +++ b/tests/agentcontacts/test_startup_order.py @@ -6,8 +6,8 @@ import pytest +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig, ContactEventStrategy class TestStartupOrder: diff --git a/tests/bridge/test_bridge.py b/tests/bridge/test_bridge.py index d9f5a511a..a8fe239e1 100644 --- a/tests/bridge/test_bridge.py +++ b/tests/bridge/test_bridge.py @@ -18,7 +18,7 @@ RoomDeletedEvent, RoomRemovedEvent, ) -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from .conftest import FakeForwarder, make_http_agent, make_link_mock diff --git a/tests/conftest.py b/tests/conftest.py index 43bd04728..90cf0cad0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,8 +63,8 @@ RoomRemovedEvent, ) from band.platform.link import BandLink +from band.runtime.messages import PlatformMessage from band.runtime.single_instance import SingleInstanceGuard -from band.runtime.types import PlatformMessage from tests.paths import ENV_TEST_FILE # Enable the `pytester` fixture (must live in the root conftest) so hook/plugin behaviour diff --git a/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py b/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py index 6b5831885..2ce122900 100644 --- a/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py +++ b/tests/e2e/baseline/smoke/inspection/test_next_actionable_semantics.py @@ -29,7 +29,7 @@ from band.core.exceptions import RoomExecutionStoppedError from band.platform.link import BandLink from band.platform.posting import post_event, post_message -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from tests.e2e.baseline.agents import Lane, lane from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.capture import CaptureFactory diff --git a/tests/e2e/baseline/toolkit/control.py b/tests/e2e/baseline/toolkit/control.py index 9eae9b4ac..70e5c1764 100644 --- a/tests/e2e/baseline/toolkit/control.py +++ b/tests/e2e/baseline/toolkit/control.py @@ -17,7 +17,7 @@ from band.platform.link import BandLink from band.runtime.execution import ExecutionContext from band.runtime.runtime import AgentRuntime -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.logs import sdk_logs_at from tests.e2e.baseline.toolkit.provisioning import ProvisionedAgent diff --git a/tests/e2e/baseline/toolkit/provisioning.py b/tests/e2e/baseline/toolkit/provisioning.py index f5ecc5105..0f6385a65 100644 --- a/tests/e2e/baseline/toolkit/provisioning.py +++ b/tests/e2e/baseline/toolkit/provisioning.py @@ -36,7 +36,7 @@ from band.agent import Agent from band.core.simple_adapter import SimpleAdapter -from band.runtime.types import AgentConfig +from band.runtime.agentconfig import AgentConfig from band.workspaces import WorkspaceResolver from tests.e2e.baseline.settings import BaselineSettings from tests.e2e.baseline.toolkit.adapters import build_adapter diff --git a/tests/integration/test_agent_contacts.py b/tests/integration/test_agent_contacts.py index e177baac6..ad2a7a4d1 100644 --- a/tests/integration/test_agent_contacts.py +++ b/tests/integration/test_agent_contacts.py @@ -21,7 +21,7 @@ ContactEvent, ) from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy PYDANTIC_AI_MODEL = f"openai:{OPENAI_MODEL}" diff --git a/tests/integration/test_callback_strategy.py b/tests/integration/test_callback_strategy.py index 214cd773b..2e6e9b9c6 100644 --- a/tests/integration/test_callback_strategy.py +++ b/tests/integration/test_callback_strategy.py @@ -24,7 +24,7 @@ ) from band.runtime.contact_handler import ContactEventHandler from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from tests.integration.conftest import requires_api, requires_multi_agent logger = logging.getLogger(__name__) diff --git a/tests/integration/test_hub_room_strategy.py b/tests/integration/test_hub_room_strategy.py index 7e189e8b3..0c360caff 100644 --- a/tests/integration/test_hub_room_strategy.py +++ b/tests/integration/test_hub_room_strategy.py @@ -20,7 +20,7 @@ from band.platform.link import BandLink from band.runtime.contact_handler import ContactEventHandler from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy from tests.integration.conftest import ( fetch_all_context, requires_api, diff --git a/tests/integrations/acp/test_cursor_message_delivery.py b/tests/integrations/acp/test_cursor_message_delivery.py index ae93f13a0..87e133ac6 100644 --- a/tests/integrations/acp/test_cursor_message_delivery.py +++ b/tests/integrations/acp/test_cursor_message_delivery.py @@ -9,7 +9,7 @@ from band.adapters.cursor_acp import CursorACPAdapter, CursorACPAdapterConfig from band.client.streaming import ControlMode from band.platform.link import BandLink -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.integrations.acp.acp_toolkit.agent import FakeACPAgent, allow_or_reject from tests.integrations.acp.acp_toolkit.harness import pair_in_process diff --git a/tests/markdown_docs/globals.py b/tests/markdown_docs/globals.py index 8fcb782ae..6daa35e99 100644 --- a/tests/markdown_docs/globals.py +++ b/tests/markdown_docs/globals.py @@ -19,7 +19,7 @@ from band import AdapterFeatures, BandConfigError, Capability, Emit from band import Agent as BandAgent from band.platform.event import ContactRequestReceivedEvent -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy MARKDOWN_AGENT_ID = "markdown-docs-agent" MARKDOWN_RESEARCHER_AGENT_ID = "markdown-docs-researcher" diff --git a/tests/platformruntime/test_initialization.py b/tests/platformruntime/test_initialization.py index b0a28f9db..f5b942b09 100644 --- a/tests/platformruntime/test_initialization.py +++ b/tests/platformruntime/test_initialization.py @@ -2,8 +2,9 @@ from __future__ import annotations +from band.runtime.agentconfig import AgentConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestInitialization: diff --git a/tests/platformruntime/test_interrupted_cleanup.py b/tests/platformruntime/test_interrupted_cleanup.py index f2f2ea504..25b3e0949 100644 --- a/tests/platformruntime/test_interrupted_cleanup.py +++ b/tests/platformruntime/test_interrupted_cleanup.py @@ -8,8 +8,8 @@ import pytest from band.core.exceptions import AgentAlreadyRunningError, CleanupIncompleteError +from band.runtime.contacts import ContactEventConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import ContactEventConfig from tests.platformruntime.kit import ROOMS, serving_link from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import RoomCleanup diff --git a/tests/platformruntime/test_single_instance.py b/tests/platformruntime/test_single_instance.py index 100b87608..0599bc864 100644 --- a/tests/platformruntime/test_single_instance.py +++ b/tests/platformruntime/test_single_instance.py @@ -7,8 +7,8 @@ import pytest from band.core.exceptions import AgentAlreadyRunningError +from band.runtime.agentconfig import AgentConfig from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig class TestSingleInstance: diff --git a/tests/platformruntime/test_start.py b/tests/platformruntime/test_start.py index 48833db1f..e0fc99947 100644 --- a/tests/platformruntime/test_start.py +++ b/tests/platformruntime/test_start.py @@ -8,9 +8,10 @@ from band_rest.core.api_error import ApiError from band.core.types import ConflictPolicy +from band.runtime.agentconfig import AgentConfig from band.runtime.capabilities import FeatureFlag from band.runtime.platform_runtime import PlatformRuntime -from band.runtime.types import AgentConfig, SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestStart: diff --git a/tests/preprocessing/test_commitment.py b/tests/preprocessing/test_commitment.py index f3bfd2f11..13bdbecf5 100644 --- a/tests/preprocessing/test_commitment.py +++ b/tests/preprocessing/test_commitment.py @@ -15,7 +15,7 @@ from band.core.types import AgentInput, PlatformMessage from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.preprocessing.test_default import make_message_event diff --git a/tests/preprocessing/test_controls.py b/tests/preprocessing/test_controls.py index 602918469..d3f2bd7b0 100644 --- a/tests/preprocessing/test_controls.py +++ b/tests/preprocessing/test_controls.py @@ -13,7 +13,7 @@ from band.platform.link import BandLink from band.preprocessing.default import DefaultPreprocessor from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.helpers import AGENT_ID, ROOM_ID, LifecyclePlatform, rest_client_over from tests.runtime.test_deferral import ApprovalAdapter diff --git a/tests/preprocessing/test_default.py b/tests/preprocessing/test_default.py index 063917c39..532fc0ef8 100644 --- a/tests/preprocessing/test_default.py +++ b/tests/preprocessing/test_default.py @@ -13,7 +13,7 @@ RoomAddedEvent, ) from band.preprocessing.default import DefaultPreprocessor -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig def make_message_payload( diff --git a/tests/runtime/conftest.py b/tests/runtime/conftest.py index 581be1694..46f8a06ed 100644 --- a/tests/runtime/conftest.py +++ b/tests/runtime/conftest.py @@ -7,7 +7,7 @@ these fixtures the same way it would break production, instead of a loose mock silently tolerating the drift. -Note: ``band.runtime.types.PlatformMessage`` (used by the root +Note: ``band.runtime.messages.PlatformMessage`` (used by the root ``tests/conftest.py`` WS-event builders) is a *different* class from ``band.core.types.PlatformMessage`` used here — the two aren't interchangeable, so don't reach for ``sample_platform_message`` from the root diff --git a/tests/runtime/executioninterrupt/kit.py b/tests/runtime/executioninterrupt/kit.py index d8545a173..9ebd388f5 100644 --- a/tests/runtime/executioninterrupt/kit.py +++ b/tests/runtime/executioninterrupt/kit.py @@ -18,7 +18,8 @@ from band.platform.link import BandLink from band.runtime.cycle import TurnScope from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.helpers import AGENT_ID, ROOM_ID diff --git a/tests/runtime/executioninterrupt/test_backlog_interrupt.py b/tests/runtime/executioninterrupt/test_backlog_interrupt.py index 1c810ee84..769149aaa 100644 --- a/tests/runtime/executioninterrupt/test_backlog_interrupt.py +++ b/tests/runtime/executioninterrupt/test_backlog_interrupt.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock from band.runtime.execution import ExecutionContext, ResyncOutcome, TurnOutcome -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import BlockingHandler from tests.runtime.executioninterrupt.kit import ( backlog_message, diff --git a/tests/runtime/executioninterrupt/test_cycle_watchdog.py b/tests/runtime/executioninterrupt/test_cycle_watchdog.py index 0cc73fde2..24d0eeb75 100644 --- a/tests/runtime/executioninterrupt/test_cycle_watchdog.py +++ b/tests/runtime/executioninterrupt/test_cycle_watchdog.py @@ -6,7 +6,7 @@ from unittest.mock import patch from band.runtime.execution import ExecutionContext, TurnOutcome -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import BlockingHandler, make_message_event from tests.runtime.executioninterrupt.kit import ( assert_fresh_cycle_still_propagates_shutdown_cancel, diff --git a/tests/runtime/executioninterrupt/test_late_rest_refusals.py b/tests/runtime/executioninterrupt/test_late_rest_refusals.py index 3e7bca076..b80c7de4a 100644 --- a/tests/runtime/executioninterrupt/test_late_rest_refusals.py +++ b/tests/runtime/executioninterrupt/test_late_rest_refusals.py @@ -9,8 +9,8 @@ from band.client.streaming import ControlMode from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.runtime.executioninterrupt.kit import ( backlog_message, diff --git a/tests/runtime/helpers.py b/tests/runtime/helpers.py index 534c6c4d5..0a96e4340 100644 --- a/tests/runtime/helpers.py +++ b/tests/runtime/helpers.py @@ -29,7 +29,7 @@ from band.platform.event import MessageEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.identifiers import UUID_ID diff --git a/tests/runtime/resync/conftest.py b/tests/runtime/resync/conftest.py index 2f7b0a80c..d0bb19cc0 100644 --- a/tests/runtime/resync/conftest.py +++ b/tests/runtime/resync/conftest.py @@ -13,14 +13,13 @@ from band.platform.event import MessageEvent from band.platform.link import BandLink from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.runtime.helpers import ( AGENT_ID, ROOM_ID, - LifecyclePlatform, rest_client_over, ) -from tests.runtime.resync.kit import ReconciledRoom +from tests.runtime.resync.kit import ReconciledRoom, ReconciliationPeer @pytest.fixture @@ -74,9 +73,10 @@ def mock_handler(): @pytest_asyncio.fixture(loop_scope="function") async def reconciled_room( monkeypatch: pytest.MonkeyPatch, + request: pytest.FixtureRequest, ) -> AsyncIterator[ReconciledRoom]: monkeypatch.setattr(random, "uniform", lambda lower, upper: upper) - peer = LifecyclePlatform() + peer = ReconciliationPeer() invoked: list[str] = [] async def handler(ctx: ExecutionContext, event: Any) -> None: @@ -92,7 +92,9 @@ async def handler(ctx: ExecutionContext, event: Any) -> None: handler, agent_id=AGENT_ID, config=SessionConfig( - enable_context_hydration=False, enable_working_state=False + enable_context_hydration=False, + enable_working_state=False, + **getattr(request, "param", {}), ), ) try: diff --git a/tests/runtime/resync/kit.py b/tests/runtime/resync/kit.py index 4fcfb2d1c..5d3be76db 100644 --- a/tests/runtime/resync/kit.py +++ b/tests/runtime/resync/kit.py @@ -2,15 +2,38 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime +import httpx + from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage from tests.runtime.helpers import ( LifecyclePlatform, ) -ReconciledRoom = tuple[ExecutionContext, LifecyclePlatform, list[str]] + +class ReconciliationPeer(LifecyclePlatform): + """Expose polling observations without test-side delays.""" + + def __init__(self) -> None: + super().__init__() + self._next_read = asyncio.Event() + + def answer(self, request: httpx.Request) -> httpx.Response: + response = super().answer(request) + if request.url.path.endswith("/next"): + self._next_read.set() + return response + + async def wait_for_reads(self, count: int) -> None: + while len(self.next_reads) < count: + self._next_read.clear() + await self._next_read.wait() + + +ReconciledRoom = tuple[ExecutionContext, ReconciliationPeer, list[str]] def make_platform_message( diff --git a/tests/runtime/resync/test_idle_timeout.py b/tests/runtime/resync/test_idle_timeout.py index fe898d013..116038c78 100644 --- a/tests/runtime/resync/test_idle_timeout.py +++ b/tests/runtime/resync/test_idle_timeout.py @@ -5,7 +5,7 @@ import random from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition diff --git a/tests/runtime/resync/test_interval_validation.py b/tests/runtime/resync/test_interval_validation.py index a3909daff..9fdec637c 100644 --- a/tests/runtime/resync/test_interval_validation.py +++ b/tests/runtime/resync/test_interval_validation.py @@ -4,14 +4,15 @@ import pytest -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig class TestIntervalValidation: """Reconciliation intervals must be finite and positive.""" @pytest.mark.parametrize( - "field", ["idle_resync_seconds", "idle_resync_max_seconds"] + "field", + ["idle_resync_seconds", "idle_resync_max_seconds", "stopped_resync_seconds"], ) @pytest.mark.parametrize( "value", [0, -1, float("nan"), float("inf"), -float("inf")] @@ -21,3 +22,10 @@ def test_unsafe_reconciliation_intervals_are_rejected( ) -> None: with pytest.raises(ValueError, match=field): SessionConfig(**{field: value}) + + @pytest.mark.parametrize( + "field", ["idle_resync_seconds", "idle_resync_max_seconds"] + ) + def test_required_intervals_reject_none(self, field: str) -> None: + with pytest.raises(ValueError, match=field): + SessionConfig(**{field: None}) diff --git a/tests/runtime/resync/test_reconciliation_cadence.py b/tests/runtime/resync/test_reconciliation_cadence.py index 837cab3ba..d70717f74 100644 --- a/tests/runtime/resync/test_reconciliation_cadence.py +++ b/tests/runtime/resync/test_reconciliation_cadence.py @@ -12,8 +12,6 @@ from band.client.streaming import ParticipantRemovedPayload from band.platform.event import ParticipantRemovedEvent from band.runtime.execution import ExecutionContext -from band.runtime.resync import ResyncSchedule -from band.runtime.types import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import ( @@ -23,10 +21,10 @@ from tests.runtime.resync.kit import ReconciledRoom +@pytest.mark.looptime(end=1000) class TestReconciliationCadence: """Periodic /next reconciliation: its interval, growth and what resets it.""" - @pytest.mark.looptime async def test_traffic_cannot_postpone_missed_message_recovery( self, reconciled_room: ReconciledRoom, @@ -49,7 +47,6 @@ async def test_traffic_cannot_postpone_missed_message_recovery( assert invoked == ["missed"] assert peer.marked("processed") == ["missed"] - @pytest.mark.looptime async def test_quiet_reconciliation_grows_to_conservative_cap( self, reconciled_room: ReconciledRoom, @@ -61,7 +58,6 @@ async def test_quiet_reconciliation_grows_to_conservative_cap( assert peer.next_reads == pytest.approx([0, 60, 180, 300]) assert invoked == [] - @pytest.mark.looptime async def test_new_work_brings_backed_off_deadline_forward( self, reconciled_room: ReconciledRoom, @@ -78,7 +74,6 @@ async def test_new_work_brings_backed_off_deadline_forward( assert invoked == ["live"] assert peer.next_reads[-1] == pytest.approx(work_at + 60) - @pytest.mark.looptime async def test_duplicate_delivery_does_not_reset_quiet_growth( self, reconciled_room: ReconciledRoom, @@ -93,7 +88,6 @@ async def test_duplicate_delivery_does_not_reset_quiet_growth( assert peer.next_reads == pytest.approx([0, 60, 180]) assert invoked == [] - @pytest.mark.looptime async def test_stop_shortens_an_already_armed_quiet_wait( self, reconciled_room: ReconciledRoom, @@ -109,7 +103,65 @@ async def test_stop_shortens_an_already_armed_quiet_wait( assert peer.next_reads[-1] == pytest.approx(stopped_at + 60) assert invoked == [] - @pytest.mark.looptime + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_stopped_room_probes_at_its_configured_interval( + self, + reconciled_room: ReconciledRoom, + ) -> None: + ctx, peer, invoked = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + peer.add_message("paused") + await peer.wait_for_reads(4) + assert peer.next_reads == pytest.approx([0, 60, 360, 660]) + assert invoked == [] + + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_missed_play_restores_base_interval( + self, + reconciled_room: ReconciledRoom, + ) -> None: + ctx, peer, invoked = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + peer.add_message("resumed") + await peer.wait_for_reads(2) + peer.stopped = False + await peer.wait_for_reads(5) + assert invoked == ["resumed"] + # The resuming probe drains /next until empty, so 360 is read twice. + assert peer.next_reads == pytest.approx([0, 60, 360, 360, 420]) + + @pytest.mark.parametrize( + "reconciled_room", [{"stopped_resync_seconds": 300}], indirect=True + ) + async def test_play_reconciles_immediately_and_restores_active_cadence( + self, reconciled_room: ReconciledRoom + ) -> None: + ctx, peer, _ = reconciled_room + await ctx.start() + await peer.wait_for_reads(1) + ctx.stop_room() + peer.stopped = True + await peer.wait_for_reads(2) + + peer.stopped = False + played_at = asyncio.get_running_loop().time() + await ctx.resume_room() + await peer.wait_for_reads(5) + + assert peer.next_reads == pytest.approx( + [0, 60, played_at, played_at + 60, played_at + 180] + ) + async def test_deadline_does_not_cancel_handler_or_issue_catchup_burst( self, reconciled_room: ReconciledRoom, @@ -138,20 +190,18 @@ async def handler(context: ExecutionContext, event: Any) -> None: assert peer.marked("processed") == ["slow"] assert len(peer.next_reads) == 2 - @pytest.mark.looptime + @pytest.mark.parametrize( + "reconciled_room", [{"idle_resync_seconds": 300}], indirect=True + ) async def test_custom_base_above_default_maximum_keeps_its_interval( self, reconciled_room: ReconciledRoom, ) -> None: ctx, peer, _ = reconciled_room - ctx.config = SessionConfig(idle_resync_seconds=300, enable_working_state=False) - ctx._resync_schedule = ResyncSchedule(300, 120) await ctx.start() - await wait_for_condition(lambda: ctx._sync_complete) - await asyncio.sleep(601) + await peer.wait_for_reads(3) assert peer.next_reads == pytest.approx([0, 300, 600]) - @pytest.mark.looptime async def test_initial_spread_does_not_shorten_recurring_intervals( self, reconciled_room: ReconciledRoom, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -162,7 +212,6 @@ async def test_initial_spread_does_not_shorten_recurring_intervals( await asyncio.sleep(271) assert peer.next_reads == pytest.approx([0, 30, 150, 270]) - @pytest.mark.looptime async def test_http_error_retains_base_retry_cadence( self, reconciled_room: ReconciledRoom, diff --git a/tests/runtime/resync/test_stopped_interval_settings.py b/tests/runtime/resync/test_stopped_interval_settings.py new file mode 100644 index 000000000..f0491bd29 --- /dev/null +++ b/tests/runtime/resync/test_stopped_interval_settings.py @@ -0,0 +1,40 @@ +"""Stopped-room settings resolve and validate at session construction.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from band.runtime.sessionconfig import SessionConfig + + +class TestStoppedIntervalSettings: + @pytest.mark.parametrize( + ("environment", "overrides", "expected"), + [ + ("300", {}, 300), + ("", {}, None), + ("300", {"stopped_resync_seconds": 15}, 15), + ("300", {"stopped_resync_seconds": None}, None), + ("invalid", {"stopped_resync_seconds": 15}, 15), + ], + ids=["environment", "empty", "explicit", "base", "overridden-invalid-env"], + ) + def test_explicit_interval_overrides_environment_default( + self, + monkeypatch: pytest.MonkeyPatch, + environment: str, + overrides: dict[str, float | None], + expected: float | None, + ) -> None: + monkeypatch.setenv("STOPPED_RESYNC_SECONDS", environment) + config = SessionConfig(**overrides) + assert config.stopped_resync_seconds == expected + + @pytest.mark.parametrize("value", ["invalid", "0", "-1", "nan", "inf", "-inf"]) + def test_invalid_environment_interval_fails_before_execution( + self, monkeypatch: pytest.MonkeyPatch, value: str + ) -> None: + monkeypatch.setenv("STOPPED_RESYNC_SECONDS", value) + with pytest.raises(ValidationError, match="stopped_resync_seconds"): + SessionConfig() diff --git a/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py b/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py index 467437e46..310a5d191 100644 --- a/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py +++ b/tests/runtime/runtimecontrol/test_stop_survives_reconnect.py @@ -6,7 +6,7 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage +from band.runtime.messages import PlatformMessage class TestStopSurvivesReconnect: diff --git a/tests/runtime/test_activity_stop.py b/tests/runtime/test_activity_stop.py index 352fac52b..27e061a48 100644 --- a/tests/runtime/test_activity_stop.py +++ b/tests/runtime/test_activity_stop.py @@ -16,8 +16,8 @@ from band.platform.link import BandLink from band.runtime.execution import ExecutionContext, TurnOutcome from band.runtime.runtime import AgentRuntime +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from tests.conftest import BlockingHandler, make_message_event from tests.runtime.helpers import ( AGENT_ID, diff --git a/tests/runtime/test_broadcast_changes.py b/tests/runtime/test_broadcast_changes.py index d01397b15..0a35ac030 100644 --- a/tests/runtime/test_broadcast_changes.py +++ b/tests/runtime/test_broadcast_changes.py @@ -17,7 +17,7 @@ ContactRequestUpdatedEvent, ) from band.runtime.contact_handler import ContactEventHandler -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy @pytest.fixture diff --git a/tests/runtime/test_contact_config.py b/tests/runtime/test_contact_config.py index d3c56fe9e..295730d62 100644 --- a/tests/runtime/test_contact_config.py +++ b/tests/runtime/test_contact_config.py @@ -2,7 +2,7 @@ import pytest -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy class TestContactEventStrategy: diff --git a/tests/runtime/test_contact_handler.py b/tests/runtime/test_contact_handler.py index cbd906f3e..30d63871e 100644 --- a/tests/runtime/test_contact_handler.py +++ b/tests/runtime/test_contact_handler.py @@ -24,7 +24,7 @@ ContactEventHandler, ) from band.runtime.contact_tools import ContactTools -from band.runtime.types import ContactEventConfig, ContactEventStrategy +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy @pytest.fixture diff --git a/tests/runtime/test_deferral.py b/tests/runtime/test_deferral.py index 665345126..be2dca088 100644 --- a/tests/runtime/test_deferral.py +++ b/tests/runtime/test_deferral.py @@ -13,7 +13,7 @@ from band.platform.event import ReconnectedEvent from band.platform.link import BandLink from band.runtime.oneshot import OneShotInvoker, OneShotStatus -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.conftest import make_message_event from tests.runtime.conftest import wait_for_condition from tests.runtime.helpers import ( diff --git a/tests/runtime/test_execution.py b/tests/runtime/test_execution.py index 694330403..8fdb098d5 100644 --- a/tests/runtime/test_execution.py +++ b/tests/runtime/test_execution.py @@ -21,6 +21,7 @@ from band.logging_config import TRACE_CONTEXT, trace_context_scope from band.platform.event import PlatformEvent from band.platform.link import BandLink +from band.runtime.context import ConversationContext from band.runtime.execution import ( Execution, ExecutionContext, @@ -30,8 +31,9 @@ _error_label, _observe_phase, ) +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools import AgentTools -from band.runtime.types import ConversationContext, PlatformMessage, SessionConfig # Import test helpers from conftest from tests.conftest import ( diff --git a/tests/runtime/test_execution_working_state.py b/tests/runtime/test_execution_working_state.py index ffb6bedcc..fa39effdf 100644 --- a/tests/runtime/test_execution_working_state.py +++ b/tests/runtime/test_execution_working_state.py @@ -9,7 +9,8 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig from tests.conftest import ( make_message_event, make_participant_added_event, diff --git a/tests/runtime/test_session_settings.py b/tests/runtime/test_session_settings.py new file mode 100644 index 000000000..f46fd08d6 --- /dev/null +++ b/tests/runtime/test_session_settings.py @@ -0,0 +1,44 @@ +"""Session configuration resolves environment values and explicit overrides.""" + +from __future__ import annotations + +import pytest + +from band.runtime.sessionconfig import SessionConfig + + +class TestSessionSettings: + def test_environment_values_are_typed_and_explicit_values_take_precedence( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("ENABLE_CONTEXT_HYDRATION", "false") + monkeypatch.setenv("MAX_CONTEXT_MESSAGES", "25") + monkeypatch.setenv("IDLE_RESYNC_SECONDS", "30") + monkeypatch.setenv("MAX_CYCLE_SECONDS", "") + + config = SessionConfig(max_context_messages=50) + + assert config.model_dump( + include={ + "enable_context_hydration", + "max_context_messages", + "idle_resync_seconds", + "max_cycle_seconds", + } + ) == { + "enable_context_hydration": False, + "max_context_messages": 50, + "idle_resync_seconds": 30, + "max_cycle_seconds": None, + } + + def test_environment_timing_is_checked_against_working_state_constraints( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("WORKING_KEEP_ALIVE_SECONDS", "5") + + with pytest.raises(ValueError, match="working_keep_alive_seconds"): + SessionConfig(enable_working_state=True) + + config = SessionConfig(enable_working_state=False) + assert config.working_keep_alive_seconds == 5 diff --git a/tests/runtime/test_turn_verdict.py b/tests/runtime/test_turn_verdict.py index d17b1373d..a4c12b81c 100644 --- a/tests/runtime/test_turn_verdict.py +++ b/tests/runtime/test_turn_verdict.py @@ -33,9 +33,9 @@ PlatformMessage, ) from band.runtime.execution import ExecutionContext +from band.runtime.sessionconfig import SessionConfig from band.runtime.tools import BandTool from band.runtime.tools.agent import AgentTools -from band.runtime.types import SessionConfig from band.testing import MISSING_REPLY_FAILURE from tests.adapters.codexturns import ( FakeCodexClient, diff --git a/tests/runtime/turnadmission/conftest.py b/tests/runtime/turnadmission/conftest.py index 86d6b47d6..18c99fb5d 100644 --- a/tests/runtime/turnadmission/conftest.py +++ b/tests/runtime/turnadmission/conftest.py @@ -11,7 +11,7 @@ from band.platform.link import BandLink from band.runtime.cycle import TurnScope from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from tests.runtime.helpers import ( AGENT_ID, LifecyclePlatform, diff --git a/tests/scenarios/world.py b/tests/scenarios/world.py index 5929984a6..4a95a9e89 100644 --- a/tests/scenarios/world.py +++ b/tests/scenarios/world.py @@ -11,7 +11,7 @@ from band.platform.link import BandLink from band.runtime.execution import Execution, ExecutionContext from band.runtime.runtime import AgentRuntime -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig from band.testing import fake_phoenix_server from band.testing.phoenix_server import FakePhoenixServer from tests.runtime.helpers import rest_client_over diff --git a/tests/test_readme_snippets.py b/tests/test_readme_snippets.py index 4cedbd6da..f641c7e91 100644 --- a/tests/test_readme_snippets.py +++ b/tests/test_readme_snippets.py @@ -13,6 +13,8 @@ import pytest from pydantic import BaseModel, Field +from band.runtime.contacts import ContactEventConfig, ContactEventStrategy + try: from band.adapters import PydanticAIAdapter as _PydanticAICheck # noqa: F401 @@ -522,15 +524,6 @@ def graph_factory(band_tools): class TestContactManagementSnippets: """README shows ContactEventConfig with HUB_ROOM and CALLBACK strategies.""" - def test_contact_event_imports(self) -> None: - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventStrategy, - ) - - assert ContactEventStrategy.DISABLED is not None - assert ContactEventStrategy.HUB_ROOM is not None - assert ContactEventStrategy.CALLBACK is not None - @patch.dict( os.environ, { @@ -543,10 +536,6 @@ def test_hub_room_config(self) -> None: from band import ( # noqa: PLC0415 -- pins the exact import path this test exercises Agent, ) - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventConfig, - ContactEventStrategy, - ) adapter = MagicMock() @@ -576,10 +565,6 @@ def test_callback_config(self) -> None: from band.platform.event import ( # noqa: PLC0415 -- pins the exact import path this test exercises ContactRequestReceivedEvent, ) - from band.runtime.types import ( # noqa: PLC0415 -- pins the exact import path this test exercises - ContactEventConfig, - ContactEventStrategy, - ) TRUSTED_HANDLES = {"@teammate"} diff --git a/tests/test_session_config.py b/tests/test_session_config.py index 08fd9bce7..b5d272657 100644 --- a/tests/test_session_config.py +++ b/tests/test_session_config.py @@ -15,7 +15,7 @@ import pytest from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event diff --git a/tests/test_session_shutdown.py b/tests/test_session_shutdown.py index c9e1c90ff..67142414e 100644 --- a/tests/test_session_shutdown.py +++ b/tests/test_session_shutdown.py @@ -15,7 +15,7 @@ import pytest from band.runtime.execution import ExecutionContext -from band.runtime.types import SessionConfig +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event diff --git a/tests/test_session_sync.py b/tests/test_session_sync.py index 6fbcf7b5f..5f6d3328f 100644 --- a/tests/test_session_sync.py +++ b/tests/test_session_sync.py @@ -16,7 +16,8 @@ from band.platform.event import ReconnectedEvent from band.runtime.execution import ExecutionContext, ResyncRequest -from band.runtime.types import PlatformMessage, SessionConfig +from band.runtime.messages import PlatformMessage +from band.runtime.sessionconfig import SessionConfig # Import test helpers from conftest from tests.conftest import make_message_event, make_participant_added_event From 21b4c95238a1f777a2d5b7bfe769d9b7e897b514 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:07:57 +0000 Subject: [PATCH 08/13] chore(deps): bump the uv-minor-and-patch group across 1 directory with 27 updates (#796) * chore(deps): bump the uv-minor-and-patch group across 1 directory with 27 updates --- updated-dependencies: - dependency-name: band-client-rest dependency-version: 0.0.46 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: python-dotenv dependency-version: 1.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: anthropic dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: langchain dependency-version: 1.4.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: langchain-core dependency-version: 1.6.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: langgraph dependency-version: 1.2.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: langchain-openai dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: langchain-anthropic dependency-version: 1.7.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: langchain-text-splitters dependency-version: 1.1.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: claude-agent-sdk dependency-version: 0.2.165 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: github-copilot-sdk dependency-version: 1.0.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: werkzeug dependency-version: 3.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: crewai dependency-version: 1.15.27 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: a2a-sdk dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: slack-sdk dependency-version: 3.45.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: aiohttp dependency-version: 3.14.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: boto3 dependency-version: 1.43.111 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: fastapi dependency-version: 0.143.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: agno dependency-version: 3.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch - dependency-name: pytest-mock dependency-version: 3.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: packaging dependency-version: '26.3' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: pydantic-ai-slim dependency-version: 2.55.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: opentelemetry-sdk dependency-version: 1.45.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: opentelemetry-instrumentation-logging dependency-version: 0.66b1 dependency-type: direct:production dependency-group: uv-minor-and-patch - dependency-name: ruff dependency-version: 0.17.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: commitizen dependency-version: 4.19.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: uv-minor-and-patch - dependency-name: typer dependency-version: 0.27.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: uv-minor-and-patch ... Signed-off-by: dependabot[bot] * fix: adapt Pydantic AI adapter for pydantic-ai 2.55 / httpx2 Resolve OpenAI models with legacy httpx clients, end terminal declines without an extra model step under tight usage limits, and align test tool-call fixtures with JSON wire shapes. Co-authored-by: Cursor * refactor: use public pydantic-ai types for the OpenAI httpx workaround The provider-prefix list was duplicated, the factory had no return type, and nothing checked that openai model strings still get a legacy httpx client. Co-authored-by: Cursor --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Zaikman Co-authored-by: Cursor --- pyproject.toml | 6 +- src/band/adapters/pydantic_ai.py | 65 +- tests/adapters/test_pydantic_ai_adapter.py | 43 +- tests/runtime/test_tools.py | 2 +- uv.lock | 871 +++++++++++---------- 5 files changed, 571 insertions(+), 416 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d493a866e..355033dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ - "band-client-rest==0.0.41", + "band-client-rest==0.0.46", "band-sdk-core==2.8.0", "phoenix-channels-python-client>=0.2.5,<0.3", "python-dotenv>=1.2.2", @@ -100,7 +100,7 @@ parlant = [ "werkzeug>=3.1.6", ] crewai = [ - "crewai==1.15.22", + "crewai==1.15.27", "openai>=2.0.0", # crewai 1.14.3 requires openai>=2.0.0 "nest-asyncio>=1.6.0", # Required for sync-to-async bridging in CrewAI tools "pillow>=12.1.1", @@ -285,7 +285,7 @@ dev-crewai = [ "band-testing-python==0.1.4", "httpx>=0.24.0", # CrewAI and its deps - "crewai==1.15.22", + "crewai==1.15.27", "openai>=2.0.0", "nest-asyncio>=1.6.0", "pillow>=12.1.1", diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 2079aace7..44ad1ed72 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -24,11 +24,13 @@ FunctionToolCallEvent, FunctionToolResultEvent, InstrumentationSettings, + ModelRequestNode, RunContext, UnexpectedModelBehavior, capture_run_messages, ) from pydantic_ai.capabilities import Hooks, ProcessHistory +from pydantic_ai.capabilities.abstract import AgentNode, NodeResult from pydantic_ai.exceptions import SkipModelRequest from pydantic_ai.messages import ( BinaryContent, @@ -41,7 +43,16 @@ ToolReturnPart, UserPromptPart, ) -from pydantic_ai.models import Model, ModelRequestContext +from pydantic_ai.models import ( + Model, + ModelRequestContext, + create_async_http_client, + infer_model, +) +from pydantic_ai.providers import Provider, infer_provider +from pydantic_ai.providers.openai import OpenAIProvider +from pydantic_ai.result import FinalResult +from pydantic_graph import End from typing_extensions import Unpack from band.converters.pydantic_ai import ( @@ -156,6 +167,27 @@ def _explicit_decline_is_terminal(request: ModelRequest) -> bool: return declined +async def _finish_explicit_decline_after_tools( + ctx: RunContext[AgentToolsProtocol], + *, + node: AgentNode[Any], + result: NodeResult[Any], +) -> NodeResult[Any]: + """End the run after a terminal decline without scheduling another model step. + + pydantic-ai 2.55+ enforces ``request_limit`` before ``before_model_request`` hooks, + so a follow-up ``ModelRequestNode`` after tools can trip the limit even when the + hook would have skipped the call. Converting that node to ``End`` avoids the extra + step entirely. + """ + if isinstance(result, ModelRequestNode) and _explicit_decline_is_terminal( + result.request + ): + ctx.messages.append(result.request) + return End(FinalResult(None)) + return result + + def _finish_explicit_decline( ctx: RunContext[AgentToolsProtocol], request_context: ModelRequestContext ) -> ModelRequestContext: @@ -297,6 +329,29 @@ def _takes_run_context(fn: Callable[..., Any]) -> bool: return annotation is RunContext or get_origin(annotation) is RunContext +_OPENAI_LEGACY_HTTP_PROVIDERS = frozenset({"openai", "openai-chat"}) + + +def _resolve_pydantic_ai_model(model: Model | str) -> Model | str: + """Resolve a model string, patching OpenAI providers to use legacy ``httpx``. + + pydantic-ai's OpenAI-compatible providers default to ``httpx2``, while the + ``openai`` Python SDK still type-checks for ``httpx.AsyncClient``. + """ + if not isinstance(model, str): + return model + provider_name = model.partition(":")[0] + if provider_name not in _OPENAI_LEGACY_HTTP_PROVIDERS: + return model + + def provider_factory(name: str) -> Provider[Any]: + if name in _OPENAI_LEGACY_HTTP_PROVIDERS: + return OpenAIProvider(http_client=create_async_http_client()) + return infer_provider(name) + + return infer_model(model, provider_factory) + + class PydanticAIAdapterConfig(BaseAdapterConfig): """Settings for a Pydantic AI agent. @@ -421,7 +476,7 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]: self._system_prompt = system agent: Agent[AgentToolsProtocol, str | None] = Agent( - self._model, + _resolve_pydantic_ai_model(self._model), # Pass the rendered prompt as `instructions`, not `system_prompt`. # pydantic-ai materializes `system_prompt` as a single SystemPromptPart # only on the first request, after which it ages into buried history; @@ -455,8 +510,10 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]: # handled where the run is driven. retries={"tools": 3, "output": 0}, capabilities=[ - # Inspect native run metadata before replay history is processed. - Hooks(before_model_request=_finish_explicit_decline), + Hooks( + before_model_request=_finish_explicit_decline, + after_node_run=_finish_explicit_decline_after_tools, + ), # Strip content:null responses on every request, including mid-run # ones the storage filter can't reach (see the function docstring). ProcessHistory(_drop_non_replayable_messages), diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index 8256f360c..b5670d18a 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -34,6 +34,7 @@ FunctionToolCallEvent, FunctionToolResultEvent, InstrumentationSettings, + PydanticAIDeprecationWarning, RunContext, UnexpectedModelBehavior, _tool_execution, @@ -53,6 +54,7 @@ UserPromptPart, ) from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel +from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel from pydantic_ai.models.test import TestModel from pydantic_ai.usage import UsageLimits @@ -66,6 +68,7 @@ _is_replayable_history_message, _log_native_progress, _native_turn, + _resolve_pydantic_ai_model, ) from band.core.exceptions import BandToolError from band.core.protocols import ( @@ -346,6 +349,31 @@ async def test_agent_runs_the_live_llm(self): assert adapter._agent.model is llm + def test_non_openai_models_skip_the_legacy_http_patch(self): + llm = TestModel() + + assert _resolve_pydantic_ai_model(llm) is llm + assert _resolve_pydantic_ai_model("anthropic:claude") == "anthropic:claude" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("model_name", "model_type"), + [ + ("openai:gpt-4o-mini", OpenAIResponsesModel), + ("openai-chat:gpt-4o-mini", OpenAIChatModel), + ], + ) + async def test_openai_model_strings_use_the_legacy_http_client( + self, monkeypatch: pytest.MonkeyPatch, model_name: str, model_type: type[Any] + ) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + + with pytest.warns(PydanticAIDeprecationWarning, match="httpx.AsyncClient"): + model = _resolve_pydantic_ai_model(model_name) + + assert isinstance(model, model_type) + await model.provider.client.close() + @pytest.mark.parametrize( ("config", "llm"), [ @@ -366,7 +394,7 @@ def test_create_agent_registers_content_null_history_processor(self): an empty/thinking-only response within a single turn, and pydantic-ai would otherwise replay it to the provider as assistant content:null. """ - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test")) adapter.agent_name = "TestBot" with patch("band.adapters.pydantic_ai.Agent") as MockAgent: @@ -697,7 +725,7 @@ async def test_persists_rendered_system_prompt(self): """Should persist rendered prompt for capability-gating visibility.""" with patch("band.adapters.pydantic_ai.Agent"): adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), + PydanticAIAdapterConfig(model="test"), capabilities=Capability.MEMORY, ) await adapter.on_started( @@ -734,6 +762,13 @@ async def test_agent_has_tools_registered(self, mock_pydantic_agent): assert tool in tool_names, f"Tool {tool} not found" +def _wire_tool_call_args(args: Any) -> dict[str, Any] | str: + """Shape tool args the way providers send them on the wire (JSON text or object).""" + if isinstance(args, dict | str): + return args + return json.dumps(args) + + def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel: """A model that makes ``calls``, one per turn, then answers in plain text. @@ -746,7 +781,7 @@ def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel: def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: if pending: name, args = pending.pop(0) - return ModelResponse(parts=[ToolCallPart(name, args)]) + return ModelResponse(parts=[ToolCallPart(name, _wire_tool_call_args(args))]) return ModelResponse(parts=[TextPart("done")]) return FunctionModel(respond) @@ -2969,7 +3004,7 @@ async def my_echo(ctx: RunContext[AgentToolsProtocol], message: str) -> str: return f"Echo: {message}" adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), additional_tools=[my_echo] + PydanticAIAdapterConfig(model="test"), additional_tools=[my_echo] ) # Mock the Agent class to track tool registrations diff --git a/tests/runtime/test_tools.py b/tests/runtime/test_tools.py index 728a4a839..19bdca380 100644 --- a/tests/runtime/test_tools.py +++ b/tests/runtime/test_tools.py @@ -903,7 +903,7 @@ async def test_find_attachment_rejects_expired_naive_timestamp( now().""" expired = _attachment( "file-1", - expires_at=datetime(2020, 1, 1), # noqa: DTZ001 -- naive on purpose, see docstring + expires_at=datetime(2020, 1, 1), ) _mock_attachment_page(mock_rest_client, expired) tools = AgentTools("room-123", mock_rest_client) diff --git a/uv.lock b/uv.lock index 3f62a5c05..73cca6e45 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -67,7 +67,7 @@ constraints = [ [[package]] name = "a2a-sdk" -version = "1.1.5" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "culsans", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, @@ -79,9 +79,9 @@ dependencies = [ { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/b3/db19deba777c2ac8d963d2d0c123023730502d21c62b725eb4bdcb351c33/a2a_sdk-1.1.5.tar.gz", hash = "sha256:f81837f86a9bbb3ed4909539be642d61e8539ce8300a6578fa71dc901ee65b97", size = 402715, upload-time = "2026-09-21T09:52:26.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/35/55b15fd6467ed4e40860badd5fd2040dd4612f879c98a9c196f5ee4349c9/a2a_sdk-1.2.2.tar.gz", hash = "sha256:09be001a90fae9634d54041471feca1014201ec22f7107f0883309a2b4027a15", size = 431163, upload-time = "2026-10-05T09:50:44.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/91/087e4281bae91e19c4bb0736dc0807725e2e1f641dcc1ac86e625137c32c/a2a_sdk-1.1.5-py3-none-any.whl", hash = "sha256:c1d63d36b79a097c62dd9be001fce21d5939f5a811a9c5afd0ba8082f13f68bf", size = 255110, upload-time = "2026-09-21T09:52:24.741Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/1f00b7f1cd5691b78045e495d7c46af810557414695cf68b1847e2b5e25d/a2a_sdk-1.2.2-py3-none-any.whl", hash = "sha256:f2aa9692f3e3034f58ebff501e091d43affa416878c734fd63016ae0b8ea8c66", size = 275972, upload-time = "2026-10-05T09:50:42.784Z" }, ] [package.optional-dependencies] @@ -113,7 +113,7 @@ wheels = [ [[package]] name = "agno" -version = "3.1.0" +version = "3.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "agnoctl" }, @@ -128,9 +128,9 @@ dependencies = [ { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/7c/11b507cc29e415c9299597357a68e59bb26a0c4dc96ad8f5998e90b2aa48/agno-3.1.0.tar.gz", hash = "sha256:ca4da62231c7231db5d6a70713a3a0b51598aa17ae518defc9a28ba99bd69abf", size = 3691589, upload-time = "2026-10-01T09:30:27.527Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/4a/1a76527a0dd9de97e18aca48cb043249e4077f3382230a5d2df37dedd333/agno-3.1.2.tar.gz", hash = "sha256:cd215d0267a236820bfe4a3897e5cb3d92e1b88e325662cdb77426f5f58d453f", size = 3766253, upload-time = "2026-10-08T19:53:33.476Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/ff/76ce43000e5235e670f1b431648d83aee7c2916aba1e1c61e497b9ca4768/agno-3.1.0-py3-none-any.whl", hash = "sha256:725dedef256937e282c82796fe204959310be0c4286a927dc0a5e029892048c0", size = 4191348, upload-time = "2026-10-01T09:30:24.224Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6f/dc48f29d790d426e9fcb8a63b870bdb2020d70e4a6c34fca8b6d6c199d0d/agno-3.1.2-py3-none-any.whl", hash = "sha256:7e49aa04c7ddbfd27382e5a6ba71dc49d8e431fdc83bfa3cf5d9e2da992f56b8", size = 4273104, upload-time = "2026-10-08T19:53:30.29Z" }, ] [[package]] @@ -210,7 +210,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.3" +version = "3.14.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -222,108 +222,150 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, - { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, - { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, - { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, - { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/93/2f/6a91adaa2dc26877d6ed2f54c0370c8910f019db7d77c5c6a194611e93ea/aiohttp-3.14.4.tar.gz", hash = "sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9", size = 8077839, upload-time = "2026-10-05T17:43:48.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e3/998dc75634ecd2a24f33bab480b57313059d55bcadff8825a46b7c80a8af/aiohttp-3.14.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb0093ca0817019d539f0d27bdf98e823944e05bb7efd9d2337a45b92e4d1b06", size = 832892, upload-time = "2026-10-05T17:38:16.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/c9/9bacc26397854440d169f70ae00ea51721f504611d3a64cf043643792c32/aiohttp-3.14.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f482dc9c8309801b4259dd218609d3dae0a7bcdedeac56d90500eabaa0c23465", size = 559893, upload-time = "2026-10-05T17:38:18.74Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/90c5856014328a8b1e9a753de794edea282182b776ff65f0602ad4ffa520/aiohttp-3.14.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa800bb7c1d00e166493931f2ddacb982db913292503dc2f4884db1f2bc5f105", size = 552850, upload-time = "2026-10-05T17:38:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/9b/88/e71c29ce6daa78fcee0feb1992f0fdc408f09f42c3311b1f1c68c36f16ea/aiohttp-3.14.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d90e4dae71b26597f6d1fd12af7d092ae67551edf0e524b73e0687214b79ee33", size = 1928941, upload-time = "2026-10-05T17:38:22.218Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4f/f0f3bebfa521a2ce5fd4302c937701cbc50cd6d908beaa597388639b0b98/aiohttp-3.14.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b681054b8c3b86aa6a0384b6ad7f5cb78dccbabab1f4a3a6793f98ad1378959", size = 1882092, upload-time = "2026-10-05T17:38:24.016Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0d/5df041fd25dceb4f4cafbbee49bb2c1e2d54a91aedecaf03b77cbb404c44/aiohttp-3.14.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dfe80b41bcf8d80d2125fb99a171adeb86db2fcb35c1a68e0226acba0ba7f03", size = 1986051, upload-time = "2026-10-05T17:38:26.022Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/e95eddafffef7c3a74446f67ac4e477a49cec2c78f2ab6c86d1acfd90d2d/aiohttp-3.14.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:71e4a59c6a8c5a6ae8b0b696c32635913e8f526cc01448762eaf5a3c59df8a4f", size = 2107840, upload-time = "2026-10-05T17:38:28.112Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/1f83990fea249dcb10f48086726953546efc9199c96b6be7114532ac361f/aiohttp-3.14.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f887c3b7fb3058fc1ccd917f52078540c18d938d12018cb9edd64b6811d20c57", size = 1930721, upload-time = "2026-10-05T17:38:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d1/48184a186ea26df4f81dd466144dcab7f9cdd1e131bfee0f0e41278c6f0d/aiohttp-3.14.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0da4525e9ba145a11617d2cd7e44fe1acaf471f9c72a0cad28e72879c1020fbb", size = 1774508, upload-time = "2026-10-05T17:38:31.8Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ef/a9ec758c6d3850453838c31b56d1e4499eb47c80883753a12dcb203f9afa/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7e8d3c5caa44fdbee3eba1aa1417aa58c74c95d4089fe3c451911117fcbb2f3", size = 1902311, upload-time = "2026-10-05T17:38:33.791Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/9a2be54900f2dea6e63bbf9628fced604686a2e301130ccde0b1aa2e7e53/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:09092cf906c18c824b6b16881da5a89cc9cfa9b15344e496bcf880e9045bee33", size = 1912107, upload-time = "2026-10-05T17:38:35.614Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/151254abfc0fe5cbb8e6c41ad04d52122db0af176fb31dbf3bc44e081bda/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:dfd144837e92264878bba6d3a7ffa8206668b21e87748740afd45d193e48c87e", size = 1960207, upload-time = "2026-10-05T17:38:37.322Z" }, + { url = "https://files.pythonhosted.org/packages/23/99/92c8dce336ac5784755a3144623528516f2e3b6b4871d02cad5a0642ae66/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9413cffe4e0d654b9b524f9c99dde8cbdb37e0ed9a2c4edd2e63adbac6b374e8", size = 1760168, upload-time = "2026-10-05T17:38:39.214Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/f05fd8d1a9595730db8d522882eea76429b9be2ed5337609f7c6e3d9d49c/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f50d719f97ba488dfb8e306cf8c8cc172ff0683ec0af53917b288229aa555ba5", size = 1979946, upload-time = "2026-10-05T17:38:40.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/8941aa73ffceabc2a0eb26878b4f1b2958fb7b5358f97cc49b1c00ed9258/aiohttp-3.14.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68ed4824d96b7afec1f3a8d7fba190fe282718041c5772e20298003e1e3e5d66", size = 1923375, upload-time = "2026-10-05T17:38:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/18/b5/ef275b63c8bfe0a21e7e8d3ef0e67c4745ed403f1894fe5205057cedd3b9/aiohttp-3.14.4-cp311-cp311-win32.whl", hash = "sha256:d7a41d1427136828e6b11d3f3fd194d4357d5205a5b610be7ae07b1ba52ab974", size = 488414, upload-time = "2026-10-05T17:38:44.978Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0b/21bdd70a1e072222d9dbd96bed4fbed26f7c46c5a20af11b78d32fd049d6/aiohttp-3.14.4-cp311-cp311-win_amd64.whl", hash = "sha256:2efbdb87e79d596325c4eefaef0f4495d701145e882351310ebc538e854dc7a3", size = 512755, upload-time = "2026-10-05T17:38:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/bb324a7d20a9598187bb028ca8d6bcf66c63589c8632420919dd576964ed/aiohttp-3.14.4-cp311-cp311-win_arm64.whl", hash = "sha256:ac6e6f90d9360e460c873f945f11dc8698db0a71fec6612823f1aaaab2c11faa", size = 492960, upload-time = "2026-10-05T17:38:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/22/02/db5935d45347fa8ede56b777930740fb0e72bbb4473ddb6ff8541f6f2afa/aiohttp-3.14.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:55b0b335982b0db7117dff7f6a8e3aad43c4105f9e392ca04f9fc8d958a95cce", size = 817689, upload-time = "2026-10-05T17:38:50.69Z" }, + { url = "https://files.pythonhosted.org/packages/e6/56/f00aa46127e6342f019d567853eeeb932e04fb9b9424b5fb981ad68f4178/aiohttp-3.14.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d0d8e435582b0d53009dac99e26a3d2be932ed7c9dbfcfc70f3fce1cf943bd5", size = 549599, upload-time = "2026-10-05T17:38:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/825c09b09962253a2103e4a88c04374ee939c0c8ea31c270daa0308efb07/aiohttp-3.14.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf734016932d68e1324cfb638cac7a0866a83933a33badf15e836a7e24efb8c0", size = 548132, upload-time = "2026-10-05T17:38:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/40/80/8a1bc8036540fe7cb38219cc5c0aa67d32e9068165725e8199ea8200b08b/aiohttp-3.14.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:322e3d741b3133bcbb981a41d4f9b8ac3545c18de49c662c2b1f4e1c770b98ad", size = 1898485, upload-time = "2026-10-05T17:38:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fe/65b464d8520ace0d65b4ca69bc9abe175f842c8ce8d1522d363d9669903f/aiohttp-3.14.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15a3bb5f90a4e515071f3617a45dfe26560beb834a6b6e108cdf4bc9e9719ec3", size = 1875334, upload-time = "2026-10-05T17:38:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/9c583be502720e927560a8414f4dfb7c7de41945e1a30ccf7003b93c6f85/aiohttp-3.14.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c044e52df1a466af82cad3518c92ea80cc4ddcfea86966ff083bb34bab29bf4", size = 1937772, upload-time = "2026-10-05T17:39:00.376Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3d/b56512df7ea9cbb5f59099a6b375088dd578356a5a3fb27198ab82e19832/aiohttp-3.14.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d613d7d51bf06fe9a5e3aab86ce06d835bb0ceb1a31ea9e8766df4a403ae62a6", size = 2066220, upload-time = "2026-10-05T17:39:02.54Z" }, + { url = "https://files.pythonhosted.org/packages/11/6c/a7a42701d9ea319140a3d44a24ec72a00b0b46d1b899b26d8a705e65bdd8/aiohttp-3.14.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5e93ab6e6330dce40f5b43996f1fc5f2543296d263a5e4b59ff09e73870b7d8", size = 1922895, upload-time = "2026-10-05T17:39:04.638Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/f8337bd824ca3fe3968d34dc5037ace1c4872551c4621f8266239311d301/aiohttp-3.14.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20134dc8e68c67e7478124d516449428684c0545b2d3faa575691c8bcbd56678", size = 1712589, upload-time = "2026-10-05T17:39:06.671Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a6/d20e2c940b592d7c4a21690889dbff64fe0ca3facd9190696368694e1488/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f0daef5ef012369dddc391c61f604111424aa9a96313a69fb899018b09d1b01e", size = 1862251, upload-time = "2026-10-05T17:39:08.523Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e4/d50edd5ae5d024b380a8e892e333840bb22967a9eb9c8492108a403f678a/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:19290a108de0718b73bc69b86e788975c6a59855c671ebe2a98a07a6f968328f", size = 1895155, upload-time = "2026-10-05T17:39:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/f36f2931f807ccf8437ec15e8f8bfaa4748d4792639401cc767a866abaa0/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1494877625baeffab669f4a25a09ec4eea721b49e35363081a2a8e4231fe141f", size = 1906623, upload-time = "2026-10-05T17:39:12.411Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0a/94821b711493142bf5e11c27b0d7502776778f7792c77ecc6647357d20c1/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a779ce4bf099ce4ad2cdfed62f1ca536d435f53237c54ff40172fddada24d1b5", size = 1698932, upload-time = "2026-10-05T17:39:14.446Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/3d5a0d28362f0053c1c457024749911932481e8fd105fddf8305b1f93c81/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0b4cec9fca876e2d4f4c6cb32b177390132d04618867689f43fefabf3f5c3980", size = 1934528, upload-time = "2026-10-05T17:39:16.354Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/9784a21dc05d2e0f6acaf864f16db53dab0fbd7b0753cef168601eacb012/aiohttp-3.14.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:217707313e23aedde9bea67cf8365c7b138b549ae179121ef3f476cf67e3173a", size = 1901984, upload-time = "2026-10-05T17:39:18.299Z" }, + { url = "https://files.pythonhosted.org/packages/44/4f/f98c88703e6d620243aa08473a946ca4e3756b6288690e38f0d9f9c1d007/aiohttp-3.14.4-cp312-cp312-win32.whl", hash = "sha256:18a9fb9a3f6e6e63f4d27f9adeef313a2d3c69a84d31170a844623824e722534", size = 480278, upload-time = "2026-10-05T17:39:20.206Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bc/8555013f84b53e4fdde423131b641af50e822c6d7d9118b24c52b31f6900/aiohttp-3.14.4-cp312-cp312-win_amd64.whl", hash = "sha256:69dcb02d33dfe415d5ee342cc63b7d320efcaf4b761701c7bd7bfb91c715481a", size = 506317, upload-time = "2026-10-05T17:39:22.706Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/ca5bad7bfdaa7ddca053a044a1c7c0d4f3ad9d1f12f96dcab10bfb9c50e7/aiohttp-3.14.4-cp312-cp312-win_arm64.whl", hash = "sha256:d36b0263e7c2fbf1750b9f35d4e7e48b0442d8c45e4a89e3e74168821fb014bb", size = 487683, upload-time = "2026-10-05T17:39:24.482Z" }, + { url = "https://files.pythonhosted.org/packages/23/ec/7fee140da700ec3f5aa5e0fd2319f67be5e86de197e2384d27e5e6209e0e/aiohttp-3.14.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:264b5a7568306590c44ae4a0237ad1715e35d447ea44ebf324e4c8fd9f78f67a", size = 539114, upload-time = "2026-10-05T17:39:26.322Z" }, + { url = "https://files.pythonhosted.org/packages/4e/04/930c01ce9e186e787046433d7dc5b45b188885f8406c14b58435b4431974/aiohttp-3.14.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:17e5d7c7775d8dd8e894c7ef6ebfa26509de36868a9a6c7c4fcfaf6a1bf43d60", size = 553514, upload-time = "2026-10-05T17:39:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d7/983e3cea81c07e95f35d34da4d1e182b5c05489c49b7560b198a83f0da79/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e91dc8fe9cbd052d16f7f1267e285cf8bbac4d2194bd5741f05ef201c77ed09e", size = 521381, upload-time = "2026-10-05T17:39:30.419Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/80baed11659093883f51245755105557d3253e45bd0b693d70df0059d68b/aiohttp-3.14.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8df6157d31703972aec9e3b93963f039ff06318301e30b007c3e941ff8a7ac42", size = 528347, upload-time = "2026-10-05T17:39:32.344Z" }, + { url = "https://files.pythonhosted.org/packages/c8/18/bfda255c311096c08dab26dc65f3d219f090df37dc5f5f6345f8c3aefc1f/aiohttp-3.14.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:220912b351549dc736c59d104dc443fa615753102bb778a4e98f86e4d94bff6a", size = 539914, upload-time = "2026-10-05T17:39:34.265Z" }, + { url = "https://files.pythonhosted.org/packages/51/fc/3b6ca6117c0c42481d719411fff7b5d3970ebd906543d3dc1ba40fe2462f/aiohttp-3.14.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2efa42b39bb3f524d5eca3760639b584afdd1143a6b047fda29910ffb75e4993", size = 813997, upload-time = "2026-10-05T17:39:36.061Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/86e3e02a9712aadc1ae0a1076bb2d1adf3d9addfe5542a28190c8f9b6d9b/aiohttp-3.14.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e7356059c8262d2c1fd95242e2b7b70fbc6eee3135bf470049cbec3e968b5b0", size = 548058, upload-time = "2026-10-05T17:39:38.109Z" }, + { url = "https://files.pythonhosted.org/packages/85/7e/6b9c2e271419d40ca76589613b3c7ab185fd3050a407bd2565b6aa46f695/aiohttp-3.14.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fac247d0cc956d732df1211ac2d8a9999ec8dc1a92a90401de9d52e4ead546f7", size = 545933, upload-time = "2026-10-05T17:39:40.193Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/33d6e24a5f8ac32423230088c415e2a1c6efee23d902b62f20506631e40b/aiohttp-3.14.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2eb0f8b03b4f2bd154ed8cf7c1a119a1ad04808c258bf58f90138c83b8c6d5", size = 1888265, upload-time = "2026-10-05T17:39:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e8/2eca1d468d83a514621d1280f9992d08649191db2028c9236b4edfa7c723/aiohttp-3.14.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbe827d27e0e369fd7bb88ceeaa5b46ea006e73e015b9dd77e3723a53162293", size = 1872300, upload-time = "2026-10-05T17:39:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/5872983de71b0214acdfb86822f5cca295b39be4832ec589fa224aee0f6c/aiohttp-3.14.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70387146d03af9f047c629b71aa27d9594b3b31ec4c9c91e1a73d0d8bf38e0e", size = 1926213, upload-time = "2026-10-05T17:39:46.199Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/ebfefa11a546c33c717af63c67868408ecf0b33b23a9d75669af95485407/aiohttp-3.14.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d0934926fc65744e2fdd44ce68b2d79d5ee608a1e23f0596b35dd696519bb7c", size = 2056609, upload-time = "2026-10-05T17:39:48.221Z" }, + { url = "https://files.pythonhosted.org/packages/69/c0/70667e82ec041f4122a17afce711bbcf94a71bec5888b69a49aed8817be2/aiohttp-3.14.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4b366510c733974adb7a4102c094e4a6308e6f369315a4cf14d2d0a619cf82", size = 1914740, upload-time = "2026-10-05T17:39:50.304Z" }, + { url = "https://files.pythonhosted.org/packages/bf/86/559e9dfea676206b20737d7ccb5b3716bff5e58d2a8d53682e5301724cd1/aiohttp-3.14.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:85fac7c99e0ac3dcbd6ae1c33774e0603bfc732ddb967a6fce4a731103c1888e", size = 1703390, upload-time = "2026-10-05T17:39:52.385Z" }, + { url = "https://files.pythonhosted.org/packages/fc/91/c98897d4cf23e120b2f3980042835cb34c86bbe3c653f3eaacf4bf986349/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef1e9c3b4a2300023a5dc865889ce532cfaf6798ba4ef78fbbefe738a9d23efa", size = 1852642, upload-time = "2026-10-05T17:39:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/14/13/0258f352cb2c236f358a5b57d138e15fa2566e17accc0758d968dbf2ceb6/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:97e65916588be0f952b3307aaa8460c0771fa98dbea2dd27dabc9f5d84d53c3b", size = 1890616, upload-time = "2026-10-05T17:39:56.466Z" }, + { url = "https://files.pythonhosted.org/packages/64/3c/f43e294ce1a06d1234a97bd862c1aa31acfa1a0eea3786c22fd0dc1ea731/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:840775cb39a9424f9edf142133a1192263ac5bd79117ad2a16602cef6a0c1e9d", size = 1896638, upload-time = "2026-10-05T17:39:58.585Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f7/ce1e3a85b73d05037d729d3d32da7d6959b9e433435e9abd46c2fb38410f/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:54f9256430ca040d58affbd551513de13a5cff7d065204fc52b9a1b0af09a049", size = 1693003, upload-time = "2026-10-05T17:40:00.698Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/45eb900d6a9ee046728f5589f3d07f5d78f049b5fb84f48881fa4174d5df/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6112d5931bf12c776188cab81b82bbb17d86e2e1381f2ade6edc43e6b240f0cb", size = 1927730, upload-time = "2026-10-05T17:40:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/b2/43/a83ff79dc8375953a1f00b808a21c82590f4e68304fecccdcc42a25fb7d8/aiohttp-3.14.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:facc6df39047c4732cbd885bb62e40df20885fcd709fc399e11f81b45ce0fff9", size = 1891177, upload-time = "2026-10-05T17:40:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/fbdc41608d4e2fb6084d960a41b392fa82beb5b126c2466de549b5c67fc8/aiohttp-3.14.4-cp313-cp313-win32.whl", hash = "sha256:7968634fa3a967a2bc0b9aeba1d5a1955501dcd7d0de44b96f7ebfb662797735", size = 479943, upload-time = "2026-10-05T17:40:07.402Z" }, + { url = "https://files.pythonhosted.org/packages/35/51/d75629750e705832fd5cd52516a82943ed58b6e09c30e14830f5d87452fa/aiohttp-3.14.4-cp313-cp313-win_amd64.whl", hash = "sha256:c0a894b0265d139cd4c2c8bd4643822cc289a4a5331e21e810881bd000ffb2dc", size = 506209, upload-time = "2026-10-05T17:40:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/02/64/66452d2ffcc13e5e5ee4ba4a968656fa4a98c29ed5ff0205d4fde02b2943/aiohttp-3.14.4-cp313-cp313-win_arm64.whl", hash = "sha256:da16c3037178e72589d91de59536d271dffc92c2be2b47c38a45538cba54fd29", size = 487163, upload-time = "2026-10-05T17:40:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/b653b1f969e2e949fe4726af7a8043e8ef117f202bed7757ac8564e077a1/aiohttp-3.14.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:749bfc43bb1df71bbe6ed215044d5412d786698fc5771d2037436ca49622f3c9", size = 540738, upload-time = "2026-10-05T17:40:14.109Z" }, + { url = "https://files.pythonhosted.org/packages/90/b5/eea49a0bb697deffac5165d707ec53a24748d6720b3af77a544107b49573/aiohttp-3.14.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c113e4b489d711f606ca278cd8dc89179746244ea841aa0638b1b134600afad0", size = 554494, upload-time = "2026-10-05T17:40:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/11/ea/cf8f4efbbfa8572d9965cd43b3c6c84c581aa006772e5ce3ab4020027e9b/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:50969522bc7022026a2c7968e67da5c789f6b716b988edac55dc630390efd5df", size = 522653, upload-time = "2026-10-05T17:40:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/a3/24/9f6721f0b52323789cb1041ffc3afa729462d9925607e733a065b6fc5e17/aiohttp-3.14.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:cbf92cd7a1c800cee244558dd5630abb45c1c1093ef7b0d546a99f526ff7a9eb", size = 529697, upload-time = "2026-10-05T17:40:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a4/399debc3c2ca0ab88a08e3082b4c4f62d0f66978af60243c63f2345ef337/aiohttp-3.14.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fc76be08ff407fba717a769aff04ca1b4bf9ecfdcb5caadef84a563fdbbd146d", size = 540526, upload-time = "2026-10-05T17:40:23.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/97/90379813b18b795bccdeb3a5a85e9b93ac2c69f9c2a5d0faba59da3ad6d1/aiohttp-3.14.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b416f995b68a921069e6ec61fa07f2ac3ff033d92e97a69f673af89f5bb954d", size = 818866, upload-time = "2026-10-05T17:40:25.842Z" }, + { url = "https://files.pythonhosted.org/packages/78/70/824fd0dac819652f1df895b3f621a75bf0a3050c30e38d8d2316e34ab821/aiohttp-3.14.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2c9585101cfca10e74a07cc0ef2bdc6d90616e6750926b20456f429d0608f740", size = 549666, upload-time = "2026-10-05T17:40:28.523Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/eafb2dd200dd2b770ed25a2dc5731c4ba5d58ab3fa8e2e82494bb946808e/aiohttp-3.14.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3c4dcf611b095e7983421f49d01ffbed7ed574891533d606ea115fc23e7c4e90", size = 548961, upload-time = "2026-10-05T17:40:30.613Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/6c733a1c71300293471ac7ef1dffd12b5b4dc9a43c53461224b2b8a7f667/aiohttp-3.14.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8f61a9b5fa56c58e3441f9dc64137f786232d40fb89127190d0e0f84e1e5865", size = 1883165, upload-time = "2026-10-05T17:40:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/885ddbd351679a31141b25e0d1a5e4f67738806e564c858b399709b61239/aiohttp-3.14.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d43e538dfc85b705fd832620d050970ef5613b3869a7169b44a554b9dab9eee9", size = 1844119, upload-time = "2026-10-05T17:40:35.266Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ef/0a64dac53c11d569851efb3e7eddb43551df72b2c172ebb34cccc93bcd3f/aiohttp-3.14.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4135c4bb4b7682a7633fadc7170ea969d843f83aa7224e70111a20675105c985", size = 1930222, upload-time = "2026-10-05T17:40:37.527Z" }, + { url = "https://files.pythonhosted.org/packages/69/5f/774792ed8f973e0d234fcb06b0f0bc9f29e9a0a0dd847f6418c2904be7d9/aiohttp-3.14.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9055324f156a94fdf6dc9a825ae60f9a2947b88f591ea81ee80826633de62ae7", size = 2038842, upload-time = "2026-10-05T17:40:39.815Z" }, + { url = "https://files.pythonhosted.org/packages/cb/05/e00f35581c97656d9e864edaa03292382c620dd1d9c836a4f73a2279acc4/aiohttp-3.14.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb22c8e4b60385a9b414c70c6e8b2d0f4b23ff7fc4bb572ceb58297b228169f1", size = 1887845, upload-time = "2026-10-05T17:40:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2e/07e3eff5b4c1b082a2eaf0ffb508b409b21ea1ddc9fdc57f3eabb51469a1/aiohttp-3.14.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b6945559a260112742ed58cde2165488d2fc51aeea34afbaa99d9371b43e1b5", size = 1702533, upload-time = "2026-10-05T17:40:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/75/44/816844b7f876e6f8fee4ecb088269fcd92241da712e4814a4d624eda13a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f4078a90b174f15efb2c8725e8720c40dbd3b2cf6350dbc085c964a938d9de7", size = 1849058, upload-time = "2026-10-05T17:40:47.818Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bd/163242e12464a1f3e58a77e28beca949652ddce84abdd5500a91c70a1e0d/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f433c5654e32f72de3ef074d715eb5c7604eb6d28a5c2f3c9e5056f20fe6b306", size = 1854600, upload-time = "2026-10-05T17:40:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/59/5c1ae708a89524eddfd9c727b87cae26b5c50c3c76683b910ac06151ead0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:871035fe79c636b6bc126ed78df37d72b830dfc751612cb4b1040360edf2719f", size = 1896565, upload-time = "2026-10-05T17:40:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/95300a7de69241fe47d3f838f3fb582c712782e172d447ff650a013ea673/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2617132524f66338f6c1d218cd16081576aefc40df4f2bcba338a99fbf67fec1", size = 1693092, upload-time = "2026-10-05T17:40:54.862Z" }, + { url = "https://files.pythonhosted.org/packages/ff/33/01a8e61d48d0094121092c74125df14c0f8f10ee3149bfe68b6f869409a9/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d8bc326133272deee8ef337581550450e4bb735c0520be893b950a1f5baaf41a", size = 1917073, upload-time = "2026-10-05T17:40:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d6/1a4f24624cda6686d5b927e26d5d1ae3ebb692faf88e93017fab4b391fd0/aiohttp-3.14.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ec57223e261b7fa9979c12dbce009ca53439be76cac3af27f860d3e4b1d6f7a", size = 1872656, upload-time = "2026-10-05T17:41:00.123Z" }, + { url = "https://files.pythonhosted.org/packages/be/be/ed286fcfa2d22061ae021b568c790123505cd482df35e23023339ecaed96/aiohttp-3.14.4-cp314-cp314-win32.whl", hash = "sha256:7242043e71fc449a19a47d92f4e9fd1e0ae8d3f88488982381f422bf6d651218", size = 485461, upload-time = "2026-10-05T17:41:02.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/74/e040e6f65f4da305777f1adb3023d47401693489e4a337c983b52d2a0a6b/aiohttp-3.14.4-cp314-cp314-win_amd64.whl", hash = "sha256:019587c7b3a44917dd61bcb312fd5c034f341dc9851b83e7b41b65e08b68ca0f", size = 512147, upload-time = "2026-10-05T17:41:04.98Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5e/bddb33ae15fdd5a68a10a986587aa9916c418a712fdd110d3b3ae9b51407/aiohttp-3.14.4-cp314-cp314-win_arm64.whl", hash = "sha256:60dc71d38db0988f37e78f6ad4a010a2f6355cfac3fff5f130cf5ef2231f3f0d", size = 494685, upload-time = "2026-10-05T17:41:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/22/2e/7b884014b40573f919fc3a528e5028bc37b249e31a93928ba61bdd25c960/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:674f53ab9d2794e2dd767619a3384bf1006f8d075e43c19d449a645f8ff641c2", size = 855696, upload-time = "2026-10-05T17:41:09.514Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e6/b6e2b4f580ab6ae39241bb9182aca60e6e1915cdad0148faaa04db3f366b/aiohttp-3.14.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6d243221f73e5ac546c2ed9255100423e4bc092b08b944f23498e8cf3096c26c", size = 567931, upload-time = "2026-10-05T17:41:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/63/99/ae8e3a1a18adcbf636839d18b3cccc51007836ccdae5209381c5cf82d1ae/aiohttp-3.14.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0daa016eb74e7888ed5f9ceec6eea3158999c1c4a115ef5cbe88baa8d924f94c", size = 567421, upload-time = "2026-10-05T17:41:13.836Z" }, + { url = "https://files.pythonhosted.org/packages/63/38/c5d4bb58be72b967a4090d2deeb38571f8c992852936c51dbbbeae49db1c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70c031a622aa20add05ebd234b90adaf6bf51c1c88cc305ec760721af83308c3", size = 2054173, upload-time = "2026-10-05T17:41:16.098Z" }, + { url = "https://files.pythonhosted.org/packages/eb/87/3c8a9c7670f56d478a75c5bb84b336fb61a67b908e36c30a4a96ed8c8e86/aiohttp-3.14.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7df363fa7c38945a896e12c69af4a1a56cbd4152791d9e41fd0bac396b72de38", size = 1918296, upload-time = "2026-10-05T17:41:18.478Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/bd00e499c0a1505b44f5e23b9739034ea9be792ad42537f2894211ba1c07/aiohttp-3.14.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02a508ba5ad91c4d730523584c0adc65882274bba5e37c33bc2f475e37577256", size = 2027513, upload-time = "2026-10-05T17:41:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/23/1d/a3666a7c224f86a881f18d673a8ffdc92fa4c35274fb4361822f8f0dc566/aiohttp-3.14.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0cb3edece6fe6a944eaf97d803b88f606da6bc1600b1894c2969e78ffdc1fc29", size = 2152686, upload-time = "2026-10-05T17:41:23.181Z" }, + { url = "https://files.pythonhosted.org/packages/46/7e/f4204b8c4959c46f6367c182afbbacef08a1b53874281912c52fbd80151c/aiohttp-3.14.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21ce1223b461bcc5fb389a7204001578f1c0b85c2da60a84d915ebb021294245", size = 2006431, upload-time = "2026-10-05T17:41:25.776Z" }, + { url = "https://files.pythonhosted.org/packages/33/43/091e69d42967cbea833ceb355c5457f8f70748fe08b88c9d91491b3fafb8/aiohttp-3.14.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35d5432f0dc1c8b71307e9112461b52f336da225e344283eec4779222c0a1dff", size = 1789101, upload-time = "2026-10-05T17:41:28.095Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/b57cbe533c01098c5d30471f057e7c3c19f281122f756acc9c716b636e2c/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6ec364493057c118d08292f7cd60865965c209167d8657b45643a26aef77495", size = 1974472, upload-time = "2026-10-05T17:41:30.499Z" }, + { url = "https://files.pythonhosted.org/packages/9f/44/79acce03611450e4ed8594f96f1cea40d70bc84fb4e918f94d7d9454a4af/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:70492983b7aed9e61bda82fe1ce80bcba9199610886443133783a288638923c0", size = 1956340, upload-time = "2026-10-05T17:41:33.009Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/36e6cbd6720e3377a02f8709aec6fe80de729ef41e78ba01f13d50363c73/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a77776673331577147e9d25cd7833a72e8891b47556336cff06ba1569e488b99", size = 1965771, upload-time = "2026-10-05T17:41:35.506Z" }, + { url = "https://files.pythonhosted.org/packages/69/0f/879735ac70ce5e6852916eed51c706a517b205150a8d384f9fa60933a3fc/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:73799000dd6fd5247094fd8e6d3e1eb871e41aab5cc35dff9d9f654cd230bc24", size = 1780297, upload-time = "2026-10-05T17:41:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/7c030ca5b6baf3b02eccd11afc3d58f0b35e407031d5cc78cae9c24921c0/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:27b370fb532e707e8c96d04fa0c96301fb7dba2d50aaca57f6c456cdfffec5bd", size = 2001374, upload-time = "2026-10-05T17:41:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/ddacbb8141d727034c51dd192d17749521d5b30b03f84aa034325396089e/aiohttp-3.14.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:884e0d1c707b6217fee3080b9b4029eee7551af788ee761010df19be158414b7", size = 1968531, upload-time = "2026-10-05T17:41:43.62Z" }, + { url = "https://files.pythonhosted.org/packages/3a/08/2214f823701eebc3e57db141faeae76df7bfd415f254cc7c1692258f228d/aiohttp-3.14.4-cp314-cp314t-win32.whl", hash = "sha256:f2e3d34edb151d3e27f93a8338e2da33e2c7261cd4c879ce5f5fe280cc190ea5", size = 504126, upload-time = "2026-10-05T17:41:46.321Z" }, + { url = "https://files.pythonhosted.org/packages/91/a1/90e9b6fd23310b4e82d0c0a37b5ecc2944053350473c57533c1da9647235/aiohttp-3.14.4-cp314-cp314t-win_amd64.whl", hash = "sha256:88cf889e51092537fe44adfbff96c2a22d6479d6f92b97b3d413fbfd0a4decf4", size = 531494, upload-time = "2026-10-05T17:41:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/5b501472c92b9b1dc53742591d6210dccb262962f4721299300d3a9f664b/aiohttp-3.14.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ca450c6d42fda7c0bfaf0e200f32f77c5f2c960a33be1e14d197c84588b7f5f6", size = 510544, upload-time = "2026-10-05T17:41:51.358Z" }, + { url = "https://files.pythonhosted.org/packages/0f/77/ef165a197850573ea07c8d77bf5f4854a1db12fdb20e1cf0b218abf025fc/aiohttp-3.14.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:3b10799df72f66c30935bc661cb89e5ac2918debfc575e350e226fbaa846cbb7", size = 540737, upload-time = "2026-10-05T17:41:53.761Z" }, + { url = "https://files.pythonhosted.org/packages/e6/aa/1663f64d1316b3be9d3f6fafa02b8d888070171a6544cba515e82f3af595/aiohttp-3.14.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:7ffd7f9dd39caccc6b2f68ec0339435ecad2b20f6ea2e2b3de91bbe464ac7137", size = 554600, upload-time = "2026-10-05T17:41:56.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/f7/d4d535855123c1a774a798643e75fdfbac62c047b0c0729d6505d09df444/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a0a7a227353f7693f73878ad14cd2623cafadd656ababcd20e2adfb7225172ea", size = 523184, upload-time = "2026-10-05T17:41:58.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/18a8d1cd2958a0b6164b047c18ccb192f2123bb70f1c0a145f6f01f858ad/aiohttp-3.14.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1c43b6af8e4708e8b3812a9ee4f79895207e12d555b7f6d70deb838e0011e12d", size = 530342, upload-time = "2026-10-05T17:42:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/d1dd2b1fcf8a3192a431375b287a8762ebfd22195ab5b201d3fac30e3a7a/aiohttp-3.14.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:97416b2b997e5e9b817e322b2f0a89b76c54cde22471f51ac17ac15e10901ba0", size = 540629, upload-time = "2026-10-05T17:42:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/d1868f3459f618f6b68a6ef549d8b3f76aa616fa083298911fd3ae65d6b9/aiohttp-3.14.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:ee2a5aa31e507c17841d592364f2148393b17f83bc2766e9211e1eb31ea72a70", size = 819347, upload-time = "2026-10-05T17:42:05.713Z" }, + { url = "https://files.pythonhosted.org/packages/84/e6/53179b721b67e0bd20974d02eca97b5691559e8ce666ba23a65aecda6b7f/aiohttp-3.14.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:5d5d9843c34866e986dd24eef713452609e304550c471320ce7b54b3cfb1391e", size = 549825, upload-time = "2026-10-05T17:42:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/00/0f/d67ddf7a43d4899176954511b6c048328abec1a5df4901f811a3b408a4fc/aiohttp-3.14.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:efcbf93e31b52c21665b6ebc489eb6fdb0dfd86b592edc677b5cffdfa7b11677", size = 549315, upload-time = "2026-10-05T17:42:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/9d/db/42f372c7f8e259505f239cb49db75f23548deaec56d22c68b4866f1c3fe2/aiohttp-3.14.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f1a79977794592dcf6485ed964ec2448db527a1bc0c72ec89663658dad3a739", size = 1892181, upload-time = "2026-10-05T17:42:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/8b1900216302a94f69b41bf0786bb992a6e67d703f4698e335c2442db615/aiohttp-3.14.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:515e7ac6d27509dc3ba2c35fb88970b00b9e7b07ee7cc947c8733dcd54ef5def", size = 1883513, upload-time = "2026-10-05T17:42:15.704Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/15c014313e567fff931ec4a5f81683cc40251257791fbd607ec37c0dad80/aiohttp-3.14.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:185154ffa3b54765b5de96f464f41cfa476f8815a03854ae5e0fe4d48fb06e9c", size = 1933283, upload-time = "2026-10-05T17:42:18.241Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b3/bf7dc39a450218efb1d5c692bac7dc85ef56da282e889bee594f3267211e/aiohttp-3.14.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6aece59f77959628a22cd2ffe48b102cda9611d680e090f204385b3998a1190f", size = 2041054, upload-time = "2026-10-05T17:42:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b6/03/ffba18d7aaedb5eafac3f41f0c26621ebb0a740bb0115ea2fae1911d902b/aiohttp-3.14.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7205aa6589f1ad1306c6994ff2e1ab5efabacf938f9ffd7682fb225c197b06a", size = 1895494, upload-time = "2026-10-05T17:42:23.518Z" }, + { url = "https://files.pythonhosted.org/packages/45/f4/68123365b86826eee82bdceb7fb4c429204cb3a104d6b7ae44bc6a543fcb/aiohttp-3.14.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f38c7b3ce60d55ac0f2a9fd2edc194789d043fefea15cfb7dcb40b7d5f561146", size = 1714098, upload-time = "2026-10-05T17:42:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/b13c0b214afc571782e8667f3b9e0bbed6843397be90fa35acf2f3ecfbb0/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:b0a1274ee4ee6203c15a341cce87f9f3df8151d5496047b3608bb62bd969cffc", size = 1857111, upload-time = "2026-10-05T17:42:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7f/4f5d3cee3c23b57119aebba5174962fed0bc493f37f38edab2f5730411b6/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:69ce2407951516f413adf3132b88451eababd710ce631f83c81a9603a3640253", size = 1892123, upload-time = "2026-10-05T17:42:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/5a6a1f014c4e17d58d4e935d0dd64e612f4eaa2f33cca2475b48ca5e3fb5/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:b84d3d057c60eb8e0c2b457a1203e868d82709d3f0c272a4975dfd88c2414939", size = 1898809, upload-time = "2026-10-05T17:42:34.34Z" }, + { url = "https://files.pythonhosted.org/packages/de/f3/d1cca5311a70fcc1c7c2082e02044578a69bcb10686db33a322df9fb849e/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4ecc84f69c9e217984f6877e832e4786a74567432c0bf2b7512919fbdfe18ade", size = 1704814, upload-time = "2026-10-05T17:42:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/0a/cb/cd58eae366148a5b9b1baa233542151154f2b72cd32daa39fd9db97d0c0d/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:878f2d7950238b2b0a8cbb88054444a125f0cfbc77f54c811aca0483132a9be3", size = 1918390, upload-time = "2026-10-05T17:42:39.674Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/f5e6c4939432734033048ba631503edf4a1e693ee5e261773e169803bbe4/aiohttp-3.14.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6ff9d3a0e6935f1cb0a6685f9f4b3f0d3e406456d7a434a03cf309b8cd5d7a2e", size = 1878321, upload-time = "2026-10-05T17:42:42.528Z" }, + { url = "https://files.pythonhosted.org/packages/2b/71/462957d6b5d4eb208f78e67745c41480bad2e738aa7caf1d9b07536920c1/aiohttp-3.14.4-cp315-cp315-win32.whl", hash = "sha256:e07dfd7cd360f20be26dc2487ad4c7f21b0b39fdd593e9ebf37d2424ad52defb", size = 485536, upload-time = "2026-10-05T17:42:45.223Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6e/48f59266a8b8c6420894ff59b8e74175252da67df85967b96997e96b7092/aiohttp-3.14.4-cp315-cp315-win_amd64.whl", hash = "sha256:921fc4f1ad549091bfc39cb5e93925b04f307f83517756a3111843851130efce", size = 512087, upload-time = "2026-10-05T17:42:47.717Z" }, + { url = "https://files.pythonhosted.org/packages/25/f5/46263b23cee4f312e28aaabbdec2e074c807700a12c9dfa18beff461f035/aiohttp-3.14.4-cp315-cp315-win_arm64.whl", hash = "sha256:5be8af106f96fd625f6c264fa9faaaa9e0104089684d9015d2fa5b6eba6407c7", size = 494709, upload-time = "2026-10-05T17:42:50.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/8d/e3b1d406c95a8fe436ae2e5f01ee9591196c6c22c44c645b24f20d73eaea/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e69727ec04a4608200ff32ec29478299df7657e2eb78c416ae338d0ca6662596", size = 856014, upload-time = "2026-10-05T17:42:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fc/e9e78d1097830040bc0a4b8af073379f964064bfa7f0a852d774f96f3168/aiohttp-3.14.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f8b658dd3ef2ebd7708b311ed96ff7cb5edaca001a30018a091504ef492bcb1c", size = 567724, upload-time = "2026-10-05T17:42:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/c813a705a1da1c111c30723b52e1efb67763e9f0b3b54a418cb419616e7a/aiohttp-3.14.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3749e018123df1205161a73ba96d336e17e9cf6b947295e53a43571de420e7b1", size = 567888, upload-time = "2026-10-05T17:42:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1f/7676f46aa24554a45686853c61552d02d67ade2b4c51adfe8b2ec4fdecd2/aiohttp-3.14.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f28a3c4fca436b2e594090e7cd22558f5e93a30ca18516c436de982da6fc25a", size = 2054356, upload-time = "2026-10-05T17:43:01.781Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/b9ef7d7442061e0a3125990d6021230b30361a972f7b8ebfb011042877ce/aiohttp-3.14.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e099aae21246992a1d84303ff262262d054c474e46cace33b0d5a69810c71877", size = 1937678, upload-time = "2026-10-05T17:43:04.492Z" }, + { url = "https://files.pythonhosted.org/packages/5c/98/5927a31540c8e6d0542effead5bd5c56e749a9d99d84e902fb80ae906ef8/aiohttp-3.14.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fafb521891e646d9cf5da89f01c75e7159d08be7254a024149630a698be28251", size = 2026705, upload-time = "2026-10-05T17:43:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/62/bd/1be001ed97dcfa57a292188c38979fb78be795b960b50f02660587bffa06/aiohttp-3.14.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d385db2cd154bce0b3dd451cf2a52bec91ebb7be2c3a5d06b14b4391631d807", size = 2147075, upload-time = "2026-10-05T17:43:10.141Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f7/d59628ccedd7c86db4c1f904467d1c80fed6cc10a1831ed5d75ff12e5a71/aiohttp-3.14.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae8a244889d8a53549851bf595b9f382f64acca57e8762da1ecb26ba786828f7", size = 2005016, upload-time = "2026-10-05T17:43:12.848Z" }, + { url = "https://files.pythonhosted.org/packages/3c/dc/472db3ab1daafa12a61d7f83314f85f502b99c143c78d66554a5e24361fd/aiohttp-3.14.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:681dde68ff8d8d5e7d5abcad4feb45bfb457a218310c919825e21fc36cac3d0b", size = 1796363, upload-time = "2026-10-05T17:43:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/61/6f9834b131b9a42e30d68c469c196033d3cae2d8fe340d4668f362c4fd1f/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b91a6441fcaed88ca7bca67726b189332df3ceb6ac0dfc98dcb9654bb723288c", size = 1974240, upload-time = "2026-10-05T17:43:18.884Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/b8eb2a276685ef1da7f7bc15348404b8b9ee12b8f32f2cd8895385cd1589/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f15dbbac49bc15eaff95c0b8166525c4050996af578c7be141e145f40ee2cf24", size = 1973387, upload-time = "2026-10-05T17:43:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/5b/32/ad559b2d6817e85f7cb294d8641e975e64b526f4da98ffe941c9af67a047/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:7abae043e87692d1c2adda1fab501bb0612d28c4a23eb082005c1e684f792dac", size = 1968766, upload-time = "2026-10-05T17:43:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/60/67/4a16e398140bdabb363428b9f439af3f77706a6df8bea6e626bbde53c2ab/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:21f4624fb1051fc1aa10d57a636a80e829f7d0417462d6f1ce380b08fe1a904c", size = 1788193, upload-time = "2026-10-05T17:43:27.467Z" }, + { url = "https://files.pythonhosted.org/packages/a9/38/17b326da7f52e530a675d789ade3e727a1b3db9959722f6049b9c392321a/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:2e8dc3a0fdaf9d12b954d3d06cc4c355d91b17fb6c671715b593624ca4c379f8", size = 1999645, upload-time = "2026-10-05T17:43:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/91b87961250010e05eb3adabaf0d98e4229ee7543147c265e834922e1b0b/aiohttp-3.14.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1410b6f6e3d52ea7c02c950496522ce6e85d5543bf154bc80a6141e505251cfa", size = 1970998, upload-time = "2026-10-05T17:43:33.364Z" }, + { url = "https://files.pythonhosted.org/packages/72/66/9bcf2c2db7b75a3b51c11470ceb67941d028727d674276ff5d5120adc87e/aiohttp-3.14.4-cp315-cp315t-win32.whl", hash = "sha256:a65189a89f3e621f7c27a968b5131bbca29126e2b8517dc72b205747a39d4c96", size = 504036, upload-time = "2026-10-05T17:43:36.212Z" }, + { url = "https://files.pythonhosted.org/packages/e2/20/349db4af12dbc16070d32028d30c2067123b4520f3dcb2d9013524efeb4f/aiohttp-3.14.4-cp315-cp315t-win_amd64.whl", hash = "sha256:2b2c95f5f769eec92b552969db1f79ea0282058ce5131ed0f2fa540b4af3f14e", size = 531284, upload-time = "2026-10-05T17:43:39.362Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/544c711bc5fad611278ebd153bdcab1bffce8b8581f8ec9096b275948a3c/aiohttp-3.14.4-cp315-cp315t-win_arm64.whl", hash = "sha256:54209fff79346ee1ef0d5cbf92fa80a5bb37396406bb4d932e5107f9c29b2d3f", size = 510623, upload-time = "2026-10-05T17:43:42.15Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/5e1f8446545be98e9416af59c396b1ed1f9f3294afb0a40ee3a709889273/aiohttp-3.14.4-py3-none-any.whl", hash = "sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd", size = 279487, upload-time = "2026-10-05T17:43:44.905Z" }, ] [[package]] @@ -412,7 +454,7 @@ wheels = [ [[package]] name = "anthropic" -version = "1.11.0" +version = "1.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -424,9 +466,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/12/9a6ffa397b172adb040008d934a1dfd85d0e4cefc77489416db294ffc880/anthropic-1.11.0.tar.gz", hash = "sha256:3906fabac7ad7b5b46c6186040398fc7826885c77ce34e4dd7849de16fc8d0f8", size = 1390390, upload-time = "2026-09-30T22:58:21.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/ed/e76e50601b7c476bcb9a2a7a4c4dab6f1a9b2c03e3922de8b3ec599ccbc5/anthropic-1.13.0.tar.gz", hash = "sha256:ad11d9bb9adafdfea26113943bcde9973e2a439ebeeda6c89ff3e3d85bb2f5c1", size = 1567362, upload-time = "2026-10-09T15:29:08.803Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/6f/20edcd2956cac68042b1bcdd13ab3311447519f86b26929ff268dc74c650/anthropic-1.11.0-py3-none-any.whl", hash = "sha256:52f97b2c485cca7ac66058374f5073a3febb7e6849b16989c145d602a3efee21", size = 1681654, upload-time = "2026-09-30T22:58:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9a/a835340c91bc9a233bd54c05fc438fe07cbef71ca667f24f3892a268ae20/anthropic-1.13.0-py3-none-any.whl", hash = "sha256:157bd74dbf6a595cf9e5fcac557c791fd297924dc48261f57470c2ed6f4e6671", size = 1958595, upload-time = "2026-10-09T15:29:10.689Z" }, ] [[package]] @@ -503,15 +545,6 @@ 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" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "backports-tarfile" version = "1.2.0" @@ -523,7 +556,7 @@ wheels = [ [[package]] name = "band-client-rest" -version = "0.0.41" +version = "0.0.46" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -531,9 +564,9 @@ dependencies = [ { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/f2/71f701fa2dad3f74de3936b0cc4ee1e5b748b60e738105f189aa40ef4362/band_client_rest-0.0.41.tar.gz", hash = "sha256:5596b7afe31a18c5af7f8c4a3da03e09d61538173c83a8bfbe65bc67e0b96dba", size = 148868, upload-time = "2026-09-24T20:09:24.768Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e4/5a7a23a9e8d94ce293921485ec8ec6c2fef94bf9330d53484e1293d59d2a/band_client_rest-0.0.46.tar.gz", hash = "sha256:d5acc1828920e7adeadef1656d968a52a22688531c196c991a6681c90e63fba2", size = 150959, upload-time = "2026-10-08T14:46:13.282Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/1b/cc20abc2fb2bb2b4b14fe4d7d91c43dc7822d7ed70a6f1c63089172bcbe0/band_client_rest-0.0.41-py3-none-any.whl", hash = "sha256:933cd941b1dfe998f2ae5a650045a93c701c6935b3e08c52301280362a8277c5", size = 328750, upload-time = "2026-09-24T20:09:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/07/c3/4e4af60e88b6f0c80b100759963bfe18860ec25ff01ae96f6664cdb1252b/band_client_rest-0.0.46-py3-none-any.whl", hash = "sha256:3ef23367dbe3e0cb550666ee50e9f9bc7cdcb6974b49114144deb8f880d71fdc", size = 333122, upload-time = "2026-10-08T14:46:11.871Z" }, ] [[package]] @@ -824,7 +857,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'dev-crewai'", specifier = ">=0.75.0,<2" }, { name = "anthropic", marker = "extra == 'dev-parlant'", specifier = ">=0.75.0,<2" }, { name = "async-lru", specifier = ">=2.3.0" }, - { name = "band-client-rest", specifier = "==0.0.41" }, + { name = "band-client-rest", specifier = "==0.0.46" }, { name = "band-sdk-core", specifier = "==2.8.0" }, { name = "band-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, @@ -838,8 +871,8 @@ requires-dist = [ { name = "click", marker = "extra == 'a2a-gateway-demo'", specifier = ">=8.0.0" }, { name = "click", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "commitizen", marker = "extra == 'dev'", specifier = ">=3.13.0" }, - { name = "crewai", marker = "extra == 'crewai'", specifier = "==1.15.22" }, - { name = "crewai", marker = "extra == 'dev-crewai'", specifier = "==1.15.22" }, + { name = "crewai", marker = "extra == 'crewai'", specifier = "==1.15.27" }, + { name = "crewai", marker = "extra == 'dev-crewai'", specifier = "==1.15.27" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "fastapi", marker = "extra == 'agentcore-runtime'", specifier = ">=0.110" }, { name = "filelock", specifier = ">=3.12" }, @@ -1100,30 +1133,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.97" +version = "1.43.111" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/bd/928bf084b0441b2efbac3e02299892dfb07899cd3617af6ebdc13be2cfb9/boto3-1.43.97.tar.gz", hash = "sha256:ffe9a93a6b4b2c1b59f4f5e07c18d2acfe8f68c320a1a12251d4ea8570b9bdfe", size = 112655, upload-time = "2026-09-17T19:28:52.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/d3/fa092ae1c109100d0c5c14c69a316cd6d53c05fb57183fa77b1fcdef86ce/boto3-1.43.111.tar.gz", hash = "sha256:5ae342a16c848909cd42d4be404f69d9082e5705460198d4d3327eca5f6cddcb", size = 112656, upload-time = "2026-10-09T19:27:48.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/7f/068104b9d07b0e3e262074bf4efb36a72c836654a14bdfa911ad1e42b393/boto3-1.43.97-py3-none-any.whl", hash = "sha256:420b45f566d3b49c6c1f021c50f47f66ea99ef05b1872d80b472c536542b321e", size = 140032, upload-time = "2026-09-17T19:28:51.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/bca42f8f7b76e567c66cc39bacc6bf31b353c9edfbb0fb1f5c534fc65369/boto3-1.43.111-py3-none-any.whl", hash = "sha256:c79994619c8d89e45f6fd0edc5c5b5a70c9358f00423f4c99cb64931f89ecf37", size = 140042, upload-time = "2026-10-09T19:27:47.599Z" }, ] [[package]] name = "botocore" -version = "1.43.98" +version = "1.43.112" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/5e/8d655aeaae195b9856a554e724322092b4cc1b8bb58a29167bea80bf800c/botocore-1.43.98.tar.gz", hash = "sha256:6135dd639ea6d1b3b49381bc253c8a139d61f7d44cb5f7dae8e7cd1791758572", size = 16127750, upload-time = "2026-09-18T20:10:00.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/49/58187bfb510831e4cdafd7ced8e2a748097da81e8b9799d93f8d6ebf9f61/botocore-1.43.112.tar.gz", hash = "sha256:9ce0d70e09fabbb3a2e1126d3ec79ed67d14c88bb3f064e62ab2881d5eaf3c7b", size = 16351533, upload-time = "2026-10-12T19:26:55.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/ca/c7617e99c3247f545233ab2181840d142c5f72034e22864172866fb1369a/botocore-1.43.98-py3-none-any.whl", hash = "sha256:84b35b10402c2fc0c265f634fbf86336eecc6489ee23f55074b329924b2cfd6f", size = 15817751, upload-time = "2026-09-18T20:09:57.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a7/dd4c7cf9cde38db5cd5a295434e25415d814536704fe084ec7ee73e5658b/botocore-1.43.112-py3-none-any.whl", hash = "sha256:1e67a3dcf4a308c695d880b65463a492a971d5b28761b49add92f71e4322130f", size = 16052210, upload-time = "2026-10-12T19:26:50.658Z" }, ] [[package]] @@ -1406,7 +1439,7 @@ wheels = [ [[package]] name = "chromadb" -version = "1.1.1" +version = "1.5.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bcrypt" }, @@ -1424,9 +1457,9 @@ dependencies = [ { name = "opentelemetry-sdk" }, { name = "orjson" }, { name = "overrides" }, - { name = "posthog" }, { name = "pybase64" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pypika" }, { name = "pyyaml" }, { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" } }, @@ -1437,18 +1470,18 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn", extra = ["standard"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/48/11851dddeadad6abe36ee071fedc99b5bdd2c324df3afa8cb952ae02798b/chromadb-1.1.1.tar.gz", hash = "sha256:ebfce0122753e306a76f1e291d4ddaebe5f01b5979b97ae0bc80b1d4024ff223", size = 1338109, upload-time = "2025-10-05T02:49:14.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/d1/5e33b26985f0c7046a0be1cee2158ada1748ee700d2545057fde1468d74d/chromadb-1.5.9.tar.gz", hash = "sha256:5c20e62a455c28bacac927f26116a73fd8e1799e0d908be8e8a4f02197a54731", size = 2595635, upload-time = "2026-05-05T05:54:51.713Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/59/0d881a9b7eb63d8d2446cf67fcbb53fb8ae34991759d2b6024a067e90a9a/chromadb-1.1.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:27fe0e25ef0f83fb09c30355ab084fe6f246808a7ea29e8c19e85cf45785b90d", size = 19175479, upload-time = "2025-10-05T02:49:12.525Z" }, - { url = "https://files.pythonhosted.org/packages/94/4f/5a9fa317c84c98e70af48f74b00aa25589626c03a0428b4381b2095f3d73/chromadb-1.1.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:95aed58869683f12e7dcbf68b039fe5f576dbe9d1b86b8f4d014c9d077ccafd2", size = 18267188, upload-time = "2025-10-05T02:49:09.236Z" }, - { url = "https://files.pythonhosted.org/packages/45/1a/02defe2f1c8d1daedb084bbe85f5b6083510a3ba192ed57797a3649a4310/chromadb-1.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06776dad41389a00e7d63d936c3a15c179d502becaf99f75745ee11b062c9b6a", size = 18855754, upload-time = "2025-10-05T02:49:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0d/80be82717e5dc19839af24558494811b6f2af2b261a8f21c51b872193b09/chromadb-1.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba0096a7f5e975875ead23a91c0d41d977fbd3767f60d3305a011b0ace7afd3", size = 19893681, upload-time = "2025-10-05T02:49:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6e/956e62975305a4e31daf6114a73b3b0683a8f36f8d70b20aabd466770edb/chromadb-1.1.1-cp39-abi3-win_amd64.whl", hash = "sha256:a77aa026a73a18181fd89bbbdb86191c9a82fd42aa0b549ff18d8cae56394c8b", size = 19844042, upload-time = "2025-10-05T02:49:16.925Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5b/3cced915244f43ed14b53fe9f63a37f05f865064f4e4fe7d9448d3f2a352/chromadb-1.5.9-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60701011b5e6409647fa40d12c7c5a66b2b0bfcf33a52db2ad53a30a2abc4957", size = 22564540, upload-time = "2026-05-05T05:54:48.906Z" }, + { url = "https://files.pythonhosted.org/packages/34/4c/adcef1f4e82a2ef69ccd3711d55fc289193d54c4c0ff7a0292a3631db46f/chromadb-1.5.9-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:814b9c95617377f6501e5757d63dfddb554a283a7739c87b9fa573850174e6f3", size = 21699698, upload-time = "2026-05-05T05:54:45.078Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/937bc4d2e6f8ab9664ec79931fbbd69efff47e513ec2924b071e4b0ff774/chromadb-1.5.9-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9192d111bd662241625867962333d99369a00769a50f8b2f58cb388731274d7e", size = 22680924, upload-time = "2026-05-05T05:54:36.25Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ec/0c42039e80b9acc534f67b73b7a42471948042859b3a64867b50a4a77fa3/chromadb-1.5.9-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc09b3df76e5a5cb386aed2715a2eea152e3949f9e1ba93c7119505377749929", size = 23316203, upload-time = "2026-05-05T05:54:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ce/0f7be6e5d0feafa2cda54b12e6542afeea7dea89d2d411e14da90f8abb96/chromadb-1.5.9-cp39-abi3-win_amd64.whl", hash = "sha256:4fd0b560e56761b7f3cb4d5c6205fd5f20814484b4a3e4e9af9038c2b428fc6c", size = 23542454, upload-time = "2026-05-05T05:54:54.942Z" }, ] [[package]] name = "claude-agent-sdk" -version = "0.2.159" +version = "0.2.165" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1457,13 +1490,13 @@ dependencies = [ { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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 = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/b2/81eb389ace55434f0119b99b62c10a7ac206def6c92ee0768eb389b0c900/claude_agent_sdk-0.2.159.tar.gz", hash = "sha256:e1905e2637b27971bb561bb6ad317aeb3d50619be5261bfa7ff2e9c2be0f0fd1", size = 354571, upload-time = "2026-09-23T20:26:37.386Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/10f00a408b480926b5e340e3507562ede1e30d4ce5b73eea9ccc3468a9e6/claude_agent_sdk-0.2.165.tar.gz", hash = "sha256:1bfa8e7a6bb36e82de9a12324c9bcfa6eebd4f96ee5890f0c656d50445240ff8", size = 365169, upload-time = "2026-10-08T18:18:49.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/06/87d76aced98f7b71cede2ebf4ae7808e52df9d3819c7f4c7b8c3d2a225b6/claude_agent_sdk-0.2.159-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba7f152560dca5001a0bc46ed13b7da14eb14b419faed25bb1af821bfa95d3de", size = 91937244, upload-time = "2026-09-23T20:26:41.375Z" }, - { url = "https://files.pythonhosted.org/packages/f4/cf/c65a1f2419fa4b2085a53e91cef41569fde4910057c3709b8ab4303be1fd/claude_agent_sdk-0.2.159-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:87f553fda74e2f57e00f2bcb1705ab9311a937f97571f4f9eceea2bd411b6e32", size = 96294436, upload-time = "2026-09-23T20:26:45.117Z" }, - { url = "https://files.pythonhosted.org/packages/f1/20/f01e211524f3d8edfe7c2ea26ac595d21afab443810cbedeca5c76f7ead0/claude_agent_sdk-0.2.159-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:b03683d12d217b0b6c189c88a3d260a045171e7fdc9f23e2d0dfb05ff3276834", size = 101405973, upload-time = "2026-09-23T20:26:48.799Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d5/de876f9b05c2a94c1d03222b6a5908b1d27f695cf862b237f04d92008d62/claude_agent_sdk-0.2.159-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d3ba57c187b07c87702b94e2b62dd7c61ed874899defcbb35bb5cef4cb94ace1", size = 101764831, upload-time = "2026-09-23T20:26:53.25Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f2/b315c307a4199cb698a6b6f9e0e90cb8da4b0c8b3d00c68e9007c4a778d1/claude_agent_sdk-0.2.159-py3-none-win_amd64.whl", hash = "sha256:cc1b92ae56f42ade2d083ef2d2e5edb4aa8c7a6d856818a9b8f60cf6ae746f3a", size = 104353298, upload-time = "2026-09-23T20:26:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a7/a7226ae5e7fa3f225d99a0acf12ee354c4af9c953661f66bbcd424e48853/claude_agent_sdk-0.2.165-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f7017bb59eaf77b9a7c7ce3b9ed2c8a397b8201c8630f011bb1b955290f06e4", size = 97973182, upload-time = "2026-10-08T18:18:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9d/1eaa82f8dbafa63a3fb26aabc088be307c4977438269cc6cb10056a32e99/claude_agent_sdk-0.2.165-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:f4b5c6f536062e3af72357b1235f05ad4513230a32a3c639341a91a607d2df1e", size = 102479136, upload-time = "2026-10-08T18:19:01.908Z" }, + { url = "https://files.pythonhosted.org/packages/40/4a/93fe172811bcd7704c4c4776d5eed1bb1881ba440d86e0f8e5cbbec0dca9/claude_agent_sdk-0.2.165-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:46a47e1e1075a8f2b7976f6bd5aa06c5610cb322c47cdc5b728fb25d02691164", size = 107447420, upload-time = "2026-10-08T18:19:09.571Z" }, + { url = "https://files.pythonhosted.org/packages/57/81/a0d3ff04ae7045218566b36d1c78729c3fc7550877e154697f263ac5122e/claude_agent_sdk-0.2.165-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:9dbee4bfc69f0bb27ee455afdc19d78f958540a99217d2e28831fb9b7d496f08", size = 107800988, upload-time = "2026-10-08T18:19:16.24Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/1685e8fc14bae5b9cbcfd6f4f6ca8b7ae8fd497a8bbf9c108978dd98620b/claude_agent_sdk-0.2.165-py3-none-win_amd64.whl", hash = "sha256:cf41dc1b1019bc7321b695d621fc94e6d8982c095cfb7d433d5c293e356a2404", size = 110401968, upload-time = "2026-10-08T18:19:24.136Z" }, ] [[package]] @@ -1507,7 +1540,7 @@ wheels = [ [[package]] name = "commitizen" -version = "4.18.1" +version = "4.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -1523,9 +1556,9 @@ dependencies = [ { name = "termcolor" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/05/5740bfcd2e89e8be97757294335b5c480bcf60b134ef7bc84428c3a15884/commitizen-4.18.1.tar.gz", hash = "sha256:27379caedd00553344e44cc8cef039598b6d3e65f9b598e31d355489c481563d", size = 68482, upload-time = "2026-09-13T21:37:43.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/ad/ad4e6fafa0627f38ebd3c65ad63767f3f07e0b33b72cbff0d3cadddf6ecb/commitizen-4.19.2.tar.gz", hash = "sha256:af0fb25f2fb60956b9d64eecd6e3ec7b710c2f9b012ce8728cb32c728c2c5a8a", size = 69721, upload-time = "2026-10-07T07:31:13.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/3e/c15f31a04a3432bce3312cde554ba73e8c5232a3615f08f39264a447c427/commitizen-4.18.1-py3-none-any.whl", hash = "sha256:16ed052ff775bf42cad85bc09075db110ca7ba84f49233fc642629a0da65862b", size = 90476, upload-time = "2026-09-13T21:37:41.152Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/904e5c6b231038c1f66f75dceb7600db010631eb657d5c1b75ef26d0d42c/commitizen-4.19.2-py3-none-any.whl", hash = "sha256:178505ff30a9740b33e4d8e6a01854306213d88f5068fbc88554dbcb1a39fd4b", size = 91930, upload-time = "2026-10-07T07:31:12.221Z" }, ] [[package]] @@ -1726,7 +1759,7 @@ toml = [ [[package]] name = "crewai" -version = "1.15.22" +version = "1.15.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles", version = "24.1.0", source = { registry = "https://pypi.org/simple" } }, @@ -1744,6 +1777,7 @@ dependencies = [ { name = "jsonref" }, { name = "lancedb" }, { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" } }, + { name = "oauthlib" }, { name = "openai" }, { name = "openpyxl" }, { name = "opentelemetry-api" }, @@ -1761,14 +1795,14 @@ dependencies = [ { name = "tomli", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, { name = "tomli-w" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/3e/e79f59badc4194f5d80e9ae5b07d35d96206d705270a2b2a2b5508b0a54f/crewai-1.15.22.tar.gz", hash = "sha256:0b1c654f35e07dfe41720337c56542b334644a32c5e4498a6e38e5e7da7dd82e", size = 8057957, upload-time = "2026-09-16T22:10:43.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/d4/a5a1555a80581275ee69ca4ae5215f72be27baaee04ee53fa066ae41de3d/crewai-1.15.27.tar.gz", hash = "sha256:764ca35f996898369819faaa5e107bd316ba7f1fd66ad9d501e8ba38f102b602", size = 8144930, upload-time = "2026-10-09T22:34:31.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/92/b19a70b6109ce2c2bd24dc63f8b4022a7097816ef236a1d9de1733738db8/crewai-1.15.22-py3-none-any.whl", hash = "sha256:7675c2bb1166933c60d67596936cd8b4ce63d77361dd3aeb925b210180298e5a", size = 1189089, upload-time = "2026-09-16T22:10:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/42/58bdbbaab18f2a2c8428e3ea6bc4279e646ab61d063baab09e6ab3723b84/crewai-1.15.27-py3-none-any.whl", hash = "sha256:7ceb0da04531271bf9a3871f6a583b165d266bd27be891b8431faa87ce1dc2e9", size = 1235671, upload-time = "2026-10-09T22:34:28.668Z" }, ] [[package]] name = "crewai-cli" -version = "1.15.22" +version = "1.15.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -1788,14 +1822,14 @@ dependencies = [ { name = "tomli-w" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/ed/f143440caca0fd6c5cb985710f79f0a6ebffbbb5468ccf842d55b5500ce6/crewai_cli-1.15.22.tar.gz", hash = "sha256:b7e00daedfbee9102ce4fcaffa10224b27c9c02e2b976c98a25acf214730acae", size = 244293, upload-time = "2026-09-16T22:10:46.178Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/2a/338bdabb57cae712e705e027d7397a8c48b5b371039b5afaed4cc15323d6/crewai_cli-1.15.27.tar.gz", hash = "sha256:bc87df202548e11fc51b14d0008166914757f1d1fe7e7fe557296f3e90c8674d", size = 303862, upload-time = "2026-10-09T22:34:35.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/af/5e06129c84ba60a7f48ee181d6b0c3f270f00e2dcebaf48556325cc1a59b/crewai_cli-1.15.22-py3-none-any.whl", hash = "sha256:b44abcc69b90f30c4d3b93657542879f5e2ac83506757a38a1e2e404a9d84300", size = 202381, upload-time = "2026-09-16T22:10:44.901Z" }, + { url = "https://files.pythonhosted.org/packages/21/a7/b342a9856079d34d9b47551fa7d4fdff95633da7bed5dda3147448a6eadc/crewai_cli-1.15.27-py3-none-any.whl", hash = "sha256:cb5659206ae3274fb3ac138a263eed7b3cd487a9972edece5d5cbfa1311e3f84", size = 234101, upload-time = "2026-10-09T22:34:33.232Z" }, ] [[package]] name = "crewai-core" -version = "1.15.22" +version = "1.15.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "appdirs" }, @@ -1811,9 +1845,9 @@ dependencies = [ { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" } }, { name = "tomli", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/27/1ed033b746506039e21291eb64fd4624e9de0b0489dfd5128e7efb936e92/crewai_core-1.15.22.tar.gz", hash = "sha256:6b85c609536bdca62e93eca59933f3609f79b8e207e8f6d3c59a8365a5ed5d78", size = 43090, upload-time = "2026-09-16T22:10:48.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/9a/f74ec80501d66be2845fd043283d744fada784818b8879ed53c70e13ea22/crewai_core-1.15.27.tar.gz", hash = "sha256:ec686838deb5d43b9ccf58dc17a6f296e6725ef3a195d00af692e65ba9b69319", size = 1268424, upload-time = "2026-10-09T22:34:39.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/45/f2eb1866dc98d75617ebe942e9575b6fa1f2f2b7c6352ea22cb2faf75931/crewai_core-1.15.22-py3-none-any.whl", hash = "sha256:8b97a45a35e663f17af65ed2a318bf428d5806ce20a54b0fce726ebe53eedb75", size = 44375, upload-time = "2026-09-16T22:10:47.06Z" }, + { url = "https://files.pythonhosted.org/packages/83/36/438987c689f53a0c0ade46c4c05b93180eda97dc1752b36c5aae3107a7d8/crewai_core-1.15.27-py3-none-any.whl", hash = "sha256:ba66691005f686a84afb232963a01c8d27ad34fdea30e34cbdac9645d1edc76f", size = 1278362, upload-time = "2026-10-09T22:34:36.672Z" }, ] [[package]] @@ -2057,18 +2091,19 @@ wheels = [ [[package]] name = "fastapi" -version = "0.141.1" +version = "0.143.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, ] [[package]] @@ -2358,20 +2393,20 @@ wheels = [ [[package]] name = "genai-prices" -version = "0.0.72" +version = "0.1.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/2549fa8ceaaf0bd61114cf392250a107be657233723bc4e65cb91dba934f/genai_prices-0.0.72.tar.gz", hash = "sha256:a7e481d0ea85922fcf48df6864f2491fc81a4f03dcf0ecbd9aa8c2c6d9fcdbe8", size = 82753, upload-time = "2026-07-22T21:11:03.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/ac/59521c0e0254970d64cf01974a59e97f57e7e54bb2331e939058169d5113/genai_prices-0.1.11.tar.gz", hash = "sha256:47c6b84e38089ffee606cd0d2eaaa2f9939605c486faee792f98cb7258baf812", size = 129433, upload-time = "2026-10-12T14:14:50.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/2e/1e2c31666f7e4e2d0327a80f1c920fd8764ccd27672c4bc389d79219ece6/genai_prices-0.0.72-py3-none-any.whl", hash = "sha256:21281d8df34d9bfbb736e0763a60ceda02226938cd47379ae437a44fcf8ba23d", size = 85238, upload-time = "2026-07-22T21:11:02.911Z" }, + { url = "https://files.pythonhosted.org/packages/89/60/e3718a289e4727ed3c20cef7d52f62dd9118c53b9295663e49a2c027ece2/genai_prices-0.1.11-py3-none-any.whl", hash = "sha256:b7e652e0deb28d6c3505dc755824d6f9bbfd64a1d7757720c3473b5927478435", size = 136915, upload-time = "2026-10-12T14:14:49.254Z" }, ] [[package]] name = "github-copilot-sdk" -version = "1.0.14" +version = "1.0.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2379,7 +2414,7 @@ dependencies = [ { name = "python-dateutil" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e2/4d8c622545f55f06773548cf86ffe60f59e6a4cedcc412ff84fcac244b2b/github_copilot_sdk-1.0.14-py3-none-any.whl", hash = "sha256:9f4538cec7295f24507c650d794307396458a51f280a0fbbcd840da177109050", size = 614607, upload-time = "2026-09-16T05:11:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/10/a5/4aca0e11ab250bcc5893cca442815c2dfa62191545a8d6d3d906db1125ac/github_copilot_sdk-1.0.19-py3-none-any.whl", hash = "sha256:25222c170e75d8beee29056ad68155d5345b79542b59315a3dafd40a0a3a12dd", size = 794259, upload-time = "2026-10-09T16:13:09.617Z" }, ] [[package]] @@ -4023,30 +4058,30 @@ wheels = [ [[package]] name = "langchain" -version = "1.4.2" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ff/670036674e669b81c0ddba77e1f012c099eda7b7d1251b973e5367d2824e/langchain-1.4.2.tar.gz", hash = "sha256:5b476418cacbfb8f0da6e63b3b263eefdcec13b48abd6117565a25728ec10b77", size = 735715, upload-time = "2026-09-18T17:31:11.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/a4/a3ceefc3ec734912ca3a21dd79a5e4a98af73015a87e64d284a92cd5572c/langchain-1.4.4.tar.gz", hash = "sha256:baac6e13c08460e1decd0e60061b9a87c07135f1c977424040b0b57ff7bc7887", size = 742902, upload-time = "2026-10-08T22:14:10.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/86/cc13651cfda820d96ca8c612538166334935e8cf4331a31f5dd369d27760/langchain-1.4.2-py3-none-any.whl", hash = "sha256:30a27c34cfa0004bf659dcdef488b99a9b90acb08bcc514a5a74264755da536c", size = 163740, upload-time = "2026-09-18T17:31:10.056Z" }, + { url = "https://files.pythonhosted.org/packages/02/36/c348f812eb37a137de2c80140ed1324ab11b75fa5a845e3f1035bacaac4a/langchain-1.4.4-py3-none-any.whl", hash = "sha256:2fd27e9a2d54ebcd915ac5ea51d79c772e020567ef6c6a76678182055489b804", size = 165363, upload-time = "2026-10-08T22:14:09.321Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.7.4" +version = "1.7.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/b1a7e19a4f129f567f04534e263e14e350673fb35ba5e45b2a189c07d85b/langchain_anthropic-1.7.4.tar.gz", hash = "sha256:7d540300665a1c906986e505cdec41204e759f14dbd989f53f0c180d82624b94", size = 760340, upload-time = "2026-09-23T17:55:49.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/e6/45ead93ef6d9f244b502a6a32bf9a185b3da1cc7fa8003896a102dadac09/langchain_anthropic-1.7.5.tar.gz", hash = "sha256:6016f38e5c4bf12c16ecfa76abf133a978f459f4d2887b8e28435ae1ff00beb3", size = 764625, upload-time = "2026-09-29T15:22:07.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/2c/d0389b1d8d4a93eadaa9cda07ef7614c0bbfec71f69997d42c32dabbbefa/langchain_anthropic-1.7.4-py3-none-any.whl", hash = "sha256:eec1178389358eaa4cff4f3dd353f063746b8c61090cffe9236d9035df71e66c", size = 63854, upload-time = "2026-09-23T17:55:48.137Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c507f2c1859b584843983e98bc71e0066c16a77e6c181921c928e0fdbcf7/langchain_anthropic-1.7.5-py3-none-any.whl", hash = "sha256:b21ef0e0c2d6175235a5b7eef4f5f89057df92d42dbfab66920a05605ef7173a", size = 65239, upload-time = "2026-09-29T15:22:05.832Z" }, ] [[package]] @@ -4091,7 +4126,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.6.5" +version = "1.6.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4105,14 +4140,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/ed/ceecd3bfcfc77a94cf30a9f1506e907fdea447458825e01914a72d2d1a6f/langchain_core-1.6.5.tar.gz", hash = "sha256:bdb059fef65473fd444558dbb18f50901657402c43ceed9dc0fdc7b76a393c6b", size = 1007041, upload-time = "2026-09-24T18:11:06.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/00/0a95f74a79908e7bc844a82fca35c1afc55689f55aaed086e95745946db8/langchain_core-1.6.10.tar.gz", hash = "sha256:3ad7a64eab150c1fea9f8a748b1c076aa1a960c5cf7c28d81a841a2f2dbffad1", size = 1010280, upload-time = "2026-10-12T14:13:51.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/68/447ac7e734990b7ed9dcfc3b6dee9a4cd4d4169e296429fa71a9a47c1c7b/langchain_core-1.6.5-py3-none-any.whl", hash = "sha256:54c7b0e9314b9084fb04405bb33dc5d32986d512d92b7b8863899cd5f6267556", size = 572136, upload-time = "2026-09-24T18:11:04.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/6ed698c6b451af0ed0efdbe94a703c18aea768d925347d8d1efd5645ae8c/langchain_core-1.6.10-py3-none-any.whl", hash = "sha256:14341bdd8b42d0dd9a53dbbcd8b0599ab47b0c718c7caa12e3eb5c50b32cffcb", size = 573253, upload-time = "2026-10-12T14:13:49.616Z" }, ] [[package]] name = "langchain-openai" -version = "1.6.6" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4120,9 +4155,9 @@ dependencies = [ { name = "openai" }, { name = "tiktoken" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/14/6d9c4d07b43c1eaf8a112be3524b951247c44bb195d6224cab8ffb68b3e8/langchain_openai-1.6.6.tar.gz", hash = "sha256:7b3848f42825aca40165463aa37757051165eb292ba7f24658a2f58f9bb221f9", size = 3306663, upload-time = "2026-09-24T11:31:13.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/68/dd7f7ef76efe58222d37f1b1a5595b1bb368fc9165f71526db26e4c5759e/langchain_openai-1.7.0.tar.gz", hash = "sha256:7df2db16d32320ba563b7e4291a71ec6bf7b0ea8a5369ed2a0501d74928ea6d0", size = 3404806, upload-time = "2026-10-08T16:07:35.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c6/0f6b0c9faa7d00eef702c133fd3534cb52cbdf56df9f3524666012ec90e0/langchain_openai-1.6.6-py3-none-any.whl", hash = "sha256:60dcaa443ddfdf1559feaf1d25916f394c7dda809aebe22a769fc39f02705be7", size = 127401, upload-time = "2026-09-24T11:31:11.691Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/3f40387231281eaefb0bec31bfa86a7d533de9d90b46802c3e24d436a163/langchain_openai-1.7.0-py3-none-any.whl", hash = "sha256:a4d319385019c010c579b39e8951e4d5ededeacf94cd9c79162f4cb7ecba50fb", size = 145622, upload-time = "2026-10-08T16:07:33.119Z" }, ] [[package]] @@ -4139,19 +4174,19 @@ wheels = [ [[package]] name = "langchain-text-splitters" -version = "1.1.2" +version = "1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/22/d401fffe8d3f1339f6ac880e5d637db73933efa95635304849b2d44ab0fe/langchain_text_splitters-1.1.3.tar.gz", hash = "sha256:929b6c76f99d611a5b1d8f5591ef302909b86a436ceb3217034d24e4757868d8", size = 308295, upload-time = "2026-10-02T15:46:33.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/c7/51/2880d2e88cce13c1180178f7e8dc796c2f45b2dc09cc09ed407d116c08cc/langchain_text_splitters-1.1.3-py3-none-any.whl", hash = "sha256:50edeb318b3be6a6308dddd7d616ab82823a9ccbea59a9c14c58a7b91df60295", size = 38217, upload-time = "2026-10-02T15:46:32.271Z" }, ] [[package]] name = "langgraph" -version = "1.2.12" +version = "1.2.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -4161,9 +4196,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/7e/c9e4431adb5eb78d15f0035aae162a113c80d037ceb433ddd78835217848/langgraph-1.2.12.tar.gz", hash = "sha256:5fae9fa4ce771e90f10ff19ce0d5aaef4b41d7632c713d769e89cbce1241a4b9", size = 734541, upload-time = "2026-09-21T14:43:29.684Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/3f/6e60789eab2c5ceaf34ba2c626ac345df93b026bbe7385018aa4b9c30397/langgraph-1.2.14.tar.gz", hash = "sha256:5b1a72b7a7f994ac6947b7751fbbe00e949f8bb44d9ab78881604b703247ae55", size = 752793, upload-time = "2026-10-06T14:41:19.789Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/27/5a02054c535ed6fcfb2ab1e5c33c51c810cf1e773194ad28c928f0473509/langgraph-1.2.12-py3-none-any.whl", hash = "sha256:95403af7b510de8d79164742f71daaf866c69ca804a8cb72bef2cc1fa1ab9813", size = 250206, upload-time = "2026-09-21T14:43:28.196Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4a/f976df0d3cf7fb7986dc39f656c6b7b5655834430e9bdfaef1e1e4fffaba/langgraph-1.2.14-py3-none-any.whl", hash = "sha256:e3f5f8ff4259f9281b73ff50fc9ef5cc3b025b8936b8283ecdca7cc0c4128690", size = 255980, upload-time = "2026-10-06T14:41:18.129Z" }, ] [[package]] @@ -4194,7 +4229,7 @@ wheels = [ [[package]] name = "langgraph-sdk" -version = "0.4.4" +version = "0.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4203,9 +4238,9 @@ dependencies = [ { name = "orjson" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/5d/cbeacb114f4a6269fc476f7d2feba088f6ada0c00f81bf5decc587f81511/langgraph_sdk-0.4.7.tar.gz", hash = "sha256:6827560be31e38daae1514234e9aa12c345dd40d4d4b94aa1b443729bfccda69", size = 350917, upload-time = "2026-10-12T22:54:05.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2b/996e641d2020f30b25a523e8cc43f068406eb2b5b677190b3ca917cbbd05/langgraph_sdk-0.4.7-py3-none-any.whl", hash = "sha256:a005c7ac662c318a3405e436e9effaa90c05343f9f4ae9e11dca19c9369727dd", size = 162399, upload-time = "2026-10-12T22:54:04.224Z" }, ] [[package]] @@ -5179,11 +5214,11 @@ wheels = [ [[package]] name = "oauthlib" -version = "3.3.1" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" }, ] [[package]] @@ -5359,14 +5394,14 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, ] [[package]] @@ -5399,70 +5434,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/4a/876703e8c5845198d95cd4006c8d1b2e3b129a9e288558e33133360f8d5d/opentelemetry_exporter_gcp_trace-1.11.0-py3-none-any.whl", hash = "sha256:b3dcb314e1a9985e9185cb7720b693eb393886fde98ae4c095ffc0893de6cefa", size = 14016, upload-time = "2025-11-04T19:32:09.009Z" }, ] +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + [[package]] name = "opentelemetry-exporter-otlp" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-exporter-otlp-proto-grpc" }, { name = "opentelemetry-exporter-otlp-proto-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/45/7af37fe54e5d3e66e7dcd7ba8b8aeee73f202bfac909cc94b8c4e428f9ac/opentelemetry_exporter_otlp-1.44.0.tar.gz", hash = "sha256:af1cde7c33ea8ed624bf04ac49a885730fe44c1f1ad698656e592c38f70ce106", size = 6090, upload-time = "2026-07-16T15:25:34.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/6f/5a561048ea372894f22f58f70e8478e4cd97d9a4e2d6f014559584a29bc5/opentelemetry_exporter_otlp-1.45.1.tar.gz", hash = "sha256:d0ac35592e77663a9fabf2740b4818c57196b1b764e0c8449d0d7bb2c7b2bc67", size = 6094, upload-time = "2026-10-06T17:33:00.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/c3/7b466a9463944e70b37b744072a0c1b88a425dade3fff0631adec66c9bcc/opentelemetry_exporter_otlp-1.44.0-py3-none-any.whl", hash = "sha256:4a498fa8d8fd8be9e8e2d175fe5524a3fe581ccffadd8509db86526a5fb97051", size = 6727, upload-time = "2026-07-16T15:25:14.445Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/68c28d7da4482ce882a640b2c61405a78284c0a3a17a4db125647189b0b7/opentelemetry_exporter_otlp-1.45.1-py3-none-any.whl", hash = "sha256:ef3910d32b36ccbaf62390189759bd43a8109885e2d50b738ed9c9b255bf5cb5", size = 6730, upload-time = "2026-10-06T17:32:37.236Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-common" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-common" }, { name = "opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-proto" }, { name = "opentelemetry-sdk" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, + { url = "https://files.pythonhosted.org/packages/4d/46/2d1da202f1e17c81aae7efcf702898d524b46709e4d3e2bf1f7f8ca8fbc6/opentelemetry_exporter_otlp_proto_grpc-1.45.1-py3-none-any.whl", hash = "sha256:e42ecb789d2fc5d8145e3dadc3e2991c9f18cd166d7c7514e234702540274b76", size = 19492, upload-time = "2026-10-06T17:32:42.838Z" }, ] [[package]] name = "opentelemetry-exporter-otlp-proto-http" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-http-transport", extra = ["requests"] }, + { name = "opentelemetry-exporter-otlp-common" }, { name = "opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-proto" }, { name = "opentelemetry-sdk" }, { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.65b0" +version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -5470,49 +5537,49 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/91/3c58961cb0360cd60509064734f0be4275383c8681d73c580a40ca83ddce/opentelemetry_instrumentation-0.65b0.tar.gz", hash = "sha256:071d9d9eced9bd6460444ec3b0c77229870ed05a881c22c84fdede58e4eed09b", size = 42689, upload-time = "2026-07-16T15:25:50.275Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/7b/85eab1215f72adf0e68d3dc4a679b9bff993fa679ff34cd8dd378e2659fd/opentelemetry_instrumentation-0.65b0-py3-none-any.whl", hash = "sha256:ea967a72b9939b5fcfdad572753b4306c59dcb99e3f382d95dae04286805e137", size = 36717, upload-time = "2026-07-16T15:24:51.424Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" }, ] [[package]] name = "opentelemetry-instrumentation-logging" -version = "0.65b0" +version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/0a/b70a9cddbc7b314a783e62739dbb1184f8538c1f85e8ded6d340142b9b54/opentelemetry_instrumentation_logging-0.65b0.tar.gz", hash = "sha256:c0a50cade5d54db6c6af12e2c69227ecd26f2b3b779e99ff850561d3d8dd77e3", size = 19783, upload-time = "2026-07-16T15:26:09.853Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/ca/0fcf525f238f4ab9df3a6d0423fb057085ea39a3e79ac912fef3d5fa16b8/opentelemetry_instrumentation_logging-0.66b1.tar.gz", hash = "sha256:2250240ecb1fce36939f64eafd988832338684564fb4c7b401c9b9af0ee11d46", size = 20353, upload-time = "2026-10-06T17:36:29.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/8e/7577914681d77b180f8d6dcbac435be8e4ca6add6315da2d01ac4289eaa3/opentelemetry_instrumentation_logging-0.65b0-py3-none-any.whl", hash = "sha256:68365b31755c844f1e85f07dcd217839ff92f2d278a214bdf02d4dc806f9d915", size = 15727, upload-time = "2026-07-16T15:25:18.774Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7b/76bea1e80787848a9ac9882a598cbf1e761f16cebdb399d0e675221e34a0/opentelemetry_instrumentation_logging-0.66b1-py3-none-any.whl", hash = "sha256:f13761110c52eecae9d7debedb0f88b46acc248c20437e97c2a5b3a2b4f96a02", size = 15928, upload-time = "2026-10-06T17:35:39.915Z" }, ] [[package]] name = "opentelemetry-instrumentation-threading" -version = "0.65b0" +version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/62/626ee68e07e38f792e741db351087fb7c40888e861097029faae595d91fe/opentelemetry_instrumentation_threading-0.65b0.tar.gz", hash = "sha256:aefd23eb16c5e7a7c6c6eacdcb6c6f269ed8ed3a1b458bf5dd555b878bf58937", size = 9080, upload-time = "2026-07-16T15:26:22.367Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/4f/5fa05266cf24cae93fc32d557d0d2d2f605f81cca1df90614231bbad3cd1/opentelemetry_instrumentation_threading-0.66b1.tar.gz", hash = "sha256:2c217359439d09ca4a4be524db368c05a3cc7f218b85e8086f22664db0a03538", size = 9031, upload-time = "2026-10-06T17:36:41.147Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/8d/387b69572f81f9b78c2b0d3a98acfcd43c5ef4ce16ea903f1a90c1dd1d04/opentelemetry_instrumentation_threading-0.65b0-py3-none-any.whl", hash = "sha256:d8a1a1f35418a32769d469ef2d7e8401935e097a8553abcfba833da9c74736ce", size = 8484, upload-time = "2026-07-16T15:25:37.422Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/1cd52b8be7a521958ccdb9a1c4e5ae743aa2be295ba21ec0b620dc87842a/opentelemetry_instrumentation_threading-0.66b1-py3-none-any.whl", hash = "sha256:94039c60603060d26524e31aed1181a5d153e43f58462d110dbeb4830687d7d7", size = 8493, upload-time = "2026-10-06T17:35:57.958Z" }, ] [[package]] name = "opentelemetry-proto" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, ] [[package]] @@ -5531,29 +5598,29 @@ wheels = [ [[package]] name = "opentelemetry-sdk" -version = "1.44.0" +version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.65b0" +version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, ] [[package]] @@ -5683,11 +5750,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -6058,22 +6125,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/df/d4f711d168524f5aebd7fb30969eaa31e3048cf8979688cde3b08f6e5eb8/portalocker-2.7.0-py2.py3-none-any.whl", hash = "sha256:a07c5b4f3985c3cf4798369631fb7011adb498e2a46d8440efc75a8f29a0f983", size = 15502, upload-time = "2023-01-18T23:36:12.849Z" }, ] -[[package]] -name = "posthog" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, -] - [[package]] name = "prance" version = "25.4.8.0" @@ -6518,20 +6569,21 @@ email = [ [[package]] name = "pydantic-ai-slim" -version = "2.18.0" +version = "2.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, { name = "genai-prices" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/39/c3a941027be87f6bc07e50e1e72ae93e66a1b11b838e33ad5d135d2fe2f0/pydantic_ai_slim-2.18.0.tar.gz", hash = "sha256:dfe47a3602049f779223702a684ed8091b111cfae1851236616d6b0eb3b0b077", size = 902113, upload-time = "2026-07-25T01:21:05.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/3526056afb236ac3c01b07ae05f1fefc97cbee59f8e824b96ffd5405bbfe/pydantic_ai_slim-2.55.0.tar.gz", hash = "sha256:50b5ee70d3a0509c1f36bec759249e69f4dc060f1a816b1bbf0730af54297376", size = 1878726, upload-time = "2026-10-09T19:40:52.951Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a0/a0619bddf602f69a754540cb82147f9b53dfa2d093e1fe7051c5e6a8eceb/pydantic_ai_slim-2.18.0-py3-none-any.whl", hash = "sha256:4c4076166a63ad96fe6ed5a517223c4a5aba6c9682a17d8d0ac42839cbc83622", size = 1091565, upload-time = "2026-07-25T01:20:57.323Z" }, + { url = "https://files.pythonhosted.org/packages/93/cc/16c6bc21748e3705a4c1f8e7d71dfc54228dbf142f8506a36689acb1e9c9/pydantic_ai_slim-2.55.0-py3-none-any.whl", hash = "sha256:7fcea628e78c2f247af94504534f0577151496166998d74ec145505040b19c68", size = 2197589, upload-time = "2026-10-09T19:40:38.767Z" }, ] [package.optional-dependencies] @@ -6638,17 +6690,17 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "2.18.0" +version = "2.55.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, + { name = "anyio" }, { name = "logfire-api" }, { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/f9/1554e818e6d38bcb3f66ec7da7abe17c28dcd00caa1550b034e175b8841f/pydantic_graph-2.18.0.tar.gz", hash = "sha256:9423defa047b477a561a06eadfd37aaf101a2b690d044adb79de80d7ea203e4e", size = 44017, upload-time = "2026-07-25T01:21:08.152Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/22/4e25465059448c4322e5f53f33e7b25ceb404c2d4dda8cfb0f497f7fb54b/pydantic_graph-2.55.0.tar.gz", hash = "sha256:461de7d0c33af634c3adeb21712f527f15878edf826d216184f024795e9c80c1", size = 45824, upload-time = "2026-10-09T19:40:57.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/29/fd05a4a84db9dae16c0417d8bcd6847148439a55329d000ef101d7243ded/pydantic_graph-2.18.0-py3-none-any.whl", hash = "sha256:48df74e3ae12ca44f99890e9b4f7020ae70a5a38d20df02db7cb16b0db3c3711", size = 51662, upload-time = "2026-07-25T01:21:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/630fc830897cfb0e458dea8ffcac5dd58791784220255637e99817b04cdd/pydantic_graph-2.55.0-py3-none-any.whl", hash = "sha256:774a64db707e759e22a4b2d6f8849986331fac009bb3f36ba3ed0cb8eee7a150", size = 53274, upload-time = "2026-10-09T19:40:44.876Z" }, ] [[package]] @@ -6676,11 +6728,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -6850,14 +6902,14 @@ wheels = [ [[package]] name = "pytest-mock" -version = "3.15.1" +version = "3.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" }, ] [[package]] @@ -6912,11 +6964,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.3" +version = "1.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, ] [[package]] @@ -7505,27 +7557,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, - { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, - { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, - { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] [[package]] @@ -7662,11 +7714,11 @@ wheels = [ [[package]] name = "slack-sdk" -version = "3.44.1" +version = "3.45.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/371068dd7281139307e60cd18553b96b9c8c391a4c3040617192713a3cc4/slack_sdk-3.44.1.tar.gz", hash = "sha256:ca19505423789fa2a3189ff486f989f02e49289f008d0a5813df7255b3fb93ea", size = 256661, upload-time = "2026-09-03T14:21:13.879Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/74/a4b30685bc76846b70baa9edaa45ba577ce5a28fc9e474b51524bb2aa932/slack_sdk-3.45.0.tar.gz", hash = "sha256:1ab794452f238b59db0d8a4d346263d65190288add53ec67a05751d8e7402486", size = 259739, upload-time = "2026-10-01T22:24:56.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/fc/67352b742fc6fa520a550581b0284f5757e4e31a32327c2a00276747fd30/slack_sdk-3.44.1-py2.py3-none-any.whl", hash = "sha256:d6f20a0fbe3fecf9cac955c99d686301b48a645b7045472c3a0cdd186d7c42b2", size = 319865, upload-time = "2026-09-03T14:21:12.405Z" }, + { url = "https://files.pythonhosted.org/packages/af/58/d54e4564946a9a3276f3de2905d18b66977977d4ba0fd7d11164df3c73cb/slack_sdk-3.45.0-py2.py3-none-any.whl", hash = "sha256:6356d4486d1a3ad156462c5544ab1b9c076ff426a250495c08f51b7ad71eb8fb", size = 322681, upload-time = "2026-10-01T22:24:55.133Z" }, ] [[package]] @@ -8070,7 +8122,7 @@ wheels = [ [[package]] name = "typer" -version = "0.27.2" +version = "0.27.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -8079,9 +8131,9 @@ dependencies = [ { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" }, ] [[package]] @@ -8567,14 +8619,14 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.8" +version = "3.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, ] [[package]] @@ -8753,124 +8805,135 @@ wheels = [ [[package]] name = "yarl" -version = "1.23.0" +version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, - { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, - { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, - { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, - { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, - { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, - { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, - { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, - { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, - { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, - { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, - { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, - { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, - { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, - { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, - { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] [[package]] From d7ab3429cbdb5381b9ca572cf7eac210a8196052 Mon Sep 17 00:00:00 2001 From: AlexanderZ-Band Date: Wed, 14 Oct 2026 20:07:57 +0000 Subject: [PATCH 09/13] feat!: upgrade OpenAI with isolated legacy adapters (#802) * feat!: upgrade OpenAI with isolated legacy adapters Require OpenAI 3 for Pydantic AI and modern test environments; preserve OpenAI 2 for CrewAI and shared Strands/NOOA tooling. Keep separate execution lanes, compatible LangGraph peers, and complete coverage. BREAKING CHANGE: modern Pydantic AI extras cannot co-install with current CrewAI, Strands, or NOOA dependencies on their supported platforms. Refs INT-1791 * fix: normalize legacy coverage paths on Windows Compare pytest node paths using portable forward slashes and sort the renamed shared-probe import. * refactor: clarify wheel checks and Strands NOOA environment names * refactor: clarify extracted test helper documentation * refactor: derive wheel CI cases from consumer catalog * fix: end native LangGraph loops after successful declines * fix: isolate bespoke Copilot smoke runtime homes * test: capture Parlant tool metadata in diagnostic runs * chore: expose bounded diagnostics for unsettled native runs * test: preserve upstream diagnostics coverage after rebase * fix: enforce the NOOA wheel OpenAI 2 contract --- .github/scripts/check-provider-startup.py | 115 + .github/scripts/check-wheel-extras.py | 100 + .github/scripts/wheelchecks.py | 46 + .github/workflows/ci.yml | 113 +- .github/workflows/e2e.yml | 11 +- AGENTS.md | 23 +- README.md | 21 + pyproject.toml | 77 +- src/band/adapters/langgraph.py | 5 + src/band/adapters/pydantic_ai.py | 60 +- src/band/integrations/langgraph/decline.py | 51 + .../adapters/langgraph/test_message_input.py | 6 +- tests/adapters/pydanticai/__init__.py | 0 tests/adapters/pydanticai/conftest.py | 19 + tests/adapters/pydanticai/kit.py | 442 +++ tests/adapters/pydanticai/providerpeer.py | 139 + .../pydanticai/test_advertisedtoolschemas.py | 73 + .../pydanticai/test_builtintoolexecution.py | 180 + .../test_builtintoolregistration.py | 79 + .../pydanticai/test_builtintoolresults.py | 237 ++ tests/adapters/pydanticai/test_customtools.py | 210 ++ .../pydanticai/test_emptyfinalanswer.py | 327 ++ .../pydanticai/test_executionreporting.py | 294 ++ .../pydanticai/test_explicitdecline.py | 358 ++ .../pydanticai/test_historymanagement.py | 216 ++ .../pydanticai/test_instrumentation.py | 117 + .../pydanticai/test_nativediagnostics.py | 80 + .../pydanticai/test_nativeprogress.py | 205 ++ tests/adapters/pydanticai/test_oncleanup.py | 27 + tests/adapters/pydanticai/test_onmessage.py | 178 + tests/adapters/pydanticai/test_onstarted.py | 82 + .../pydanticai/test_portablecustomtooldef.py | 134 + tests/adapters/pydanticai/test_provider.py | 54 + .../adapters/pydanticai/test_usagemapping.py | 107 + tests/adapters/test_pydantic_ai_adapter.py | 3049 +---------------- tests/e2e/baseline/README.md | 29 +- tests/e2e/baseline/agents.py | 2 +- tests/e2e/baseline/conftest.py | 2 + tests/e2e/baseline/expected-cells.json | 496 ++- tests/e2e/baseline/fixtures/diagnostics.py | 23 + .../baseline/guards/test_strands_provider.py | 90 +- tests/e2e/baseline/lane_selection.py | 4 +- .../smoke/adapters/copilotsdk/__init__.py | 0 .../smoke/adapters/copilotsdk/conftest.py | 27 + .../smoke/adapters/copilotsdk/test_handler.py | 101 + .../smoke/adapters/copilotsdk/test_resume.py | 119 + .../adapters/copilotsdk/test_roomquestion.py | 107 + .../adapters/copilotsdk/test_sharedclient.py | 112 + .../smoke/adapters/test_copilot_sdk.py | 391 --- .../test_rehydration_cross_framework.py | 162 +- .../smoke/matrix/test_strands_rehydration.py | 32 + tests/e2e/baseline/smoke/samples/provider.py | 11 + .../e2e/baseline/smoke/samples/rehydration.py | 94 + tests/e2e/baseline/toolkit/builders.py | 48 +- tests/e2e/baseline/toolkit/ci_lanes.py | 40 +- tests/e2e/baseline/toolkit/deps.py | 16 +- tests/e2e/baseline/toolkit/logs.py | 6 +- tests/framework_configs/adapters.py | 9 +- tests/framework_configs/converters.py | 16 +- tests/framework_conformance/imageprobes.py | 436 +++ tests/framework_conformance/lanekit.py | 66 + tests/framework_conformance/outcomeprobes.py | 150 + tests/framework_conformance/scorecardkit.py | 84 + .../test_collected_coverage.py | 41 + .../test_files_image_passthrough_matrix.py | 506 +-- .../test_lane_scheduling.py | 393 +-- tests/framework_conformance/test_scorecard.py | 910 +++-- .../test_strands_nooa_job_coverage.py | 73 + .../test_turn_outcome.py | 382 +-- .../venv_job_coverage.py | 153 +- tests/integrations/langgraph/declinekit.py | 55 + tests/integrations/langgraph/test_decline.py | 147 + uv.lock | 1825 ++++++---- 73 files changed, 8405 insertions(+), 5988 deletions(-) create mode 100644 .github/scripts/check-provider-startup.py create mode 100644 .github/scripts/check-wheel-extras.py create mode 100644 .github/scripts/wheelchecks.py create mode 100644 src/band/integrations/langgraph/decline.py create mode 100644 tests/adapters/pydanticai/__init__.py create mode 100644 tests/adapters/pydanticai/conftest.py create mode 100644 tests/adapters/pydanticai/kit.py create mode 100644 tests/adapters/pydanticai/providerpeer.py create mode 100644 tests/adapters/pydanticai/test_advertisedtoolschemas.py create mode 100644 tests/adapters/pydanticai/test_builtintoolexecution.py create mode 100644 tests/adapters/pydanticai/test_builtintoolregistration.py create mode 100644 tests/adapters/pydanticai/test_builtintoolresults.py create mode 100644 tests/adapters/pydanticai/test_customtools.py create mode 100644 tests/adapters/pydanticai/test_emptyfinalanswer.py create mode 100644 tests/adapters/pydanticai/test_executionreporting.py create mode 100644 tests/adapters/pydanticai/test_explicitdecline.py create mode 100644 tests/adapters/pydanticai/test_historymanagement.py create mode 100644 tests/adapters/pydanticai/test_instrumentation.py create mode 100644 tests/adapters/pydanticai/test_nativediagnostics.py create mode 100644 tests/adapters/pydanticai/test_nativeprogress.py create mode 100644 tests/adapters/pydanticai/test_oncleanup.py create mode 100644 tests/adapters/pydanticai/test_onmessage.py create mode 100644 tests/adapters/pydanticai/test_onstarted.py create mode 100644 tests/adapters/pydanticai/test_portablecustomtooldef.py create mode 100644 tests/adapters/pydanticai/test_provider.py create mode 100644 tests/adapters/pydanticai/test_usagemapping.py create mode 100644 tests/e2e/baseline/fixtures/diagnostics.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/__init__.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/conftest.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/test_handler.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/test_resume.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/test_roomquestion.py create mode 100644 tests/e2e/baseline/smoke/adapters/copilotsdk/test_sharedclient.py delete mode 100644 tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py create mode 100644 tests/e2e/baseline/smoke/matrix/test_strands_rehydration.py create mode 100644 tests/e2e/baseline/smoke/samples/provider.py create mode 100644 tests/e2e/baseline/smoke/samples/rehydration.py create mode 100644 tests/framework_conformance/imageprobes.py create mode 100644 tests/framework_conformance/lanekit.py create mode 100644 tests/framework_conformance/outcomeprobes.py create mode 100644 tests/framework_conformance/scorecardkit.py create mode 100644 tests/framework_conformance/test_collected_coverage.py create mode 100644 tests/framework_conformance/test_strands_nooa_job_coverage.py create mode 100644 tests/integrations/langgraph/declinekit.py create mode 100644 tests/integrations/langgraph/test_decline.py diff --git a/.github/scripts/check-provider-startup.py b/.github/scripts/check-provider-startup.py new file mode 100644 index 000000000..e803ae200 --- /dev/null +++ b/.github/scripts/check-provider-startup.py @@ -0,0 +1,115 @@ +"""Start installed providers without inference and verify the selected OpenAI major.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +from importlib.metadata import version + +from pydantic import BaseModel +from wheelchecks import Framework + +logger = logging.getLogger(__name__) +MODEL_ID = "gpt-4o-mini" +API_KEY = "test" + + +async def start_pydantic_ai() -> None: + import httpx2 # noqa: PLC0415 -- only modern consumers install HTTPX2 + from pydantic_ai.models import infer_model # noqa: PLC0415 -- optional consumer dependency + + from band.adapters.pydantic_ai import ( # noqa: PLC0415 -- only the Pydantic AI consumer installs this adapter + PydanticAIAdapter, + PydanticAIAdapterConfig, + ) + + for alias in ("openai", "openai-chat"): + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model=f"{alias}:{MODEL_ID}") + ) + await adapter.on_started("Wheel consumer", "Provider startup check") + assert adapter._agent is not None + async with adapter._agent: + model = infer_model(f"{alias}:{MODEL_ID}") + assert isinstance(model.provider.client._client, httpx2.AsyncClient) + + +async def start_crewai() -> None: + from crewai import LLM # noqa: PLC0415 -- only the CrewAI consumer installs this provider + + LLM(model=f"openai/{MODEL_ID}", api_key=API_KEY) + + +async def start_strands() -> None: + from strands.models.openai_responses import OpenAIResponsesModel # noqa: PLC0415 -- only the Strands consumer installs this provider + + OpenAIResponsesModel(model_id=MODEL_ID, client_args={"api_key": API_KEY}) + + +async def start_nooa() -> None: + from nooa.unifiedllm.registry import get_llm_client # noqa: PLC0415 -- only the NOOA consumer installs this provider + + get_llm_client(f"openai/{MODEL_ID}", api_key=API_KEY) + + +async def start_langgraph() -> None: + from langchain_openai import ChatOpenAI # noqa: PLC0415 -- only LangGraph consumers install this provider + + model = ChatOpenAI(model=MODEL_ID, api_key=API_KEY) + await model.root_async_client.close() + model.root_client.close() + + +async def start_parlant() -> None: + import httpx2 # noqa: PLC0415 -- only modern consumers install HTTPX2 + from parlant.adapters.nlp.openai_service import OpenAIService # noqa: PLC0415 -- only the Parlant consumer installs this provider + from parlant.core.loggers import StdoutLogger # noqa: PLC0415 -- optional consumer dependency + from parlant.core.meter import LocalMeter # noqa: PLC0415 -- optional consumer dependency + from parlant.core.tracer import LocalTracer # noqa: PLC0415 -- optional consumer dependency + + tracer = LocalTracer() + provider_logger = StdoutLogger(tracer) + service = OpenAIService(provider_logger, tracer, LocalMeter(provider_logger)) + generator = await service.get_schematic_generator(BaseModel) + assert isinstance(generator._client._client, httpx2.AsyncClient) + await generator._client.close() + + +async def start_provider(framework: Framework) -> None: + match framework: + case Framework.PYDANTIC_AI: + await start_pydantic_ai() + case Framework.CREWAI: + await start_crewai() + case Framework.STRANDS: + await start_strands() + case Framework.NOOA: + await start_nooa() + case Framework.LANGGRAPH: + await start_langgraph() + case Framework.PARLANT: + await start_parlant() + logger.info("%s provider starts", framework) + + +async def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--framework", type=Framework, choices=Framework, action="append", required=True + ) + parser.add_argument("--openai-major", type=int, required=True) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(message)s") + selected_version = version("openai") + assert int(selected_version.split(".")[0]) == args.openai_major, selected_version + # Upstream string-based providers read this; no real credentials or inference are needed. + os.environ["OPENAI_API_KEY"] = API_KEY + for framework in args.framework: + await start_provider(framework) + logger.info("OpenAI %s: provider startup checks passed", selected_version) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/.github/scripts/check-wheel-extras.py b/.github/scripts/check-wheel-extras.py new file mode 100644 index 000000000..e069002cf --- /dev/null +++ b/.github/scripts/check-wheel-extras.py @@ -0,0 +1,100 @@ +"""Verify clean wheel installs, provider startup, and incompatible-extra rejection. + +Run outside the repository resolver: each case gets a fresh virtual environment +and installs the built wheel through its published Requires-Dist metadata. +""" + +from __future__ import annotations + +import argparse +import logging +import subprocess +import sys +import tempfile +from pathlib import Path + +from wheelchecks import CONSUMERS, Consumer, Framework + +logger = logging.getLogger(__name__) +PROVIDER_PROBE = Path(__file__).with_name("check-provider-startup.py") + + +def run(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, cwd=cwd, text=True, capture_output=True, check=False + ) + logger.info("$ %s\n%s%s", " ".join(command), result.stdout, result.stderr) + return result + + +def require_success(command: list[str], cwd: Path) -> None: + result = run(command, cwd) + if result.returncode != 0: + raise RuntimeError(f"Wheel consumer check failed: {command}") + + +def install_consumer(wheel: Path, consumer: Consumer, python: Path, cwd: Path) -> None: + requirements = [f"{wheel}[{consumer.extras}]"] + if consumer.openai_major == 3: + requirements.append("openai>=3.26,<4") + require_success( + ["uv", "pip", "install", "--python", str(python), *requirements], cwd + ) + require_success(["uv", "pip", "check", "--python", str(python)], cwd) + + +def check_providers(consumer: Consumer, python: Path, cwd: Path) -> None: + framework_flags = [ + value + for framework in consumer.frameworks + for value in ("--framework", str(framework)) + ] + require_success( + [ + str(python), + str(PROVIDER_PROBE), + "--openai-major", + str(consumer.openai_major), + *framework_flags, + ], + cwd, + ) + + +def check_incompatible_extras( + wheel: Path, consumer: Consumer, python: Path, cwd: Path +) -> None: + extras = f"{Framework.PYDANTIC_AI},{consumer.extras}" + result = run( + ["uv", "pip", "install", "--python", str(python), f"{wheel}[{extras}]"], cwd + ) + if result.returncode == 0 or "No solution found" not in result.stderr: + raise RuntimeError("Incompatible extras did not fail dependency resolution") + logger.info("Incompatible extras rejected: %s", extras) + + +def check_consumer(wheel: Path, consumer: Consumer) -> None: + with tempfile.TemporaryDirectory(prefix="band-wheel-") as directory: + cwd = Path(directory) + environment = cwd / "venv" + require_success(["uv", "venv", "--python", "3.12", str(environment)], cwd) + python = environment / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + install_consumer(wheel, consumer, python, cwd) + check_providers(consumer, python, cwd) + if consumer.rejects_pydantic_ai: + check_incompatible_extras(wheel, consumer, python, cwd) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument("--case", choices=CONSUMERS, required=True) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(message)s") + check_consumer(args.wheel.resolve(), CONSUMERS[args.case]) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/wheelchecks.py b/.github/scripts/wheelchecks.py new file mode 100644 index 000000000..675632048 --- /dev/null +++ b/.github/scripts/wheelchecks.py @@ -0,0 +1,46 @@ +"""Declared wheel-consumer combinations and their expected OpenAI compatibility.""" + +from __future__ import annotations + +import json +import sys +from dataclasses import dataclass +from enum import StrEnum + + +class Framework(StrEnum): + PYDANTIC_AI = "pydantic-ai" + CREWAI = "crewai" + STRANDS = "strands" + NOOA = "nooa" + LANGGRAPH = "langgraph" + PARLANT = "parlant" + + +@dataclass(frozen=True) +class Consumer: + frameworks: tuple[Framework, ...] + openai_major: int + rejects_pydantic_ai: bool = False + + @property + def extras(self) -> str: + return ",".join(self.frameworks) + + +CONSUMERS: dict[str, Consumer] = { + consumer.extras.replace(",", "-"): consumer + for consumer in ( + Consumer((Framework.PYDANTIC_AI,), 3), + Consumer((Framework.CREWAI,), 2, rejects_pydantic_ai=True), + Consumer((Framework.STRANDS,), 2, rejects_pydantic_ai=True), + Consumer((Framework.NOOA,), 2, rejects_pydantic_ai=True), + Consumer((Framework.LANGGRAPH, Framework.STRANDS), 2, rejects_pydantic_ai=True), + Consumer((Framework.LANGGRAPH,), 3), + Consumer((Framework.PARLANT,), 3), + ) +} + + +if __name__ == "__main__": + sys.stdout.write(json.dumps({"case": list(CONSUMERS)}) + "\n") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf26d457..c0830d6ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,10 +102,10 @@ jobs: # Linux-only: doc snippets are OS-independent, and this step relies on # git ls-files globbing that we don't need to re-run per platform. if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - # examples/ READMEs have illustrative fragments not yet snippet-testable + # NOOA snippets run in test-strands-nooa; examples/ READMEs are illustrative. run: | # shellcheck disable=SC2046 # word-splitting the ls-files output is the point - uv run --all-packages pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*') --no-cov + uv run --all-packages pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*' ':!:docs/adapters/nooa.md') --no-cov test-crewai: @@ -177,6 +177,79 @@ jobs: src/band/integrations/crewai/ + test-strands-nooa: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.11', '3.12', '3.13'] + fail-fast: false + + # Use bash on every OS so the multiline pytest and pyrefly commands stay + # identical across Linux and Windows. + defaults: + run: + shell: bash + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Configure git to use HTTPS for GitHub + uses: ./.github/actions/git-https-auth + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + enable-cache: true + cache-dependency-glob: "**/pyproject.toml" + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Strands and NOOA dependencies + run: uv sync --all-packages --locked --extra dev-strands-nooa + + - name: Collect baseline E2E in the Strands and NOOA environment + env: + BAND_ALLOW_MISSING_FRAMEWORKS: "1" + BAND_TEST_EXTRA: "dev-strands-nooa" + run: uv run --all-packages pytest tests/e2e/baseline/ --collect-only -q --no-cov + + - name: Run Strands and NOOA tests + env: + # dev-strands-nooa lacks modern framework packages; + # tolerate missing framework configs instead of failing fast. + BAND_ALLOW_MISSING_FRAMEWORKS: "1" + BAND_TEST_EXTRA: "dev-strands-nooa" + run: | + uv run --all-packages pytest \ + tests/adapters/test_strands_adapter.py \ + tests/adapters/nooa/ \ + tests/converters/test_strands.py \ + tests/framework_conformance/ -k 'strands or nooa or legacy or probe_registry' + + - name: Verify selected Strands and NOOA coverage + env: + BAND_ALLOW_MISSING_FRAMEWORKS: "1" + BAND_TEST_EXTRA: "dev-strands-nooa" + run: uv run --all-packages pytest tests/framework_conformance/test_strands_nooa_job_coverage.py + + - name: Run NOOA markdown examples + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + run: uv run --all-packages pytest --markdown-docs docs/adapters/nooa.md --no-cov + + - name: Pyrefly check (Strands and NOOA sources) + run: | + uv run --all-packages pyrefly check \ + src/band/adapters/strands.py \ + src/band/converters/strands.py \ + src/band/adapters/nooa/ \ + src/band/converters/nooa.py + + test-parlant: runs-on: ${{ matrix.os }} strategy: @@ -369,3 +442,39 @@ jobs: - name: Verify band-mcp CLI entry point run: /tmp/band-mcp-install/bin/band-mcp --version + + + wheel-extra-cases: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.cases.outputs.matrix }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + - name: Resolve wheel consumer cases + id: cases + run: echo "matrix=$(python3 .github/scripts/wheelchecks.py)" >> "$GITHUB_OUTPUT" + + wheel-extras: + needs: wheel-extra-cases + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.wheel-extra-cases.outputs.matrix) }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.12' + - name: Build wheel + run: uv build --wheel + - name: Check published extras and provider startup + run: python3 .github/scripts/check-wheel-extras.py dist/band_sdk-*.whl --case ${{ matrix.case }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0e66e8734..5910dfcf2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -45,7 +45,8 @@ name: E2E (baseline) # inside the pytest process; the container reaches it back via # host.docker.internal. Own lane (shares the `dev` extra) for the # server it stands up. -# - nooa — NooaAdapter (NVIDIA NOOA). Shares the `dev` extra, which installs +# - strands — Strands Responses provider on OpenAI 2 (`dev-strands-nooa`). +# - nooa — NooaAdapter (NVIDIA NOOA). Shares the `dev-strands-nooa` extra, which installs # NOOA only on Python 3.12-3.13 off Windows (it imports fcntl), so # the lane is Linux-only. # @@ -79,6 +80,7 @@ on: - google - backends - letta + - strands - nooa - parlant os: @@ -190,10 +192,9 @@ jobs: # @pytest.mark.timeout(extra=...). See tests/e2e/baseline/conftest.py. # The registry scopes which adapters run in this lane. BAND_E2E_LANE: ${{ matrix.lane }} - # The dev-crewai and dev-parlant venvs each lack the other frameworks; - # tolerate missing framework configs there instead of failing fast (mirrors - # ci.yml's crewai and parlant jobs). - BAND_ALLOW_MISSING_FRAMEWORKS: ${{ (matrix.extra == 'dev-crewai' || matrix.extra == 'dev-parlant') && '1' || '0' }} + # Isolated extras lack modern frameworks; their owning jobs verify + # required configs while tolerating other environments' dependencies. + BAND_ALLOW_MISSING_FRAMEWORKS: ${{ (matrix.extra == 'dev-crewai' || matrix.extra == 'dev-parlant' || matrix.extra == 'dev-strands-nooa') && '1' || '0' }} # Secrets carry an E2E_ prefix in GitHub (so they're clearly scoped to this # workflow), but the code reads the unprefixed names — the mapping happens # here: the env var keeps its code-expected name, the secret is prefixed. diff --git a/AGENTS.md b/AGENTS.md index 59a2a888d..9b571bb0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,12 +187,15 @@ To wire a new framework adapter into the matrix, follow ## Commands ```bash -# Install dependencies (all extras except crewai and parlant — see Dependency Conflicts below) +# Install dependencies (modern frameworks; see Dependency Conflicts below) uv sync --extra dev # Install crewai adapter deps (isolated from dev/parlant/pydantic-ai) uv sync --extra dev-crewai +# Install Strands/NOOA adapter deps in their OpenAI-2 environment +uv sync --extra dev-strands-nooa + # Install parlant adapter deps (isolated from dev/crewai/pydantic-ai) uv sync --extra dev-parlant @@ -230,9 +233,13 @@ uv run pyrefly check environment (crewai's exact `~=` pins on `mcp`, `aiofiles`, `regex` and `tomli` drag the whole lock), and **parlant cannot coexist with pydantic-ai** either (a `griffe`/`griffelib` namespace collision uv cannot see as a version conflict). -That's why there are three separate extras — `dev` (everything except crewai -and parlant), `dev-crewai`, `dev-parlant` — each installed in its own CI -job/venv. The `[tool.uv] conflicts` comments in `pyproject.toml` carry the +Strands and NOOA/LiteLLM require OpenAI 2, while modern Pydantic AI requires +OpenAI 3. There are four isolated development extras: `dev` (modern frameworks), +`dev-strands-nooa` (Strands, conditional NOOA, and their LangGraph peer), `dev-crewai`, +and `dev-parlant`. Each has its own CI job/venv. Public LangGraph and Parlant +remain compatible with either OpenAI major; LangGraph + Strands is supported. +Use `uv run --locked --isolated --python 3.12 --all-packages --extra ` +for concurrent work so one lane never overwrites another lane's environment. The `[tool.uv] conflicts` comments in `pyproject.toml` carry the mechanism. ## Environment Variables @@ -264,7 +271,7 @@ agent keys and platform URLs should stay aligned with `.env.test` / Baseline lane scoping (see `tests/e2e/baseline/README.md`): -- `BAND_E2E_LANE`: The CI lane (a job: a `uv` extra + optional server/CLI setup) to scope the run to. Lane ids are content-based and decoupled from the `uv` extra — `core` (anthropic/openai-family adapters plus `copilot_sdk`, which self-downloads its CLI runtime and uses Anthropic BYOK without GitHub auth; `dev` extra), `crewai` (`dev-crewai` extra), `google` (gemini/google_adk, split out for rate-limit isolation), `backends` (codex, opencode, copilot_acp, and cursor_acp coding agents), `letta` (self-hosted letta server), `nooa` (NOOA, Linux-only: it imports `fcntl`), `parlant` (`dev-parlant` extra — split from `core` because parlant's griffe/griffelib transitive deps collide with pydantic_ai's; registers no matrix adapter, a bespoke `@lane`-pinned smoke only). Resolves the lane's adapters from the registry (`ci_lanes()`, derived from each adapter's `requires`); out-of-lane adapters skip-with-reason (they're covered by their own lane) while in-lane adapters keep fail-loud (an unwired backend stays red). Unset (the local default) = full matrix, no scoping. CI never lists adapters — it derives lanes from the registry. A test's lane is derived from **all** the frameworks it touches (a matrix cell's adapter plus its `@per_adapter(peer=...)`, or a `@with_adapters` set); a test whose frameworks span more than one home lane fails collection (`assert_every_item_is_schedulable`) unless pinned with `@lane(Lane.X)` to a lane whose extra hosts them all. To add a lane, see `tests/e2e/baseline/README.md` ("Adding a CI lane"). +- `BAND_E2E_LANE`: The CI lane (a job: a `uv` extra + optional server/CLI setup) to scope the run to. Lane ids are content-based and decoupled from the `uv` extra — `core` (anthropic/openai-family adapters plus `copilot_sdk`, which self-downloads its CLI runtime and uses Anthropic BYOK without GitHub auth; `dev` extra), `crewai` (`dev-crewai` extra), `google` (gemini/google_adk, split out for rate-limit isolation), `backends` (codex, opencode, copilot_acp, and cursor_acp coding agents), `letta` (self-hosted letta server), `strands` (`dev-strands-nooa` extra), `nooa` (same `dev-strands-nooa` extra, Linux-only: it imports `fcntl`), `parlant` (`dev-parlant` extra — split from `core` because parlant's griffe/griffelib transitive deps collide with pydantic_ai's; registers no matrix adapter, a bespoke `@lane`-pinned smoke only). Resolves the lane's adapters from the registry (`ci_lanes()`, derived from each adapter's `requires`); out-of-lane adapters skip-with-reason (they're covered by their own lane) while in-lane adapters keep fail-loud (an unwired backend stays red). Unset (the local default) = full matrix, no scoping. CI never lists adapters — it derives lanes from the registry. A test's lane is derived from **all** the frameworks it touches (a matrix cell's adapter plus its `@per_adapter(peer=...)`, or a `@with_adapters` set); a test whose frameworks span more than one home lane fails collection (`assert_every_item_is_schedulable`) unless pinned with `@lane(Lane.X)` to a lane whose extra hosts them all. To add a lane, see `tests/e2e/baseline/README.md` ("Adding a CI lane"). Baseline provisioning/cleanup policy (see `tests/e2e/baseline/README.md`): @@ -315,9 +322,13 @@ Tracked `.md` files (except `examples/`) run in CI as tests via `pytest-markdown — so `python` snippets in the docs must stay correct and runnable, not rot: ```bash -uv run pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*') --no-cov +uv run pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*' ':!:docs/adapters/nooa.md') --no-cov +uv run --locked --isolated --python 3.12 --all-packages --extra dev-strands-nooa pytest --markdown-docs docs/adapters/nooa.md --no-cov ``` +NOOA's runnable documentation requires its legacy environment; the two commands +above cover the complete tracked documentation suite. + Fence tags: plain ` ```python ` **executes** (top-level `assert`s are the checks); ` ```python notest ` is collected out — reserve it for pseudo-code, placeholder names, or snippets that genuinely need a live platform/LLM; diff --git a/README.md b/README.md index e6998d7e1..72fc5332b 100644 --- a/README.md +++ b/README.md @@ -844,6 +844,27 @@ The SDK reconnects automatically and resubscribes to active rooms. No action is ### Adapter Dependency Conflicts +Pydantic AI uses OpenAI 3 and its default HTTPX2 client. Current CrewAI, Strands, +and NOOA/LiteLLM require OpenAI 2; install these in separate environments from +Pydantic AI. This is a breaking dependency compatibility change for consumers +that previously combined those extras. Adapter signatures and Band REST's +HTTPX transport remain unchanged. Injected OpenAI clients must match their +installed OpenAI major. + +LangGraph and Parlant support either major. LangGraph + Strands remains a +supported OpenAI-2 combination. Agents from separate environments can still +collaborate through Band. + +For testing, `dev` and `dev-parlant` use OpenAI 3; `dev-crewai` and `dev-strands-nooa` +use OpenAI 2. `dev-strands-nooa` contains Strands, conditional NOOA, and LangGraph. +Run isolated environments without replacing a concurrently used `.venv`: + +```bash +uv run --locked --isolated --python 3.12 --all-packages --extra dev pytest tests/ --ignore=tests/e2e/ --ignore=tests/integration/ +uv run --locked --isolated --python 3.12 --all-packages --extra dev-strands-nooa pytest tests/adapters/test_strands_adapter.py tests/adapters/nooa/ tests/framework_conformance/ -k 'strands or nooa or legacy' +``` + + Three extras are resolved separately, because crewai carries the narrowest transitive pins and they have repeatedly collided: | Pair | crewai's pin | Other side | diff --git a/pyproject.toml b/pyproject.toml index 355033dcf..6620fabc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,8 +68,8 @@ letta = [ "mcp>=1.28.1,<2", ] pydantic-ai = [ - "pydantic-ai-slim[anthropic]>=2.18.0", - "openai>=2.0.0", + "pydantic-ai-slim[anthropic,openai]>=2.55.0", + "openai>=3.26.0,<4", ] anthropic = [ "anthropic>=0.75.0,<2", @@ -78,7 +78,7 @@ langgraph = [ "langchain>=1.0.0", "langchain-core>=1.2.11", "langgraph>=1.0.0", - "langchain-openai>=0.3.0", + "langchain-openai>=1.5.0", "langchain-anthropic>=0.3.0", "langchain-community>=0.4.0", # 0.4.0+ required for langchain 1.0 compatibility "langchain-text-splitters>=0.3.0", @@ -101,7 +101,7 @@ parlant = [ ] crewai = [ "crewai==1.15.27", - "openai>=2.0.0", # crewai 1.14.3 requires openai>=2.0.0 + "openai>=2.0.0,<3", # CrewAI requires the legacy OpenAI client "nest-asyncio>=1.6.0", # Required for sync-to-async bridging in CrewAI tools "pillow>=12.1.1", ] @@ -122,7 +122,7 @@ a2a_gateway_demo = [ "starlette>=0.40.0", # HTTP server "uvicorn>=0.29", # ASGI server "langgraph>=1.0.0", # LangGraph for orchestrator agent - "langchain-openai>=0.3.0", # OpenAI model for LLM + "langchain-openai>=1.5.0", # OpenAI model for LLM "click>=8.0.0", # CLI for demo orchestrator ] acp = [ @@ -172,15 +172,17 @@ agno = [ ] strands = [ "strands-agents[openai]>=1.50.1,<2", - "openai>=2.0.0", + "openai>=2.0.0,<3", ] # NOOA supports Python 3.12 and 3.13 on POSIX only (it imports fcntl); elsewhere # the extra installs nothing. nooa = [ "nooa>=0.0.10,<0.1; python_version >= '3.12' and python_version < '3.14' and sys_platform != 'win32'", + "openai>=2.0.0,<3; python_version >= '3.12' and python_version < '3.14' and sys_platform != 'win32'", ] -# dev extra includes ALL framework deps for testing EXCEPT crewai and parlant. +# Modern dev excludes CrewAI, Parlant, Strands and NOOA. +# Strands and NOOA require OpenAI 2 and live in dev-strands-nooa. # crewai is declared conflicting with both parlant and pydantic-ai (its pydantic # ceiling and exact `~=` pins); parlant is declared conflicting with pydantic-ai # because their `griffe`/`griffelib` transitive deps are two different PyPI @@ -203,7 +205,7 @@ dev = [ "httpx>=0.24.0", # Already in main deps, for mocking "pytest-httpx>=0.35.0", # httpx_mock fixture: REST header-emission proof # Include pydantic-ai for testing - "pydantic-ai-slim>=2.18.0", + "pydantic-ai-slim[anthropic,openai]>=2.55.0", # Include anthropic for testing "anthropic>=0.75.0,<2", # Include claude_sdk for testing @@ -214,11 +216,11 @@ dev = [ "langchain>=1.0.0", "langchain-core>=1.2.11", "langgraph>=1.0.0", - "langchain-openai>=0.3.0", + "langchain-openai>=1.5.0", "langchain-anthropic>=0.3.0", "langchain-community>=0.4.0", "langchain-text-splitters>=0.3.0", - "openai>=2.0.0", + "openai>=3.26.0,<4", "beautifulsoup4>=4.12.0", # Include a2a-sdk for testing "a2a-sdk>=1.1.2,<2", @@ -241,10 +243,6 @@ dev = [ "opentelemetry-resourcedetector-gcp>=1.12.0a0,!=1.13.0", # Include Agno for testing "agno>=2.6.0", - # Include Strands Agents for testing - "strands-agents[openai]>=1.50.1,<2", - # Include NOOA for testing (Python 3.12 and 3.13, POSIX only) - "nooa>=0.0.10,<0.1; python_version >= '3.12' and python_version < '3.14' and sys_platform != 'win32'", # Include letta-client for testing "letta-client>=1.0.0", # Include bridge deps for testing @@ -286,7 +284,7 @@ dev-crewai = [ "httpx>=0.24.0", # CrewAI and its deps "crewai==1.15.27", - "openai>=2.0.0", + "openai>=2.0.0,<3", "nest-asyncio>=1.6.0", "pillow>=12.1.1", # Anthropic client: the baseline E2E conftest imports it at module load and @@ -318,7 +316,7 @@ dev-parlant = [ "httpx>=0.24.0", # Parlant and its deps (see the parlant extra for why >=3.3.2) "parlant>=3.3.2", - "openai>=2.0.0", + "openai>=3.26.0,<4", "werkzeug>=3.1.6", # Anthropic client: the baseline E2E conftest imports it at module load and # the LLM judge needs it. A thin client (pydantic <3), so no parlant conflict. @@ -332,6 +330,42 @@ dev-parlant = [ "pyrefly>=0.18.0", ] +# OpenAI-2 frameworks share test tooling and retain separate E2E lanes. +dev-strands-nooa = [ + "pydantic-settings>=2.0.0", + "pytest>=7.0.0", + "pytest-markdown-docs>=0.9.2", + "pytest-asyncio>=0.21.0", + "looptime>=0.7", + "pytest-mock>=3.10.0", + "pytest-cov>=4.0.0", + "pytest-rerunfailures>=14.0.0", + "tenacity>=8.0.0", + "pytest-timeout>=2.4.0", + "band-testing-python==0.1.4", + "httpx>=0.24.0", + # OpenAI-2 frameworks and their LangGraph peer + "strands-agents[openai]>=1.50.1,<2", + "nooa>=0.0.10,<0.1; python_version >= '3.12' and python_version < '3.14' and sys_platform != 'win32'", + "openai>=2.0.0,<3", + # Shared clients for baseline collection and the judge. + "anthropic>=0.75.0,<2", + "a2a-sdk>=1.1.2,<2", + "langchain>=1.0.0", + "langchain-core>=1.2.11", + "langgraph>=1.0.0", + "langchain-openai>=1.5.0", + "langchain-anthropic>=0.3.0", + "langchain-community>=0.4.0", # 0.4.0+ required for langchain 1.0 compatibility + "langchain-text-splitters>=0.3.0", + "beautifulsoup4>=4.12.0", + # Pretty E2E test output + "rich>=13.0.0", + # Development tools + "ruff>=0.8.0", + "pyrefly>=0.18.0", +] + [project.scripts] band-acp = "band.integrations.acp.cli:entry_point" band-kit = "band.docker.provision:main" @@ -477,6 +511,15 @@ conflicts = [ { extra = "dev-parlant" }, { extra = "crewai" }, ], + [{ extra = "dev" }, { extra = "strands" }], + [{ extra = "dev" }, { extra = "nooa" }], + [{ extra = "dev" }, { extra = "dev-strands-nooa" }], + [{ extra = "pydantic-ai" }, { extra = "strands" }], + [{ extra = "pydantic-ai" }, { extra = "nooa" }], + [{ extra = "pydantic-ai" }, { extra = "dev-strands-nooa" }], + [{ extra = "dev-parlant" }, { extra = "strands" }], + [{ extra = "dev-parlant" }, { extra = "nooa" }], + [{ extra = "dev-parlant" }, { extra = "dev-strands-nooa" }], ] [tool.ruff.lint] @@ -523,6 +566,8 @@ project_excludes = [ ignore-missing-imports = [ "crewai.*", "parlant.*", + "strands.*", + "nooa.*", "nest_asyncio", ] # Disable the default **/.[!/.]*/** exclude so pyrefly works inside diff --git a/src/band/adapters/langgraph.py b/src/band/adapters/langgraph.py index a8906259f..8bc60fc09 100644 --- a/src/band/adapters/langgraph.py +++ b/src/band/adapters/langgraph.py @@ -192,6 +192,10 @@ def __init__( create_agent, ) + from band.integrations.langgraph.decline import ( # noqa: PLC0415 -- middleware is only used by the langchain-owned simple pattern + DeclineMiddleware, + ) + if checkpointer is None: checkpointer = InMemorySaver() @@ -200,6 +204,7 @@ def factory(turn_tools: list[Any]) -> Pregel: model=llm, tools=turn_tools, checkpointer=checkpointer, + middleware=[DeclineMiddleware()], ) graph_factory = factory diff --git a/src/band/adapters/pydantic_ai.py b/src/band/adapters/pydantic_ai.py index 44ad1ed72..732a4dcdb 100644 --- a/src/band/adapters/pydantic_ai.py +++ b/src/band/adapters/pydantic_ai.py @@ -46,11 +46,7 @@ from pydantic_ai.models import ( Model, ModelRequestContext, - create_async_http_client, - infer_model, ) -from pydantic_ai.providers import Provider, infer_provider -from pydantic_ai.providers.openai import OpenAIProvider from pydantic_ai.result import FinalResult from pydantic_graph import End from typing_extensions import Unpack @@ -329,29 +325,6 @@ def _takes_run_context(fn: Callable[..., Any]) -> bool: return annotation is RunContext or get_origin(annotation) is RunContext -_OPENAI_LEGACY_HTTP_PROVIDERS = frozenset({"openai", "openai-chat"}) - - -def _resolve_pydantic_ai_model(model: Model | str) -> Model | str: - """Resolve a model string, patching OpenAI providers to use legacy ``httpx``. - - pydantic-ai's OpenAI-compatible providers default to ``httpx2``, while the - ``openai`` Python SDK still type-checks for ``httpx.AsyncClient``. - """ - if not isinstance(model, str): - return model - provider_name = model.partition(":")[0] - if provider_name not in _OPENAI_LEGACY_HTTP_PROVIDERS: - return model - - def provider_factory(name: str) -> Provider[Any]: - if name in _OPENAI_LEGACY_HTTP_PROVIDERS: - return OpenAIProvider(http_client=create_async_http_client()) - return infer_provider(name) - - return infer_model(model, provider_factory) - - class PydanticAIAdapterConfig(BaseAdapterConfig): """Settings for a Pydantic AI agent. @@ -476,7 +449,7 @@ def _create_agent(self) -> Agent[AgentToolsProtocol, str | None]: self._system_prompt = system agent: Agent[AgentToolsProtocol, str | None] = Agent( - _resolve_pydantic_ai_model(self._model), + self._model, # Pass the rendered prompt as `instructions`, not `system_prompt`. # pydantic-ai materializes `system_prompt` as a single SystemPromptPart # only on the first request, after which it ages into buried history; @@ -609,6 +582,11 @@ async def on_message( # pydantic-ai's result.usage is already summed across the run's model # calls, so it's set once (on the result event), not accumulated. turn_usage = TurnUsage() + tool_call_count = 0 + tool_result_count = 0 + failed_tool_result_count = 0 + native_output_type: str | None = None + native_output_chars = 0 # Snapshot the prior messages' identities so the fallback usage path # (any run that raises) can sum only THIS run's responses: capture_run_messages() # records the passed message_history + the new turn, so summing the whole @@ -653,6 +631,7 @@ async def on_message( events=event_count, tool=event.part.tool_name, ) + tool_call_count += 1 if Emit.TOOL_CALLS in self.features.emit: try: await tools.send_event( @@ -679,6 +658,15 @@ async def on_message( events=event_count, tool=event.part.tool_name, ) + tool_result_count += 1 + if isinstance(event.part, RetryPromptPart) or ( + isinstance(event.part, ToolReturnPart) + and ( + event.part.outcome != "success" + or is_failed_tool_output(event.part.content) + ) + ): + failed_tool_result_count += 1 # Native custom tools run outside execute_custom_tool, so # their declared effect is recorded here. if ( @@ -720,6 +708,9 @@ async def on_message( ) elif isinstance(event, AgentRunResultEvent): _log_native_progress("result_received", events=event_count) + native_output_type = type(event.result.output).__name__ + if isinstance(event.result.output, str): + native_output_chars = len(event.result.output) turn_usage = self._usage_from_result(event.result) # Keep native run history, but drop responses that replay as # content:null (e.g. thinking-only) — providers reject them @@ -791,6 +782,19 @@ async def on_message( raise finally: _native_turn.reset(trace_token) + if native_output_type is not None and not tools.turn.complete: + logger.warning( + "Room %s message %s: native run ended without a turn outcome " + "(output_type=%s, output_chars=%s, tool_calls=%s, " + "tool_results=%s, failed_tool_results=%s)", + room_id, + msg.id, + native_output_type, + native_output_chars, + tool_call_count, + tool_result_count, + failed_tool_result_count, + ) capture_cm.__exit__(None, None, None) # Single emit point for every exit. A run that raised (benign # empty-final or a hard failure) never saw the result event, so diff --git a/src/band/integrations/langgraph/decline.py b/src/band/integrations/langgraph/decline.py new file mode 100644 index 000000000..e2de86920 --- /dev/null +++ b/src/band/integrations/langgraph/decline.py @@ -0,0 +1,51 @@ +"""Finish a native agent loop after a successful explicit decline.""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Any + +from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langgraph.runtime import Runtime + +from band.runtime.tools import BandTool +from band.runtime.tools.schema import is_failed_tool_output + + +def failed_result(message: ToolMessage) -> bool: + content = message.content + if isinstance(content, str): + try: + content = json.loads(content) + except ValueError: + pass + return message.status != "success" or is_failed_tool_output(content) + + +def terminal_decline(messages: Sequence[BaseMessage]) -> bool: + batch: list[ToolMessage] = [] + for message in reversed(messages): + if isinstance(message, ToolMessage): + batch.append(message) + continue + if not isinstance(message, AIMessage) or not batch: + return False + expected = {call["id"] for call in message.tool_calls} + if {result.tool_call_id for result in batch} != expected: + return False + return not any(failed_result(result) for result in batch) and any( + result.name == BandTool.NO_REPLY for result in batch + ) + return False + + +class DeclineMiddleware(AgentMiddleware): + @hook_config(can_jump_to=["end"]) + def before_model( + self, state: AgentState, runtime: Runtime[Any] + ) -> dict[str, Any] | None: + if terminal_decline(state["messages"]): + return {"jump_to": "end"} + return None diff --git a/tests/adapters/langgraph/test_message_input.py b/tests/adapters/langgraph/test_message_input.py index e937e72d3..4febea4d2 100644 --- a/tests/adapters/langgraph/test_message_input.py +++ b/tests/adapters/langgraph/test_message_input.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest from langchain_core.messages import AIMessage, HumanMessage, SystemMessage @@ -12,8 +12,7 @@ from band.adapters.langgraph import LangGraphAdapter, LangGraphAdapterConfig from band.core.types import Capability, Emit, PlatformMessage - -from .helpers import make_capture_graph +from tests.adapters.langgraph.helpers import make_capture_graph class TestOnMessage: @@ -277,6 +276,7 @@ async def test_simple_factory_passes_platform_and_additional_tools( model=mock_llm, tools=[platform_tool, additional_tool], checkpointer=mock_checkpointer, + middleware=[ANY], ) @pytest.mark.asyncio diff --git a/tests/adapters/pydanticai/__init__.py b/tests/adapters/pydanticai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapters/pydanticai/conftest.py b/tests/adapters/pydanticai/conftest.py new file mode 100644 index 000000000..b589c4891 --- /dev/null +++ b/tests/adapters/pydanticai/conftest.py @@ -0,0 +1,19 @@ +"""Shared Pydantic AI fixtures.""" + +from __future__ import annotations + +from tests.adapters.pydanticai.kit import ( + instrument_all_restored, + mock_pydantic_agent, + mock_tools, + sample_message, + trace_capture, +) + +__all__ = [ + "instrument_all_restored", + "mock_pydantic_agent", + "mock_tools", + "sample_message", + "trace_capture", +] diff --git a/tests/adapters/pydanticai/kit.py b/tests/adapters/pydanticai/kit.py new file mode 100644 index 000000000..cff7d66d5 --- /dev/null +++ b/tests/adapters/pydanticai/kit.py @@ -0,0 +1,442 @@ +"""Fixtures and provider doubles for Pydantic AI adapter tests.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from types import SimpleNamespace +from typing import Any, NamedTuple +from unittest.mock import AsyncMock, MagicMock + +import pytest +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from pydantic_ai import ( + Agent, + AgentRunResultEvent, + FunctionToolCallEvent, + FunctionToolResultEvent, + InstrumentationSettings, +) +from pydantic_ai.messages import ( + ModelMessage, + ModelResponse, + RetryPromptPart, + TextPart, + ToolCallPart, + ToolReturnPart, +) +from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel +from pydantic_ai.models.test import TestModel + +from band.adapters.pydantic_ai import ( + PydanticAIAdapter, + PydanticAIAdapterConfig, + _custom_tool_def_to_callable, +) +from band.core.protocols import ( + AgentToolsProtocol, +) +from band.core.types import ( + MessageType, + PlatformMessage, + ToolEventKey, +) +from band.runtime.custom_tools import ( + CustomToolDef, +) +from band.runtime.tools import ( + BandTool, +) +from band.testing import ( + FakeAgentTools, + events_of_type, +) +from tests.adapters.usage_events import recorded_usage_payloads + + +def make_stream_events( + result_messages: list | None = None, + tool_calls: list[tuple[str, dict, str]] | None = None, + tool_results: list[tuple[str, str, str]] | None = None, +): + """Stand in for ``Agent.run_stream_events()``: an async CM over stream events. + + pydantic-ai 2.x makes ``run_stream_events()`` an async context manager that + yields the event iterator (it starts the run on first iteration and tears the + background task down on exit), so the fake has to be one too. + + Args: + result_messages: Messages to return in AgentRunResultEvent + tool_calls: List of (tool_name, args, tool_call_id) tuples + tool_results: List of (tool_name, output, tool_call_id) tuples + """ + + async def stream(): + # Emit tool call events + if tool_calls: + for tool_name, args, tool_call_id in tool_calls: + event = MagicMock(spec=FunctionToolCallEvent) + event.part = MagicMock() + event.part.tool_name = tool_name + event.part.args = args + event.part.args_as_dict = MagicMock(return_value=args) + event.part.tool_call_id = tool_call_id + yield event + + # Emit tool result events + if tool_results: + for tool_name, output, tool_call_id in tool_results: + event = MagicMock(spec=FunctionToolResultEvent) + event.part = MagicMock() + event.part.tool_name = tool_name # tool_name is on the part, not event + event.part.content = output + event.tool_call_id = tool_call_id + yield event + + # Always emit final result event + result_event = MagicMock(spec=AgentRunResultEvent) + result_event.result = MagicMock() + result_event.result.all_messages.return_value = result_messages or [] + yield result_event + + @asynccontextmanager + async def events() -> AsyncIterator[AsyncIterator]: + yield stream() + + return events() + + +def make_usage_response( + inp: int, out: int, parts: list[Any] | None = None +) -> ModelResponse: + """A ModelResponse carrying explicit usage counts. + + Real construction (fully initialized, so the dataclass stays repr-safe), + then the usage field is overridden past any frozen/validated assignment. + """ + response = ModelResponse(parts=parts or []) + object.__setattr__( + response, "usage", SimpleNamespace(input_tokens=inp, output_tokens=out) + ) + return response + + +@pytest.fixture +def sample_message(): + """Create a sample platform message.""" + return PlatformMessage( + id="msg-123", + room_id="room-123", + content="Hello, agent!", + sender_id="user-456", + sender_type="User", + sender_name="Alice", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ) + + +@pytest.fixture +def mock_tools(): + """Create mock AgentToolsProtocol (MagicMock base, AsyncMock methods).""" + tools = MagicMock() + tools.send_message = AsyncMock(return_value={"status": "sent"}) + tools.send_event = AsyncMock(return_value={"status": "sent"}) + tools.send_failure = AsyncMock(return_value={"status": "sent"}) + tools.add_participant = AsyncMock(return_value={"id": "user-1"}) + tools.remove_participant = AsyncMock(return_value={"status": "removed"}) + tools.lookup_peers = AsyncMock(return_value={"peers": []}) + tools.get_participants = AsyncMock(return_value=[]) + tools.create_chatroom = AsyncMock(return_value="new-room-123") + return tools + + +@pytest.fixture +def mock_pydantic_agent(): + """Create a mock Pydantic AI Agent.""" + agent = MagicMock() + agent._function_tools = { + "band_send_message": MagicMock(name="band_send_message"), + "band_send_event": MagicMock(name="band_send_event"), + "band_add_participant": MagicMock(name="band_add_participant"), + "band_remove_participant": MagicMock(name="band_remove_participant"), + "band_lookup_peers": MagicMock(name="band_lookup_peers"), + "band_get_participants": MagicMock(name="band_get_participants"), + "band_create_chatroom": MagicMock(name="band_create_chatroom"), + } + return agent + + +class TraceCapture(NamedTuple): + """A tracer wired to memory, plus the settings that route an agent into it.""" + + provider: TracerProvider + settings: InstrumentationSettings + exporter: InMemorySpanExporter + + def operations(self) -> list[str]: + """The exported spans' operation names (``chat``, ``invoke_agent``, ...). + + The full span name carries the model and agent, which the assertions here + don't care about — the question is only whether the run was traced. + """ + return [span.name.split()[0] for span in self.exporter.get_finished_spans()] + + +@pytest.fixture +def trace_capture() -> Iterator[TraceCapture]: + """Host-owned tracer pipeline, exporting to memory instead of a collector.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + try: + yield TraceCapture( + provider=provider, + settings=InstrumentationSettings(tracer_provider=provider), + exporter=exporter, + ) + finally: + provider.shutdown() + + +@pytest.fixture +def instrument_all_restored() -> Iterator[None]: + """Undo ``Agent.instrument_all()``; it is process-wide state on the class.""" + previous = Agent._instrument_default + try: + yield + finally: + Agent.instrument_all(previous) + + +def message_types(mock_tools: MagicMock) -> list[str]: + """``message_type`` of every event posted through send_event, in order.""" + return [ + call.kwargs.get("message_type") for call in mock_tools.send_event.call_args_list + ] + + +def _reply(text: str) -> FunctionModel: + """A model that answers in plain text, so a run needs no network or tools.""" + + def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + return ModelResponse(parts=[TextPart(content=text)]) + + return FunctionModel(respond) + + +def _wire_tool_call_args(args: Any) -> dict[str, Any] | str: + """Shape tool args the way providers send them on the wire (JSON text or object).""" + if isinstance(args, dict | str): + return args + return json.dumps(args) + + +def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel: + """A model that makes ``calls``, one per turn, then answers in plain text. + + A rejected call brings the model straight back here with nothing left + pending, so it is attempted exactly once — which is what makes "never + dispatched" observable rather than merely slow. + """ + pending = list(calls) + + def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + if pending: + name, args = pending.pop(0) + return ModelResponse(parts=[ToolCallPart(name, _wire_tool_call_args(args))]) + return ModelResponse(parts=[TextPart("done")]) + + return FunctionModel(respond) + + +async def _started_adapter(**features: Any) -> PydanticAIAdapter: + """A started adapter, i.e. one whose agent is really built.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test"), **features) + await adapter.on_started(agent_name="Probe", agent_description="probe") + return adapter + + +def _registered_names(adapter: PydanticAIAdapter) -> set[str]: + return set(adapter._agent._function_toolset.tools) + + +async def _call_tool( + adapter: PydanticAIAdapter, deps: Any, name: str, args: Any +) -> Any: + """Drive one built-in tool call through pydantic-ai's own execution path. + + Not the tool function directly: validation, argument splatting and the + retry conversion are pydantic-ai's, and calling the function by hand would + skip every one of them. + """ + with adapter._agent.override(model=_scripted_tool_calls((name, args))): + return await adapter._agent.run("go", deps=deps) + + +def _parts(result: Any, part_type: type) -> list[Any]: + return [ + part + for message in result.all_messages() + for part in message.parts + if isinstance(part, part_type) + ] + + +def _tool_returns(result: Any) -> list[Any]: + return [part.content for part in _parts(result, ToolReturnPart)] + + +def _streamed_tool_calls(*calls: tuple[str, dict[str, Any]]) -> FunctionModel: + """A streaming model (what ``on_message`` drives) making ``calls``, one per + request, then ending with nothing left to say.""" + pending = list(calls) + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: + if pending: + name, args = pending.pop(0) + yield {0: DeltaToolCall(name=name, json_args=json.dumps(args))} + else: + yield "" + + return FunctionModel(stream_function=stream) + + +class DecliningModel: + """A native streaming model that keeps declining if asked again.""" + + def __init__(self) -> None: + self.requests = 0 + self.model = FunctionModel(stream_function=self.stream) + + async def stream( + self, messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[dict[int, DeltaToolCall]]: + self.requests += 1 + yield { + 0: DeltaToolCall( + name=BandTool.NO_REPLY, + json_args=json.dumps({"reason": "Already answered"}), + tool_call_id=f"decline-{self.requests}", + ) + } + + +def _reply_arguments(message: PlatformMessage, content: str) -> dict[str, Any]: + return {"content": content, "mentions": [message.sender_name]} + + +def _tool_feedback(messages: list[ModelMessage]) -> str: + """Tool results and retry prompts a later model request can read.""" + return "\n".join( + str(part.content) + for message in messages + for part in message.parts + if isinstance(part, ToolReturnPart | RetryPromptPart) + ) + + +def _history_tool_returns(messages: list[ModelMessage]) -> list[tuple[str, str, Any]]: + return [ + (part.tool_name, part.tool_call_id, part.content) + for message in messages + for part in message.parts + if isinstance(part, ToolReturnPart) + ] + + +def _emitted_tool_results(tools: FakeAgentTools) -> list[tuple[str, str, str]]: + results = [ + json.loads(event["content"]) + for event in events_of_type(tools, MessageType.TOOL_RESULT) + ] + return [ + ( + result[ToolEventKey.NAME], + result[ToolEventKey.OUTPUT], + result[ToolEventKey.TOOL_CALL_ID], + ) + for result in results + ] + + +def _assert_billed_responses( + tools: FakeAgentTools, messages: list[ModelMessage], *, model_requests: int = 1 +) -> None: + responses = [message for message in messages if isinstance(message, ModelResponse)] + assert len(responses) == model_requests + (payload,) = recorded_usage_payloads(tools) + assert ( + payload["input_tokens"] + == sum(response.usage.input_tokens for response in responses) + > 0 + ) + assert ( + payload["output_tokens"] + == sum(response.usage.output_tokens for response in responses) + > 0 + ) + + +async def _run_on_message( + adapter: PydanticAIAdapter, msg: PlatformMessage, tools: FakeAgentTools +) -> None: + await adapter.on_message( + msg=msg, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=tools.room_id, + ) + + +def make_raising_stream( + error: BaseException, + *, + work: Callable[[], Awaitable[object]] | None = None, +): + """Run stream (async CM, as 2.x returns) that does ``work``, then raises. + + ``work`` stands in for the tool calls the run made through the room's + tools, so the turn's ledger sees exactly what those calls did. + """ + + async def stream(): + if work is not None: + await work() + raise error + yield # an async generator, like the real event stream + + @asynccontextmanager + async def events() -> AsyncIterator[AsyncIterator]: + yield stream() + + return events() + + +def _portable_tool_agent( + tool_def: CustomToolDef, +) -> Agent[AgentToolsProtocol, str]: + """A bare agent running one converted ``CustomToolDef`` on pydantic-ai's own path.""" + agent = Agent(TestModel(), deps_type=AgentToolsProtocol, output_type=str) + agent.tool(_custom_tool_def_to_callable(tool_def)) + return agent + + +async def _run_portable_tool( + tool_def: CustomToolDef, tools: FakeAgentTools | None = None +) -> list[Any]: + """Run one converted ``CustomToolDef`` in a room; return what the model saw.""" + result = await _portable_tool_agent(tool_def).run( + "go", deps=tools or FakeAgentTools() + ) + return _tool_returns(result) diff --git a/tests/adapters/pydanticai/providerpeer.py b/tests/adapters/pydanticai/providerpeer.py new file mode 100644 index 000000000..bf8b5f866 --- /dev/null +++ b/tests/adapters/pydanticai/providerpeer.py @@ -0,0 +1,139 @@ +"""Local HTTP peer for the released default OpenAI provider.""" + +from __future__ import annotations + +import json +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + + +class ProviderPeer: + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + self.paths: list[str] = [] + + def response(self, path: str, request: dict[str, Any]) -> dict[str, Any]: + self.paths.append(path) + self.requests.append(request) + first = len(self.requests) == 1 + if path.endswith("/chat/completions"): + message = ( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": { + "name": "record", + "arguments": '{"note":"hello"}', + }, + } + ], + } + if first + else {"role": "assistant", "content": "done"} + ) + return { + "id": "chat-1", + "object": "chat.completion", + "created": 1, + "model": request["model"], + "choices": [ + { + "index": 0, + "message": message, + "finish_reason": "tool_calls" if first else "stop", + } + ], + "usage": { + "prompt_tokens": 7, + "completion_tokens": 3, + "total_tokens": 10, + }, + } + output = ( + [ + { + "type": "function_call", + "id": "fc-1", + "call_id": "call-1", + "name": "record", + "arguments": '{"note":"hello"}', + "status": "completed", + } + ] + if first + else [ + { + "type": "message", + "id": "msg-1", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "done", "annotations": []} + ], + } + ] + ) + return { + "id": "resp-1", + "object": "response", + "created_at": 1, + "status": "completed", + "model": request["model"], + "output": output, + "usage": { + "input_tokens": 7, + "output_tokens": 3, + "total_tokens": 10, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}, + }, + } + + def tool_results(self) -> list[str]: + request = self.requests[1] + if "input" in request: + return [ + item["output"] + for item in request["input"] + if item.get("type") == "function_call_output" + ] + return [ + item["content"] + for item in request["messages"] + if item.get("role") == "tool" + ] + + @contextmanager + def serve(self) -> Iterator[str]: + peer = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + request = json.loads( + self.rfile.read(int(self.headers["Content-Length"])) + ) + data = json.dumps(peer.response(self.path, request)).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, format: str, *args: Any) -> None: + pass + + with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}/v1" + finally: + server.shutdown() + thread.join() diff --git a/tests/adapters/pydanticai/test_advertisedtoolschemas.py b/tests/adapters/pydanticai/test_advertisedtoolschemas.py new file mode 100644 index 000000000..f66eaa7f0 --- /dev/null +++ b/tests/adapters/pydanticai/test_advertisedtoolschemas.py @@ -0,0 +1,73 @@ +"""AdvertisedToolSchemas behavior for Pydantic AI.""" + +from __future__ import annotations + +import json + +import pytest + +from band.core.tool_filter import sanitize_tool_schema +from band.core.types import Capability +from band.runtime.tools import ( + TASK_TOOL_NAMES, + BandTool, + get_tool_description, + platform_args_schema, +) +from tests.adapters.pydanticai.kit import _started_adapter +from tests.framework_configs.adapters import pydantic_ai_probe_tools + + +class TestAdvertisedToolSchemas: + """Per-argument text fidelity is asserted in test_tool_text_drift. + + What is pydantic-ai-specific, and checked here, is that the master model's + own description and provider-safe JSON schema reach the framework — nothing + is re-derived from a hand-written function signature any more. + """ + + @pytest.mark.asyncio + async def test_tool_blurb_is_the_master_docstring(self): + blurbs = { + name: schema.description + for name, schema in (await pydantic_ai_probe_tools()).items() + } + + assert blurbs, "no tools registered, so nothing was actually checked" + assert blurbs == {name: get_tool_description(name).strip() for name in blurbs} + + @pytest.mark.asyncio + async def test_advertised_schema_is_the_master_schema(self): + schemas = { + name: schema.json_schema + for name, schema in (await pydantic_ai_probe_tools()).items() + } + + assert schemas, "no tools registered, so nothing was actually checked" + assert schemas == { + name: sanitize_tool_schema( + platform_args_schema(name).model_json_schema(), + drop_numeric_bounds=True, + ) + for name in schemas + } + + @pytest.mark.asyncio + async def test_task_schemas_avoid_single_value_const(self): + """Task tools are the ones with a single-value ``Literal``. + + Pydantic renders that as ``const``, which some providers' restricted + JSON-Schema subsets reject; ``platform_args_schema`` normalizes it to a + one-member ``enum``, and this is the proof the sanitized schema — not a + raw ``model_json_schema()`` — is what gets advertised. + """ + adapter = await _started_adapter(capabilities=Capability.TASKS) + tools = adapter._agent._function_toolset.tools + + for name in TASK_TOOL_NAMES: + assert "const" not in json.dumps(tools[name].function_schema.json_schema) + + include = tools[BandTool.GET_TASK].function_schema.json_schema["properties"][ + "include" + ] + assert {"enum": ["history"], "type": "string"} in include["anyOf"] diff --git a/tests/adapters/pydanticai/test_builtintoolexecution.py b/tests/adapters/pydanticai/test_builtintoolexecution.py new file mode 100644 index 000000000..49eb4bc87 --- /dev/null +++ b/tests/adapters/pydanticai/test_builtintoolexecution.py @@ -0,0 +1,180 @@ +"""BuiltinToolExecution behavior for Pydantic AI.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic_ai.messages import RetryPromptPart + +from band.core.types import Capability +from band.runtime.tools import BandTool +from tests.adapters.pydanticai.kit import _call_tool, _parts, _started_adapter + + +class TestBuiltinToolExecution: + """Argument handling through pydantic-ai's real tool-calling loop. + + The integration supplies pydantic-ai with the master schema and a validator + that preserves its normalized kwargs and retry behavior. + """ + + @pytest.mark.asyncio + async def test_invalid_arguments_retry_without_dispatching(self): + deps = MagicMock() + deps.send_message = AsyncMock(return_value={"status": "sent"}) + adapter = await _started_adapter() + + result = await _call_tool( + adapter, + deps, + BandTool.SEND_MESSAGE, + {"content": "hi", "mentions": "alice"}, + ) + + (retry,) = _parts(result, RetryPromptPart) + assert "Invalid arguments for band_send_message" in str(retry.content) + assert "mentions" in str(retry.content) + deps.send_message.assert_not_called() + + @pytest.mark.asyncio + async def test_unknown_arguments_retry_without_dispatching(self): + """The schema-driven tool boundary keeps native tools' extra rejection.""" + deps = MagicMock() + deps.send_message = AsyncMock(return_value={"status": "sent"}) + adapter = await _started_adapter() + + result = await _call_tool( + adapter, + deps, + BandTool.SEND_MESSAGE, + {"content": "hi", "mentions": [], "unexpected": "value"}, + ) + + (retry,) = _parts(result, RetryPromptPart) + assert "unexpected" in str(retry.content) + deps.send_message.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "arguments", [["hi"], '"hi"', 123], ids=["list", "string", "number"] + ) + async def test_non_object_arguments_retry_without_dispatching(self, arguments): + """Valid JSON with a non-object shape is a model retry, not a crash.""" + deps = MagicMock() + deps.send_message = AsyncMock(return_value={"status": "sent"}) + adapter = await _started_adapter() + + result = await _call_tool( + adapter, + deps, + BandTool.SEND_MESSAGE, + arguments, + ) + + (_retry,) = _parts(result, RetryPromptPart) + deps.send_message.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("tool", "method_name", "expected_kwargs"), + [ + ( + BandTool.ADD_PARTICIPANT, + "add_participant", + {"identifier": "alice", "role": "member"}, + ), + ( + BandTool.REMOVE_PARTICIPANT, + "remove_participant", + {"identifier": "alice"}, + ), + ], + ids=["add_participant", "remove_participant"], + ) + async def test_validation_alias_argument_is_not_rejected_as_unknown( + self, tool, method_name, expected_kwargs + ): + """A field's ``validation_alias`` secondary name is a real accepted + argument, not an unrecognized one, even though it's absent from the + model's advertised JSON schema (which only lists the primary alias).""" + deps = MagicMock() + method = AsyncMock(return_value={"id": "user-1"}) + setattr(deps, method_name, method) + adapter = await _started_adapter() + + await _call_tool(adapter, deps, tool, {"name": "alice"}) + + method.assert_called_once_with(**expected_kwargs) + + @pytest.mark.asyncio + async def test_coercible_arguments_reach_the_dependency_normalized(self): + deps = MagicMock() + deps.list_tasks = AsyncMock(return_value={"tasks": []}) + adapter = await _started_adapter(capabilities=Capability.TASKS) + + await _call_tool(adapter, deps, BandTool.LIST_TASKS, {"limit": "5"}) + + # limit coerced to int; the two omitted optionals are not passed at all + # rather than handed over as None. + deps.list_tasks.assert_called_once_with(limit=5) + + @pytest.mark.asyncio + async def test_explicit_nulls_are_dropped_before_dispatch(self): + deps = MagicMock() + deps.send_event = AsyncMock(return_value={"status": "sent"}) + adapter = await _started_adapter() + + await _call_tool( + adapter, + deps, + BandTool.SEND_EVENT, + {"content": "thinking", "message_type": "thought", "metadata": None}, + ) + + deps.send_event.assert_called_once_with( + content="thinking", message_type="thought" + ) + + @pytest.mark.asyncio + async def test_dependency_is_called_with_master_field_names(self): + """The master model's field names are the dependency's keyword names. + + Keyword dispatch is what removes a whole class of bug the hand-written + wrappers had: ``send_room_file`` takes (content, filename, caption, + mentions) while the model lists them in another order. + """ + deps = MagicMock() + deps.send_room_file = AsyncMock(return_value={"message_id": "msg-1"}) + adapter = await _started_adapter(capabilities=Capability.FILES) + + await _call_tool( + adapter, + deps, + BandTool.SEND_ROOM_FILE, + { + "content": "file body", + "filename": "notes.txt", + "caption": "here's a file", + "mentions": ["Alice", "Bob"], + }, + ) + + deps.send_room_file.assert_called_once_with( + content="file body", + filename="notes.txt", + caption="here's a file", + mentions=["Alice", "Bob"], + ) + + @pytest.mark.asyncio + async def test_missing_backend_method_raises_an_actionable_error(self): + """A stale/typo'd registry method name is a registry bug -- it should + surface with the tool and method name, not a bare AttributeError.""" + deps = MagicMock(spec=[]) + adapter = await _started_adapter() + + with pytest.raises(RuntimeError, match="send_message"): + await _call_tool( + adapter, deps, BandTool.SEND_MESSAGE, {"content": "hi", "mentions": []} + ) diff --git a/tests/adapters/pydanticai/test_builtintoolregistration.py b/tests/adapters/pydanticai/test_builtintoolregistration.py new file mode 100644 index 000000000..bc39b72b7 --- /dev/null +++ b/tests/adapters/pydanticai/test_builtintoolregistration.py @@ -0,0 +1,79 @@ +"""BuiltinToolRegistration behavior for Pydantic AI.""" + +from __future__ import annotations + +import pytest + +from band.core.types import ALL_CAPABILITIES, Capability +from band.runtime.tools import ( + ALL_TOOL_NAMES, + CHAT_TOOL_NAMES, + CONTACT_TOOL_NAMES, + FILE_TOOL_NAMES, + MEMORY_TOOL_NAMES, + TASK_TOOL_NAMES, + BandTool, + ToolCategory, +) +from tests.adapters.pydanticai.kit import _registered_names, _started_adapter + + +class TestBuiltinToolRegistration: + """Exactly which built-in tools a set of features puts on the agent. + + Expectations are the registry's own name sets, so a newly added tool is + covered by whichever bucket it was declared in rather than by a literal + list someone has to remember to extend. + """ + + @pytest.mark.asyncio + async def test_default_features_register_only_chat_tools(self): + adapter = await _started_adapter() + + assert _registered_names(adapter) == CHAT_TOOL_NAMES + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("capabilities", "expected"), + [ + (Capability.CONTACTS, CHAT_TOOL_NAMES | CONTACT_TOOL_NAMES), + (Capability.MEMORY, CHAT_TOOL_NAMES | MEMORY_TOOL_NAMES), + (Capability.FILES, CHAT_TOOL_NAMES | FILE_TOOL_NAMES), + (Capability.TASKS, CHAT_TOOL_NAMES | TASK_TOOL_NAMES), + (ALL_CAPABILITIES, ALL_TOOL_NAMES), + ], + ids=["contacts", "memory", "files", "tasks", "all"], + ) + async def test_capability_selects_exact_tool_set(self, capabilities, expected): + adapter = await _started_adapter(capabilities=capabilities) + + assert _registered_names(adapter) == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("feature_filter", "expected"), + [ + ({"include_tools": [BandTool.SEND_MESSAGE]}, {BandTool.SEND_MESSAGE}), + ( + {"exclude_tools": [BandTool.SEND_EVENT]}, + ALL_TOOL_NAMES - {BandTool.SEND_EVENT}, + ), + ({"include_categories": [ToolCategory.TASKS]}, TASK_TOOL_NAMES), + ], + ids=["include_tools", "exclude_tools", "include_categories"], + ) + async def test_feature_filters_narrow_the_capability_set( + self, feature_filter, expected + ): + adapter = await _started_adapter( + capabilities=ALL_CAPABILITIES, **feature_filter + ) + + assert _registered_names(adapter) == expected + + @pytest.mark.asyncio + async def test_filters_cannot_reintroduce_a_gated_tool(self): + """A filter narrows what the capabilities allow; it never widens it.""" + adapter = await _started_adapter(include_tools=[BandTool.STORE_MEMORY]) + + assert _registered_names(adapter) == set() diff --git a/tests/adapters/pydanticai/test_builtintoolresults.py b/tests/adapters/pydanticai/test_builtintoolresults.py new file mode 100644 index 000000000..21c8d0e25 --- /dev/null +++ b/tests/adapters/pydanticai/test_builtintoolresults.py @@ -0,0 +1,237 @@ +"""BuiltinToolResults behavior for Pydantic AI.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import BaseModel +from pydantic_ai.messages import BinaryContent, ModelMessage +from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.core.types import Capability, MessageType, ToolEventKey +from band.runtime.tools import BandTool, band_tool_errored +from band.testing import FakeAgentTools, events_of_type +from tests.adapters.pydanticai.kit import ( + _call_tool, + _started_adapter, + _streamed_tool_calls, + _tool_returns, +) +from tests.framework_conformance.turnprobes import ROOM_ID, turn_input +from tests.testing.support import seeded_peer + + +class TestBuiltinToolResults: + """What a finished built-in tool call hands back to the model.""" + + @pytest.mark.asyncio + async def test_add_participant_result_event_reads_as_plain_data(self): + """The tool_result event renders the result with str(), so a status + must read as its value, not as an enum member's repr.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test")) + await adapter.on_started("Probe", "probe") + adapter._agent.model = _streamed_tool_calls( + (BandTool.ADD_PARTICIPANT, {"identifier": "bob"}) + ) + tools = FakeAgentTools( + room_id=ROOM_ID, peers=[seeded_peer("bob-id", handle="bob", name="Bob")] + ) + + await adapter.on_event(turn_input(tools)) + + [event] = events_of_type(tools, MessageType.TOOL_RESULT) + output = json.loads(event["content"])[ToolEventKey.OUTPUT] + assert "'status': 'added'" in output + + @pytest.mark.asyncio + async def test_pydantic_results_are_serialized(self): + class Peer(BaseModel): + id: str + handle: str + + deps = MagicMock() + deps.lookup_peers = AsyncMock(return_value=Peer(id="p-1", handle="@ann")) + adapter = await _started_adapter() + + result = await _call_tool(adapter, deps, BandTool.LOOKUP_PEERS, {}) + + assert _tool_returns(result) == [{"id": "p-1", "handle": "@ann"}] + + @pytest.mark.asyncio + async def test_backend_failure_becomes_an_llm_readable_error(self): + deps = MagicMock() + deps.send_message = AsyncMock(side_effect=RuntimeError("backend down")) + adapter = await _started_adapter() + + result = await _call_tool( + adapter, deps, BandTool.SEND_MESSAGE, {"content": "hi", "mentions": []} + ) + + (content,) = _tool_returns(result) + assert "backend down" in content + # The wording is a contract with band_tool_errored, which the adapter + # reads to tell a failed Band tool from productive work. + assert band_tool_errored(BandTool.SEND_MESSAGE, content) + + @pytest.mark.asyncio + async def test_read_room_file_image_becomes_binary_content(self): + deps = MagicMock() + deps.read_room_file = AsyncMock( + return_value={ + "content": [ + {"type": "image", "data": "ZmFrZQ==", "mimeType": "image/png"} + ] + } + ) + adapter = await _started_adapter(capabilities=Capability.FILES) + + result = await _call_tool( + adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} + ) + + ((binary,),) = _tool_returns(result) + assert isinstance(binary, BinaryContent) + assert binary.data == b"fake" + assert binary.media_type == "image/png" + + @pytest.mark.asyncio + async def test_malformed_image_block_becomes_an_llm_readable_error(self): + """A block that fails to decode is a tool error, not a crashed run.""" + deps = MagicMock() + deps.read_room_file = AsyncMock( + return_value={ + "content": [ + { + "type": "image", + "data": "not-valid-base64!!", + "mimeType": "image/png", + } + ] + } + ) + adapter = await _started_adapter(capabilities=Capability.FILES) + + result = await _call_tool( + adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} + ) + + (content,) = _tool_returns(result) + assert band_tool_errored(BandTool.READ_ROOM_FILE, content) + + @pytest.mark.asyncio + async def test_non_image_file_result_passes_through(self): + deps = MagicMock() + deps.read_room_file = AsyncMock( + return_value={"name": "report.txt", "text": "hello world"} + ) + adapter = await _started_adapter(capabilities=Capability.FILES) + + result = await _call_tool( + adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} + ) + + assert _tool_returns(result) == [{"name": "report.txt", "text": "hello world"}] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "room_event_error", + [None, RuntimeError("room offline")], + ids=["room_notified", "room_event_also_fails"], + ) + async def test_contact_request_failure_reaches_the_room(self, room_event_error): + """A contact request the agent failed to answer is invisible in the room + otherwise — it looks exactly like one that never arrived. Reporting it + is best-effort, so a failed room event must not change the tool's own + error output.""" + deps = MagicMock() + deps.respond_contact_request = AsyncMock(side_effect=RuntimeError("nope")) + deps.send_event = AsyncMock(side_effect=room_event_error) + adapter = await _started_adapter(capabilities=Capability.CONTACTS) + + result = await _call_tool( + adapter, + deps, + BandTool.RESPOND_CONTACT_REQUEST, + {"action": "approve", "handle": "@ann"}, + ) + + (content,) = _tool_returns(result) + assert "nope" in content + deps.send_event.assert_called_once_with(content, MessageType.ERROR) + + @pytest.mark.asyncio + async def test_contact_request_success_does_not_report_a_normalization_failure( + self, + ): + """A response that succeeded must not be reported to the room as + failed just because shaping its result afterward blew up.""" + deps = MagicMock() + approved = MagicMock() + approved.model_dump.side_effect = TypeError("boom during serialization") + deps.respond_contact_request = AsyncMock(return_value=approved) + deps.send_event = AsyncMock(return_value={"status": "sent"}) + adapter = await _started_adapter(capabilities=Capability.CONTACTS) + + result = await _call_tool( + adapter, + deps, + BandTool.RESPOND_CONTACT_REQUEST, + {"action": "approve", "handle": "@ann"}, + ) + + deps.respond_contact_request.assert_called_once_with( + action="approve", handle="@ann" + ) + (content,) = _tool_returns(result) + assert "boom during serialization" in content + deps.send_event.assert_not_called() + + @pytest.mark.asyncio + async def test_send_message_normalization_failure_counts_as_completed_work( + self, sample_message + ): + """A posted message is terminal even when its response cannot serialize.""" + calls_pending = True + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: + nonlocal calls_pending + if calls_pending: + calls_pending = False + yield { + 0: DeltaToolCall( + name=BandTool.SEND_MESSAGE, + json_args=json.dumps({"content": "hi", "mentions": []}), + tool_call_id="call-1", + ) + } + else: + yield "done" + + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test"), emit=()) + await adapter.on_started("Probe", "probe") + adapter._agent.model = FunctionModel(stream_function=stream) + + deps = MagicMock() + result = MagicMock() + result.model_dump.side_effect = TypeError("boom during serialization") + deps.send_message = AsyncMock(return_value=result) + deps.send_event = AsyncMock() + + await adapter.on_message( + msg=sample_message, + tools=deps, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + deps.send_message.assert_called_once_with(content="hi", mentions=[]) + deps.send_event.assert_not_called() diff --git a/tests/adapters/pydanticai/test_customtools.py b/tests/adapters/pydanticai/test_customtools.py new file mode 100644 index 000000000..affd28e91 --- /dev/null +++ b/tests/adapters/pydanticai/test_customtools.py @@ -0,0 +1,210 @@ +"""CustomTools behavior for Pydantic AI.""" + +from __future__ import annotations + +import contextlib +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from pydantic_ai import RunContext +from pydantic_ai.messages import ModelRequest, UserPromptPart + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.core.protocols import AgentToolsProtocol, TurnResultAlreadyReported +from band.runtime.custom_tools import declares_turn_effect +from band.runtime.tools import TurnEffect +from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools, failure_reports +from tests.adapters.pydanticai.kit import _streamed_tool_calls, make_stream_events +from tests.framework_conformance.turnprobes import ROOM_ID, turn_input + + +class TestCustomTools: + """Tests for custom tool support (PydanticAI-native functions).""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("declared", "reported"), [(True, []), (False, [MISSING_REPLY_FAILURE])] + ) + async def test_native_tool_counts_only_the_effect_it_declared( + self, declared: bool, reported: list + ): + """A native tool runs outside the portable custom-tool path, so its + declared effect still has to reach the turn; an undeclared one only + observes.""" + + async def finish(ctx: RunContext[AgentToolsProtocol], note: str) -> str: + """Finish the task.""" + return "done" + + if declared: + declares_turn_effect(TurnEffect.ACT)(finish) + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="test"), additional_tools=[finish] + ) + await adapter.on_started("Probe", "probe") + adapter._agent.model = _streamed_tool_calls(("finish", {"note": "go"})) + tools = FakeAgentTools(room_id=ROOM_ID) + + with contextlib.suppress(TurnResultAlreadyReported): + await adapter.on_event(turn_input(tools)) + + assert failure_reports(tools) == reported + + @pytest.mark.asyncio + async def test_a_native_tool_failure_value_leaves_the_turn_unanswered(self) -> None: + @declares_turn_effect(TurnEffect.ACT) + async def finish( + ctx: RunContext[AgentToolsProtocol], note: str + ) -> dict[str, Any]: + """Finish the task.""" + return {"ok": False, "error": "upstream refused"} + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="test"), additional_tools=[finish] + ) + await adapter.on_started("Probe", "probe") + adapter._agent.model = _streamed_tool_calls(("finish", {"note": "go"})) + tools = FakeAgentTools(room_id=ROOM_ID) + + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_event(turn_input(tools)) + + assert failure_reports(tools) == [MISSING_REPLY_FAILURE] + + def test_accepts_additional_tools_parameter(self): + """Adapter accepts list of callables.""" + + async def my_tool(ctx: RunContext[AgentToolsProtocol], message: str) -> str: + """A custom tool.""" + return f"Echo: {message}" + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), additional_tools=[my_tool] + ) + + assert len(adapter._custom_tools) == 1 + assert adapter._custom_tools[0] == my_tool + + def test_multiple_custom_tools(self): + """Should accept multiple custom tools.""" + + async def tool_one(ctx: RunContext[AgentToolsProtocol], a: int) -> int: + """Tool one.""" + return a + 1 + + def tool_two(ctx: RunContext[AgentToolsProtocol], b: str) -> str: + """Tool two.""" + return b.upper() + + async def tool_three( + ctx: RunContext[AgentToolsProtocol], x: float, y: float + ) -> float: + """Tool three.""" + return x + y + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), + additional_tools=[tool_one, tool_two, tool_three], + ) + + assert len(adapter._custom_tools) == 3 + + @pytest.mark.asyncio + async def test_registers_custom_tools_with_agent(self): + """Custom tools should be registered via agent.tool().""" + + async def my_echo(ctx: RunContext[AgentToolsProtocol], message: str) -> str: + """Echo the message.""" + return f"Echo: {message}" + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="test"), additional_tools=[my_echo] + ) + + # Mock the Agent class to track tool registrations + registered_tools = [] + + with patch("band.adapters.pydantic_ai.Agent") as MockAgent: + mock_agent = MagicMock() + mock_agent.tool = MagicMock( + side_effect=lambda f: registered_tools.append(f) + ) + MockAgent.return_value = mock_agent + + await adapter.on_started("TestBot", "Test bot") + + # Should have registered platform tools + custom tool + tool_names = [t.__name__ for t in registered_tools] + assert "my_echo" in tool_names + + @pytest.mark.asyncio + async def test_custom_tool_appears_in_agent_function_tools( + self, mock_pydantic_agent + ): + """Custom tool should appear in agent._function_tools after registration.""" + + async def calculator( + ctx: RunContext[AgentToolsProtocol], a: float, b: float + ) -> float: + """Add two numbers.""" + return a + b + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), + additional_tools=[calculator], + ) + + # Add calculator to mock agent's function tools when tool() is called + def register_tool(func): + mock_pydantic_agent._function_tools[func.__name__] = MagicMock( + name=func.__name__ + ) + + mock_pydantic_agent.tool = MagicMock(side_effect=register_tool) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + # Manually call tool registration since we're mocking _create_agent + for custom_tool in adapter._custom_tools: + mock_pydantic_agent.tool(custom_tool) + + assert "calculator" in mock_pydantic_agent._function_tools + + @pytest.mark.asyncio + async def test_custom_tools_work_with_on_message( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Custom tools should work during message handling.""" + + async def my_helper(ctx: RunContext[AgentToolsProtocol], value: str) -> str: + """Helper tool.""" + return f"Helped: {value}" + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), + additional_tools=[my_helper], + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + # Should not raise + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + assert "room-123" in adapter._message_history diff --git a/tests/adapters/pydanticai/test_emptyfinalanswer.py b/tests/adapters/pydanticai/test_emptyfinalanswer.py new file mode 100644 index 000000000..94d0cdddf --- /dev/null +++ b/tests/adapters/pydanticai/test_emptyfinalanswer.py @@ -0,0 +1,327 @@ +"""EmptyFinalAnswer behavior for Pydantic AI.""" + +from __future__ import annotations + +import contextlib +import logging +from collections.abc import Awaitable, Callable +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from pydantic_ai import UnexpectedModelBehavior, _tool_execution +from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart + +from band.adapters.pydantic_ai import ( + OUTPUT_RETRIES_EXHAUSTED, + PydanticAIAdapter, + PydanticAIAdapterConfig, + _is_output_retries_exhausted, +) +from band.core.exceptions import BandToolError +from band.core.protocols import GENERIC_PROVIDER_FAILURE_MESSAGE +from band.core.types import Emit, PlatformMessage +from band.testing import FakeAgentTools, failure_reports, reported_failures +from tests.adapters.pydanticai.kit import ( + _run_on_message, + make_raising_stream, + make_usage_response, +) +from tests.adapters.usage_events import sent_usage_payloads + + +class TestEmptyFinalAnswer: + """A model can end a turn with output pydantic-ai cannot accept — blank text, + say — after the agent already replied/acted via tools, exhausting the refused + output-retry budget. That is benign — the work already went out — so it must not + fail the message, but a genuine no-work failure must still surface. + """ + + def test_swallow_matches_the_wording_pydantic_ai_actually_raises(self) -> None: + """The swallow keys on message text, since pydantic-ai exposes no code for it. + + Every other test here builds the exception by hand, so a reword upstream + would leave them green while the swallow quietly stopped matching — which is + exactly what 2.x did to the 1.x phrasing ("Exceeded maximum retries (N) for + output validation"). Read the real source so a future reword fails here. + """ + + source = Path(_tool_execution.__file__).read_text(encoding="utf-8").lower() + assert OUTPUT_RETRIES_EXHAUSTED in source + assert _is_output_retries_exhausted( + UnexpectedModelBehavior("Exceeded maximum output retries (3)") + ) + assert not _is_output_retries_exhausted( + UnexpectedModelBehavior("Invalid response, unable to find output") + ) + + @pytest.mark.asyncio + async def test_empty_output_after_tool_is_benign( + self, + sample_message, + mock_pydantic_agent, + caplog: pytest.LogCaptureFixture, + ): + """Output-retry exhaustion after the turn replied is swallowed.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + tools = FakeAgentTools(room_id="room-123") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + UnexpectedModelBehavior("Exceeded maximum output retries (1)"), + work=lambda: tools.send_message("hi", ["@Alice"]), + ) + ) + caplog.set_level(logging.WARNING, logger="band.adapters.pydantic_ai") + + # Must not raise: the reply already went out via the tool this turn. + await adapter.on_message( + msg=sample_message, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Regression (fallback path): with the run mocked, capture_run_messages records + # nothing, so the swallow falls back to preserving at least the user prompt so + # the next same-session turn isn't amnesiac. + + preserved = adapter._message_history["room-123"] + assert preserved, "swallowed turn should still record the user message" + assert isinstance(preserved[-1], ModelRequest) + assert any( + isinstance(part, UserPromptPart) and "Hello, agent!" in str(part.content) + for part in preserved[-1].parts + ) + assert reported_failures(tools) == [] + assert "Exceeded maximum output retries (1)" in caplog.text + + @pytest.mark.asyncio + async def test_empty_output_preserves_full_captured_turn( + self, sample_message, mock_pydantic_agent + ): + """The swallow persists the whole captured turn — not just the user prompt — + so a later 'what did you just say?' has the agent's reply in context.""" + + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + tools = FakeAgentTools(room_id="room-xyz") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + UnexpectedModelBehavior("Exceeded maximum output retries (1)"), + work=lambda: tools.send_message("hi", ["@Alice"]), + ) + ) + + # pydantic-ai populates capture_run_messages during a real run; simulate a + # run that captured the full turn (user prompt + the agent's response). + full_turn = [ + ModelRequest(parts=[UserPromptPart(content="[Alice]: hi")]), + ModelResponse(parts=[TextPart(content="replied via tool")]), + ] + + @contextmanager + def fake_capture(): + yield full_turn + + with patch("band.adapters.pydantic_ai.capture_run_messages", fake_capture): + await adapter.on_message( + msg=sample_message, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-xyz", + ) + + preserved = adapter._message_history["room-xyz"] + # The full turn is kept — crucially the assistant response, not only the user. + assert preserved == full_turn + assert any(isinstance(message, ModelResponse) for message in preserved) + + @pytest.mark.asyncio + async def test_empty_output_without_tool_propagates( + self, sample_message, mock_pydantic_agent + ): + """Same error with no tool executed is a real failure — propagate.""" + await self._assert_exhaustion_propagates( + sample_message, mock_pydantic_agent, work=None + ) + + @pytest.mark.asyncio + async def test_failed_run_still_emits_captured_usage( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """A run that raises still emits the usage its captured responses accrued. + + Tokens spent before the failure were still spent: the finally-based emit + falls back to summing this run's captured ModelResponses when no result + event fired, so a hard mid-run failure doesn't silently drop usage.""" + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.USAGE + ) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + UnexpectedModelBehavior("Invalid response, unable to find output"), + ) + ) + + # Simulate a run that captured a response with usage before raising. + captured_turn = [ + ModelRequest(parts=[UserPromptPart(content="[Alice]: hi")]), + make_usage_response(100, 20, parts=[TextPart(content="partial")]), + ] + + @contextmanager + def fake_capture(): + yield captured_turn + + with ( + patch("band.adapters.pydantic_ai.capture_run_messages", fake_capture), + pytest.raises(UnexpectedModelBehavior), + ): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + usage_payloads = sent_usage_payloads(mock_tools) + assert usage_payloads == [ + { + "input_tokens": 100, + "output_tokens": 20, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + } + ], f"expected the captured run's usage to be emitted, got {usage_payloads}" + + @pytest.mark.asyncio + async def test_unrelated_model_error_propagates_even_after_tool( + self, sample_message, mock_pydantic_agent + ): + """The swallow is narrow: other model errors still surface after a reply.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + tools = FakeAgentTools(room_id="room-123") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + UnexpectedModelBehavior("Invalid response, unable to find output"), + work=lambda: tools.send_message("hi", ["@Alice"]), + ) + ) + + with pytest.raises(UnexpectedModelBehavior): + await _run_on_message(adapter, sample_message, tools) + + assert failure_reports(tools) == [ + ("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) + ] + + @pytest.mark.asyncio + async def test_generic_provider_error_reports_and_propagates( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """A failure that isn't UnexpectedModelBehavior at all (a raw provider/API + error) must still surface as a failure and propagate, not vanish uncaught.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + RuntimeError("provider connection reset"), + ) + ) + + with pytest.raises(RuntimeError, match="provider connection reset"): + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_failure.assert_awaited_once() + failure = mock_tools.send_failure.call_args.args[0] + assert failure.provider == "pydantic_ai" + assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE + + @pytest.mark.asyncio + async def test_empty_output_after_read_only_tool_propagates( + self, sample_message, mock_pydantic_agent + ): + """A read-only lookup is not terminal work — output-validation exhaustion + after only a lookup is a genuine no-response failure and must propagate.""" + tools = FakeAgentTools(room_id="room-123") + + await self._assert_exhaustion_propagates( + sample_message, mock_pydantic_agent, tools=tools, work=tools.lookup_peers + ) + + @pytest.mark.asyncio + async def test_empty_output_after_failed_band_tool_propagates( + self, sample_message, mock_pydantic_agent + ): + """A band tool that raised did no work — exhausting output validation + afterward is a genuine failure and must propagate.""" + tools = FakeAgentTools(room_id="room-123") + + async def rejected_send() -> None: + # The band tool wrapper turns this raise into an error string for + # the model; the turn records nothing either way. + with contextlib.suppress(BandToolError): + await tools.send_message("hi", mentions=[]) + + await self._assert_exhaustion_propagates( + sample_message, mock_pydantic_agent, tools=tools, work=rejected_send + ) + + @staticmethod + async def _assert_exhaustion_propagates( + sample_message: PlatformMessage, + mock_pydantic_agent: MagicMock, + *, + work: Callable[[], Awaitable[object]] | None, + tools: FakeAgentTools | None = None, + ) -> None: + """Output-retry exhaustion after ``work`` fails the turn as a provider error.""" + tools = tools or FakeAgentTools(room_id="room-123") + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + adapter._agent.run_stream_events = MagicMock( + return_value=make_raising_stream( + UnexpectedModelBehavior("Exceeded maximum output retries (1)"), + work=work, + ) + ) + + with pytest.raises(UnexpectedModelBehavior): + await _run_on_message(adapter, sample_message, tools) + + assert failure_reports(tools) == [ + ("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) + ] diff --git a/tests/adapters/pydanticai/test_executionreporting.py b/tests/adapters/pydanticai/test_executionreporting.py new file mode 100644 index 000000000..d9c323963 --- /dev/null +++ b/tests/adapters/pydanticai/test_executionreporting.py @@ -0,0 +1,294 @@ +"""ExecutionReporting behavior for Pydantic AI.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from pydantic_ai.messages import BinaryContent, ModelRequest, UserPromptPart + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.core.types import Emit +from tests.adapters.pydanticai.kit import make_stream_events, message_types + + +class TestExecutionReporting: + """Tests for execution reporting (tool_call and tool_result events).""" + + @pytest.mark.asyncio + async def test_emits_tool_call_events_when_enabled( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should emit tool_call events when emit=Emit.TOOL_CALLS.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], + tool_results=[("band_send_message", "Message sent", "call-123")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Verify send_event was called with tool_call + mock_tools.send_event.assert_any_call( + content='{"name": "band_send_message", "args": {"content": "Hello"}, "tool_call_id": "call-123"}', + message_type="tool_call", + ) + + @pytest.mark.asyncio + async def test_tool_call_event_redacts_send_room_file_content( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """band_send_room_file's content arg can carry up to + MAX_SEND_CONTENT_BYTES of real file bytes; the tool_call event must + report a bounded placeholder instead of the raw content.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_calls=[ + ( + "band_send_room_file", + {"content": "SECRET FILE BYTES", "filename": "f.txt"}, + "call-123", + ) + ], + tool_results=[("band_send_message", "Message sent", "call-2")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + reported_content = mock_tools.send_event.call_args_list[0].kwargs["content"] + assert "SECRET FILE BYTES" not in reported_content + assert "byte file content" in reported_content + assert '"filename": "f.txt"' in reported_content + + @pytest.mark.asyncio + async def test_emits_tool_result_events_when_enabled( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should emit tool_result events when emit=Emit.TOOL_CALLS.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_results=[ + ("band_send_message", "Message sent successfully", "call-123") + ], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Verify send_event was called with tool_result + mock_tools.send_event.assert_any_call( + content='{"name": "band_send_message", "output": "Message sent successfully", "tool_call_id": "call-123"}', + message_type="tool_result", + ) + + @pytest.mark.asyncio + async def test_tool_result_event_redacts_binary_content( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """band_read_room_file's image result is a list[BinaryContent]; str() + on that embeds the raw image bytes via BinaryContent.__repr__. The + tool_result event must report a bounded placeholder instead.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + image = BinaryContent( + data=b"\x89PNG\r\n\x1a\n" + b"\x00" * 64, media_type="image/png" + ) + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_results=[ + ("band_read_room_file", [image], "call-1"), + ("band_send_message", "Message sent", "call-2"), + ], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_tools.send_event.assert_any_call( + content='{"name": "band_read_room_file", "output": "<1 image content block(s)>", "tool_call_id": "call-1"}', + message_type="tool_result", + ) + + @pytest.mark.asyncio + async def test_no_events_when_reporting_disabled( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """emit=() disables tool_call/tool_result events (emit otherwise defaults on).""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=() + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], + tool_results=[("band_send_message", "Message sent", "call-123")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Verify send_event was NOT called for tool_call or tool_result + assert not set(message_types(mock_tools)) & {"tool_call", "tool_result"} + + @pytest.mark.asyncio + async def test_multiple_tool_calls_all_reported( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should emit events for all tool calls in sequence.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_calls=[ + ("band_lookup_peers", {}, "call-1"), + ("band_add_participant", {"identifier": "Helper"}, "call-2"), + ("band_send_message", {"content": "Done"}, "call-3"), + ], + tool_results=[ + ("band_lookup_peers", "[{...}]", "call-1"), + ("band_add_participant", "Added", "call-2"), + ("band_send_message", "Sent", "call-3"), + ], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Count tool_call and tool_result events + types = message_types(mock_tools) + assert types.count("tool_call") == 3 + assert types.count("tool_result") == 3 + + @pytest.mark.asyncio + async def test_event_failure_does_not_crash_run( + self, sample_message, mock_pydantic_agent + ): + """Should continue running if send_event fails.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS + ) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + # Mock tools where send_event fails with a real transport error — the + # tool_call event's own local guard swallows this and logs a warning; + # a generic Exception would be a bug and is intentionally left to + # propagate. + failing_tools = AsyncMock() + failing_tools.send_event = AsyncMock( + side_effect=httpx.ConnectError("Network error") + ) + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[ModelRequest(parts=[UserPromptPart(content="test")])], + tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], + tool_results=[("band_send_message", "Message sent", "call-123")], + ) + ) + + # Should not raise + await adapter.on_message( + msg=sample_message, + tools=failing_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # History should still be updated + assert "room-123" in adapter._message_history diff --git a/tests/adapters/pydanticai/test_explicitdecline.py b/tests/adapters/pydanticai/test_explicitdecline.py new file mode 100644 index 000000000..703933544 --- /dev/null +++ b/tests/adapters/pydanticai/test_explicitdecline.py @@ -0,0 +1,358 @@ +"""ExplicitDecline behavior for Pydantic AI.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from pydantic_ai import AgentRunResultEvent +from pydantic_ai.messages import ModelMessage +from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel +from pydantic_ai.usage import UsageLimits + +from band.adapters.pydantic_ai import PydanticAIAdapter, _is_replayable_history_message +from band.core.types import Emit, PlatformMessage +from band.runtime.custom_tools import declares_turn_effect +from band.runtime.tools import BandTool, TurnEffect +from band.testing import FakeAgentTools, reported_failures +from tests.adapters.pydanticai.kit import ( + DecliningModel, + _assert_billed_responses, + _emitted_tool_results, + _history_tool_returns, + _reply_arguments, + _run_on_message, + _started_adapter, + _streamed_tool_calls, + _tool_feedback, + _tool_returns, +) + + +class TestExplicitDecline: + @pytest.mark.asyncio + @pytest.mark.parametrize("request_limit", [1, 3]) + async def test_decline_ends_native_stream_on_last_allowed_request( + self, request_limit: int + ) -> None: + model = DecliningModel() + adapter = PydanticAIAdapter(llm=model.model) + await adapter.on_started("Probe", "probe") + tools = FakeAgentTools() + results = [] + async with adapter._agent.run_stream_events( + "go", deps=tools, usage_limits=UsageLimits(request_limit=request_limit) + ) as events: + async for event in events: + if isinstance(event, AgentRunResultEvent): + results.append(event.result) + + assert model.requests == 1 + assert len(results) == 1 + assert results[0].output is None + assert _tool_returns(results[0]) == [{"status": "no_reply"}] + assert tools.turn.replied + assert reported_failures(tools) == [] + + @pytest.mark.asyncio + async def test_adapter_preserves_decline_events_history_and_per_turn_usage( + self, sample_message: PlatformMessage + ) -> None: + model = DecliningModel() + adapter = PydanticAIAdapter(llm=model.model, emit=(Emit.TOOL_CALLS, Emit.USAGE)) + await adapter.on_started("Probe", "probe") + tools = FakeAgentTools() + await _run_on_message(adapter, sample_message, tools) + + assert model.requests == 1 + assert _emitted_tool_results(tools) == [ + (BandTool.NO_REPLY, "{'status': 'no_reply'}", "decline-1") + ] + prior = list(adapter._message_history[tools.room_id]) + assert _history_tool_returns(prior) == [ + (BandTool.NO_REPLY, "decline-1", {"status": "no_reply"}) + ] + assert all(_is_replayable_history_message(message) for message in prior) + _assert_billed_responses(tools, prior) + assert reported_failures(tools) == [] + + seen_history: list[ModelMessage] = [] + + async def followup( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: + if not seen_history: + seen_history.extend(messages) + yield { + 0: DeltaToolCall( + name=BandTool.SEND_MESSAGE, + json_args=json.dumps( + _reply_arguments(sample_message, "A fresh reply") + ), + ) + } + else: + yield "done" + + next_tools = FakeAgentTools(room_id=tools.room_id) + with adapter._agent.override(model=FunctionModel(stream_function=followup)): + await adapter.on_message( + msg=sample_message, + tools=next_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, + room_id=tools.room_id, + ) + + assert _history_tool_returns(seen_history) == _history_tool_returns(prior) + assert next_tools.chat[0] == "A fresh reply" + new_history = adapter._message_history[tools.room_id][len(prior) :] + _assert_billed_responses(next_tools, new_history, model_requests=2) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "rejection", + [RuntimeError("decline rejected"), {"ok": False}, None], + ids=["raised", "returned_failure", "invalid_arguments"], + ) + async def test_failed_decline_keeps_required_reply_executable( + self, sample_message: PlatformMessage, rejection: Any + ) -> None: + tools = FakeAgentTools() + if isinstance(rejection, Exception): + tools.no_reply = AsyncMock(side_effect=rejection) + elif rejection is not None: + tools.no_reply = AsyncMock(return_value=rejection) + decline_args = ( + {"unexpected": True} if rejection is None else {"reason": "decline"} + ) + adapter = await _started_adapter() + with adapter._agent.override( + model=_streamed_tool_calls( + (BandTool.NO_REPLY, decline_args), + ( + BandTool.SEND_MESSAGE, + _reply_arguments(sample_message, "Required reply"), + ), + ) + ): + await _run_on_message(adapter, sample_message, tools) + + assert tools.chat == ["Required reply"] + assert reported_failures(tools) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "first_tool", [BandTool.SEND_MESSAGE, BandTool.GET_PARTICIPANTS, "do_work"] + ) + async def test_other_successful_tools_do_not_end_the_loop( + self, sample_message: PlatformMessage, first_tool: str + ) -> None: + work_done = [] + + @declares_turn_effect(TurnEffect.ACT) + async def do_work() -> str: + work_done.append("worked") + return "worked" + + tools = FakeAgentTools() + adapter = await _started_adapter(additional_tools=[do_work]) + arguments = ( + _reply_arguments(sample_message, "First reply") + if first_tool == BandTool.SEND_MESSAGE + else {} + ) + with adapter._agent.override( + model=_streamed_tool_calls( + (first_tool, arguments), + ( + BandTool.SEND_MESSAGE, + _reply_arguments(sample_message, "Required next reply"), + ), + ) + ): + await _run_on_message(adapter, sample_message, tools) + + assert tools.chat[-1] == "Required next reply" + if first_tool == "do_work": + assert work_done == ["worked"] + assert reported_failures(tools) == [] + + @pytest.mark.asyncio + async def test_decline_preserves_every_result_in_its_tool_batch( + self, sample_message: PlatformMessage + ) -> None: + async def batch( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[dict[int, DeltaToolCall]]: + yield { + 0: DeltaToolCall( + name=BandTool.NO_REPLY, json_args="{}", tool_call_id="decline" + ), + 1: DeltaToolCall( + name=BandTool.SEND_MESSAGE, + json_args=json.dumps( + _reply_arguments(sample_message, "Already sent") + ), + tool_call_id="reply", + ), + 2: DeltaToolCall( + name=BandTool.GET_PARTICIPANTS, json_args="{}", tool_call_id="read" + ), + } + + adapter = PydanticAIAdapter( + llm=FunctionModel(stream_function=batch), emit=Emit.TOOL_CALLS + ) + await adapter.on_started("Probe", "probe") + tools = FakeAgentTools() + await _run_on_message(adapter, sample_message, tools) + + returns = _history_tool_returns(adapter._message_history[tools.room_id]) + assert {(name, call_id) for name, call_id, _ in returns} == { + (BandTool.NO_REPLY, "decline"), + (BandTool.SEND_MESSAGE, "reply"), + (BandTool.GET_PARTICIPANTS, "read"), + } + assert { + (name, call_id) for name, _, call_id in _emitted_tool_results(tools) + } == {(name, call_id) for name, call_id, _ in returns} + assert tools.chat == ["Already sent"] + assert reported_failures(tools) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("failure", "shown"), + [ + ("raised", "Error executing band_send_message: post failed"), + ("returned_failure", "{'ok': False}"), + ("invalid_arguments", "Invalid arguments for band_send_message"), + ], + ) + async def test_a_failed_sibling_keeps_the_decline_batch_open( + self, sample_message: PlatformMessage, failure: str, shown: str + ) -> None: + """A successful decline leaves the loop running when another tool in the same response failed.""" + tools = FakeAgentTools() + if failure != "invalid_arguments": + send_message = tools.send_message + + async def fail_named_post( + content: str, + mentions: list[str] | list[dict[str, str]] | None = None, + ) -> Any: + if content != "Will fail": + return await send_message(content, mentions) + if failure == "raised": + raise RuntimeError("post failed") + return {"ok": False} + + tools.send_message = fail_named_post + + requests = 0 + followup: list[ModelMessage] = [] + send_arguments: dict[str, Any] = ( + {"content": "Will fail", "mentions": "alice"} + if failure == "invalid_arguments" + else _reply_arguments(sample_message, "Will fail") + ) + + async def batch( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: + nonlocal requests + requests += 1 + if requests == 1: + yield { + 0: DeltaToolCall( + name=BandTool.NO_REPLY, + json_args=json.dumps({"reason": "Settled"}), + tool_call_id="decline", + ), + 1: DeltaToolCall( + name=BandTool.SEND_MESSAGE, + json_args=json.dumps(send_arguments), + tool_call_id="reply", + ), + } + return + if requests == 2: + followup.extend(messages) + yield { + 0: DeltaToolCall( + name=BandTool.SEND_MESSAGE, + json_args=json.dumps( + _reply_arguments(sample_message, "Recovered") + ), + tool_call_id="recovered", + ) + } + return + yield "" + + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=batch)) + await adapter.on_started("Probe", "probe") + await _run_on_message(adapter, sample_message, tools) + + assert requests == 3 + assert shown in _tool_feedback(followup) + assert shown in _tool_feedback(adapter._message_history[tools.room_id]) + assert tools.chat == ["Recovered"] + assert reported_failures(tools) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("cancel", [False, True], ids=["continue", "cancel"]) + async def test_concurrent_room_keeps_its_lifecycle( + self, sample_message: PlatformMessage, cancel: bool + ) -> None: + entered, release, cleaned = asyncio.Event(), asyncio.Event(), asyncio.Event() + + async def wait_for_work() -> str: + entered.set() + try: + await release.wait() + return "work completed" + finally: + cleaned.set() + + adapter = await _started_adapter(additional_tools=[wait_for_work]) + slow_tools, decline_tools = ( + FakeAgentTools(room_id="slow"), + FakeAgentTools(room_id="decline"), + ) + + async def slow_room() -> None: + with adapter._agent.override( + model=_streamed_tool_calls( + ("wait_for_work", {}), + ( + BandTool.SEND_MESSAGE, + _reply_arguments(sample_message, "Finished work"), + ), + ) + ): + await _run_on_message(adapter, sample_message, slow_tools) + + async with asyncio.TaskGroup() as group: + task = group.create_task(slow_room()) + try: + await entered.wait() + with adapter._agent.override(model=DecliningModel().model): + await _run_on_message(adapter, sample_message, decline_tools) + assert not task.done() + if cancel: + task.cancel() + finally: + release.set() + + assert cleaned.is_set() + assert task.cancelled() is cancel + assert slow_tools.chat == ([] if cancel else ["Finished work"]) + assert decline_tools.chat == [] + assert reported_failures(slow_tools) == reported_failures(decline_tools) == [] diff --git a/tests/adapters/pydanticai/test_historymanagement.py b/tests/adapters/pydanticai/test_historymanagement.py new file mode 100644 index 000000000..123922fef --- /dev/null +++ b/tests/adapters/pydanticai/test_historymanagement.py @@ -0,0 +1,216 @@ +"""HistoryManagement behavior for Pydantic AI.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from pydantic_ai.messages import ( + ModelRequest, + ModelResponse, + NativeToolCallPart, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) + +from band.adapters.pydantic_ai import ( + PydanticAIAdapter, + PydanticAIAdapterConfig, + _drop_non_replayable_messages, + _is_replayable_history_message, +) +from tests.adapters.pydanticai.kit import make_stream_events + + +class TestHistoryManagement: + """Tests for message history management.""" + + @pytest.mark.asyncio + async def test_updates_history_after_run( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should update stored history with all messages from run.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + new_messages = [ + ModelRequest(parts=[UserPromptPart(content="Q1")]), + ModelResponse(parts=[TextPart(content="A1")]), + ] + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=new_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + assert adapter._message_history["room-123"] == new_messages + + @pytest.mark.asyncio + async def test_keeps_native_history_and_drops_content_null_responses( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should keep native tool history but drop responses that replay as null.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + user_request = ModelRequest(parts=[UserPromptPart(content="Q1")]) + tool_call_response = ModelResponse( + parts=[ + ToolCallPart( + tool_name="band_send_message", + args={"content": "A1", "mentions": ["Alice"]}, + tool_call_id="call_1", + ) + ] + ) + tool_return_request = ModelRequest( + parts=[ + ToolReturnPart( + tool_name="band_send_message", + content={"id": "msg_1"}, + tool_call_id="call_1", + ) + ] + ) + content_null_response = ModelResponse(parts=[]) + text_response = ModelResponse(parts=[TextPart(content="A1")]) + result_messages = [ + user_request, + tool_call_response, + tool_return_request, + content_null_response, + text_response, + ] + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", {"id": "msg_1"}, "call_1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + stored_history = adapter._message_history["room-123"] + assert stored_history == [ + user_request, + tool_call_response, + tool_return_request, + text_response, + ] + assert content_null_response not in stored_history + + def test_keeps_response_with_only_native_tool_part(self): + """Native tool calls carry content the provider expects — keep them.""" + response = ModelResponse( + parts=[ + NativeToolCallPart( + tool_name="web_search", + args={"query": "weather"}, + tool_call_id="call_1", + ) + ] + ) + + assert _is_replayable_history_message(response) is True + + def test_history_processor_strips_content_null_responses(self): + """The processor drops empty/thinking-only responses, keeps real content. + + This runs before every model request (mid-run included), so an empty or + thinking-only response the model emits within a turn is never replayed as + assistant content:null — which providers reject. + """ + user_request = ModelRequest(parts=[UserPromptPart(content="Q1")]) + tool_call = ModelResponse( + parts=[ + ToolCallPart( + tool_name="band_send_message", + args={"content": "hi", "mentions": ["Alice"]}, + tool_call_id="call_1", + ) + ] + ) + tool_return = ModelRequest( + parts=[ + ToolReturnPart( + tool_name="band_send_message", + content={"id": "msg_1"}, + tool_call_id="call_1", + ) + ] + ) + empty_response = ModelResponse(parts=[]) + thinking_only = ModelResponse(parts=[ThinkingPart(content="hmm")]) + text_response = ModelResponse(parts=[TextPart(content="done")]) + + processed = _drop_non_replayable_messages( + [ + user_request, + tool_call, + tool_return, + empty_response, + thinking_only, + text_response, + ] + ) + + assert processed == [user_request, tool_call, tool_return, text_response] + assert empty_response not in processed + assert thinking_only not in processed + + @pytest.mark.asyncio + async def test_ensures_history_exists_for_non_bootstrap( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should create history if not bootstrap and room doesn't exist.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=False, # Not bootstrap + room_id="new-room", + ) + + # Should have created empty history + assert "new-room" in adapter._message_history diff --git a/tests/adapters/pydanticai/test_instrumentation.py b/tests/adapters/pydanticai/test_instrumentation.py new file mode 100644 index 000000000..d6b39e361 --- /dev/null +++ b/tests/adapters/pydanticai/test_instrumentation.py @@ -0,0 +1,117 @@ +"""Instrumentation behavior for Pydantic AI.""" + +from __future__ import annotations + +import pytest +from pydantic_ai import Agent +from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelResponse, + TextPart, + ThinkingPart, + UserPromptPart, +) +from pydantic_ai.models.function import AgentInfo, FunctionModel + +from band.adapters.pydantic_ai import PydanticAIAdapter +from tests.adapters.pydanticai.kit import TraceCapture, _reply + + +class TestInstrumentation: + """Tests for the ``instrument`` pass-through to pydantic-ai. + + Band creates no TracerProvider and no exporter: the host owns the pipeline and + hands the agent an ``InstrumentationSettings`` (or flips ``Agent.instrument_all``). + """ + + def test_settings_route_the_run_into_the_host_tracer( + self, trace_capture: TraceCapture, mock_tools + ): + """Explicit settings trace the model call and the agent run.""" + adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=trace_capture.settings) + adapter.agent_name = "TestBot" + + adapter._create_agent().run_sync("hello", deps=mock_tools) + + assert trace_capture.operations() == ["chat", "invoke_agent"] + + def test_true_traces_through_the_ambient_provider( + self, + trace_capture: TraceCapture, + monkeypatch: pytest.MonkeyPatch, + mock_tools, + ): + """``True`` is the shorthand for a host that published its providers. + + pydantic-ai resolves the ambient ``TracerProvider`` when it builds the + default settings, so this only traces in a process that set one — which + is why examples/opentelemetry hands over settings instead. + """ + monkeypatch.setattr( + "pydantic_ai.models.instrumented.get_tracer_provider", + lambda: trace_capture.provider, + ) + adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=True) + adapter.agent_name = "TestBot" + + adapter._create_agent().run_sync("hello", deps=mock_tools) + + assert trace_capture.operations() == ["chat", "invoke_agent"] + + def test_default_inherits_instrument_all( + self, trace_capture: TraceCapture, instrument_all_restored, mock_tools + ): + """Passing nothing leaves the host's process-wide choice in force.""" + Agent.instrument_all(trace_capture.settings) + adapter = PydanticAIAdapter(llm=_reply("ok")) + adapter.agent_name = "TestBot" + + adapter._create_agent().run_sync("hello", deps=mock_tools) + + assert trace_capture.operations() == ["chat", "invoke_agent"] + + def test_false_opts_out_of_instrument_all( + self, trace_capture: TraceCapture, instrument_all_restored, mock_tools + ): + """``False`` is not "unset": it excludes this agent from a traced process.""" + Agent.instrument_all(trace_capture.settings) + adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=False) + adapter.agent_name = "TestBot" + + adapter._create_agent().run_sync("hello", deps=mock_tools) + + assert trace_capture.operations() == [] + + def test_instrumented_agent_still_drops_content_null_history( + self, trace_capture: TraceCapture, mock_tools + ): + """Instrumentation must not cost the ProcessHistory capability. + + Both ride on the agent, and an implementation that passed instrumentation + as a capability would silently replace the history processor — sending + providers the thinking-only response as assistant ``content: null``. + """ + seen: list[list[ModelMessage]] = [] + + def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + seen.append(list(messages)) + return ModelResponse(parts=[TextPart(content="ok")]) + + adapter = PydanticAIAdapter( + llm=FunctionModel(respond), instrument=trace_capture.settings + ) + adapter.agent_name = "TestBot" + + adapter._create_agent().run_sync( + "hello", + deps=mock_tools, + message_history=[ + ModelRequest(parts=[UserPromptPart(content="earlier")]), + ModelResponse(parts=[ThinkingPart(content="hmm")]), + ], + ) + + (sent_to_model,) = seen + assert not [m for m in sent_to_model if isinstance(m, ModelResponse)] + assert trace_capture.operations() == ["chat", "invoke_agent"] diff --git a/tests/adapters/pydanticai/test_nativediagnostics.py b/tests/adapters/pydanticai/test_nativediagnostics.py new file mode 100644 index 000000000..0aecaa876 --- /dev/null +++ b/tests/adapters/pydanticai/test_nativediagnostics.py @@ -0,0 +1,80 @@ +"""Unsettled native runs expose bounded metadata without their content.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator + +import pytest +from pydantic_ai.messages import ModelMessage +from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel + +import band.adapters.pydantic_ai as pydantic_adapter +from band.adapters.pydantic_ai import PydanticAIAdapter +from band.core.types import PlatformMessage +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools + + +class TestNativeDiagnostics: + @pytest.mark.parametrize( + ("first_tool", "native_text", "expected_metadata"), + [ + ( + None, + "private native reply", + "output_type=str, output_chars=20, tool_calls=0, tool_results=0, failed_tool_results=0", + ), + ( + None, + " ", + "output_type=NoneType, output_chars=0, tool_calls=0, tool_results=0, failed_tool_results=0", + ), + ( + BandTool.SEND_MESSAGE, + "private native reply", + "output_type=str, output_chars=20, tool_calls=1, tool_results=1, failed_tool_results=1", + ), + (BandTool.NO_REPLY, "private native reply", None), + ], + ) + async def test_native_outcomes_are_distinguishable_without_logging_content( + self, + sample_message: PlatformMessage, + caplog: pytest.LogCaptureFixture, + first_tool: BandTool | None, + native_text: str, + expected_metadata: str | None, + ) -> None: + requests = 0 + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: + nonlocal requests + requests += 1 + if first_tool is not None and requests == 1: + yield {0: DeltaToolCall(name=first_tool, json_args="{}")} + else: + yield native_text + + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + await adapter.on_started("Bot", "Bot") + tools = FakeAgentTools(room_id=sample_message.room_id) + with caplog.at_level(logging.WARNING, logger=pydantic_adapter.__name__): + await adapter.on_message( + msg=sample_message, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=tools.room_id, + ) + if expected_metadata is None: + assert "native run ended without a turn outcome" not in caplog.text + assert tools.turn.replied + else: + assert expected_metadata in caplog.text + assert not tools.turn.complete + assert "private native reply" not in caplog.text diff --git a/tests/adapters/pydanticai/test_nativeprogress.py b/tests/adapters/pydanticai/test_nativeprogress.py new file mode 100644 index 000000000..62d70c0b7 --- /dev/null +++ b/tests/adapters/pydanticai/test_nativeprogress.py @@ -0,0 +1,205 @@ +"""Native run progress remains observable without logging room contents.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from dataclasses import replace + +import pytest +from pydantic_ai.messages import ModelMessage +from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel + +from band.adapters.pydantic_ai import ( + PydanticAIAdapter, + _log_native_progress, + _native_turn, +) +from band.core.types import Emit, PlatformMessage +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools, failure_reports, reported_failures +from tests.adapters.pydanticai.kit import ( + _assert_billed_responses, + _reply_arguments, + _run_on_message, + _streamed_tool_calls, +) + + +class TestNativeDiagnostics: + @pytest.mark.asyncio + async def test_initial_acknowledgement_then_decline_finishes_without_replay( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + requests = 0 + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[dict[int, DeltaToolCall]]: + nonlocal requests + requests += 1 + replying = requests == 1 + yield { + 0: DeltaToolCall( + name=BandTool.SEND_MESSAGE if replying else BandTool.NO_REPLY, + json_args=json.dumps( + _reply_arguments(sample_message, "Noted") + if replying + else {"reason": "Already acknowledged"} + ), + ) + } + + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter( + llm=FunctionModel(stream_function=stream), emit=Emit.USAGE + ) + await _run_on_message(adapter, sample_message, tools) + + assert requests == 2 + assert tools.chat == ["Noted"] + assert reported_failures(tools) == [] + assert "phase=run_completed" in caplog.text + _assert_billed_responses( + tools, adapter._message_history[tools.room_id], model_requests=2 + ) + + @pytest.mark.asyncio + async def test_streamed_tool_turn_keeps_effects_and_redacts_payloads( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + message = replace(sample_message, content="private prompt body") + tools = FakeAgentTools(room_id=message.room_id) + adapter = PydanticAIAdapter( + llm=_streamed_tool_calls( + ( + BandTool.SEND_MESSAGE, + {"content": "private tool body", "mentions": ["Alice"]}, + ) + ) + ) + + await _run_on_message(adapter, message, tools) + + tools.assert_message_sent(content="private tool body") + assert caplog.text.count("phase=request_attempt") == 2 + assert caplog.text.count("phase=response_ready") == 2 + assert "phase=tool_succeeded" in caplog.text + assert f"tool='{BandTool.SEND_MESSAGE}'" in caplog.text + assert f"room={message.room_id} message={message.id}" in caplog.text + assert ( + caplog.text.index("phase=result_received") + < caplog.text.index("phase=iterator_completed") + < caplog.text.index("phase=run_completed") + ) + assert "private prompt body" not in caplog.text + assert "private tool body" not in caplog.text + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "yield_first", [False, True], ids=["before-first", "midstream"] + ) + async def test_stream_error_is_observed_and_preserves_exception( + self, + sample_message: PlatformMessage, + caplog: pytest.LogCaptureFixture, + yield_first: bool, + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + failure = RuntimeError("private provider error body") + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + if yield_first: + yield "private response body" + raise failure + + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + + with pytest.raises(RuntimeError) as raised: + await _run_on_message(adapter, sample_message, tools) + + assert raised.value is failure + assert "phase=run_error" in caplog.text + assert "error=RuntimeError" in caplog.text + assert "phase=response_ready" not in caplog.text + assert "phase=run_completed" not in caplog.text + assert "private provider error body" not in caplog.text + assert "private response body" not in caplog.text + assert len(failure_reports(tools)) == 1 + + @pytest.mark.asyncio + async def test_cancellation_closes_native_stream_without_reporting_failure( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + blocked = asyncio.Event() + closed = asyncio.Event() + release = asyncio.Event() + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + try: + yield "private response body" + blocked.set() + await release.wait() + finally: + closed.set() + + tools = FakeAgentTools(room_id=sample_message.room_id) + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + async with asyncio.TaskGroup() as tasks: + task = tasks.create_task(_run_on_message(adapter, sample_message, tools)) + await asyncio.wait_for(blocked.wait(), timeout=5) + task.cancel() + + assert task.cancelled() + assert closed.is_set() + assert "phase=stream_progress" in caplog.text + assert "phase=run_cancelled" in caplog.text + assert "error=CancelledError" in caplog.text + assert "phase=response_ready" not in caplog.text + assert "phase=run_completed" not in caplog.text + assert "private response body" not in caplog.text + assert not failure_reports(tools) + + @pytest.mark.asyncio + async def test_long_stream_reports_bounded_progress( + self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + + async def stream( + messages: list[ModelMessage], info: AgentInfo + ) -> AsyncIterator[str]: + for _ in range(128): + yield "private token" + + adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) + await _run_on_message( + adapter, sample_message, FakeAgentTools(room_id=sample_message.room_id) + ) + + assert 1 < caplog.text.count("phase=stream_progress") <= 9 + assert "phase=run_completed" in caplog.text + assert "private token" not in caplog.text + + def test_progress_lines_are_debug(self, caplog: pytest.LogCaptureFixture) -> None: + """Boundary traces stay off the default INFO log.""" + token = _native_turn.set(("room-1", "msg-1", 0.0)) + try: + caplog.set_level(logging.INFO, logger="band.adapters.pydantic_ai") + _log_native_progress("run_completed") + assert "phase=run_completed" not in caplog.text + caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") + _log_native_progress("run_completed") + finally: + _native_turn.reset(token) + assert "phase=run_completed" in caplog.text diff --git a/tests/adapters/pydanticai/test_oncleanup.py b/tests/adapters/pydanticai/test_oncleanup.py new file mode 100644 index 000000000..9546eca87 --- /dev/null +++ b/tests/adapters/pydanticai/test_oncleanup.py @@ -0,0 +1,27 @@ +"""OnCleanup behavior for Pydantic AI.""" + +from __future__ import annotations + +import pytest +from pydantic_ai.messages import ModelRequest, UserPromptPart + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig + + +class TestOnCleanup: + """Tests for on_cleanup() method.""" + + @pytest.mark.asyncio + async def test_cleans_up_room_history(self): + """Should remove room history on cleanup.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + # Add some history + adapter._message_history["room-123"] = [ + ModelRequest(parts=[UserPromptPart(content="test")]) + ] + assert "room-123" in adapter._message_history + + await adapter.on_cleanup("room-123") + + assert "room-123" not in adapter._message_history diff --git a/tests/adapters/pydanticai/test_onmessage.py b/tests/adapters/pydanticai/test_onmessage.py new file mode 100644 index 000000000..aeca25526 --- /dev/null +++ b/tests/adapters/pydanticai/test_onmessage.py @@ -0,0 +1,178 @@ +"""OnMessage behavior for Pydantic AI.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.core.protocols import TurnResultAlreadyReported +from band.testing import MISSING_REPLY_FAILURE, FakeAgentTools, failure_reports +from tests.adapters.pydanticai.kit import make_stream_events +from tests.framework_conformance.turnprobes import ROOM_ID, turn_input + + +class TestOnMessage: + """Tests for on_message() method.""" + + @pytest.mark.asyncio + async def test_initializes_history_on_bootstrap( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should initialize room history on first message.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + assert "room-123" in adapter._message_history + + @pytest.mark.asyncio + async def test_loads_existing_history( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should load historical messages on bootstrap.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + existing_history = [ + ModelRequest(parts=[UserPromptPart(content="[Bob]: Previous message")]), + ModelResponse(parts=[TextPart(content="Previous response")]), + ] + + result_messages = existing_history + [ + ModelRequest(parts=[UserPromptPart(content="new")]) + ] + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=result_messages, + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=existing_history, + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Verify history was passed to agent.run_stream_events() + call_kwargs = adapter._agent.run_stream_events.call_args.kwargs + assert "message_history" in call_kwargs + assert len(call_kwargs["message_history"]) == 2 + + @pytest.mark.asyncio + async def test_injects_participants_message( + self, sample_message, mock_tools, mock_pydantic_agent + ): + """Should inject participants update when provided.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg="Alice joined the room", + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + # Check that participant message was added to history before run + call_kwargs = adapter._agent.run_stream_events.call_args.kwargs + message_history = call_kwargs.get("message_history", []) + # First message should be the participant update + if message_history: + first_msg = message_history[0] + assert isinstance(first_msg, ModelRequest) + assert "[System]: Alice joined" in first_msg.parts[0].content + + @pytest.mark.asyncio + async def test_creates_agent_lazily_if_not_started( + self, sample_message, mock_tools + ): + """Should create agent lazily if on_started wasn't called.""" + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig( + model="openai:gpt-5.4", custom_section="Test section" + ) + ) + # Don't call on_started - set agent_name directly for prompt rendering + adapter.agent_name = "LazyBot" + + with patch.object(adapter, "_create_agent") as mock_create: + mock_agent = MagicMock() + mock_agent.run_stream_events = MagicMock( + return_value=make_stream_events( + result_messages=[], + tool_results=[("band_send_message", "Message sent", "call-1")], + ) + ) + mock_create.return_value = mock_agent + + await adapter.on_message( + msg=sample_message, + tools=mock_tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="room-123", + ) + + mock_create.assert_called_once() + + @pytest.mark.asyncio + async def test_a_turn_that_never_replied_is_reported_by_the_verdict( + self, mock_pydantic_agent + ): + """A clean run that never replied, declined or acted is a silently + dropped turn — the shared verdict reports it, even without an exception.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started("TestBot", "Test bot") + adapter._agent.run_stream_events = MagicMock( + return_value=make_stream_events(result_messages=[]) + ) + tools = FakeAgentTools(room_id=ROOM_ID) + + with pytest.raises(TurnResultAlreadyReported): + await adapter.on_event(turn_input(tools)) + + assert failure_reports(tools) == [MISSING_REPLY_FAILURE] diff --git a/tests/adapters/pydanticai/test_onstarted.py b/tests/adapters/pydanticai/test_onstarted.py new file mode 100644 index 000000000..3fd191775 --- /dev/null +++ b/tests/adapters/pydanticai/test_onstarted.py @@ -0,0 +1,82 @@ +"""OnStarted behavior for Pydantic AI.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.core.types import Capability + + +class TestOnStarted: + """Tests for on_started() method.""" + + @pytest.mark.asyncio + async def test_sets_agent_name_and_description(self, mock_pydantic_agent): + """Should set agent_name and agent_description.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + assert adapter.agent_name == "TestBot" + assert adapter.agent_description == "A test bot" + + @pytest.mark.asyncio + async def test_creates_pydantic_agent(self, mock_pydantic_agent): + """Should create Pydantic AI agent after start.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + assert adapter._agent is None + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + assert adapter._agent is not None + + @pytest.mark.asyncio + async def test_persists_rendered_system_prompt(self): + """Should persist rendered prompt for capability-gating visibility.""" + with patch("band.adapters.pydantic_ai.Agent"): + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="test"), + capabilities=Capability.MEMORY, + ) + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + assert adapter._system_prompt is not None + assert "Memory Tools" in adapter._system_prompt + + @pytest.mark.asyncio + async def test_agent_has_tools_registered(self, mock_pydantic_agent): + """Should register all platform tools on the agent.""" + adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) + + with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): + await adapter.on_started( + agent_name="TestBot", agent_description="A test bot" + ) + + # Get registered tool names + tool_names = list(adapter._agent._function_tools.keys()) + + expected_tools = [ + "band_send_message", + "band_send_event", + "band_add_participant", + "band_remove_participant", + "band_lookup_peers", + "band_get_participants", + "band_create_chatroom", + ] + + for tool in expected_tools: + assert tool in tool_names, f"Tool {tool} not found" diff --git a/tests/adapters/pydanticai/test_portablecustomtooldef.py b/tests/adapters/pydanticai/test_portablecustomtooldef.py new file mode 100644 index 000000000..a2097c9f6 --- /dev/null +++ b/tests/adapters/pydanticai/test_portablecustomtooldef.py @@ -0,0 +1,134 @@ +"""PortableCustomToolDef behavior for Pydantic AI.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest +from pydantic import BaseModel, Field + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.testing import FakeAgentTools +from tests.adapters.pydanticai.kit import ( + _portable_tool_agent, + _run_portable_tool, + _scripted_tool_calls, +) +from tests.framework_conformance.turnprobes import CUSTOM_TOOL_DECLARATIONS + + +class TestPortableCustomToolDef: + """pydantic accepts the portable CustomToolDef (InputModel, handler) tuple form — + the same custom-tool shape anthropic/crewai/claude_sdk/langgraph take.""" + + @pytest.mark.asyncio + async def test_tuple_is_normalized_to_a_named_callable(self): + + class LookupInput(BaseModel): + """look up a code.""" + + key: str + + def lookup(args: LookupInput) -> str: + return f"code:{args.key}" + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="openai:gpt-5.4"), + additional_tools=[(LookupInput, lookup)], + ) + # Normalized to a native callable named from the model (not the handler). + assert [t.__name__ for t in adapter._custom_tools] == ["lookup"] + + def test_converted_tuple_flattens_in_pydantic_ai(self): + + class LookupInput(BaseModel): + """look up a code.""" + + key: str + + def lookup(args: LookupInput) -> str: + return f"code:{args.key}" + + (tool,) = _portable_tool_agent( + (LookupInput, lookup) + )._function_toolset.tools.values() + schema = tool.function_schema.json_schema + # pydantic-ai flattens the single model param into the tool's args. + assert tool.name == "lookup" + assert sorted((schema.get("properties") or {}).keys()) == ["key"] + + @pytest.mark.asyncio + async def test_async_handler_tuple_is_awaited_end_to_end(self): + """An async CustomToolDef handler returns its awaited value through a real + pydantic-ai run — not an unawaited coroutine (which the previous sync + passthrough produced, failing serialization).""" + + class LookupInput(BaseModel): + """look up a code.""" + + key: str + + async def lookup(args: LookupInput) -> str: + return f"code:{args.key}" + + (content,) = await _run_portable_tool((LookupInput, lookup)) + assert isinstance(content, str) + assert content.startswith("code:") + + @pytest.mark.asyncio + async def test_zero_arg_handler_tuple_runs_end_to_end(self): + """A zero-argument handler with an empty InputModel executes through a real + pydantic-ai run — the previous sync passthrough called it with one + positional arg and raised TypeError.""" + + class PingInput(BaseModel): + """ping.""" + + def ping() -> str: + return "pong" + + assert await _run_portable_tool((PingInput, ping)) == ["pong"] + + @pytest.mark.asyncio + async def test_aliased_input_model_runs_end_to_end(self): + """An InputModel using a field alias executes through a real pydantic-ai + run — a dump/re-validate round-trip would emit field names and fail + re-validation against the alias-only model.""" + + class AliasedInput(BaseModel): + """look up a user.""" + + user_id: str = Field(alias="userId") + + def lookup(args: AliasedInput) -> str: + return f"user:{args.user_id}" + + (content,) = await _run_portable_tool((AliasedInput, lookup)) + assert isinstance(content, str) + assert content.startswith("user:") + + @pytest.mark.asyncio + @pytest.mark.parametrize(("declare", "complete"), CUSTOM_TOOL_DECLARATIONS) + async def test_custom_tool_records_its_effect_on_the_room_turn( + self, declare: Callable[..., Any], complete: bool + ): + class FileInput(BaseModel): + """File the report.""" + + note: str + + @declare + def file_report(args: FileInput) -> str: + return "filed" + + tools = FakeAgentTools() + adapter = PydanticAIAdapter( + llm=_scripted_tool_calls(("file", {"note": "go"})), + additional_tools=[(FileInput, file_report)], + ) + adapter.agent_name = "TestBot" + + await adapter._create_agent().run("go", deps=tools) + + assert tools.turn.complete is complete diff --git a/tests/adapters/pydanticai/test_provider.py b/tests/adapters/pydanticai/test_provider.py new file mode 100644 index 000000000..a54a72279 --- /dev/null +++ b/tests/adapters/pydanticai/test_provider.py @@ -0,0 +1,54 @@ +"""Model strings start the upstream HTTPX2 provider and execute a real tool loop.""" + +from __future__ import annotations + +import warnings + +import httpx2 +import pytest +from pydantic_ai.models import infer_model + +from band.adapters.pydantic_ai import PydanticAIAdapter, PydanticAIAdapterConfig +from band.testing import FakeAgentTools +from tests.adapters.pydanticai.providerpeer import ProviderPeer + + +class TestProvider: + @pytest.mark.parametrize("alias", ["openai", "openai-chat"]) + async def test_default_provider_executes_a_tool_round_trip( + self, monkeypatch: pytest.MonkeyPatch, alias: str + ) -> None: + peer = ProviderPeer() + recorded: list[str] = [] + + def record(note: str) -> str: + """Record a note.""" + recorded.append(note) + return "recorded" + + with peer.serve() as base_url, warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + monkeypatch.setenv("OPENAI_API_KEY", "test") + monkeypatch.setenv("OPENAI_BASE_URL", base_url) + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model=f"{alias}:configured-model"), + additional_tools=[record], + ) + await adapter.on_started("Bot", "A bot") + assert adapter._agent is not None + async with adapter._agent as agent: + result = await agent.run("record hello", deps=FakeAgentTools()) + assert result.output == "done" + model = infer_model(f"{alias}:configured-model") + assert isinstance(model.provider.client._client, httpx2.AsyncClient) + await model.provider.client.close() + + assert recorded == ["hello"] + assert peer.tool_results() == ["recorded"] + assert len(peer.requests) == 2 + assert all(request["model"] == "configured-model" for request in peer.requests) + assert not [ + warning + for warning in captured + if "httpx.AsyncClient" in str(warning.message) + ] diff --git a/tests/adapters/pydanticai/test_usagemapping.py b/tests/adapters/pydanticai/test_usagemapping.py new file mode 100644 index 000000000..9790902f0 --- /dev/null +++ b/tests/adapters/pydanticai/test_usagemapping.py @@ -0,0 +1,107 @@ +"""UsageMapping behavior for Pydantic AI.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from pydantic_ai.messages import ModelRequest, UserPromptPart + +from band.adapters.pydantic_ai import PydanticAIAdapter +from band.core.types import TurnUsage +from tests.adapters.pydanticai.kit import make_usage_response + + +class TestUsageMapping: + """Tests for the Emit.USAGE seam's usage mapping.""" + + def test_usage_from_result_reads_the_usage_property(self): + """Maps the run result's ``usage`` (a property since 2.x) onto TurnUsage. + + Reading it as a method instead would raise, and the guarded read would then + report zeros for every turn — silent, so this is the guard. + """ + + result = SimpleNamespace( + usage=SimpleNamespace( + input_tokens=100, + output_tokens=20, + cache_read_tokens=5, + cache_write_tokens=0, + ) + ) + assert PydanticAIAdapter._usage_from_result(result) == TurnUsage( + input_tokens=100, + output_tokens=20, + cache_read_tokens=5, + cache_write_tokens=0, + ) + + def test_usage_from_result_swallows_errors(self): + """Usage that fails to read yields empty usage, never propagates.""" + + class Unreadable: + @property + def usage(self) -> Any: + raise RuntimeError("no usage") + + assert PydanticAIAdapter._usage_from_result(Unreadable()) == TurnUsage() + + def test_usage_from_messages_sums_model_responses(self): + """The benign-path fallback sums usage across captured ModelResponses. + + Covers the empty-final-response path (no AgentRunResultEvent fires) where + the turn still spent tokens — each ModelResponse carries its own usage. + """ + + messages = [ + ModelRequest(parts=[]), # non-response: ignored + make_usage_response(100, 20), + make_usage_response(130, 8), + ] + assert PydanticAIAdapter._usage_from_messages(messages) == TurnUsage( + input_tokens=230, output_tokens=28 + ) + + def test_usage_from_messages_empty_when_no_responses(self): + """No ModelResponse in the captured messages → empty usage.""" + + assert ( + PydanticAIAdapter._usage_from_messages([ModelRequest(parts=[])]) + == TurnUsage() + ) + + def test_new_run_messages_isolates_this_run_despite_history_merge(self): + """Identity (not position) isolates this run when pydantic-ai merges history. + + Regression guard: pydantic-ai's ``_clean_message_history`` merges adjacent + same-type messages in the passed history (e.g. the injected participants + + contacts requests), so ``captured`` is *shorter* than the raw prior history + and a ``len(prior)`` slice would drop this turn's response. Real API + responses keep their identity, so the identity filter still isolates this + run — and combined with the ModelResponse-only sum, yields only this turn's + usage. + """ + + # Prior history: a real response, then two instruction-less requests that + # pydantic-ai would merge into one on the next run. + prior_resp = make_usage_response(100, 20) + req_participants = ModelRequest(parts=[UserPromptPart(content="[System]: p")]) + req_contacts = ModelRequest(parts=[UserPromptPart(content="[System]: c")]) + prior = [prior_resp, req_participants, req_contacts] + prior_ids = {id(m) for m in prior} + + # captured after cleaning: prior_resp survives by identity, the two + # requests are merged into ONE new object, then this run's new response is + # appended. So len(captured)=3 < len(prior)=3+... a positional + # captured[len(prior):] would slice to empty and drop new_resp. + merged_req = ModelRequest(parts=[UserPromptPart(content="[System]: p\nc")]) + new_resp = make_usage_response(130, 8) + captured = [prior_resp, merged_req, new_resp] + + this_run = PydanticAIAdapter._new_run_messages(captured, prior_ids) + # The merged request (new identity) rides along but carries no usage; only + # this run's response contributes. + assert PydanticAIAdapter._usage_from_messages(this_run) == TurnUsage( + input_tokens=130, output_tokens=8 + ) diff --git a/tests/adapters/test_pydantic_ai_adapter.py b/tests/adapters/test_pydantic_ai_adapter.py index b5670d18a..d977e2c14 100644 --- a/tests/adapters/test_pydantic_ai_adapter.py +++ b/tests/adapters/test_pydantic_ai_adapter.py @@ -1,334 +1,36 @@ -"""Tests for PydanticAIAdapter. - -Tests for shared adapter behavior (initialization defaults, custom kwargs, -history_converter, on_message callable, cleanup safety) live in -tests/framework_conformance/test_adapter_conformance.py. -This file contains PydanticAI-specific behavior: agent creation, tool registration, -stream event handling, execution reporting, and custom tools. -""" +"""Initialization behavior for Pydantic AI.""" from __future__ import annotations -import asyncio -import contextlib -import json -import logging -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator -from contextlib import asynccontextmanager, contextmanager -from dataclasses import replace -from datetime import UTC, datetime -from pathlib import Path -from types import SimpleNamespace -from typing import Any, NamedTuple -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch -import httpx import pytest -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -from pydantic import BaseModel, Field -from pydantic_ai import ( - Agent, - AgentRunResultEvent, - FunctionToolCallEvent, - FunctionToolResultEvent, - InstrumentationSettings, - PydanticAIDeprecationWarning, - RunContext, - UnexpectedModelBehavior, - _tool_execution, -) +from pydantic import BaseModel from pydantic_ai.capabilities import ProcessHistory from pydantic_ai.messages import ( - BinaryContent, ModelMessage, - ModelRequest, ModelResponse, - NativeToolCallPart, - RetryPromptPart, TextPart, ThinkingPart, ToolCallPart, - ToolReturnPart, - UserPromptPart, ) -from pydantic_ai.models.function import AgentInfo, DeltaToolCall, FunctionModel -from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel +from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel -from pydantic_ai.usage import UsageLimits from band.adapters.pydantic_ai import ( - OUTPUT_RETRIES_EXHAUSTED, PydanticAIAdapter, PydanticAIAdapterConfig, - _custom_tool_def_to_callable, _drop_non_replayable_messages, - _is_output_retries_exhausted, - _is_replayable_history_message, - _log_native_progress, - _native_turn, - _resolve_pydantic_ai_model, -) -from band.core.exceptions import BandToolError -from band.core.protocols import ( - GENERIC_PROVIDER_FAILURE_MESSAGE, - AgentToolsProtocol, - TurnResultAlreadyReported, -) -from band.core.tool_filter import sanitize_tool_schema -from band.core.types import ( - ALL_CAPABILITIES, - Capability, - Emit, - MessageType, - PlatformMessage, - ToolEventKey, - TurnUsage, ) -from band.runtime.custom_tools import ( - CustomToolDef, - declares_turn_effect, - get_custom_tool_name, +from band.runtime.custom_tools import get_custom_tool_name +from band.testing import FakeAgentTools +from tests.adapters.pydanticai.kit import ( + instrument_all_restored, + mock_pydantic_agent, + mock_tools, + sample_message, + trace_capture, ) -from band.runtime.tools import ( - ALL_TOOL_NAMES, - CHAT_TOOL_NAMES, - CONTACT_TOOL_NAMES, - FILE_TOOL_NAMES, - MEMORY_TOOL_NAMES, - TASK_TOOL_NAMES, - BandTool, - ToolCategory, - TurnEffect, - band_tool_errored, - get_tool_description, - platform_args_schema, -) -from band.testing import ( - MISSING_REPLY_FAILURE, - FakeAgentTools, - events_of_type, - failure_reports, - reported_failures, -) -from tests.adapters.usage_events import recorded_usage_payloads, sent_usage_payloads -from tests.framework_configs.adapters import pydantic_ai_probe_tools -from tests.framework_conformance.turnprobes import ( - CUSTOM_TOOL_DECLARATIONS, - ROOM_ID, - turn_input, -) -from tests.testing.support import seeded_peer - - -def make_stream_events( - result_messages: list | None = None, - tool_calls: list[tuple[str, dict, str]] | None = None, - tool_results: list[tuple[str, str, str]] | None = None, -): - """Stand in for ``Agent.run_stream_events()``: an async CM over stream events. - - pydantic-ai 2.x makes ``run_stream_events()`` an async context manager that - yields the event iterator (it starts the run on first iteration and tears the - background task down on exit), so the fake has to be one too. - - Args: - result_messages: Messages to return in AgentRunResultEvent - tool_calls: List of (tool_name, args, tool_call_id) tuples - tool_results: List of (tool_name, output, tool_call_id) tuples - """ - - async def stream(): - # Emit tool call events - if tool_calls: - for tool_name, args, tool_call_id in tool_calls: - event = MagicMock(spec=FunctionToolCallEvent) - event.part = MagicMock() - event.part.tool_name = tool_name - event.part.args = args - event.part.args_as_dict = MagicMock(return_value=args) - event.part.tool_call_id = tool_call_id - yield event - - # Emit tool result events - if tool_results: - for tool_name, output, tool_call_id in tool_results: - event = MagicMock(spec=FunctionToolResultEvent) - event.part = MagicMock() - event.part.tool_name = tool_name # tool_name is on the part, not event - event.part.content = output - event.tool_call_id = tool_call_id - yield event - - # Always emit final result event - result_event = MagicMock(spec=AgentRunResultEvent) - result_event.result = MagicMock() - result_event.result.all_messages.return_value = result_messages or [] - yield result_event - - @asynccontextmanager - async def events() -> AsyncIterator[AsyncIterator]: - yield stream() - - return events() - - -def make_usage_response( - inp: int, out: int, parts: list[Any] | None = None -) -> ModelResponse: - """A ModelResponse carrying explicit usage counts. - - Real construction (fully initialized, so the dataclass stays repr-safe), - then the usage field is overridden past any frozen/validated assignment. - """ - response = ModelResponse(parts=parts or []) - object.__setattr__( - response, "usage", SimpleNamespace(input_tokens=inp, output_tokens=out) - ) - return response - - -@pytest.fixture -def sample_message(): - """Create a sample platform message.""" - return PlatformMessage( - id="msg-123", - room_id="room-123", - content="Hello, agent!", - sender_id="user-456", - sender_type="User", - sender_name="Alice", - message_type="text", - metadata={}, - created_at=datetime.now(UTC), - ) - - -@pytest.fixture -def mock_tools(): - """Create mock AgentToolsProtocol (MagicMock base, AsyncMock methods).""" - tools = MagicMock() - tools.send_message = AsyncMock(return_value={"status": "sent"}) - tools.send_event = AsyncMock(return_value={"status": "sent"}) - tools.send_failure = AsyncMock(return_value={"status": "sent"}) - tools.add_participant = AsyncMock(return_value={"id": "user-1"}) - tools.remove_participant = AsyncMock(return_value={"status": "removed"}) - tools.lookup_peers = AsyncMock(return_value={"peers": []}) - tools.get_participants = AsyncMock(return_value=[]) - tools.create_chatroom = AsyncMock(return_value="new-room-123") - return tools - - -@pytest.fixture -def mock_pydantic_agent(): - """Create a mock Pydantic AI Agent.""" - agent = MagicMock() - agent._function_tools = { - "band_send_message": MagicMock(name="band_send_message"), - "band_send_event": MagicMock(name="band_send_event"), - "band_add_participant": MagicMock(name="band_add_participant"), - "band_remove_participant": MagicMock(name="band_remove_participant"), - "band_lookup_peers": MagicMock(name="band_lookup_peers"), - "band_get_participants": MagicMock(name="band_get_participants"), - "band_create_chatroom": MagicMock(name="band_create_chatroom"), - } - return agent - - -class TestUsageMapping: - """Tests for the Emit.USAGE seam's usage mapping.""" - - def test_usage_from_result_reads_the_usage_property(self): - """Maps the run result's ``usage`` (a property since 2.x) onto TurnUsage. - - Reading it as a method instead would raise, and the guarded read would then - report zeros for every turn — silent, so this is the guard. - """ - - result = SimpleNamespace( - usage=SimpleNamespace( - input_tokens=100, - output_tokens=20, - cache_read_tokens=5, - cache_write_tokens=0, - ) - ) - assert PydanticAIAdapter._usage_from_result(result) == TurnUsage( - input_tokens=100, - output_tokens=20, - cache_read_tokens=5, - cache_write_tokens=0, - ) - - def test_usage_from_result_swallows_errors(self): - """Usage that fails to read yields empty usage, never propagates.""" - - class Unreadable: - @property - def usage(self) -> Any: - raise RuntimeError("no usage") - - assert PydanticAIAdapter._usage_from_result(Unreadable()) == TurnUsage() - - def test_usage_from_messages_sums_model_responses(self): - """The benign-path fallback sums usage across captured ModelResponses. - - Covers the empty-final-response path (no AgentRunResultEvent fires) where - the turn still spent tokens — each ModelResponse carries its own usage. - """ - - messages = [ - ModelRequest(parts=[]), # non-response: ignored - make_usage_response(100, 20), - make_usage_response(130, 8), - ] - assert PydanticAIAdapter._usage_from_messages(messages) == TurnUsage( - input_tokens=230, output_tokens=28 - ) - - def test_usage_from_messages_empty_when_no_responses(self): - """No ModelResponse in the captured messages → empty usage.""" - - assert ( - PydanticAIAdapter._usage_from_messages([ModelRequest(parts=[])]) - == TurnUsage() - ) - - def test_new_run_messages_isolates_this_run_despite_history_merge(self): - """Identity (not position) isolates this run when pydantic-ai merges history. - - Regression guard: pydantic-ai's ``_clean_message_history`` merges adjacent - same-type messages in the passed history (e.g. the injected participants + - contacts requests), so ``captured`` is *shorter* than the raw prior history - and a ``len(prior)`` slice would drop this turn's response. Real API - responses keep their identity, so the identity filter still isolates this - run — and combined with the ModelResponse-only sum, yields only this turn's - usage. - """ - - # Prior history: a real response, then two instruction-less requests that - # pydantic-ai would merge into one on the next run. - prior_resp = make_usage_response(100, 20) - req_participants = ModelRequest(parts=[UserPromptPart(content="[System]: p")]) - req_contacts = ModelRequest(parts=[UserPromptPart(content="[System]: c")]) - prior = [prior_resp, req_participants, req_contacts] - prior_ids = {id(m) for m in prior} - - # captured after cleaning: prior_resp survives by identity, the two - # requests are merged into ONE new object, then this run's new response is - # appended. So len(captured)=3 < len(prior)=3+... a positional - # captured[len(prior):] would slice to empty and drop new_resp. - merged_req = ModelRequest(parts=[UserPromptPart(content="[System]: p\nc")]) - new_resp = make_usage_response(130, 8) - captured = [prior_resp, merged_req, new_resp] - - this_run = PydanticAIAdapter._new_run_messages(captured, prior_ids) - # The merged request (new identity) rides along but carries no usage; only - # this run's response contributes. - assert PydanticAIAdapter._usage_from_messages(this_run) == TurnUsage( - input_tokens=130, output_tokens=8 - ) class TestInitialization: @@ -349,31 +51,6 @@ async def test_agent_runs_the_live_llm(self): assert adapter._agent.model is llm - def test_non_openai_models_skip_the_legacy_http_patch(self): - llm = TestModel() - - assert _resolve_pydantic_ai_model(llm) is llm - assert _resolve_pydantic_ai_model("anthropic:claude") == "anthropic:claude" - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("model_name", "model_type"), - [ - ("openai:gpt-4o-mini", OpenAIResponsesModel), - ("openai-chat:gpt-4o-mini", OpenAIChatModel), - ], - ) - async def test_openai_model_strings_use_the_legacy_http_client( - self, monkeypatch: pytest.MonkeyPatch, model_name: str, model_type: type[Any] - ) -> None: - monkeypatch.setenv("OPENAI_API_KEY", "sk-test") - - with pytest.warns(PydanticAIDeprecationWarning, match="httpx.AsyncClient"): - model = _resolve_pydantic_ai_model(model_name) - - assert isinstance(model, model_type) - await model.provider.client.close() - @pytest.mark.parametrize( ("config", "llm"), [ @@ -533,2698 +210,10 @@ def think_only(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: assert result.output is None -class TraceCapture(NamedTuple): - """A tracer wired to memory, plus the settings that route an agent into it.""" - - provider: TracerProvider - settings: InstrumentationSettings - exporter: InMemorySpanExporter - - def operations(self) -> list[str]: - """The exported spans' operation names (``chat``, ``invoke_agent``, ...). - - The full span name carries the model and agent, which the assertions here - don't care about — the question is only whether the run was traced. - """ - return [span.name.split()[0] for span in self.exporter.get_finished_spans()] - - -@pytest.fixture -def trace_capture() -> Iterator[TraceCapture]: - """Host-owned tracer pipeline, exporting to memory instead of a collector.""" - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - try: - yield TraceCapture( - provider=provider, - settings=InstrumentationSettings(tracer_provider=provider), - exporter=exporter, - ) - finally: - provider.shutdown() - - -@pytest.fixture -def instrument_all_restored() -> Iterator[None]: - """Undo ``Agent.instrument_all()``; it is process-wide state on the class.""" - previous = Agent._instrument_default - try: - yield - finally: - Agent.instrument_all(previous) - - -def message_types(mock_tools: MagicMock) -> list[str]: - """``message_type`` of every event posted through send_event, in order.""" - return [ - call.kwargs.get("message_type") for call in mock_tools.send_event.call_args_list - ] - - -def _reply(text: str) -> FunctionModel: - """A model that answers in plain text, so a run needs no network or tools.""" - - def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: - return ModelResponse(parts=[TextPart(content=text)]) - - return FunctionModel(respond) - - -class TestInstrumentation: - """Tests for the ``instrument`` pass-through to pydantic-ai. - - Band creates no TracerProvider and no exporter: the host owns the pipeline and - hands the agent an ``InstrumentationSettings`` (or flips ``Agent.instrument_all``). - """ - - def test_settings_route_the_run_into_the_host_tracer( - self, trace_capture: TraceCapture, mock_tools - ): - """Explicit settings trace the model call and the agent run.""" - adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=trace_capture.settings) - adapter.agent_name = "TestBot" - - adapter._create_agent().run_sync("hello", deps=mock_tools) - - assert trace_capture.operations() == ["chat", "invoke_agent"] - - def test_true_traces_through_the_ambient_provider( - self, - trace_capture: TraceCapture, - monkeypatch: pytest.MonkeyPatch, - mock_tools, - ): - """``True`` is the shorthand for a host that published its providers. - - pydantic-ai resolves the ambient ``TracerProvider`` when it builds the - default settings, so this only traces in a process that set one — which - is why examples/opentelemetry hands over settings instead. - """ - monkeypatch.setattr( - "pydantic_ai.models.instrumented.get_tracer_provider", - lambda: trace_capture.provider, - ) - adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=True) - adapter.agent_name = "TestBot" - - adapter._create_agent().run_sync("hello", deps=mock_tools) - - assert trace_capture.operations() == ["chat", "invoke_agent"] - - def test_default_inherits_instrument_all( - self, trace_capture: TraceCapture, instrument_all_restored, mock_tools - ): - """Passing nothing leaves the host's process-wide choice in force.""" - Agent.instrument_all(trace_capture.settings) - adapter = PydanticAIAdapter(llm=_reply("ok")) - adapter.agent_name = "TestBot" - - adapter._create_agent().run_sync("hello", deps=mock_tools) - - assert trace_capture.operations() == ["chat", "invoke_agent"] - - def test_false_opts_out_of_instrument_all( - self, trace_capture: TraceCapture, instrument_all_restored, mock_tools - ): - """``False`` is not "unset": it excludes this agent from a traced process.""" - Agent.instrument_all(trace_capture.settings) - adapter = PydanticAIAdapter(llm=_reply("ok"), instrument=False) - adapter.agent_name = "TestBot" - - adapter._create_agent().run_sync("hello", deps=mock_tools) - - assert trace_capture.operations() == [] - - def test_instrumented_agent_still_drops_content_null_history( - self, trace_capture: TraceCapture, mock_tools - ): - """Instrumentation must not cost the ProcessHistory capability. - - Both ride on the agent, and an implementation that passed instrumentation - as a capability would silently replace the history processor — sending - providers the thinking-only response as assistant ``content: null``. - """ - seen: list[list[ModelMessage]] = [] - - def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: - seen.append(list(messages)) - return ModelResponse(parts=[TextPart(content="ok")]) - - adapter = PydanticAIAdapter( - llm=FunctionModel(respond), instrument=trace_capture.settings - ) - adapter.agent_name = "TestBot" - - adapter._create_agent().run_sync( - "hello", - deps=mock_tools, - message_history=[ - ModelRequest(parts=[UserPromptPart(content="earlier")]), - ModelResponse(parts=[ThinkingPart(content="hmm")]), - ], - ) - - (sent_to_model,) = seen - assert not [m for m in sent_to_model if isinstance(m, ModelResponse)] - assert trace_capture.operations() == ["chat", "invoke_agent"] - - -class TestOnStarted: - """Tests for on_started() method.""" - - @pytest.mark.asyncio - async def test_sets_agent_name_and_description(self, mock_pydantic_agent): - """Should set agent_name and agent_description.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started( - agent_name="TestBot", agent_description="A test bot" - ) - - assert adapter.agent_name == "TestBot" - assert adapter.agent_description == "A test bot" - - @pytest.mark.asyncio - async def test_creates_pydantic_agent(self, mock_pydantic_agent): - """Should create Pydantic AI agent after start.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - assert adapter._agent is None - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started( - agent_name="TestBot", agent_description="A test bot" - ) - - assert adapter._agent is not None - - @pytest.mark.asyncio - async def test_persists_rendered_system_prompt(self): - """Should persist rendered prompt for capability-gating visibility.""" - with patch("band.adapters.pydantic_ai.Agent"): - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="test"), - capabilities=Capability.MEMORY, - ) - await adapter.on_started( - agent_name="TestBot", agent_description="A test bot" - ) - - assert adapter._system_prompt is not None - assert "Memory Tools" in adapter._system_prompt - - @pytest.mark.asyncio - async def test_agent_has_tools_registered(self, mock_pydantic_agent): - """Should register all platform tools on the agent.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started( - agent_name="TestBot", agent_description="A test bot" - ) - - # Get registered tool names - tool_names = list(adapter._agent._function_tools.keys()) - - expected_tools = [ - "band_send_message", - "band_send_event", - "band_add_participant", - "band_remove_participant", - "band_lookup_peers", - "band_get_participants", - "band_create_chatroom", - ] - - for tool in expected_tools: - assert tool in tool_names, f"Tool {tool} not found" - - -def _wire_tool_call_args(args: Any) -> dict[str, Any] | str: - """Shape tool args the way providers send them on the wire (JSON text or object).""" - if isinstance(args, dict | str): - return args - return json.dumps(args) - - -def _scripted_tool_calls(*calls: tuple[str, Any]) -> FunctionModel: - """A model that makes ``calls``, one per turn, then answers in plain text. - - A rejected call brings the model straight back here with nothing left - pending, so it is attempted exactly once — which is what makes "never - dispatched" observable rather than merely slow. - """ - pending = list(calls) - - def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: - if pending: - name, args = pending.pop(0) - return ModelResponse(parts=[ToolCallPart(name, _wire_tool_call_args(args))]) - return ModelResponse(parts=[TextPart("done")]) - - return FunctionModel(respond) - - -async def _started_adapter(**features: Any) -> PydanticAIAdapter: - """A started adapter, i.e. one whose agent is really built.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test"), **features) - await adapter.on_started(agent_name="Probe", agent_description="probe") - return adapter - - -def _registered_names(adapter: PydanticAIAdapter) -> set[str]: - return set(adapter._agent._function_toolset.tools) - - -async def _call_tool( - adapter: PydanticAIAdapter, deps: Any, name: str, args: Any -) -> Any: - """Drive one built-in tool call through pydantic-ai's own execution path. - - Not the tool function directly: validation, argument splatting and the - retry conversion are pydantic-ai's, and calling the function by hand would - skip every one of them. - """ - with adapter._agent.override(model=_scripted_tool_calls((name, args))): - return await adapter._agent.run("go", deps=deps) - - -def _parts(result: Any, part_type: type) -> list[Any]: - return [ - part - for message in result.all_messages() - for part in message.parts - if isinstance(part, part_type) - ] - - -def _tool_returns(result: Any) -> list[Any]: - return [part.content for part in _parts(result, ToolReturnPart)] - - -def _streamed_tool_calls(*calls: tuple[str, dict[str, Any]]) -> FunctionModel: - """A streaming model (what ``on_message`` drives) making ``calls``, one per - request, then ending with nothing left to say.""" - pending = list(calls) - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: - if pending: - name, args = pending.pop(0) - yield {0: DeltaToolCall(name=name, json_args=json.dumps(args))} - else: - yield "" - - return FunctionModel(stream_function=stream) - - -class DecliningModel: - """A native streaming model that keeps declining if asked again.""" - - def __init__(self) -> None: - self.requests = 0 - self.model = FunctionModel(stream_function=self.stream) - - async def stream( - self, messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[dict[int, DeltaToolCall]]: - self.requests += 1 - yield { - 0: DeltaToolCall( - name=BandTool.NO_REPLY, - json_args=json.dumps({"reason": "Already answered"}), - tool_call_id=f"decline-{self.requests}", - ) - } - - -class TestExplicitDecline: - @pytest.mark.asyncio - @pytest.mark.parametrize("request_limit", [1, 3]) - async def test_decline_ends_native_stream_on_last_allowed_request( - self, request_limit: int - ) -> None: - model = DecliningModel() - adapter = PydanticAIAdapter(llm=model.model) - await adapter.on_started("Probe", "probe") - tools = FakeAgentTools() - results = [] - async with adapter._agent.run_stream_events( - "go", deps=tools, usage_limits=UsageLimits(request_limit=request_limit) - ) as events: - async for event in events: - if isinstance(event, AgentRunResultEvent): - results.append(event.result) - - assert model.requests == 1 - assert len(results) == 1 - assert results[0].output is None - assert _tool_returns(results[0]) == [{"status": "no_reply"}] - assert tools.turn.replied - assert reported_failures(tools) == [] - - @pytest.mark.asyncio - async def test_adapter_preserves_decline_events_history_and_per_turn_usage( - self, sample_message: PlatformMessage - ) -> None: - model = DecliningModel() - adapter = PydanticAIAdapter(llm=model.model, emit=(Emit.TOOL_CALLS, Emit.USAGE)) - await adapter.on_started("Probe", "probe") - tools = FakeAgentTools() - await _run_on_message(adapter, sample_message, tools) - - assert model.requests == 1 - assert _emitted_tool_results(tools) == [ - (BandTool.NO_REPLY, "{'status': 'no_reply'}", "decline-1") - ] - prior = list(adapter._message_history[tools.room_id]) - assert _history_tool_returns(prior) == [ - (BandTool.NO_REPLY, "decline-1", {"status": "no_reply"}) - ] - assert all(_is_replayable_history_message(message) for message in prior) - _assert_billed_responses(tools, prior) - assert reported_failures(tools) == [] - - seen_history: list[ModelMessage] = [] - - async def followup( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: - if not seen_history: - seen_history.extend(messages) - yield { - 0: DeltaToolCall( - name=BandTool.SEND_MESSAGE, - json_args=json.dumps( - _reply_arguments(sample_message, "A fresh reply") - ), - ) - } - else: - yield "done" - - next_tools = FakeAgentTools(room_id=tools.room_id) - with adapter._agent.override(model=FunctionModel(stream_function=followup)): - await adapter.on_message( - msg=sample_message, - tools=next_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, - room_id=tools.room_id, - ) - - assert _history_tool_returns(seen_history) == _history_tool_returns(prior) - assert next_tools.chat[0] == "A fresh reply" - new_history = adapter._message_history[tools.room_id][len(prior) :] - _assert_billed_responses(next_tools, new_history, model_requests=2) - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "rejection", - [RuntimeError("decline rejected"), {"ok": False}, None], - ids=["raised", "returned_failure", "invalid_arguments"], - ) - async def test_failed_decline_keeps_required_reply_executable( - self, sample_message: PlatformMessage, rejection: Any - ) -> None: - tools = FakeAgentTools() - if isinstance(rejection, Exception): - tools.no_reply = AsyncMock(side_effect=rejection) - elif rejection is not None: - tools.no_reply = AsyncMock(return_value=rejection) - decline_args = ( - {"unexpected": True} if rejection is None else {"reason": "decline"} - ) - adapter = await _started_adapter() - with adapter._agent.override( - model=_streamed_tool_calls( - (BandTool.NO_REPLY, decline_args), - ( - BandTool.SEND_MESSAGE, - _reply_arguments(sample_message, "Required reply"), - ), - ) - ): - await _run_on_message(adapter, sample_message, tools) - - assert tools.chat == ["Required reply"] - assert reported_failures(tools) == [] - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "first_tool", [BandTool.SEND_MESSAGE, BandTool.GET_PARTICIPANTS, "do_work"] - ) - async def test_other_successful_tools_do_not_end_the_loop( - self, sample_message: PlatformMessage, first_tool: str - ) -> None: - work_done = [] - - @declares_turn_effect(TurnEffect.ACT) - async def do_work() -> str: - work_done.append("worked") - return "worked" - - tools = FakeAgentTools() - adapter = await _started_adapter(additional_tools=[do_work]) - arguments = ( - _reply_arguments(sample_message, "First reply") - if first_tool == BandTool.SEND_MESSAGE - else {} - ) - with adapter._agent.override( - model=_streamed_tool_calls( - (first_tool, arguments), - ( - BandTool.SEND_MESSAGE, - _reply_arguments(sample_message, "Required next reply"), - ), - ) - ): - await _run_on_message(adapter, sample_message, tools) - - assert tools.chat[-1] == "Required next reply" - if first_tool == "do_work": - assert work_done == ["worked"] - assert reported_failures(tools) == [] - - @pytest.mark.asyncio - async def test_decline_preserves_every_result_in_its_tool_batch( - self, sample_message: PlatformMessage - ) -> None: - async def batch( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[dict[int, DeltaToolCall]]: - yield { - 0: DeltaToolCall( - name=BandTool.NO_REPLY, json_args="{}", tool_call_id="decline" - ), - 1: DeltaToolCall( - name=BandTool.SEND_MESSAGE, - json_args=json.dumps( - _reply_arguments(sample_message, "Already sent") - ), - tool_call_id="reply", - ), - 2: DeltaToolCall( - name=BandTool.GET_PARTICIPANTS, json_args="{}", tool_call_id="read" - ), - } - - adapter = PydanticAIAdapter( - llm=FunctionModel(stream_function=batch), emit=Emit.TOOL_CALLS - ) - await adapter.on_started("Probe", "probe") - tools = FakeAgentTools() - await _run_on_message(adapter, sample_message, tools) - - returns = _history_tool_returns(adapter._message_history[tools.room_id]) - assert {(name, call_id) for name, call_id, _ in returns} == { - (BandTool.NO_REPLY, "decline"), - (BandTool.SEND_MESSAGE, "reply"), - (BandTool.GET_PARTICIPANTS, "read"), - } - assert { - (name, call_id) for name, _, call_id in _emitted_tool_results(tools) - } == {(name, call_id) for name, call_id, _ in returns} - assert tools.chat == ["Already sent"] - assert reported_failures(tools) == [] - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("failure", "shown"), - [ - ("raised", "Error executing band_send_message: post failed"), - ("returned_failure", "{'ok': False}"), - ("invalid_arguments", "Invalid arguments for band_send_message"), - ], - ) - async def test_a_failed_sibling_keeps_the_decline_batch_open( - self, sample_message: PlatformMessage, failure: str, shown: str - ) -> None: - """A successful decline leaves the loop running when another tool in the same response failed.""" - tools = FakeAgentTools() - if failure != "invalid_arguments": - send_message = tools.send_message - - async def fail_named_post( - content: str, - mentions: list[str] | list[dict[str, str]] | None = None, - ) -> Any: - if content != "Will fail": - return await send_message(content, mentions) - if failure == "raised": - raise RuntimeError("post failed") - return {"ok": False} - - tools.send_message = fail_named_post - - requests = 0 - followup: list[ModelMessage] = [] - send_arguments: dict[str, Any] = ( - {"content": "Will fail", "mentions": "alice"} - if failure == "invalid_arguments" - else _reply_arguments(sample_message, "Will fail") - ) - - async def batch( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: - nonlocal requests - requests += 1 - if requests == 1: - yield { - 0: DeltaToolCall( - name=BandTool.NO_REPLY, - json_args=json.dumps({"reason": "Settled"}), - tool_call_id="decline", - ), - 1: DeltaToolCall( - name=BandTool.SEND_MESSAGE, - json_args=json.dumps(send_arguments), - tool_call_id="reply", - ), - } - return - if requests == 2: - followup.extend(messages) - yield { - 0: DeltaToolCall( - name=BandTool.SEND_MESSAGE, - json_args=json.dumps( - _reply_arguments(sample_message, "Recovered") - ), - tool_call_id="recovered", - ) - } - return - yield "" - - adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=batch)) - await adapter.on_started("Probe", "probe") - await _run_on_message(adapter, sample_message, tools) - - assert requests == 3 - assert shown in _tool_feedback(followup) - assert shown in _tool_feedback(adapter._message_history[tools.room_id]) - assert tools.chat == ["Recovered"] - assert reported_failures(tools) == [] - - @pytest.mark.asyncio - @pytest.mark.parametrize("cancel", [False, True], ids=["continue", "cancel"]) - async def test_concurrent_room_keeps_its_lifecycle( - self, sample_message: PlatformMessage, cancel: bool - ) -> None: - entered, release, cleaned = asyncio.Event(), asyncio.Event(), asyncio.Event() - - async def wait_for_work() -> str: - entered.set() - try: - await release.wait() - return "work completed" - finally: - cleaned.set() - - adapter = await _started_adapter(additional_tools=[wait_for_work]) - slow_tools, decline_tools = ( - FakeAgentTools(room_id="slow"), - FakeAgentTools(room_id="decline"), - ) - - async def slow_room() -> None: - with adapter._agent.override( - model=_streamed_tool_calls( - ("wait_for_work", {}), - ( - BandTool.SEND_MESSAGE, - _reply_arguments(sample_message, "Finished work"), - ), - ) - ): - await _run_on_message(adapter, sample_message, slow_tools) - - async with asyncio.TaskGroup() as group: - task = group.create_task(slow_room()) - try: - await entered.wait() - with adapter._agent.override(model=DecliningModel().model): - await _run_on_message(adapter, sample_message, decline_tools) - assert not task.done() - if cancel: - task.cancel() - finally: - release.set() - - assert cleaned.is_set() - assert task.cancelled() is cancel - assert slow_tools.chat == ([] if cancel else ["Finished work"]) - assert decline_tools.chat == [] - assert reported_failures(slow_tools) == reported_failures(decline_tools) == [] - - -def _reply_arguments(message: PlatformMessage, content: str) -> dict[str, Any]: - return {"content": content, "mentions": [message.sender_name]} - - -def _tool_feedback(messages: list[ModelMessage]) -> str: - """Tool results and retry prompts a later model request can read.""" - return "\n".join( - str(part.content) - for message in messages - for part in message.parts - if isinstance(part, ToolReturnPart | RetryPromptPart) - ) - - -def _history_tool_returns(messages: list[ModelMessage]) -> list[tuple[str, str, Any]]: - return [ - (part.tool_name, part.tool_call_id, part.content) - for message in messages - for part in message.parts - if isinstance(part, ToolReturnPart) - ] - - -def _emitted_tool_results(tools: FakeAgentTools) -> list[tuple[str, str, str]]: - results = [ - json.loads(event["content"]) - for event in events_of_type(tools, MessageType.TOOL_RESULT) - ] - return [ - ( - result[ToolEventKey.NAME], - result[ToolEventKey.OUTPUT], - result[ToolEventKey.TOOL_CALL_ID], - ) - for result in results - ] - - -def _assert_billed_responses( - tools: FakeAgentTools, messages: list[ModelMessage], *, model_requests: int = 1 -) -> None: - responses = [message for message in messages if isinstance(message, ModelResponse)] - assert len(responses) == model_requests - (payload,) = recorded_usage_payloads(tools) - assert ( - payload["input_tokens"] - == sum(response.usage.input_tokens for response in responses) - > 0 - ) - assert ( - payload["output_tokens"] - == sum(response.usage.output_tokens for response in responses) - > 0 - ) - - -async def _run_on_message( - adapter: PydanticAIAdapter, msg: PlatformMessage, tools: FakeAgentTools -) -> None: - await adapter.on_message( - msg=msg, - tools=tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id=tools.room_id, - ) - - -class TestNativeDiagnostics: - @pytest.mark.asyncio - async def test_initial_acknowledgement_then_decline_finishes_without_replay( - self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture - ) -> None: - requests = 0 - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[dict[int, DeltaToolCall]]: - nonlocal requests - requests += 1 - replying = requests == 1 - yield { - 0: DeltaToolCall( - name=BandTool.SEND_MESSAGE if replying else BandTool.NO_REPLY, - json_args=json.dumps( - _reply_arguments(sample_message, "Noted") - if replying - else {"reason": "Already acknowledged"} - ), - ) - } - - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - tools = FakeAgentTools(room_id=sample_message.room_id) - adapter = PydanticAIAdapter( - llm=FunctionModel(stream_function=stream), emit=Emit.USAGE - ) - await _run_on_message(adapter, sample_message, tools) - - assert requests == 2 - assert tools.chat == ["Noted"] - assert reported_failures(tools) == [] - assert "phase=run_completed" in caplog.text - _assert_billed_responses( - tools, adapter._message_history[tools.room_id], model_requests=2 - ) - - @pytest.mark.asyncio - async def test_streamed_tool_turn_keeps_effects_and_redacts_payloads( - self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture - ) -> None: - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - message = replace(sample_message, content="private prompt body") - tools = FakeAgentTools(room_id=message.room_id) - adapter = PydanticAIAdapter( - llm=_streamed_tool_calls( - ( - BandTool.SEND_MESSAGE, - {"content": "private tool body", "mentions": ["Alice"]}, - ) - ) - ) - - await _run_on_message(adapter, message, tools) - - tools.assert_message_sent(content="private tool body") - assert caplog.text.count("phase=request_attempt") == 2 - assert caplog.text.count("phase=response_ready") == 2 - assert "phase=tool_succeeded" in caplog.text - assert f"tool='{BandTool.SEND_MESSAGE}'" in caplog.text - assert f"room={message.room_id} message={message.id}" in caplog.text - assert ( - caplog.text.index("phase=result_received") - < caplog.text.index("phase=iterator_completed") - < caplog.text.index("phase=run_completed") - ) - assert "private prompt body" not in caplog.text - assert "private tool body" not in caplog.text - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "yield_first", [False, True], ids=["before-first", "midstream"] - ) - async def test_stream_error_is_observed_and_preserves_exception( - self, - sample_message: PlatformMessage, - caplog: pytest.LogCaptureFixture, - yield_first: bool, - ) -> None: - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - failure = RuntimeError("private provider error body") - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str]: - if yield_first: - yield "private response body" - raise failure - - tools = FakeAgentTools(room_id=sample_message.room_id) - adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) - - with pytest.raises(RuntimeError) as raised: - await _run_on_message(adapter, sample_message, tools) - - assert raised.value is failure - assert "phase=run_error" in caplog.text - assert "error=RuntimeError" in caplog.text - assert "phase=response_ready" not in caplog.text - assert "phase=run_completed" not in caplog.text - assert "private provider error body" not in caplog.text - assert "private response body" not in caplog.text - assert len(failure_reports(tools)) == 1 - - @pytest.mark.asyncio - async def test_cancellation_closes_native_stream_without_reporting_failure( - self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture - ) -> None: - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - blocked = asyncio.Event() - closed = asyncio.Event() - release = asyncio.Event() - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str]: - try: - yield "private response body" - blocked.set() - await release.wait() - finally: - closed.set() - - tools = FakeAgentTools(room_id=sample_message.room_id) - adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) - async with asyncio.TaskGroup() as tasks: - task = tasks.create_task(_run_on_message(adapter, sample_message, tools)) - await asyncio.wait_for(blocked.wait(), timeout=5) - task.cancel() - - assert task.cancelled() - assert closed.is_set() - assert "phase=stream_progress" in caplog.text - assert "phase=run_cancelled" in caplog.text - assert "error=CancelledError" in caplog.text - assert "phase=response_ready" not in caplog.text - assert "phase=run_completed" not in caplog.text - assert "private response body" not in caplog.text - assert not failure_reports(tools) - - @pytest.mark.asyncio - async def test_long_stream_reports_bounded_progress( - self, sample_message: PlatformMessage, caplog: pytest.LogCaptureFixture - ) -> None: - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str]: - for _ in range(128): - yield "private token" - - adapter = PydanticAIAdapter(llm=FunctionModel(stream_function=stream)) - await _run_on_message( - adapter, sample_message, FakeAgentTools(room_id=sample_message.room_id) - ) - - assert 1 < caplog.text.count("phase=stream_progress") <= 9 - assert "phase=run_completed" in caplog.text - assert "private token" not in caplog.text - - def test_progress_lines_are_debug(self, caplog: pytest.LogCaptureFixture) -> None: - """Boundary traces stay off the default INFO log.""" - token = _native_turn.set(("room-1", "msg-1", 0.0)) - try: - caplog.set_level(logging.INFO, logger="band.adapters.pydantic_ai") - _log_native_progress("run_completed") - assert "phase=run_completed" not in caplog.text - caplog.set_level(logging.DEBUG, logger="band.adapters.pydantic_ai") - _log_native_progress("run_completed") - finally: - _native_turn.reset(token) - assert "phase=run_completed" in caplog.text - - -class TestAdvertisedToolSchemas: - """Per-argument text fidelity is asserted in test_tool_text_drift. - - What is pydantic-ai-specific, and checked here, is that the master model's - own description and provider-safe JSON schema reach the framework — nothing - is re-derived from a hand-written function signature any more. - """ - - @pytest.mark.asyncio - async def test_tool_blurb_is_the_master_docstring(self): - blurbs = { - name: schema.description - for name, schema in (await pydantic_ai_probe_tools()).items() - } - - assert blurbs, "no tools registered, so nothing was actually checked" - assert blurbs == {name: get_tool_description(name).strip() for name in blurbs} - - @pytest.mark.asyncio - async def test_advertised_schema_is_the_master_schema(self): - schemas = { - name: schema.json_schema - for name, schema in (await pydantic_ai_probe_tools()).items() - } - - assert schemas, "no tools registered, so nothing was actually checked" - assert schemas == { - name: sanitize_tool_schema( - platform_args_schema(name).model_json_schema(), - drop_numeric_bounds=True, - ) - for name in schemas - } - - @pytest.mark.asyncio - async def test_task_schemas_avoid_single_value_const(self): - """Task tools are the ones with a single-value ``Literal``. - - Pydantic renders that as ``const``, which some providers' restricted - JSON-Schema subsets reject; ``platform_args_schema`` normalizes it to a - one-member ``enum``, and this is the proof the sanitized schema — not a - raw ``model_json_schema()`` — is what gets advertised. - """ - adapter = await _started_adapter(capabilities=Capability.TASKS) - tools = adapter._agent._function_toolset.tools - - for name in TASK_TOOL_NAMES: - assert "const" not in json.dumps(tools[name].function_schema.json_schema) - - include = tools[BandTool.GET_TASK].function_schema.json_schema["properties"][ - "include" - ] - assert {"enum": ["history"], "type": "string"} in include["anyOf"] - - -class TestBuiltinToolRegistration: - """Exactly which built-in tools a set of features puts on the agent. - - Expectations are the registry's own name sets, so a newly added tool is - covered by whichever bucket it was declared in rather than by a literal - list someone has to remember to extend. - """ - - @pytest.mark.asyncio - async def test_default_features_register_only_chat_tools(self): - adapter = await _started_adapter() - - assert _registered_names(adapter) == CHAT_TOOL_NAMES - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("capabilities", "expected"), - [ - (Capability.CONTACTS, CHAT_TOOL_NAMES | CONTACT_TOOL_NAMES), - (Capability.MEMORY, CHAT_TOOL_NAMES | MEMORY_TOOL_NAMES), - (Capability.FILES, CHAT_TOOL_NAMES | FILE_TOOL_NAMES), - (Capability.TASKS, CHAT_TOOL_NAMES | TASK_TOOL_NAMES), - (ALL_CAPABILITIES, ALL_TOOL_NAMES), - ], - ids=["contacts", "memory", "files", "tasks", "all"], - ) - async def test_capability_selects_exact_tool_set(self, capabilities, expected): - adapter = await _started_adapter(capabilities=capabilities) - - assert _registered_names(adapter) == expected - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("feature_filter", "expected"), - [ - ({"include_tools": [BandTool.SEND_MESSAGE]}, {BandTool.SEND_MESSAGE}), - ( - {"exclude_tools": [BandTool.SEND_EVENT]}, - ALL_TOOL_NAMES - {BandTool.SEND_EVENT}, - ), - ({"include_categories": [ToolCategory.TASKS]}, TASK_TOOL_NAMES), - ], - ids=["include_tools", "exclude_tools", "include_categories"], - ) - async def test_feature_filters_narrow_the_capability_set( - self, feature_filter, expected - ): - adapter = await _started_adapter( - capabilities=ALL_CAPABILITIES, **feature_filter - ) - - assert _registered_names(adapter) == expected - - @pytest.mark.asyncio - async def test_filters_cannot_reintroduce_a_gated_tool(self): - """A filter narrows what the capabilities allow; it never widens it.""" - adapter = await _started_adapter(include_tools=[BandTool.STORE_MEMORY]) - - assert _registered_names(adapter) == set() - - -class TestBuiltinToolExecution: - """Argument handling through pydantic-ai's real tool-calling loop. - - The integration supplies pydantic-ai with the master schema and a validator - that preserves its normalized kwargs and retry behavior. - """ - - @pytest.mark.asyncio - async def test_invalid_arguments_retry_without_dispatching(self): - deps = MagicMock() - deps.send_message = AsyncMock(return_value={"status": "sent"}) - adapter = await _started_adapter() - - result = await _call_tool( - adapter, - deps, - BandTool.SEND_MESSAGE, - {"content": "hi", "mentions": "alice"}, - ) - - (retry,) = _parts(result, RetryPromptPart) - assert "Invalid arguments for band_send_message" in str(retry.content) - assert "mentions" in str(retry.content) - deps.send_message.assert_not_called() - - @pytest.mark.asyncio - async def test_unknown_arguments_retry_without_dispatching(self): - """The schema-driven tool boundary keeps native tools' extra rejection.""" - deps = MagicMock() - deps.send_message = AsyncMock(return_value={"status": "sent"}) - adapter = await _started_adapter() - - result = await _call_tool( - adapter, - deps, - BandTool.SEND_MESSAGE, - {"content": "hi", "mentions": [], "unexpected": "value"}, - ) - - (retry,) = _parts(result, RetryPromptPart) - assert "unexpected" in str(retry.content) - deps.send_message.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "arguments", [["hi"], '"hi"', 123], ids=["list", "string", "number"] - ) - async def test_non_object_arguments_retry_without_dispatching(self, arguments): - """Valid JSON with a non-object shape is a model retry, not a crash.""" - deps = MagicMock() - deps.send_message = AsyncMock(return_value={"status": "sent"}) - adapter = await _started_adapter() - - result = await _call_tool( - adapter, - deps, - BandTool.SEND_MESSAGE, - arguments, - ) - - (_retry,) = _parts(result, RetryPromptPart) - deps.send_message.assert_not_called() - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("tool", "method_name", "expected_kwargs"), - [ - ( - BandTool.ADD_PARTICIPANT, - "add_participant", - {"identifier": "alice", "role": "member"}, - ), - ( - BandTool.REMOVE_PARTICIPANT, - "remove_participant", - {"identifier": "alice"}, - ), - ], - ids=["add_participant", "remove_participant"], - ) - async def test_validation_alias_argument_is_not_rejected_as_unknown( - self, tool, method_name, expected_kwargs - ): - """A field's ``validation_alias`` secondary name is a real accepted - argument, not an unrecognized one, even though it's absent from the - model's advertised JSON schema (which only lists the primary alias).""" - deps = MagicMock() - method = AsyncMock(return_value={"id": "user-1"}) - setattr(deps, method_name, method) - adapter = await _started_adapter() - - await _call_tool(adapter, deps, tool, {"name": "alice"}) - - method.assert_called_once_with(**expected_kwargs) - - @pytest.mark.asyncio - async def test_coercible_arguments_reach_the_dependency_normalized(self): - deps = MagicMock() - deps.list_tasks = AsyncMock(return_value={"tasks": []}) - adapter = await _started_adapter(capabilities=Capability.TASKS) - - await _call_tool(adapter, deps, BandTool.LIST_TASKS, {"limit": "5"}) - - # limit coerced to int; the two omitted optionals are not passed at all - # rather than handed over as None. - deps.list_tasks.assert_called_once_with(limit=5) - - @pytest.mark.asyncio - async def test_explicit_nulls_are_dropped_before_dispatch(self): - deps = MagicMock() - deps.send_event = AsyncMock(return_value={"status": "sent"}) - adapter = await _started_adapter() - - await _call_tool( - adapter, - deps, - BandTool.SEND_EVENT, - {"content": "thinking", "message_type": "thought", "metadata": None}, - ) - - deps.send_event.assert_called_once_with( - content="thinking", message_type="thought" - ) - - @pytest.mark.asyncio - async def test_dependency_is_called_with_master_field_names(self): - """The master model's field names are the dependency's keyword names. - - Keyword dispatch is what removes a whole class of bug the hand-written - wrappers had: ``send_room_file`` takes (content, filename, caption, - mentions) while the model lists them in another order. - """ - deps = MagicMock() - deps.send_room_file = AsyncMock(return_value={"message_id": "msg-1"}) - adapter = await _started_adapter(capabilities=Capability.FILES) - - await _call_tool( - adapter, - deps, - BandTool.SEND_ROOM_FILE, - { - "content": "file body", - "filename": "notes.txt", - "caption": "here's a file", - "mentions": ["Alice", "Bob"], - }, - ) - - deps.send_room_file.assert_called_once_with( - content="file body", - filename="notes.txt", - caption="here's a file", - mentions=["Alice", "Bob"], - ) - - @pytest.mark.asyncio - async def test_missing_backend_method_raises_an_actionable_error(self): - """A stale/typo'd registry method name is a registry bug -- it should - surface with the tool and method name, not a bare AttributeError.""" - deps = MagicMock(spec=[]) - adapter = await _started_adapter() - - with pytest.raises(RuntimeError, match="send_message"): - await _call_tool( - adapter, deps, BandTool.SEND_MESSAGE, {"content": "hi", "mentions": []} - ) - - -class TestBuiltinToolResults: - """What a finished built-in tool call hands back to the model.""" - - @pytest.mark.asyncio - async def test_add_participant_result_event_reads_as_plain_data(self): - """The tool_result event renders the result with str(), so a status - must read as its value, not as an enum member's repr.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test")) - await adapter.on_started("Probe", "probe") - adapter._agent.model = _streamed_tool_calls( - (BandTool.ADD_PARTICIPANT, {"identifier": "bob"}) - ) - tools = FakeAgentTools( - room_id=ROOM_ID, peers=[seeded_peer("bob-id", handle="bob", name="Bob")] - ) - - await adapter.on_event(turn_input(tools)) - - [event] = events_of_type(tools, MessageType.TOOL_RESULT) - output = json.loads(event["content"])[ToolEventKey.OUTPUT] - assert "'status': 'added'" in output - - @pytest.mark.asyncio - async def test_pydantic_results_are_serialized(self): - class Peer(BaseModel): - id: str - handle: str - - deps = MagicMock() - deps.lookup_peers = AsyncMock(return_value=Peer(id="p-1", handle="@ann")) - adapter = await _started_adapter() - - result = await _call_tool(adapter, deps, BandTool.LOOKUP_PEERS, {}) - - assert _tool_returns(result) == [{"id": "p-1", "handle": "@ann"}] - - @pytest.mark.asyncio - async def test_backend_failure_becomes_an_llm_readable_error(self): - deps = MagicMock() - deps.send_message = AsyncMock(side_effect=RuntimeError("backend down")) - adapter = await _started_adapter() - - result = await _call_tool( - adapter, deps, BandTool.SEND_MESSAGE, {"content": "hi", "mentions": []} - ) - - (content,) = _tool_returns(result) - assert "backend down" in content - # The wording is a contract with band_tool_errored, which the adapter - # reads to tell a failed Band tool from productive work. - assert band_tool_errored(BandTool.SEND_MESSAGE, content) - - @pytest.mark.asyncio - async def test_read_room_file_image_becomes_binary_content(self): - deps = MagicMock() - deps.read_room_file = AsyncMock( - return_value={ - "content": [ - {"type": "image", "data": "ZmFrZQ==", "mimeType": "image/png"} - ] - } - ) - adapter = await _started_adapter(capabilities=Capability.FILES) - - result = await _call_tool( - adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} - ) - - ((binary,),) = _tool_returns(result) - assert isinstance(binary, BinaryContent) - assert binary.data == b"fake" - assert binary.media_type == "image/png" - - @pytest.mark.asyncio - async def test_malformed_image_block_becomes_an_llm_readable_error(self): - """A block that fails to decode is a tool error, not a crashed run.""" - deps = MagicMock() - deps.read_room_file = AsyncMock( - return_value={ - "content": [ - { - "type": "image", - "data": "not-valid-base64!!", - "mimeType": "image/png", - } - ] - } - ) - adapter = await _started_adapter(capabilities=Capability.FILES) - - result = await _call_tool( - adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} - ) - - (content,) = _tool_returns(result) - assert band_tool_errored(BandTool.READ_ROOM_FILE, content) - - @pytest.mark.asyncio - async def test_non_image_file_result_passes_through(self): - deps = MagicMock() - deps.read_room_file = AsyncMock( - return_value={"name": "report.txt", "text": "hello world"} - ) - adapter = await _started_adapter(capabilities=Capability.FILES) - - result = await _call_tool( - adapter, deps, BandTool.READ_ROOM_FILE, {"file_id": "file-1"} - ) - - assert _tool_returns(result) == [{"name": "report.txt", "text": "hello world"}] - - @pytest.mark.asyncio - @pytest.mark.parametrize( - "room_event_error", - [None, RuntimeError("room offline")], - ids=["room_notified", "room_event_also_fails"], - ) - async def test_contact_request_failure_reaches_the_room(self, room_event_error): - """A contact request the agent failed to answer is invisible in the room - otherwise — it looks exactly like one that never arrived. Reporting it - is best-effort, so a failed room event must not change the tool's own - error output.""" - deps = MagicMock() - deps.respond_contact_request = AsyncMock(side_effect=RuntimeError("nope")) - deps.send_event = AsyncMock(side_effect=room_event_error) - adapter = await _started_adapter(capabilities=Capability.CONTACTS) - - result = await _call_tool( - adapter, - deps, - BandTool.RESPOND_CONTACT_REQUEST, - {"action": "approve", "handle": "@ann"}, - ) - - (content,) = _tool_returns(result) - assert "nope" in content - deps.send_event.assert_called_once_with(content, MessageType.ERROR) - - @pytest.mark.asyncio - async def test_contact_request_success_does_not_report_a_normalization_failure( - self, - ): - """A response that succeeded must not be reported to the room as - failed just because shaping its result afterward blew up.""" - deps = MagicMock() - approved = MagicMock() - approved.model_dump.side_effect = TypeError("boom during serialization") - deps.respond_contact_request = AsyncMock(return_value=approved) - deps.send_event = AsyncMock(return_value={"status": "sent"}) - adapter = await _started_adapter(capabilities=Capability.CONTACTS) - - result = await _call_tool( - adapter, - deps, - BandTool.RESPOND_CONTACT_REQUEST, - {"action": "approve", "handle": "@ann"}, - ) - - deps.respond_contact_request.assert_called_once_with( - action="approve", handle="@ann" - ) - (content,) = _tool_returns(result) - assert "boom during serialization" in content - deps.send_event.assert_not_called() - - @pytest.mark.asyncio - async def test_send_message_normalization_failure_counts_as_completed_work( - self, sample_message - ): - """A posted message is terminal even when its response cannot serialize.""" - calls_pending = True - - async def stream( - messages: list[ModelMessage], info: AgentInfo - ) -> AsyncIterator[str | dict[int, DeltaToolCall]]: - nonlocal calls_pending - if calls_pending: - calls_pending = False - yield { - 0: DeltaToolCall( - name=BandTool.SEND_MESSAGE, - json_args=json.dumps({"content": "hi", "mentions": []}), - tool_call_id="call-1", - ) - } - else: - yield "done" - - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="test"), emit=()) - await adapter.on_started("Probe", "probe") - adapter._agent.model = FunctionModel(stream_function=stream) - - deps = MagicMock() - result = MagicMock() - result.model_dump.side_effect = TypeError("boom during serialization") - deps.send_message = AsyncMock(return_value=result) - deps.send_event = AsyncMock() - - await adapter.on_message( - msg=sample_message, - tools=deps, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - deps.send_message.assert_called_once_with(content="hi", mentions=[]) - deps.send_event.assert_not_called() - - -class TestOnMessage: - """Tests for on_message() method.""" - - @pytest.mark.asyncio - async def test_initializes_history_on_bootstrap( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should initialize room history on first message.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=result_messages, - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - assert "room-123" in adapter._message_history - - @pytest.mark.asyncio - async def test_loads_existing_history( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should load historical messages on bootstrap.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - existing_history = [ - ModelRequest(parts=[UserPromptPart(content="[Bob]: Previous message")]), - ModelResponse(parts=[TextPart(content="Previous response")]), - ] - - result_messages = existing_history + [ - ModelRequest(parts=[UserPromptPart(content="new")]) - ] - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=result_messages, - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=existing_history, - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Verify history was passed to agent.run_stream_events() - call_kwargs = adapter._agent.run_stream_events.call_args.kwargs - assert "message_history" in call_kwargs - assert len(call_kwargs["message_history"]) == 2 - - @pytest.mark.asyncio - async def test_injects_participants_message( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should inject participants update when provided.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg="Alice joined the room", - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Check that participant message was added to history before run - call_kwargs = adapter._agent.run_stream_events.call_args.kwargs - message_history = call_kwargs.get("message_history", []) - # First message should be the participant update - if message_history: - first_msg = message_history[0] - assert isinstance(first_msg, ModelRequest) - assert "[System]: Alice joined" in first_msg.parts[0].content - - @pytest.mark.asyncio - async def test_creates_agent_lazily_if_not_started( - self, sample_message, mock_tools - ): - """Should create agent lazily if on_started wasn't called.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig( - model="openai:gpt-5.4", custom_section="Test section" - ) - ) - # Don't call on_started - set agent_name directly for prompt rendering - adapter.agent_name = "LazyBot" - - with patch.object(adapter, "_create_agent") as mock_create: - mock_agent = MagicMock() - mock_agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - mock_create.return_value = mock_agent - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - mock_create.assert_called_once() - - @pytest.mark.asyncio - async def test_a_turn_that_never_replied_is_reported_by_the_verdict( - self, mock_pydantic_agent - ): - """A clean run that never replied, declined or acted is a silently - dropped turn — the shared verdict reports it, even without an exception.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events(result_messages=[]) - ) - tools = FakeAgentTools(room_id=ROOM_ID) - - with pytest.raises(TurnResultAlreadyReported): - await adapter.on_event(turn_input(tools)) - - assert failure_reports(tools) == [MISSING_REPLY_FAILURE] - - -class TestOnCleanup: - """Tests for on_cleanup() method.""" - - @pytest.mark.asyncio - async def test_cleans_up_room_history(self): - """Should remove room history on cleanup.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - # Add some history - adapter._message_history["room-123"] = [ - ModelRequest(parts=[UserPromptPart(content="test")]) - ] - assert "room-123" in adapter._message_history - - await adapter.on_cleanup("room-123") - - assert "room-123" not in adapter._message_history - - -class TestHistoryManagement: - """Tests for message history management.""" - - @pytest.mark.asyncio - async def test_updates_history_after_run( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should update stored history with all messages from run.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - new_messages = [ - ModelRequest(parts=[UserPromptPart(content="Q1")]), - ModelResponse(parts=[TextPart(content="A1")]), - ] - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=new_messages, - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - assert adapter._message_history["room-123"] == new_messages - - @pytest.mark.asyncio - async def test_keeps_native_history_and_drops_content_null_responses( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should keep native tool history but drop responses that replay as null.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - user_request = ModelRequest(parts=[UserPromptPart(content="Q1")]) - tool_call_response = ModelResponse( - parts=[ - ToolCallPart( - tool_name="band_send_message", - args={"content": "A1", "mentions": ["Alice"]}, - tool_call_id="call_1", - ) - ] - ) - tool_return_request = ModelRequest( - parts=[ - ToolReturnPart( - tool_name="band_send_message", - content={"id": "msg_1"}, - tool_call_id="call_1", - ) - ] - ) - content_null_response = ModelResponse(parts=[]) - text_response = ModelResponse(parts=[TextPart(content="A1")]) - result_messages = [ - user_request, - tool_call_response, - tool_return_request, - content_null_response, - text_response, - ] - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=result_messages, - tool_results=[("band_send_message", {"id": "msg_1"}, "call_1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - stored_history = adapter._message_history["room-123"] - assert stored_history == [ - user_request, - tool_call_response, - tool_return_request, - text_response, - ] - assert content_null_response not in stored_history - - def test_keeps_response_with_only_native_tool_part(self): - """Native tool calls carry content the provider expects — keep them.""" - response = ModelResponse( - parts=[ - NativeToolCallPart( - tool_name="web_search", - args={"query": "weather"}, - tool_call_id="call_1", - ) - ] - ) - - assert _is_replayable_history_message(response) is True - - def test_history_processor_strips_content_null_responses(self): - """The processor drops empty/thinking-only responses, keeps real content. - - This runs before every model request (mid-run included), so an empty or - thinking-only response the model emits within a turn is never replayed as - assistant content:null — which providers reject. - """ - user_request = ModelRequest(parts=[UserPromptPart(content="Q1")]) - tool_call = ModelResponse( - parts=[ - ToolCallPart( - tool_name="band_send_message", - args={"content": "hi", "mentions": ["Alice"]}, - tool_call_id="call_1", - ) - ] - ) - tool_return = ModelRequest( - parts=[ - ToolReturnPart( - tool_name="band_send_message", - content={"id": "msg_1"}, - tool_call_id="call_1", - ) - ] - ) - empty_response = ModelResponse(parts=[]) - thinking_only = ModelResponse(parts=[ThinkingPart(content="hmm")]) - text_response = ModelResponse(parts=[TextPart(content="done")]) - - processed = _drop_non_replayable_messages( - [ - user_request, - tool_call, - tool_return, - empty_response, - thinking_only, - text_response, - ] - ) - - assert processed == [user_request, tool_call, tool_return, text_response] - assert empty_response not in processed - assert thinking_only not in processed - - @pytest.mark.asyncio - async def test_ensures_history_exists_for_non_bootstrap( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should create history if not bootstrap and room doesn't exist.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=False, # Not bootstrap - room_id="new-room", - ) - - # Should have created empty history - assert "new-room" in adapter._message_history - - -class TestExecutionReporting: - """Tests for execution reporting (tool_call and tool_result events).""" - - @pytest.mark.asyncio - async def test_emits_tool_call_events_when_enabled( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should emit tool_call events when emit=Emit.TOOL_CALLS.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], - tool_results=[("band_send_message", "Message sent", "call-123")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Verify send_event was called with tool_call - mock_tools.send_event.assert_any_call( - content='{"name": "band_send_message", "args": {"content": "Hello"}, "tool_call_id": "call-123"}', - message_type="tool_call", - ) - - @pytest.mark.asyncio - async def test_tool_call_event_redacts_send_room_file_content( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """band_send_room_file's content arg can carry up to - MAX_SEND_CONTENT_BYTES of real file bytes; the tool_call event must - report a bounded placeholder instead of the raw content.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_calls=[ - ( - "band_send_room_file", - {"content": "SECRET FILE BYTES", "filename": "f.txt"}, - "call-123", - ) - ], - tool_results=[("band_send_message", "Message sent", "call-2")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - reported_content = mock_tools.send_event.call_args_list[0].kwargs["content"] - assert "SECRET FILE BYTES" not in reported_content - assert "byte file content" in reported_content - assert '"filename": "f.txt"' in reported_content - - @pytest.mark.asyncio - async def test_emits_tool_result_events_when_enabled( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should emit tool_result events when emit=Emit.TOOL_CALLS.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_results=[ - ("band_send_message", "Message sent successfully", "call-123") - ], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Verify send_event was called with tool_result - mock_tools.send_event.assert_any_call( - content='{"name": "band_send_message", "output": "Message sent successfully", "tool_call_id": "call-123"}', - message_type="tool_result", - ) - - @pytest.mark.asyncio - async def test_tool_result_event_redacts_binary_content( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """band_read_room_file's image result is a list[BinaryContent]; str() - on that embeds the raw image bytes via BinaryContent.__repr__. The - tool_result event must report a bounded placeholder instead.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - image = BinaryContent( - data=b"\x89PNG\r\n\x1a\n" + b"\x00" * 64, media_type="image/png" - ) - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_results=[ - ("band_read_room_file", [image], "call-1"), - ("band_send_message", "Message sent", "call-2"), - ], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - mock_tools.send_event.assert_any_call( - content='{"name": "band_read_room_file", "output": "<1 image content block(s)>", "tool_call_id": "call-1"}', - message_type="tool_result", - ) - - @pytest.mark.asyncio - async def test_no_events_when_reporting_disabled( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """emit=() disables tool_call/tool_result events (emit otherwise defaults on).""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=() - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], - tool_results=[("band_send_message", "Message sent", "call-123")], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Verify send_event was NOT called for tool_call or tool_result - assert not set(message_types(mock_tools)) & {"tool_call", "tool_result"} - - @pytest.mark.asyncio - async def test_multiple_tool_calls_all_reported( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Should emit events for all tool calls in sequence.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[], - tool_calls=[ - ("band_lookup_peers", {}, "call-1"), - ("band_add_participant", {"identifier": "Helper"}, "call-2"), - ("band_send_message", {"content": "Done"}, "call-3"), - ], - tool_results=[ - ("band_lookup_peers", "[{...}]", "call-1"), - ("band_add_participant", "Added", "call-2"), - ("band_send_message", "Sent", "call-3"), - ], - ) - ) - - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Count tool_call and tool_result events - types = message_types(mock_tools) - assert types.count("tool_call") == 3 - assert types.count("tool_result") == 3 - - @pytest.mark.asyncio - async def test_event_failure_does_not_crash_run( - self, sample_message, mock_pydantic_agent - ): - """Should continue running if send_event fails.""" - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.TOOL_CALLS - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - # Mock tools where send_event fails with a real transport error — the - # tool_call event's own local guard swallows this and logs a warning; - # a generic Exception would be a bug and is intentionally left to - # propagate. - failing_tools = AsyncMock() - failing_tools.send_event = AsyncMock( - side_effect=httpx.ConnectError("Network error") - ) - - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=[ModelRequest(parts=[UserPromptPart(content="test")])], - tool_calls=[("band_send_message", {"content": "Hello"}, "call-123")], - tool_results=[("band_send_message", "Message sent", "call-123")], - ) - ) - - # Should not raise - await adapter.on_message( - msg=sample_message, - tools=failing_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # History should still be updated - assert "room-123" in adapter._message_history - - -def make_raising_stream( - error: BaseException, - *, - work: Callable[[], Awaitable[object]] | None = None, -): - """Run stream (async CM, as 2.x returns) that does ``work``, then raises. - - ``work`` stands in for the tool calls the run made through the room's - tools, so the turn's ledger sees exactly what those calls did. - """ - - async def stream(): - if work is not None: - await work() - raise error - yield # an async generator, like the real event stream - - @asynccontextmanager - async def events() -> AsyncIterator[AsyncIterator]: - yield stream() - - return events() - - -class TestEmptyFinalAnswer: - """A model can end a turn with output pydantic-ai cannot accept — blank text, - say — after the agent already replied/acted via tools, exhausting the refused - output-retry budget. That is benign — the work already went out — so it must not - fail the message, but a genuine no-work failure must still surface. - """ - - def test_swallow_matches_the_wording_pydantic_ai_actually_raises(self) -> None: - """The swallow keys on message text, since pydantic-ai exposes no code for it. - - Every other test here builds the exception by hand, so a reword upstream - would leave them green while the swallow quietly stopped matching — which is - exactly what 2.x did to the 1.x phrasing ("Exceeded maximum retries (N) for - output validation"). Read the real source so a future reword fails here. - """ - - source = Path(_tool_execution.__file__).read_text(encoding="utf-8").lower() - assert OUTPUT_RETRIES_EXHAUSTED in source - assert _is_output_retries_exhausted( - UnexpectedModelBehavior("Exceeded maximum output retries (3)") - ) - assert not _is_output_retries_exhausted( - UnexpectedModelBehavior("Invalid response, unable to find output") - ) - - @pytest.mark.asyncio - async def test_empty_output_after_tool_is_benign( - self, - sample_message, - mock_pydantic_agent, - caplog: pytest.LogCaptureFixture, - ): - """Output-retry exhaustion after the turn replied is swallowed.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - tools = FakeAgentTools(room_id="room-123") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - UnexpectedModelBehavior("Exceeded maximum output retries (1)"), - work=lambda: tools.send_message("hi", ["@Alice"]), - ) - ) - caplog.set_level(logging.WARNING, logger="band.adapters.pydantic_ai") - - # Must not raise: the reply already went out via the tool this turn. - await adapter.on_message( - msg=sample_message, - tools=tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - # Regression (fallback path): with the run mocked, capture_run_messages records - # nothing, so the swallow falls back to preserving at least the user prompt so - # the next same-session turn isn't amnesiac. - - preserved = adapter._message_history["room-123"] - assert preserved, "swallowed turn should still record the user message" - assert isinstance(preserved[-1], ModelRequest) - assert any( - isinstance(part, UserPromptPart) and "Hello, agent!" in str(part.content) - for part in preserved[-1].parts - ) - assert reported_failures(tools) == [] - assert "Exceeded maximum output retries (1)" in caplog.text - - @pytest.mark.asyncio - async def test_empty_output_preserves_full_captured_turn( - self, sample_message, mock_pydantic_agent - ): - """The swallow persists the whole captured turn — not just the user prompt — - so a later 'what did you just say?' has the agent's reply in context.""" - - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - tools = FakeAgentTools(room_id="room-xyz") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - UnexpectedModelBehavior("Exceeded maximum output retries (1)"), - work=lambda: tools.send_message("hi", ["@Alice"]), - ) - ) - - # pydantic-ai populates capture_run_messages during a real run; simulate a - # run that captured the full turn (user prompt + the agent's response). - full_turn = [ - ModelRequest(parts=[UserPromptPart(content="[Alice]: hi")]), - ModelResponse(parts=[TextPart(content="replied via tool")]), - ] - - @contextmanager - def fake_capture(): - yield full_turn - - with patch("band.adapters.pydantic_ai.capture_run_messages", fake_capture): - await adapter.on_message( - msg=sample_message, - tools=tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-xyz", - ) - - preserved = adapter._message_history["room-xyz"] - # The full turn is kept — crucially the assistant response, not only the user. - assert preserved == full_turn - assert any(isinstance(message, ModelResponse) for message in preserved) - - @pytest.mark.asyncio - async def test_empty_output_without_tool_propagates( - self, sample_message, mock_pydantic_agent - ): - """Same error with no tool executed is a real failure — propagate.""" - await self._assert_exhaustion_propagates( - sample_message, mock_pydantic_agent, work=None - ) - - @pytest.mark.asyncio - async def test_failed_run_still_emits_captured_usage( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """A run that raises still emits the usage its captured responses accrued. - - Tokens spent before the failure were still spent: the finally-based emit - falls back to summing this run's captured ModelResponses when no result - event fired, so a hard mid-run failure doesn't silently drop usage.""" - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), emit=Emit.USAGE - ) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - UnexpectedModelBehavior("Invalid response, unable to find output"), - ) - ) - - # Simulate a run that captured a response with usage before raising. - captured_turn = [ - ModelRequest(parts=[UserPromptPart(content="[Alice]: hi")]), - make_usage_response(100, 20, parts=[TextPart(content="partial")]), - ] - - @contextmanager - def fake_capture(): - yield captured_turn - - with ( - patch("band.adapters.pydantic_ai.capture_run_messages", fake_capture), - pytest.raises(UnexpectedModelBehavior), - ): - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - usage_payloads = sent_usage_payloads(mock_tools) - assert usage_payloads == [ - { - "input_tokens": 100, - "output_tokens": 20, - "cache_read_tokens": 0, - "cache_write_tokens": 0, - } - ], f"expected the captured run's usage to be emitted, got {usage_payloads}" - - @pytest.mark.asyncio - async def test_unrelated_model_error_propagates_even_after_tool( - self, sample_message, mock_pydantic_agent - ): - """The swallow is narrow: other model errors still surface after a reply.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - tools = FakeAgentTools(room_id="room-123") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - UnexpectedModelBehavior("Invalid response, unable to find output"), - work=lambda: tools.send_message("hi", ["@Alice"]), - ) - ) - - with pytest.raises(UnexpectedModelBehavior): - await _run_on_message(adapter, sample_message, tools) - - assert failure_reports(tools) == [ - ("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) - ] - - @pytest.mark.asyncio - async def test_generic_provider_error_reports_and_propagates( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """A failure that isn't UnexpectedModelBehavior at all (a raw provider/API - error) must still surface as a failure and propagate, not vanish uncaught.""" - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - RuntimeError("provider connection reset"), - ) - ) - - with pytest.raises(RuntimeError, match="provider connection reset"): - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - mock_tools.send_failure.assert_awaited_once() - failure = mock_tools.send_failure.call_args.args[0] - assert failure.provider == "pydantic_ai" - assert failure.message == GENERIC_PROVIDER_FAILURE_MESSAGE - - @pytest.mark.asyncio - async def test_empty_output_after_read_only_tool_propagates( - self, sample_message, mock_pydantic_agent - ): - """A read-only lookup is not terminal work — output-validation exhaustion - after only a lookup is a genuine no-response failure and must propagate.""" - tools = FakeAgentTools(room_id="room-123") - - await self._assert_exhaustion_propagates( - sample_message, mock_pydantic_agent, tools=tools, work=tools.lookup_peers - ) - - @pytest.mark.asyncio - async def test_empty_output_after_failed_band_tool_propagates( - self, sample_message, mock_pydantic_agent - ): - """A band tool that raised did no work — exhausting output validation - afterward is a genuine failure and must propagate.""" - tools = FakeAgentTools(room_id="room-123") - - async def rejected_send() -> None: - # The band tool wrapper turns this raise into an error string for - # the model; the turn records nothing either way. - with contextlib.suppress(BandToolError): - await tools.send_message("hi", mentions=[]) - - await self._assert_exhaustion_propagates( - sample_message, mock_pydantic_agent, tools=tools, work=rejected_send - ) - - @staticmethod - async def _assert_exhaustion_propagates( - sample_message: PlatformMessage, - mock_pydantic_agent: MagicMock, - *, - work: Callable[[], Awaitable[object]] | None, - tools: FakeAgentTools | None = None, - ) -> None: - """Output-retry exhaustion after ``work`` fails the turn as a provider error.""" - tools = tools or FakeAgentTools(room_id="room-123") - adapter = PydanticAIAdapter(PydanticAIAdapterConfig(model="openai:gpt-5.4")) - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - adapter._agent.run_stream_events = MagicMock( - return_value=make_raising_stream( - UnexpectedModelBehavior("Exceeded maximum output retries (1)"), - work=work, - ) - ) - - with pytest.raises(UnexpectedModelBehavior): - await _run_on_message(adapter, sample_message, tools) - - assert failure_reports(tools) == [ - ("pydantic_ai", GENERIC_PROVIDER_FAILURE_MESSAGE) - ] - - -class TestCustomTools: - """Tests for custom tool support (PydanticAI-native functions).""" - - @pytest.mark.asyncio - @pytest.mark.parametrize( - ("declared", "reported"), [(True, []), (False, [MISSING_REPLY_FAILURE])] - ) - async def test_native_tool_counts_only_the_effect_it_declared( - self, declared: bool, reported: list - ): - """A native tool runs outside the portable custom-tool path, so its - declared effect still has to reach the turn; an undeclared one only - observes.""" - - async def finish(ctx: RunContext[AgentToolsProtocol], note: str) -> str: - """Finish the task.""" - return "done" - - if declared: - declares_turn_effect(TurnEffect.ACT)(finish) - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="test"), additional_tools=[finish] - ) - await adapter.on_started("Probe", "probe") - adapter._agent.model = _streamed_tool_calls(("finish", {"note": "go"})) - tools = FakeAgentTools(room_id=ROOM_ID) - - with contextlib.suppress(TurnResultAlreadyReported): - await adapter.on_event(turn_input(tools)) - - assert failure_reports(tools) == reported - - @pytest.mark.asyncio - async def test_a_native_tool_failure_value_leaves_the_turn_unanswered(self) -> None: - @declares_turn_effect(TurnEffect.ACT) - async def finish( - ctx: RunContext[AgentToolsProtocol], note: str - ) -> dict[str, Any]: - """Finish the task.""" - return {"ok": False, "error": "upstream refused"} - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="test"), additional_tools=[finish] - ) - await adapter.on_started("Probe", "probe") - adapter._agent.model = _streamed_tool_calls(("finish", {"note": "go"})) - tools = FakeAgentTools(room_id=ROOM_ID) - - with pytest.raises(TurnResultAlreadyReported): - await adapter.on_event(turn_input(tools)) - - assert failure_reports(tools) == [MISSING_REPLY_FAILURE] - - def test_accepts_additional_tools_parameter(self): - """Adapter accepts list of callables.""" - - async def my_tool(ctx: RunContext[AgentToolsProtocol], message: str) -> str: - """A custom tool.""" - return f"Echo: {message}" - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), additional_tools=[my_tool] - ) - - assert len(adapter._custom_tools) == 1 - assert adapter._custom_tools[0] == my_tool - - def test_multiple_custom_tools(self): - """Should accept multiple custom tools.""" - - async def tool_one(ctx: RunContext[AgentToolsProtocol], a: int) -> int: - """Tool one.""" - return a + 1 - - def tool_two(ctx: RunContext[AgentToolsProtocol], b: str) -> str: - """Tool two.""" - return b.upper() - - async def tool_three( - ctx: RunContext[AgentToolsProtocol], x: float, y: float - ) -> float: - """Tool three.""" - return x + y - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), - additional_tools=[tool_one, tool_two, tool_three], - ) - - assert len(adapter._custom_tools) == 3 - - @pytest.mark.asyncio - async def test_registers_custom_tools_with_agent(self): - """Custom tools should be registered via agent.tool().""" - - async def my_echo(ctx: RunContext[AgentToolsProtocol], message: str) -> str: - """Echo the message.""" - return f"Echo: {message}" - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="test"), additional_tools=[my_echo] - ) - - # Mock the Agent class to track tool registrations - registered_tools = [] - - with patch("band.adapters.pydantic_ai.Agent") as MockAgent: - mock_agent = MagicMock() - mock_agent.tool = MagicMock( - side_effect=lambda f: registered_tools.append(f) - ) - MockAgent.return_value = mock_agent - - await adapter.on_started("TestBot", "Test bot") - - # Should have registered platform tools + custom tool - tool_names = [t.__name__ for t in registered_tools] - assert "my_echo" in tool_names - - @pytest.mark.asyncio - async def test_custom_tool_appears_in_agent_function_tools( - self, mock_pydantic_agent - ): - """Custom tool should appear in agent._function_tools after registration.""" - - async def calculator( - ctx: RunContext[AgentToolsProtocol], a: float, b: float - ) -> float: - """Add two numbers.""" - return a + b - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), - additional_tools=[calculator], - ) - - # Add calculator to mock agent's function tools when tool() is called - def register_tool(func): - mock_pydantic_agent._function_tools[func.__name__] = MagicMock( - name=func.__name__ - ) - - mock_pydantic_agent.tool = MagicMock(side_effect=register_tool) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - # Manually call tool registration since we're mocking _create_agent - for custom_tool in adapter._custom_tools: - mock_pydantic_agent.tool(custom_tool) - - assert "calculator" in mock_pydantic_agent._function_tools - - @pytest.mark.asyncio - async def test_custom_tools_work_with_on_message( - self, sample_message, mock_tools, mock_pydantic_agent - ): - """Custom tools should work during message handling.""" - - async def my_helper(ctx: RunContext[AgentToolsProtocol], value: str) -> str: - """Helper tool.""" - return f"Helped: {value}" - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), - additional_tools=[my_helper], - ) - - with patch.object(adapter, "_create_agent", return_value=mock_pydantic_agent): - await adapter.on_started("TestBot", "Test bot") - - result_messages = [ModelRequest(parts=[UserPromptPart(content="test")])] - adapter._agent.run_stream_events = MagicMock( - return_value=make_stream_events( - result_messages=result_messages, - tool_results=[("band_send_message", "Message sent", "call-1")], - ) - ) - - # Should not raise - await adapter.on_message( - msg=sample_message, - tools=mock_tools, - history=[], - participants_msg=None, - contacts_msg=None, - is_session_bootstrap=True, - room_id="room-123", - ) - - assert "room-123" in adapter._message_history - - -def _portable_tool_agent( - tool_def: CustomToolDef, -) -> Agent[AgentToolsProtocol, str]: - """A bare agent running one converted ``CustomToolDef`` on pydantic-ai's own path.""" - agent = Agent(TestModel(), deps_type=AgentToolsProtocol, output_type=str) - agent.tool(_custom_tool_def_to_callable(tool_def)) - return agent - - -async def _run_portable_tool( - tool_def: CustomToolDef, tools: FakeAgentTools | None = None -) -> list[Any]: - """Run one converted ``CustomToolDef`` in a room; return what the model saw.""" - result = await _portable_tool_agent(tool_def).run( - "go", deps=tools or FakeAgentTools() - ) - return _tool_returns(result) - - -class TestPortableCustomToolDef: - """pydantic accepts the portable CustomToolDef (InputModel, handler) tuple form — - the same custom-tool shape anthropic/crewai/claude_sdk/langgraph take.""" - - @pytest.mark.asyncio - async def test_tuple_is_normalized_to_a_named_callable(self): - - class LookupInput(BaseModel): - """look up a code.""" - - key: str - - def lookup(args: LookupInput) -> str: - return f"code:{args.key}" - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="openai:gpt-5.4"), - additional_tools=[(LookupInput, lookup)], - ) - # Normalized to a native callable named from the model (not the handler). - assert [t.__name__ for t in adapter._custom_tools] == ["lookup"] - - def test_converted_tuple_flattens_in_pydantic_ai(self): - - class LookupInput(BaseModel): - """look up a code.""" - - key: str - - def lookup(args: LookupInput) -> str: - return f"code:{args.key}" - - (tool,) = _portable_tool_agent( - (LookupInput, lookup) - )._function_toolset.tools.values() - schema = tool.function_schema.json_schema - # pydantic-ai flattens the single model param into the tool's args. - assert tool.name == "lookup" - assert sorted((schema.get("properties") or {}).keys()) == ["key"] - - @pytest.mark.asyncio - async def test_async_handler_tuple_is_awaited_end_to_end(self): - """An async CustomToolDef handler returns its awaited value through a real - pydantic-ai run — not an unawaited coroutine (which the previous sync - passthrough produced, failing serialization).""" - - class LookupInput(BaseModel): - """look up a code.""" - - key: str - - async def lookup(args: LookupInput) -> str: - return f"code:{args.key}" - - (content,) = await _run_portable_tool((LookupInput, lookup)) - assert isinstance(content, str) - assert content.startswith("code:") - - @pytest.mark.asyncio - async def test_zero_arg_handler_tuple_runs_end_to_end(self): - """A zero-argument handler with an empty InputModel executes through a real - pydantic-ai run — the previous sync passthrough called it with one - positional arg and raised TypeError.""" - - class PingInput(BaseModel): - """ping.""" - - def ping() -> str: - return "pong" - - assert await _run_portable_tool((PingInput, ping)) == ["pong"] - - @pytest.mark.asyncio - async def test_aliased_input_model_runs_end_to_end(self): - """An InputModel using a field alias executes through a real pydantic-ai - run — a dump/re-validate round-trip would emit field names and fail - re-validation against the alias-only model.""" - - class AliasedInput(BaseModel): - """look up a user.""" - - user_id: str = Field(alias="userId") - - def lookup(args: AliasedInput) -> str: - return f"user:{args.user_id}" - - (content,) = await _run_portable_tool((AliasedInput, lookup)) - assert isinstance(content, str) - assert content.startswith("user:") - - @pytest.mark.asyncio - @pytest.mark.parametrize(("declare", "complete"), CUSTOM_TOOL_DECLARATIONS) - async def test_custom_tool_records_its_effect_on_the_room_turn( - self, declare: Callable[..., Any], complete: bool - ): - class FileInput(BaseModel): - """File the report.""" - - note: str - - @declare - def file_report(args: FileInput) -> str: - return "filed" - - tools = FakeAgentTools() - adapter = PydanticAIAdapter( - llm=_scripted_tool_calls(("file", {"note": "go"})), - additional_tools=[(FileInput, file_report)], - ) - adapter.agent_name = "TestBot" - - await adapter._create_agent().run("go", deps=tools) - - assert tools.turn.complete is complete +__all__ = [ + "instrument_all_restored", + "mock_pydantic_agent", + "mock_tools", + "sample_message", + "trace_capture", +] diff --git a/tests/e2e/baseline/README.md b/tests/e2e/baseline/README.md index e7569d5dd..420f82bbd 100644 --- a/tests/e2e/baseline/README.md +++ b/tests/e2e/baseline/README.md @@ -239,7 +239,7 @@ These three are why the toolkit is shaped the way it is — keep them when exten | `smoke/matrix/` | runs across the adapter matrix: `test_adapter_matrix.py`, `test_capability_matrix.py` (memory store + recall), `test_context_recall.py` (in-session + rejoin), `test_rehydration_offline.py` / `test_rehydration_partial.py` (cold-boot / partial-reboot `/context` recall), `test_rehydration_cross_framework.py` (a different-framework `peer=` authors, A rehydrates), `test_room_isolation.py`, `test_noisy_room.py`, `test_tool_round_trip.py` (custom-tool subgroup) | | `smoke/behavior/` | platform/transport + scenario behavior: `test_delivery_status.py`, `test_processing_barrier.py`, `test_isolation.py`, `test_duplicate_start.py` (the platform refuses a second start of a live agent id under `ConflictPolicy.REJECT`), `test_room_rejoin.py` (a Codex, Claude SDK or ACP agent removed from a room and re-added answers again), `test_agent_scenarios.py`, `test_approvals.py` (chat-mediated approve/decline/timeout for every coding agent; builds via `smoke/samples/approvals.py`) | | `smoke/inspection/` | `capture.*` observation worked-examples: `test_tool_calls.py`, `test_events.py`, `test_memory.py`, `test_usage.py` (the `Emit.USAGE` fan), plus `test_next_actionable_semantics.py` (a platform `/next` invariant, `@lane`-pinned since it runs no adapter) | -| `smoke/adapters/` | adapter-specific showcases: `test_a2a.py`, `test_a2a_gateway.py`, `test_a2a_roundtrip.py` (+ their shared `a2aServer.py` fixture -- a scripted, non-Band A2A counterparty), `test_agno.py`, `test_copilot_acp.py`, `test_copilot_sdk.py`, `test_crewai.py`, `test_letta.py`, `test_parlant.py` | +| `smoke/adapters/` | adapter-specific showcases: `test_a2a.py`, `test_a2a_gateway.py`, `test_a2a_roundtrip.py` (+ their shared `a2aServer.py` fixture -- a scripted, non-Band A2A counterparty), `test_agno.py`, `test_copilot_acp.py`, `copilotsdk/`, `test_crewai.py`, `test_letta.py`, `test_parlant.py` | The `toolkit/` modules are pytest-free and reusable anywhere. The package root (`settings`, `requires`, `agents`, `conftest`) is the pytest wiring. @@ -574,6 +574,8 @@ the `dev` extra but are split out for isolation. | Lane | `uv` extra | Adapters | Backend the CI job provides | |------|-----------|----------|------------------------------| +| `strands` | `dev-strands-nooa` | strands | OpenAI key; LangGraph foreign peer installed; Ubuntu and Windows | +| `nooa` | `dev-strands-nooa` | nooa | Anthropic key; Python 3.12–3.13 on POSIX; Linux-only E2E lane | | `core` | `dev` | anthropic, claude_sdk, agno, langgraph, pydantic_ai, copilot_sdk | provider keys (secrets); copilot_sdk self-downloads its CLI runtime and uses Anthropic BYOK without GitHub auth | | `crewai` | `dev-crewai` | crewai, crewai_flow | provider keys; isolated venv (crewai conflicts with `dev`'s deps — `pyproject.toml [tool.uv] conflicts`) | | `google` | `dev` | gemini, google_adk | provider keys; split from `core` so Google free-tier rate-limit flakiness is isolated | @@ -616,7 +618,9 @@ fail-loud. **Home ≠ hosting.** An adapter's *home* lane is where its single-framework cells run; *hosting* is which lanes' `uv` extra can actually install a framework (`hosting_lanes` in `toolkit/ci_lanes.py`: every `dev` lane hosts every `dev` framework; `dev-crewai` -hosts only the crewai stack). A test whose frameworks share one home lane runs there. +hosts only the crewai stack. `dev-strands-nooa` hosts Strands, NOOA on supported +platforms, and the explicitly declared LangGraph peer; it does not host other +core adapters. Strands and NOOA share dependencies but own separate E2E lanes). A test whose frameworks share one home lane runs there. A test whose frameworks span **more than one** home lane is hosted by no single job by default, so `assert_every_item_is_schedulable` (the same hook) **fails collection** for it — the fail-loud guard against a test that would silently skip in every lane (false @@ -695,10 +699,11 @@ nodeid. Excluded adapters get explicit N/A rows. active cell, or failure reddens the gate. The manifest lists supported OSes for each lane and explicit N/A reasons for environment-gated file cells. - **Update the manifest when intentional coverage changes.** Collect the default - extra, then union Parlant's structurally separate extra: + extra, then collect legacy and union Parlant's structurally separate extra: ```bash uv run python -m tests.e2e.baseline.scorecard manifest + uv run --locked --isolated --python 3.12 --extra dev-strands-nooa --all-packages python -m tests.e2e.baseline.scorecard manifest --merge-existing uv run --isolated --python 3.12 --extra dev-parlant --all-packages python -m tests.e2e.baseline.scorecard manifest --merge-existing ``` @@ -885,3 +890,21 @@ workflow emits no `letta × windows` cell — every other lane runs on both OSes `e2e_pending` (which once excluded Letta) is now a reason *string* (`str | None`), pinned to an allowlist — empty today — in `tests/framework_conformance/test_agent_wiring_rules.py`. + + +### Legacy OpenAI environments + +Run Strands and NOOA separately, using the same isolated dependency extra: + +```bash +E2E_TESTS_ENABLED=true BAND_E2E_LANE=strands uv run --locked --isolated --python 3.12 --all-packages --extra dev-strands-nooa pytest tests/e2e/baseline/ -v -s --no-cov -p no:rerunfailures +E2E_TESTS_ENABLED=true BAND_E2E_LANE=nooa uv run --locked --isolated --python 3.12 --all-packages --extra dev-strands-nooa pytest tests/e2e/baseline/ -v -s --no-cov -p no:rerunfailures +``` + +NOOA remains POSIX-only on Python 3.12–3.13; its CI E2E lane is Linux-only. +Strands runs on both CI operating systems. Core tests retain exact collection +of lazy legacy matrix cells even though those cells execute in their own lanes. +Only modules absent at import time (such as Parlant) need a manifest union. +The legacy cross-framework wrapper assigns Strands + LangGraph to the Strands +lane; `@per_adapter(lane=...)` filters adapter selection and `@lane(...)` assigns +execution. These are separate decisions. diff --git a/tests/e2e/baseline/agents.py b/tests/e2e/baseline/agents.py index 88f3228f4..65c756e53 100644 --- a/tests/e2e/baseline/agents.py +++ b/tests/e2e/baseline/agents.py @@ -78,7 +78,7 @@ def lane(lane_id: Lane) -> pytest.MarkDecorator: * a multi-framework test whose frameworks live in different home lanes (otherwise a collection error): names the one lane whose ``uv`` extra hosts all of them; * a *bespoke* smoke that builds its adapter by hand and requests no ``agent`` fixture - (e.g. ``smoke/adapters/test_copilot_sdk.py``, ``test_parlant.py``): it exposes no + (e.g. ``smoke/adapters/copilotsdk/``, ``test_parlant.py``): it exposes no framework to the selector, so absent this pin it would run in *every* lane. Here ``@with_adapters`` is not an option — the wiring guard requires it to provision. """ diff --git a/tests/e2e/baseline/conftest.py b/tests/e2e/baseline/conftest.py index 09084ecbc..9a93daad6 100644 --- a/tests/e2e/baseline/conftest.py +++ b/tests/e2e/baseline/conftest.py @@ -28,6 +28,7 @@ peer, ) from tests.e2e.baseline.fixtures.capture import judge, reply_capture +from tests.e2e.baseline.fixtures.diagnostics import diagnostic_logs from tests.e2e.baseline.fixtures.platform import ( baseline_run_id, baseline_settings, @@ -59,6 +60,7 @@ "baseline_user_client", "baseline_ws", "cell", + "diagnostic_logs", "judge", "orphan_sweep", "peer", diff --git a/tests/e2e/baseline/expected-cells.json b/tests/e2e/baseline/expected-cells.json index 3e3780722..0346342db 100644 --- a/tests/e2e/baseline/expected-cells.json +++ b/tests/e2e/baseline/expected-cells.json @@ -25,6 +25,10 @@ "parlant": [ "ubuntu", "windows" + ], + "strands": [ + "ubuntu", + "windows" ] }, "cells": [ @@ -38,7 +42,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -53,7 +58,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -68,7 +74,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -83,7 +90,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -98,7 +106,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -113,7 +122,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -128,7 +138,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -143,7 +154,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -158,7 +170,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -173,7 +186,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -188,7 +202,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -203,7 +218,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -218,7 +234,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -233,7 +250,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -248,7 +266,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -263,7 +282,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -278,7 +298,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -293,7 +314,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -308,7 +330,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -323,7 +346,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -338,7 +362,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -353,7 +378,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -368,7 +394,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -383,7 +410,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -398,7 +426,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -413,7 +442,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -428,7 +458,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -443,7 +474,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -458,7 +490,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -473,7 +506,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -488,7 +522,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -503,7 +538,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -518,16 +554,17 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/guards/test_strands_provider.py::test_strands_builder_executes_a_responses_tool_once", + "test": "tests/e2e/baseline/guards/test_strands_provider.py::TestStrandsProvider::test_strands_builder_executes_a_responses_tool_once", "adapter": "suite", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -542,7 +579,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -557,7 +595,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -572,7 +611,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -587,7 +627,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -602,7 +643,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -617,7 +659,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -632,7 +675,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -647,13 +691,14 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_a2a.py::test_a2a_adapter_relays_a_real_counterparty_reply", + "test": "tests/e2e/baseline/smoke/adapters/copilotsdk/test_handler.py::TestAskUserHandler::test_copilot_ask_user_handler_round_trips_to_room_reply", "adapter": "suite", "lanes": [ "core" @@ -662,7 +707,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_a2a.py::test_a2a_adapter_surfaces_a_remote_task_failure", + "test": "tests/e2e/baseline/smoke/adapters/copilotsdk/test_resume.py::TestResumeMiss::test_copilot_recall_via_injected_history_when_resume_misses", "adapter": "suite", "lanes": [ "core" @@ -671,7 +716,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_a2a_gateway.py::test_gateway_serves_a_real_a2a_client", + "test": "tests/e2e/baseline/smoke/adapters/copilotsdk/test_roomquestion.py::TestRoomQuestion::test_copilot_ask_user_room_question_answered_by_next_message", "adapter": "suite", "lanes": [ "core" @@ -680,7 +725,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_a2a_roundtrip.py::test_band_to_band_round_trip_over_real_a2a", + "test": "tests/e2e/baseline/smoke/adapters/copilotsdk/test_sharedclient.py::TestSharedClient::test_copilot_shared_client_across_adapter_lifecycles", "adapter": "suite", "lanes": [ "core" @@ -689,7 +734,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_agno.py::test_agno_executes_native_tool", + "test": "tests/e2e/baseline/smoke/adapters/test_a2a.py::test_a2a_adapter_relays_a_real_counterparty_reply", "adapter": "suite", "lanes": [ "core" @@ -698,7 +743,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_agno.py::test_agno_handles_multiple_native_tools", + "test": "tests/e2e/baseline/smoke/adapters/test_a2a.py::test_a2a_adapter_surfaces_a_remote_task_failure", "adapter": "suite", "lanes": [ "core" @@ -707,43 +752,43 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_shell_approval_is_required[approve]", + "test": "tests/e2e/baseline/smoke/adapters/test_a2a_gateway.py::test_gateway_serves_a_real_a2a_client", "adapter": "suite", "lanes": [ - "backends" + "core" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_shell_approval_is_required[decline]", + "test": "tests/e2e/baseline/smoke/adapters/test_a2a_roundtrip.py::test_band_to_band_round_trip_over_real_a2a", "adapter": "suite", "lanes": [ - "backends" + "core" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_thoughts_are_not_placeholders", + "test": "tests/e2e/baseline/smoke/adapters/test_agno.py::test_agno_executes_native_tool", "adapter": "suite", "lanes": [ - "backends" + "core" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_band_tool_call_is_narrated", + "test": "tests/e2e/baseline/smoke/adapters/test_agno.py::test_agno_handles_multiple_native_tools", "adapter": "suite", "lanes": [ - "backends" + "core" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_band_tool_result_is_a_single_clean_payload", + "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_shell_approval_is_required[approve]", "adapter": "suite", "lanes": [ "backends" @@ -752,7 +797,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_recall_via_room_replay_when_session_load_misses", + "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_shell_approval_is_required[decline]", "adapter": "suite", "lanes": [ "backends" @@ -761,7 +806,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_hosted_auth_replies[default]", + "test": "tests/e2e/baseline/smoke/adapters/test_codex.py::test_codex_thoughts_are_not_placeholders", "adapter": "suite", "lanes": [ "backends" @@ -770,7 +815,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_hosted_auth_replies[typed-effort]", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_band_tool_call_is_narrated", "adapter": "suite", "lanes": [ "backends" @@ -779,7 +824,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_switches_model_and_effort_in_a_live_room", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_band_tool_result_is_a_single_clean_payload", "adapter": "suite", "lanes": [ "backends" @@ -788,7 +833,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_turn_fails_loudly_on_an_unadvertised_selection[byok-model]", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_acp_recall_via_room_replay_when_session_load_misses", "adapter": "suite", "lanes": [ "backends" @@ -797,7 +842,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_turn_fails_loudly_on_an_unadvertised_selection[hosted-effort]", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_hosted_auth_replies[default]", "adapter": "suite", "lanes": [ "backends" @@ -806,37 +851,37 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py::test_copilot_ask_user_handler_round_trips_to_room_reply", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_hosted_auth_replies[typed-effort]", "adapter": "suite", "lanes": [ - "core" + "backends" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py::test_copilot_ask_user_room_question_answered_by_next_message", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_switches_model_and_effort_in_a_live_room", "adapter": "suite", "lanes": [ - "core" + "backends" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py::test_copilot_recall_via_injected_history_when_resume_misses", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_turn_fails_loudly_on_an_unadvertised_selection[byok-model]", "adapter": "suite", "lanes": [ - "core" + "backends" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py::test_copilot_shared_client_across_adapter_lifecycles", + "test": "tests/e2e/baseline/smoke/adapters/test_copilot_acp.py::test_copilot_turn_fails_loudly_on_an_unadvertised_selection[hosted-effort]", "adapter": "suite", "lanes": [ - "core" + "backends" ], "status": "pass", "reason": null @@ -1238,7 +1283,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -1289,7 +1335,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -1520,7 +1567,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -1535,7 +1583,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "pass", "reason": null @@ -1712,7 +1761,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -1736,7 +1786,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "deferred: cumulative-lifetime counter, not per-turn" @@ -1751,7 +1802,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "usage lives in user-supplied flow internals" @@ -1766,7 +1818,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -1826,7 +1879,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -1853,7 +1907,7 @@ "test": "tests/e2e/baseline/smoke/inspection/test_usage.py::test_usage_not_cumulative_across_turns", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -1904,7 +1958,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -1928,7 +1983,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "deferred: cumulative-lifetime counter, not per-turn" @@ -1943,7 +1999,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "usage lives in user-supplied flow internals" @@ -1958,7 +2015,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -2018,7 +2076,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -2045,7 +2104,7 @@ "test": "tests/e2e/baseline/smoke/inspection/test_usage.py::test_usage_recorded_for_a_turn", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -2207,7 +2266,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_adapter_matrix.py::test_build_adapter_constructs_each", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -2369,7 +2428,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_adapter_matrix.py::test_per_adapter_replies", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -2447,7 +2506,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot span-recall an early fact" @@ -2537,7 +2597,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_burst_recall.py::test_burst_handled_then_spanning_recall", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -2552,7 +2612,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2567,7 +2628,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2582,7 +2644,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2597,7 +2660,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2612,7 +2676,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2627,7 +2692,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2642,7 +2708,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2657,7 +2724,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2672,7 +2740,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2687,7 +2756,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2702,7 +2772,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2717,7 +2788,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2732,7 +2804,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2747,7 +2820,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2762,7 +2836,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2777,7 +2852,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2792,7 +2868,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2807,7 +2884,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2822,7 +2900,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2837,7 +2916,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2852,7 +2932,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2867,7 +2948,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2882,7 +2964,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2897,7 +2980,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2912,7 +2996,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2927,7 +3012,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2942,7 +3028,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2957,7 +3044,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2972,7 +3060,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -2987,7 +3076,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -3002,7 +3092,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -3017,7 +3108,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -3032,7 +3124,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "E2E_FILE_TRANSFER is not true (ff_file_transfer is on-prem-only)" @@ -3167,7 +3260,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_capability_matrix.py::test_list_contacts_across_contacts_adapters", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3302,7 +3395,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_capability_matrix.py::test_memory_survives_adapter_rehydration", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3437,7 +3530,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_capability_matrix.py::test_recall_memory_across_memory_adapters", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3599,7 +3692,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_capability_matrix.py::test_store_memory_across_memory_adapters", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3734,7 +3827,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_capability_matrix.py::test_task_lifecycle_across_task_adapters", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3857,7 +3950,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "global-by-name MCP tools collide across concurrent same-adapter instances (see module docstring)" @@ -3902,7 +3996,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_concurrent_instances.py::test_concurrent_same_adapter_instances_each_reply", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -3944,7 +4038,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -3959,7 +4054,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own ACP session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -3992,7 +4088,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot recall across a rejoin" @@ -4061,7 +4158,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own ACP session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -4076,7 +4174,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -4094,7 +4193,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_context_recall.py::test_recalls_after_rejoin", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4172,7 +4271,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot recall a note from an earlier turn" @@ -4262,7 +4362,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_context_recall.py::test_recalls_within_session", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4361,7 +4461,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_create_chatroom.py::test_agent_creates_a_chatroom", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4460,7 +4560,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_custom_prompt.py::test_custom_prompt_takes_effect_and_coexists_with_platform_tools", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4559,7 +4659,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_identity_and_roster.py::test_reports_identity_and_roster", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4658,7 +4758,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_identity_and_roster.py::test_reports_peer_description_from_passive_roster", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4820,7 +4920,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_loop_suppression.py::test_peer_message_drives_turn_without_loop", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -4844,7 +4944,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ignores exclude_tools, so band_no_reply stays callable" @@ -4859,7 +4960,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ignores exclude_tools, so band_no_reply stays callable" @@ -4874,7 +4976,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "relays the model's final text, so an empty turn can't be scripted reliably" @@ -4898,7 +5001,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ignores exclude_tools, so band_no_reply stays callable" @@ -4913,7 +5017,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ignores exclude_tools, so band_no_reply stays callable" @@ -4949,7 +5054,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_missing_reply_reported.py::test_a_turn_that_did_nothing_is_reported_and_failed", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5084,7 +5189,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_no_reply.py::test_an_fyi_ends_the_turn_through_band_no_reply", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5198,7 +5303,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot recall a seeded fact" @@ -5288,7 +5394,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_noisy_room.py::test_recall_and_ignore_crosstalk_in_busy_room", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5387,7 +5493,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_participant_management.py::test_invites_messages_and_removes_a_peer", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5486,13 +5592,13 @@ "test": "tests/e2e/baseline/smoke/matrix/test_peer_delegation.py::test_peer_initiated_delegation_with_self_recall", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "agno", "lanes": [ "core" @@ -5501,7 +5607,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "anthropic", "lanes": [ "core" @@ -5510,7 +5616,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "claude_sdk", "lanes": [ "core" @@ -5519,7 +5625,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "copilot_sdk", "lanes": [ "core" @@ -5528,7 +5634,7 @@ "reason": null }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "langgraph", "lanes": [ "backends", @@ -5537,13 +5643,14 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "the fixed foreign peer here, so it can't also be the fanned cell" }, { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", + "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::TestForeignPeerRehydration::test_rehydrates_foreign_peer_message", "adapter": "pydantic_ai", "lanes": [ "core" @@ -5551,15 +5658,6 @@ "status": "pass", "reason": null }, - { - "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py::test_rehydrates_foreign_peer_message", - "adapter": "strands", - "lanes": [ - "core" - ], - "status": "pass", - "reason": null - }, { "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py::test_handled_work_not_redrained_on_restart", "adapter": "agno", @@ -5654,7 +5752,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py::test_handled_work_not_redrained_on_restart", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5696,7 +5794,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -5720,7 +5819,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "deferred: cumulative-lifetime counter, not per-turn" @@ -5735,7 +5835,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "usage lives in user-supplied flow internals" @@ -5750,7 +5851,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -5801,7 +5903,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "ACP exposes no per-turn token-usage updates" @@ -5819,7 +5922,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_idempotency.py::test_restart_usage_splits_replay_and_inference", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -5861,7 +5964,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -5975,7 +6079,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -5993,7 +6098,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_offline.py::test_recalls_offline_note_on_cold_boot", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -6035,7 +6140,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -6077,7 +6183,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot rehydrate context across a reboot" @@ -6155,7 +6262,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "recovers context by resuming its own backend session, not via platform /context \u2014 a pass would not validate this rehydration" @@ -6173,7 +6281,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_rehydration_partial.py::test_partial_reboot_preserves_context_and_peer", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -6251,7 +6359,8 @@ "google", "letta", "nooa", - "parlant" + "parlant", + "strands" ], "status": "na", "reason": "terminal echo flow with no memory \u2014 cannot recall a per-room note" @@ -6341,7 +6450,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_room_isolation.py::test_rooms_keep_isolated_context", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null @@ -6391,6 +6500,15 @@ "status": "pass", "reason": null }, + { + "test": "tests/e2e/baseline/smoke/matrix/test_strands_rehydration.py::TestStrandsForeignPeerRehydration::test_rehydrates_foreign_peer_message", + "adapter": "strands", + "lanes": [ + "strands" + ], + "status": "pass", + "reason": null + }, { "test": "tests/e2e/baseline/smoke/matrix/test_tool_round_trip.py::test_custom_tool_round_trips", "adapter": "agno", @@ -6485,7 +6603,7 @@ "test": "tests/e2e/baseline/smoke/matrix/test_tool_round_trip.py::test_custom_tool_round_trips", "adapter": "strands", "lanes": [ - "core" + "strands" ], "status": "pass", "reason": null diff --git a/tests/e2e/baseline/fixtures/diagnostics.py b/tests/e2e/baseline/fixtures/diagnostics.py new file mode 100644 index 000000000..7ce23e9a5 --- /dev/null +++ b/tests/e2e/baseline/fixtures/diagnostics.py @@ -0,0 +1,23 @@ +"""Content-free integration logs for explicitly requested diagnostic runs.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import ExitStack + +import pytest + +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.logs import sdk_logs_at + +DIAGNOSTIC_PACKAGES = ("band.integrations.parlant", "band.adapters.parlant") + + +@pytest.fixture(autouse=True) +def diagnostic_logs(baseline_settings: BaselineSettings) -> Iterator[None]: + with ExitStack() as stack: + if baseline_settings.run.first_attempt_diagnostics: + for package in DIAGNOSTIC_PACKAGES: + stack.enter_context(sdk_logs_at(package, logging.INFO)) + yield diff --git a/tests/e2e/baseline/guards/test_strands_provider.py b/tests/e2e/baseline/guards/test_strands_provider.py index 25e383346..d204da6ef 100644 --- a/tests/e2e/baseline/guards/test_strands_provider.py +++ b/tests/e2e/baseline/guards/test_strands_provider.py @@ -13,59 +13,59 @@ from tests.baseline.adapter import Adapter from tests.e2e.baseline.agents import lane from tests.e2e.baseline.settings import BaselineSettings, LLMCredentials, LLMModels +from tests.e2e.baseline.smoke.samples.provider import RecordInput from tests.e2e.baseline.toolkit.adapters import build_adapter from tests.e2e.baseline.toolkit.deps import Lane from tests.e2e.baseline.toolkit.tools import ToolSpec from tests.framework_conformance.turnprobes import turn_input -class RecordInput(BaseModel): - """Record a note.""" +class TestStrandsProvider: + @pytest.mark.asyncio + @lane(Lane.STRANDS) + async def test_strands_builder_executes_a_responses_tool_once( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + from band.testing import ( # noqa: PLC0415 -- Strands is absent from the crewai/parlant lanes + TextTurn, + ToolTurn, + ) + from tests.responsespeer import ( # noqa: PLC0415 -- this HTTP peer uses Strands turn fixtures + ResponsesPeer, + ) - note: str + ledger = tmp_path / "notes.txt" + @declares_turn_effect(TurnEffect.ACT) + def record(args: BaseModel) -> str: + assert isinstance(args, RecordInput) + with ledger.open("a", encoding="utf-8") as notes: + notes.write(args.note + "\n") + return "recorded" -@pytest.mark.asyncio -@lane(Lane.CORE) -async def test_strands_builder_executes_a_responses_tool_once( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - from band.testing import ( # noqa: PLC0415 -- Strands is absent from the crewai/parlant lanes - TextTurn, - ToolTurn, - ) - from tests.responsespeer import ( # noqa: PLC0415 -- this HTTP peer uses Strands turn fixtures - ResponsesPeer, - ) + tool = ToolSpec(RecordInput, record) + peer = ResponsesPeer([ToolTurn(tool.name, {"note": "hello"}), TextTurn("")]) + monkeypatch.setattr("openai.AsyncOpenAI", peer.client) + settings = BaselineSettings( + llm_credentials=LLMCredentials(openai_api_key="test"), + llm_models=LLMModels(E2E_LLM_MODEL="configured-model"), + ) + adapter = build_adapter(Adapter.STRANDS, settings, tools=[tool]) + await adapter.on_started("Bot", "A bot") + tools = FakeAgentTools() - ledger = tmp_path / "notes.txt" + await adapter.on_event(turn_input(tools)) - @declares_turn_effect(TurnEffect.ACT) - def record(args: BaseModel) -> str: - assert isinstance(args, RecordInput) - with ledger.open("a", encoding="utf-8") as notes: - notes.write(args.note + "\n") - return "recorded" - - tool = ToolSpec(RecordInput, record) - peer = ResponsesPeer([ToolTurn(tool.name, {"note": "hello"}), TextTurn("")]) - monkeypatch.setattr("openai.AsyncOpenAI", peer.client) - settings = BaselineSettings( - llm_credentials=LLMCredentials(openai_api_key="test"), - llm_models=LLMModels(E2E_LLM_MODEL="configured-model"), - ) - adapter = build_adapter(Adapter.STRANDS, settings, tools=[tool]) - await adapter.on_started("Bot", "A bot") - tools = FakeAgentTools() - - await adapter.on_event(turn_input(tools)) - - assert peer.paths == ["/v1/responses", "/v1/responses"] - assert all(request["model"] == "configured-model" for request in peer.requests) - assert all(request["store"] is False for request in peer.requests) - assert all("previous_response_id" not in request for request in peer.requests) - assert peer.tool_outputs(1) == [ - {"type": "function_call_output", "call_id": peer.CALL_ID, "output": "recorded"} - ] - assert ledger.read_text(encoding="utf-8") == "hello\n" - assert tools.turn.complete + assert peer.paths == ["/v1/responses", "/v1/responses"] + assert all(request["model"] == "configured-model" for request in peer.requests) + assert all(request["store"] is False for request in peer.requests) + assert all("previous_response_id" not in request for request in peer.requests) + assert peer.tool_outputs(1) == [ + { + "type": "function_call_output", + "call_id": peer.CALL_ID, + "output": "recorded", + } + ] + assert ledger.read_text(encoding="utf-8") == "hello\n" + assert tools.turn.complete diff --git a/tests/e2e/baseline/lane_selection.py b/tests/e2e/baseline/lane_selection.py index 290620bd6..f1d98cad1 100644 --- a/tests/e2e/baseline/lane_selection.py +++ b/tests/e2e/baseline/lane_selection.py @@ -199,7 +199,7 @@ def assert_every_item_is_schedulable(items: list[pytest.Item]) -> None: homes = _home_lanes(item, lane_of) override = _override_lane(item) if override is not None: - hosts = hosting_lanes(homes) + hosts = hosting_lanes(item_frameworks(item)) if override not in hosts: offenders.append( f"{item.nodeid} pins @lane({override!r}) but no lane hosts all its " @@ -208,7 +208,7 @@ def assert_every_item_is_schedulable(items: list[pytest.Item]) -> None: continue if len(homes) <= 1: continue # single home lane (or adapter-agnostic) — always schedulable - hosts = hosting_lanes(homes) + hosts = hosting_lanes(item_frameworks(item)) if hosts: offenders.append( f"{item.nodeid} spans home lanes {sorted(homes)} but shares an extra; " diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/__init__.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/conftest.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/conftest.py new file mode 100644 index 000000000..2022c28b1 --- /dev/null +++ b/tests/e2e/baseline/smoke/adapters/copilotsdk/conftest.py @@ -0,0 +1,27 @@ +"""Test-owned Copilot homes for bespoke native-runtime scenarios.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import partial +from pathlib import Path + +import pytest + +from band.adapters.copilot_sdk import CopilotSDKAdapter +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.builders import ( + build_copilot_sdk_adapter, + copilot_home_dir, +) + + +@pytest.fixture +def copilot_adapter( + baseline_settings: BaselineSettings, tmp_path: Path +) -> Callable[..., CopilotSDKAdapter]: + return partial( + build_copilot_sdk_adapter, + baseline_settings, + base_directory=copilot_home_dir(str(tmp_path)), + ) diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/test_handler.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_handler.py new file mode 100644 index 000000000..97b89f1ec --- /dev/null +++ b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_handler.py @@ -0,0 +1,101 @@ +"""Isolated Copilot SDK live behavior.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from band.adapters.copilot_sdk import ( + _COPILOT_SDK_AVAILABLE, + CopilotSDKAdapter, +) +from tests.e2e.baseline.agents import Lane, lane +from tests.e2e.baseline.flaky import flaky_infra +from tests.e2e.baseline.requires import Dep, requires +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.sample_agents import unique_marker +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import ( + ResourceManager, + running_provisioned_agent, +) +from tests.e2e.baseline.toolkit.user_ops import UserOps + +pytestmark = pytest.mark.skipif( + not _COPILOT_SDK_AVAILABLE, + reason="github-copilot-sdk not installed (pip install band-sdk[copilot_sdk])", +) + + +class TestAskUserHandler: + @lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core + @requires(Dep.ANTHROPIC) + @flaky_infra( + "Copilot runtime boot + ask_user double round-trip can time out transiently" + ) + # ask_user adds a second model round trip (ask -> handler answers -> final + # reply) on top of Copilot's own runtime boot. + @pytest.mark.timeout(extra=120) + @pytest.mark.asyncio(loop_scope="session") + async def test_copilot_ask_user_handler_round_trips_to_room_reply( + self, + copilot_adapter: Callable[..., CopilotSDKAdapter], + baseline_settings: BaselineSettings, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + ) -> None: + """``ask_user=`` pauses the turn, a programmatic operator answers. + + The operator's answer is a high-entropy token the user never utters, so the + reply containing it proves the answer flowed through the handler — not + model invention. Guards the adapter's ``ask_user`` allowlisting and handler + forwarding (without either, the model cannot ask and the handler never + fires). + """ + + operator_channel = unique_marker("channel") + asked: list[dict[str, Any]] = [] + + async def fake_operator( + request: dict[str, Any], context: dict[str, str] + ) -> dict[str, Any]: + asked.append(dict(request)) + return {"answer": operator_channel, "wasFreeform": True} + + adapter = copilot_adapter( + ask_user=fake_operator, + custom_section=( + "Keep responses short and concise. A human operator is " + "available through the ask_user tool for decisions you " + "cannot make alone." + ), + ) + + async with running_provisioned_agent( + adapter, resource_manager, label="copilot-ask-user" + ) as agent: + room_id = await resource_manager.provision_room( + title="e2e-copilot-ask-user", participants=[agent.id] + ) + async with reply_capture(room_id) as capture: + mid = await user_ops.send_message( + room_id, + "I need to deploy release v2. Use the ask_user tool to ask " + "your operator which channel to deploy to and wait for my " + "response before continuing. Then reply stating exactly " + "the channel the operator chose.", + mention_id=agent.id, + mention_name=agent.name, + ) + replies = await capture.wait_for_reply( + mid, agent.id, deadline_s=baseline_settings.e2e_timeout * 2 + ) + + assert asked, "the model never called ask_user (handler did not fire)" + assert asked[0].get("question"), f"ask_user carried no question: {asked[0]!r}" + # Only the operator handler can supply this token; the reply containing it + # proves the answer round-tripped through the turn. + replies.assert_contains_any([operator_channel]) diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/test_resume.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_resume.py new file mode 100644 index 000000000..434f5c1e8 --- /dev/null +++ b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_resume.py @@ -0,0 +1,119 @@ +"""Isolated Copilot SDK live behavior.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from band.adapters.copilot_sdk import ( + _COPILOT_SDK_AVAILABLE, + CopilotSDKAdapter, +) +from tests.e2e.baseline.agents import Lane, lane +from tests.e2e.baseline.flaky import flaky_infra +from tests.e2e.baseline.requires import Dep, requires +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.sample_agents import unique_marker +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import ( + ResourceManager, + running_agent, +) +from tests.e2e.baseline.toolkit.user_ops import UserOps + +pytestmark = pytest.mark.skipif( + not _COPILOT_SDK_AVAILABLE, + reason="github-copilot-sdk not installed (pip install band-sdk[copilot_sdk])", +) + + +class TestResumeMiss: + @lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core + @requires(Dep.ANTHROPIC) + @flaky_infra( + "two fresh Copilot runtime boots (resume-miss setup) can time out transiently" + ) + # Two agent lifecycles, each booting a fresh Copilot runtime into an empty + # base_directory (the resume-miss setup) — the heaviest boot path in this file. + @pytest.mark.timeout(extra=180) + @pytest.mark.asyncio(loop_scope="session") + async def test_copilot_recall_via_injected_history_when_resume_misses( + self, + copilot_adapter: Callable[..., CopilotSDKAdapter], + baseline_settings: BaselineSettings, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + tmp_path: Any, + ) -> None: + """Recall must survive a restart that invalidates Copilot's *native* resume. + + A rejoin against the same on-disk state (a plain stop/restart) would let + Copilot's own session resume answer for free. This test gives each phase a + fresh ``base_directory``, so phase 2's resume finds no state and recall + must flow through the converter's injected text history instead. Two facts + are asserted after the restart: a tracking marker the USER stated (plain + injected-history recall), and a calibration answer the AGENT produced in + phase 1 — the user never utters that answer, so the agent's own injected + replies are its only possible source (the regression case for one-sided + injected history). + """ + + tracking_marker = unique_marker("marker") + agent_fact = "blue" + + def make_adapter(phase: str) -> CopilotSDKAdapter: + # A fresh base_directory per phase: phase 2 has no on-disk session + # state, so Copilot's native resume cannot help. + return copilot_adapter(base_directory=str(tmp_path / phase)) + + identity = await resource_manager.provision_agent("copilot-resume-miss") + room_id = await resource_manager.provision_room( + title="e2e-copilot-resume-miss", participants=[identity.id] + ) + + # Phase 1: seed a user fact and make the agent produce its own fact. + async with ( + running_agent(identity, make_adapter("phase1"), baseline_settings), + reply_capture(room_id) as capture, + ): + mid = await user_ops.send_message( + room_id, + "Create a short project log note for later reference. The " + f"tracking marker is {tracking_marker}. Also answer this " + "calibration question inside your reply: what color is a " + "clear daytime sky? Reply in one short sentence that includes " + "the tracking marker and the color answer.", + mention_id=identity.id, + mention_name=identity.name, + ) + replies = await capture.wait_for_reply( + mid, identity.id, deadline_s=baseline_settings.e2e_timeout + ) + replies.assert_contains_any([tracking_marker]) + # The user never uttered this answer; it must come from the + # agent's phase-1 reply when phase 2 reconstructs history. + replies.assert_contains_any([agent_fact]) + + # Phase 2: fresh process state AND fresh Copilot state directory — recall + # can only come from the platform history the adapter injects. + async with ( + running_agent(identity, make_adapter("phase2"), baseline_settings), + reply_capture(room_id) as capture, + ): + mid = await user_ops.send_message( + room_id, + "From the earlier project log, what was the tracking marker " + "and what color answer did you give? Reply with both.", + mention_id=identity.id, + mention_name=identity.name, + ) + replies = await capture.wait_for_reply( + mid, identity.id, deadline_s=baseline_settings.e2e_timeout + ) + replies.assert_contains_any([tracking_marker]) + # The user never uttered this answer — only the agent's own + # injected phase-1 reply can supply it. + replies.assert_contains_any([agent_fact]) diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/test_roomquestion.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_roomquestion.py new file mode 100644 index 000000000..bf302ac10 --- /dev/null +++ b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_roomquestion.py @@ -0,0 +1,107 @@ +"""Isolated Copilot SDK live behavior.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from band.adapters.copilot_sdk import ( + _COPILOT_SDK_AVAILABLE, + ASK_USER_ROOM, + CopilotSDKAdapter, +) +from tests.e2e.baseline.agents import Lane, lane +from tests.e2e.baseline.flaky import flaky_infra +from tests.e2e.baseline.requires import Dep, requires +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.sample_agents import unique_marker +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import ( + ResourceManager, + running_provisioned_agent, +) +from tests.e2e.baseline.toolkit.user_ops import UserOps + +pytestmark = pytest.mark.skipif( + not _COPILOT_SDK_AVAILABLE, + reason="github-copilot-sdk not installed (pip install band-sdk[copilot_sdk])", +) + + +class TestRoomQuestion: + @lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core + @requires(Dep.ANTHROPIC) + @flaky_infra( + "two full live turns plus Copilot runtime boot can time out transiently" + ) + @pytest.mark.timeout(extra=180) # two full turns (question turn + answer turn) + @pytest.mark.asyncio(loop_scope="session") + async def test_copilot_ask_user_room_question_answered_by_next_message( + self, + copilot_adapter: Callable[..., CopilotSDKAdapter], + baseline_settings: BaselineSettings, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + ) -> None: + """``ask_user="room"`` posts the question into the room and ends the turn; + the user's next room message answers it on the same persisted session. + + The answer is a high-entropy token never uttered before the answer, so a + reply containing it proves the answer flowed through the room round trip — + not model invention. + """ + + secret_channel = unique_marker("channel") + adapter = copilot_adapter( + ask_user=ASK_USER_ROOM, + custom_section=( + "Keep responses short and concise. Never invent answers to " + "questions you asked — wait for the user." + ), + ) + + async with running_provisioned_agent( + adapter, resource_manager, label="copilot-ask-user-room" + ) as agent: + room_id = await resource_manager.provision_room( + title="e2e-copilot-ask-user-room", participants=[agent.id] + ) + async with reply_capture(room_id) as capture: + # Turn 1: the trigger makes the model call ask_user; the question + # must land in the room, not anywhere else. + mark = capture.messages.snapshot() + mid = await user_ops.send_message( + room_id, + "Use the ask_user tool to ask me which channel to deploy " + "release v2 to. After I answer, reply stating exactly the " + "channel I chose.", + mention_id=agent.id, + mention_name=agent.name, + ) + replies = await capture.wait_for_reply( + mid, + agent.id, + since=mark, + deadline_s=baseline_settings.e2e_timeout * 2, + ) + replies.assert_contains_any(["channel"]) + + # Turn 2: the user's next room message is the answer. + mark = capture.messages.snapshot() + mid = await user_ops.send_message( + room_id, + f"Deploy to the {secret_channel} channel.", + mention_id=agent.id, + mention_name=agent.name, + ) + replies = await capture.wait_for_reply( + mid, + agent.id, + since=mark, + deadline_s=baseline_settings.e2e_timeout * 2, + ) + # Only the answer message contains this token; the reply relaying + # it proves the question/answer round-tripped through the room. + replies.assert_contains_any([secret_channel]) diff --git a/tests/e2e/baseline/smoke/adapters/copilotsdk/test_sharedclient.py b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_sharedclient.py new file mode 100644 index 000000000..b0e96ba1e --- /dev/null +++ b/tests/e2e/baseline/smoke/adapters/copilotsdk/test_sharedclient.py @@ -0,0 +1,112 @@ +"""Isolated Copilot SDK live behavior.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from band.adapters.copilot_sdk import ( + _COPILOT_SDK_AVAILABLE, + CopilotSDKAdapter, +) +from tests.e2e.baseline.agents import Lane, lane +from tests.e2e.baseline.flaky import flaky_infra +from tests.e2e.baseline.requires import Dep, requires +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.toolkit.builders import copilot_home_dir +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import ( + ResourceManager, + running_agent, +) +from tests.e2e.baseline.toolkit.user_ops import UserOps + +pytestmark = pytest.mark.skipif( + not _COPILOT_SDK_AVAILABLE, + reason="github-copilot-sdk not installed (pip install band-sdk[copilot_sdk])", +) + + +class TestSharedClient: + @lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core + @requires(Dep.ANTHROPIC) + @flaky_infra( + "several live turns across two adapter lifecycles can time out transiently" + ) + # Two sequential adapter lifecycles on one shared runtime, the first serving + # two concurrent room sessions — several live turns, so widen the outer budget. + @pytest.mark.timeout(extra=180) + @pytest.mark.asyncio(loop_scope="session") + async def test_copilot_shared_client_across_adapter_lifecycles( + self, + copilot_adapter: Callable[..., CopilotSDKAdapter], + baseline_settings: BaselineSettings, + tmp_path: Path, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + ) -> None: + """Proves the Copilot SDK's one-client/many-sessions model through the + adapter against the live platform: + + 1. one ``CopilotClient`` is created by the test (the owner); + 2. an adapter *borrows* it and serves TWO rooms with turns in flight + CONCURRENTLY — two isolated parallel sessions on one runtime; + 3. the first adapter shuts down and a successor adapter reuses the same + still-running client — the borrowed client must survive an adapter's + full cleanup (``owns_client=False`` contract). + """ + from copilot import ( # noqa: PLC0415 -- copilot_sdk extra; file collects even when absent, skipped via _COPILOT_SDK_AVAILABLE at test time + CopilotClient, + ) + + identity = await resource_manager.provision_agent("copilot-shared-client") + room_a = await resource_manager.provision_room( + title="e2e-copilot-shared-a", participants=[identity.id] + ) + room_b = await resource_manager.provision_room( + title="e2e-copilot-shared-b", participants=[identity.id] + ) + + async def smoke(room_id: str, *, prompt: str = "Please say hello.") -> None: + async with reply_capture(room_id) as capture: + mid = await user_ops.send_message( + room_id, + prompt, + mention_id=identity.id, + mention_name=identity.name, + ) + replies = await capture.wait_for_reply( + mid, identity.id, deadline_s=baseline_settings.e2e_timeout + ) + replies.assert_present(what="a copilot_sdk[shared-client] reply") + + def make_shared_adapter(client: Any) -> CopilotSDKAdapter: + return copilot_adapter(client=client) + + # The test owns the client; adapters only borrow it. + client = CopilotClient( + use_logged_in_user=False, base_directory=copilot_home_dir(str(tmp_path)) + ) + try: + async with running_agent( + identity, make_shared_adapter(client), baseline_settings + ): + # Two rooms with turns in flight AT THE SAME TIME — parallel + # sessions on one runtime, serialized only per room. + await asyncio.gather(smoke(room_a), smoke(room_b)) + + # First adapter fully cleaned up; the borrowed client must have + # survived — a successor adapter reuses it without a restart. + async with running_agent( + identity, make_shared_adapter(client), baseline_settings + ): + # A new request: the resumed session already answered the first, + # and a repeat of it may rightly be declined with band_no_reply. + await smoke(room_a, prompt="Please say goodbye.") + finally: + await client.stop() diff --git a/tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py b/tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py deleted file mode 100644 index 3524c09db..000000000 --- a/tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Copilot SDK showcase smokes — the toolkit driving the Copilot adapter live. - -Copilot SDK is a ``core``-lane matrix adapter gated only on the Anthropic BYOK -key (the singular provider replaces Copilot-hosted inference, so GitHub auth is -not required; see ``toolkit/builders.py``). The generic matrix (``smoke/matrix/``) -already runs the standard scenarios against it via the registry builder. These -are Copilot-focused instead: ``ask_user`` routing (handler and room mode), recall -when Copilot's *native* session resume misses, and one client shared across adapter -lifecycles — none of which the generic builder's ``prompt``/``features``/``tools`` -contract can express, so each test constructs ``CopilotSDKAdapter`` by hand (like -``test_parlant.py``) and hands it to the toolkit's run primitives. - -Because construction is bespoke, these expose no adapter binding to the lane -selector (no ``@with_adapters``/``@per_adapter`` — that would demand the ``agent`` -fixture and re-provision generically). So gating stays explicit (``@requires``) and -lane scoping is pinned with ``@lane(Lane.CORE)`` — without it the selector would see -no framework and run these heavy smokes in *every* lane. - -Run with: - E2E_TESTS_ENABLED=true BAND_E2E_LANE=core uv run pytest \\ - tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py -v -s --no-cov -""" - -from __future__ import annotations - -import asyncio -from typing import Any - -import pytest - -from band.adapters.copilot_sdk import ( - _COPILOT_SDK_AVAILABLE, - ASK_USER_ROOM, - CopilotSDKAdapter, - CopilotSDKAdapterConfig, -) -from tests.e2e.baseline.agents import Lane, lane -from tests.e2e.baseline.flaky import flaky_infra -from tests.e2e.baseline.requires import Dep, requires -from tests.e2e.baseline.settings import BaselineSettings -from tests.e2e.baseline.smoke.samples.sample_agents import unique_marker -from tests.e2e.baseline.toolkit.capture import CaptureFactory -from tests.e2e.baseline.toolkit.provisioning import ( - ResourceManager, - running_agent, - running_provisioned_agent, -) -from tests.e2e.baseline.toolkit.user_ops import UserOps - -pytestmark = pytest.mark.skipif( - not _COPILOT_SDK_AVAILABLE, - reason="github-copilot-sdk not installed (pip install band-sdk[copilot_sdk])", -) - - -def _copilot_adapter( - settings: BaselineSettings, - *, - client: Any | None = None, - ask_user: Any | None = None, - **config_overrides: Any, -) -> CopilotSDKAdapter: - """The showcase's base ``CopilotSDKAdapter`` (BYOK on Anthropic). - - Mirrors the registry builder's shape (``toolkit/builders.py``) so these - bespoke tests don't re-derive it; ``ask_user`` and ``config_overrides`` - layer the one knob each test actually cares about (``ask_user=``, - ``base_directory=``). - """ - from copilot import ( # noqa: PLC0415 -- copilot_sdk extra; file collects even when absent, skipped via _COPILOT_SDK_AVAILABLE at test time - ProviderConfig, - ) - - config = CopilotSDKAdapterConfig( - model=settings.llm_models.anthropic_model, - use_logged_in_user=False, - custom_section=config_overrides.pop( - "custom_section", "Keep responses short and concise." - ), - **config_overrides, - ) - return CopilotSDKAdapter( - config, - client=client, - provider=ProviderConfig( - type="anthropic", - base_url="https://api.anthropic.com", - api_key=settings.llm_credentials.anthropic_api_key, - ), - ask_user=ask_user, - ) - - -@lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core -@requires(Dep.ANTHROPIC) -@flaky_infra( - "Copilot runtime boot + ask_user double round-trip can time out transiently" -) -# ask_user adds a second model round trip (ask -> handler answers -> final -# reply) on top of Copilot's own runtime boot. -@pytest.mark.timeout(extra=120) -@pytest.mark.asyncio(loop_scope="session") -async def test_copilot_ask_user_handler_round_trips_to_room_reply( - baseline_settings: BaselineSettings, - resource_manager: ResourceManager, - user_ops: UserOps, - reply_capture: CaptureFactory, -) -> None: - """``ask_user=`` pauses the turn, a programmatic operator answers. - - The operator's answer is a high-entropy token the user never utters, so the - reply containing it proves the answer flowed through the handler — not - model invention. Guards the adapter's ``ask_user`` allowlisting and handler - forwarding (without either, the model cannot ask and the handler never - fires). - """ - - operator_channel = unique_marker("channel") - asked: list[dict[str, Any]] = [] - - async def fake_operator( - request: dict[str, Any], context: dict[str, str] - ) -> dict[str, Any]: - asked.append(dict(request)) - return {"answer": operator_channel, "wasFreeform": True} - - adapter = _copilot_adapter( - baseline_settings, - ask_user=fake_operator, - custom_section=( - "Keep responses short and concise. A human operator is " - "available through the ask_user tool for decisions you " - "cannot make alone." - ), - ) - - async with running_provisioned_agent( - adapter, resource_manager, label="copilot-ask-user" - ) as agent: - room_id = await resource_manager.provision_room( - title="e2e-copilot-ask-user", participants=[agent.id] - ) - async with reply_capture(room_id) as capture: - mid = await user_ops.send_message( - room_id, - "I need to deploy release v2. Use the ask_user tool to ask " - "your operator which channel to deploy to and wait for my " - "response before continuing. Then reply stating exactly " - "the channel the operator chose.", - mention_id=agent.id, - mention_name=agent.name, - ) - replies = await capture.wait_for_reply( - mid, agent.id, deadline_s=baseline_settings.e2e_timeout * 2 - ) - - assert asked, "the model never called ask_user (handler did not fire)" - assert asked[0].get("question"), f"ask_user carried no question: {asked[0]!r}" - # Only the operator handler can supply this token; the reply containing it - # proves the answer round-tripped through the turn. - replies.assert_contains_any([operator_channel]) - - -@lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core -@requires(Dep.ANTHROPIC) -@flaky_infra("two full live turns plus Copilot runtime boot can time out transiently") -@pytest.mark.timeout(extra=180) # two full turns (question turn + answer turn) -@pytest.mark.asyncio(loop_scope="session") -async def test_copilot_ask_user_room_question_answered_by_next_message( - baseline_settings: BaselineSettings, - resource_manager: ResourceManager, - user_ops: UserOps, - reply_capture: CaptureFactory, -) -> None: - """``ask_user="room"`` posts the question into the room and ends the turn; - the user's next room message answers it on the same persisted session. - - The answer is a high-entropy token never uttered before the answer, so a - reply containing it proves the answer flowed through the room round trip — - not model invention. - """ - - secret_channel = unique_marker("channel") - adapter = _copilot_adapter( - baseline_settings, - ask_user=ASK_USER_ROOM, - custom_section=( - "Keep responses short and concise. Never invent answers to " - "questions you asked — wait for the user." - ), - ) - - async with running_provisioned_agent( - adapter, resource_manager, label="copilot-ask-user-room" - ) as agent: - room_id = await resource_manager.provision_room( - title="e2e-copilot-ask-user-room", participants=[agent.id] - ) - async with reply_capture(room_id) as capture: - # Turn 1: the trigger makes the model call ask_user; the question - # must land in the room, not anywhere else. - mark = capture.messages.snapshot() - mid = await user_ops.send_message( - room_id, - "Use the ask_user tool to ask me which channel to deploy " - "release v2 to. After I answer, reply stating exactly the " - "channel I chose.", - mention_id=agent.id, - mention_name=agent.name, - ) - replies = await capture.wait_for_reply( - mid, agent.id, since=mark, deadline_s=baseline_settings.e2e_timeout * 2 - ) - replies.assert_contains_any(["channel"]) - - # Turn 2: the user's next room message is the answer. - mark = capture.messages.snapshot() - mid = await user_ops.send_message( - room_id, - f"Deploy to the {secret_channel} channel.", - mention_id=agent.id, - mention_name=agent.name, - ) - replies = await capture.wait_for_reply( - mid, agent.id, since=mark, deadline_s=baseline_settings.e2e_timeout * 2 - ) - # Only the answer message contains this token; the reply relaying - # it proves the question/answer round-tripped through the room. - replies.assert_contains_any([secret_channel]) - - -@lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core -@requires(Dep.ANTHROPIC) -@flaky_infra( - "two fresh Copilot runtime boots (resume-miss setup) can time out transiently" -) -# Two agent lifecycles, each booting a fresh Copilot runtime into an empty -# base_directory (the resume-miss setup) — the heaviest boot path in this file. -@pytest.mark.timeout(extra=180) -@pytest.mark.asyncio(loop_scope="session") -async def test_copilot_recall_via_injected_history_when_resume_misses( - baseline_settings: BaselineSettings, - resource_manager: ResourceManager, - user_ops: UserOps, - reply_capture: CaptureFactory, - tmp_path: Any, -) -> None: - """Recall must survive a restart that invalidates Copilot's *native* resume. - - A rejoin against the same on-disk state (a plain stop/restart) would let - Copilot's own session resume answer for free. This test gives each phase a - fresh ``base_directory``, so phase 2's resume finds no state and recall - must flow through the converter's injected text history instead. Two facts - are asserted after the restart: a tracking marker the USER stated (plain - injected-history recall), and a calibration answer the AGENT produced in - phase 1 — the user never utters that answer, so the agent's own injected - replies are its only possible source (the regression case for one-sided - injected history). - """ - - tracking_marker = unique_marker("marker") - agent_fact = "blue" - - def make_adapter(phase: str) -> CopilotSDKAdapter: - # A fresh base_directory per phase: phase 2 has no on-disk session - # state, so Copilot's native resume cannot help. - return _copilot_adapter(baseline_settings, base_directory=str(tmp_path / phase)) - - identity = await resource_manager.provision_agent("copilot-resume-miss") - room_id = await resource_manager.provision_room( - title="e2e-copilot-resume-miss", participants=[identity.id] - ) - - # Phase 1: seed a user fact and make the agent produce its own fact. - async with ( - running_agent(identity, make_adapter("phase1"), baseline_settings), - reply_capture(room_id) as capture, - ): - mid = await user_ops.send_message( - room_id, - "Create a short project log note for later reference. The " - f"tracking marker is {tracking_marker}. Also answer this " - "calibration question inside your reply: what color is a " - "clear daytime sky? Reply in one short sentence that includes " - "the tracking marker and the color answer.", - mention_id=identity.id, - mention_name=identity.name, - ) - replies = await capture.wait_for_reply( - mid, identity.id, deadline_s=baseline_settings.e2e_timeout - ) - replies.assert_contains_any([tracking_marker]) - # The user never uttered this answer; it must come from the - # agent's phase-1 reply when phase 2 reconstructs history. - replies.assert_contains_any([agent_fact]) - - # Phase 2: fresh process state AND fresh Copilot state directory — recall - # can only come from the platform history the adapter injects. - async with ( - running_agent(identity, make_adapter("phase2"), baseline_settings), - reply_capture(room_id) as capture, - ): - mid = await user_ops.send_message( - room_id, - "From the earlier project log, what was the tracking marker " - "and what color answer did you give? Reply with both.", - mention_id=identity.id, - mention_name=identity.name, - ) - replies = await capture.wait_for_reply( - mid, identity.id, deadline_s=baseline_settings.e2e_timeout - ) - replies.assert_contains_any([tracking_marker]) - # The user never uttered this answer — only the agent's own - # injected phase-1 reply can supply it. - replies.assert_contains_any([agent_fact]) - - -@lane(Lane.CORE) # bespoke build exposes no framework; pin scheduling to core -@requires(Dep.ANTHROPIC) -@flaky_infra( - "several live turns across two adapter lifecycles can time out transiently" -) -# Two sequential adapter lifecycles on one shared runtime, the first serving -# two concurrent room sessions — several live turns, so widen the outer budget. -@pytest.mark.timeout(extra=180) -@pytest.mark.asyncio(loop_scope="session") -async def test_copilot_shared_client_across_adapter_lifecycles( - baseline_settings: BaselineSettings, - resource_manager: ResourceManager, - user_ops: UserOps, - reply_capture: CaptureFactory, -) -> None: - """Proves the Copilot SDK's one-client/many-sessions model through the - adapter against the live platform: - - 1. one ``CopilotClient`` is created by the test (the owner); - 2. an adapter *borrows* it and serves TWO rooms with turns in flight - CONCURRENTLY — two isolated parallel sessions on one runtime; - 3. the first adapter shuts down and a successor adapter reuses the same - still-running client — the borrowed client must survive an adapter's - full cleanup (``owns_client=False`` contract). - """ - from copilot import ( # noqa: PLC0415 -- copilot_sdk extra; file collects even when absent, skipped via _COPILOT_SDK_AVAILABLE at test time - CopilotClient, - ) - - identity = await resource_manager.provision_agent("copilot-shared-client") - room_a = await resource_manager.provision_room( - title="e2e-copilot-shared-a", participants=[identity.id] - ) - room_b = await resource_manager.provision_room( - title="e2e-copilot-shared-b", participants=[identity.id] - ) - - async def smoke(room_id: str, *, prompt: str = "Please say hello.") -> None: - async with reply_capture(room_id) as capture: - mid = await user_ops.send_message( - room_id, - prompt, - mention_id=identity.id, - mention_name=identity.name, - ) - replies = await capture.wait_for_reply( - mid, identity.id, deadline_s=baseline_settings.e2e_timeout - ) - replies.assert_present(what="a copilot_sdk[shared-client] reply") - - def make_shared_adapter(client: Any) -> CopilotSDKAdapter: - return _copilot_adapter(baseline_settings, client=client) - - # The test owns the client; adapters only borrow it. - client = CopilotClient(use_logged_in_user=False) - try: - async with running_agent( - identity, make_shared_adapter(client), baseline_settings - ): - # Two rooms with turns in flight AT THE SAME TIME — parallel - # sessions on one runtime, serialized only per room. - await asyncio.gather(smoke(room_a), smoke(room_b)) - - # First adapter fully cleaned up; the borrowed client must have - # survived — a successor adapter reuses it without a restart. - async with running_agent( - identity, make_shared_adapter(client), baseline_settings - ): - # A new request: the resumed session already answered the first, - # and a repeat of it may rightly be declined with band_no_reply. - await smoke(room_a, prompt="Please say goodbye.") - finally: - await client.stop() diff --git a/tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py b/tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py index b88c29755..80bfcc0ff 100644 --- a/tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py +++ b/tests/e2e/baseline/smoke/matrix/test_rehydration_cross_framework.py @@ -1,39 +1,4 @@ -"""Matrix scenario: a *different-framework* peer's message rehydrates into A's context. - -Existing rehydration/recall tests have the **user** author the recalled fact -(``test_context_recall``, ``test_rehydration_offline``) or recall a user-authored note -alongside a live peer (``test_rehydration_partial``); none proves that one framework -rehydrates a message a *different* framework authored. Here -peer B (langgraph, an OpenAI-backed LangChain agent) authors a message — carrying a -neutral marker and mentioning A — while agent A (each of the other core frameworks) is -offline; then A cold-boots and must recall the marker from its bootstrap ``/context``. - -Why B must mention A — and why this is *text*, not a tool call: rehydrated ``/context`` -is **agent-scoped** — the platform returns only messages the agent authored or was -*mentioned in*, not the whole room (see ``execution.py`` ``get_agent_chat_context`` + -``preprocessing/default._load_history``). So B's message only reaches A's context if it -mentions A. A tool call/result B makes is B's own and mentions no one, so it can *never* -enter A's context — cross-framework **tool-event** rehydration is therefore not -observable through ``/context`` and is a deliberate non-goal here. - -What this proves: a message **authored by a different framework** rehydrates into A's -agent-scoped context and A recalls it. Message text is framework-neutral on the platform, -so this is a deliberately narrow, honest claim: the cross-framework analogue of the -same-framework ``test_rehydration_offline`` / ``test_rehydration_partial`` recall. - -Leak-avoidance / fail-loud setup: the marker is a neutral ``unique_marker`` (not a -credential-shaped token models refuse to echo). B is driven to send exactly one message -that mentions A and carries the marker; a **setup precondition** asserts that message -actually mentioned A (by metadata) and carried the marker — so a peer that failed to -mention A is a loud setup red, not a silent "A couldn't recall" that looks like a -rehydration bug. - -Lifecycle: A's identity via ``cell`` (owns its boot); B's via the ``peer`` fixture (a -different-framework cell the test drives). The two runs are strictly sequential — B's -run fully exits before A boots — so ``track_running`` never sees overlapping runs of one -identity. ``lane=Lane.CORE`` + excluding ``LANGGRAPH`` keeps A and B both in the core -lane (schedulable, no cross-lane) and guarantees A is never langgraph (so A ≠ B). -""" +"""A cold adapter recalls a message authored by its LangGraph peer.""" from __future__ import annotations @@ -41,105 +6,38 @@ from tests.e2e.baseline.agents import Adapter, ExcludedAdapter, Lane, per_adapter from tests.e2e.baseline.settings import BaselineSettings -from tests.e2e.baseline.smoke.samples.sample_agents import REPLY_PROMPT, unique_marker +from tests.e2e.baseline.smoke.samples.rehydration import rehydrate_foreign_peer +from tests.e2e.baseline.smoke.samples.sample_agents import REPLY_PROMPT from tests.e2e.baseline.toolkit.capture import CaptureFactory -from tests.e2e.baseline.toolkit.provisioning import ( - AdapterCell, - ProvisionedAgent, - ResourceManager, - agent_rest_client, -) +from tests.e2e.baseline.toolkit.provisioning import AdapterCell, ResourceManager from tests.e2e.baseline.toolkit.user_ops import UserOps -# Never names the token, so A can only answer from rehydrated context. -RECALL_TOKEN_REQUEST = ( - "Earlier the other participant sent you a short note with a token. " - "Call band_send_message to reply with exactly that complete token string " - "and nothing else." -) - - -def _relay_prompt(target: ProvisionedAgent, marker: str) -> str: - """A prompt that drives B to send one message mentioning A and carrying the marker.""" - return ( - REPLY_PROMPT + f" When asked, send exactly one message that mentions the " - f"participant '{target.name}' and contains this exact token: {marker}. Address " - "it to them and include nothing else of substance." - ) - - -@per_adapter( - lane=Lane.CORE, - exclude=[ - ExcludedAdapter( - Adapter.LANGGRAPH, - "the fixed foreign peer here, so it can't also be the fanned cell", - ) - ], - peer=Adapter.LANGGRAPH, - prompt=REPLY_PROMPT, -) -@pytest.mark.timeout(extra=300) # peer boot + relay turn + fresh A boot + recall turn -@pytest.mark.asyncio(loop_scope="session") -async def test_rehydrates_foreign_peer_message( - cell: AdapterCell, - peer: AdapterCell, - resource_manager: ResourceManager, - user_ops: UserOps, - reply_capture: CaptureFactory, - baseline_settings: BaselineSettings, -) -> None: - """A recalls a marker a *different-framework* peer stated, via agent-scoped ``/context``. - - Requests ``cell`` (A's lifecycle) and ``peer`` (B, a different framework). B authors a - message mentioning A that carries the marker (so it enters A's agent-scoped context), - then stops; A cold-boots and must recall the marker from bootstrap rehydration. The - per-cell ``@requires`` gate (folded with the peer's) rides on the parametrization. - """ - marker = unique_marker("note") - recaller = await cell.provision(label=f"recaller-{cell.adapter_id}") - speaker = await peer.provision(label=f"speaker-{peer.adapter_id}") - room_id = await resource_manager.provision_room( - title=f"e2e-xframework-{cell.adapter_id}", - participants=[recaller.id, speaker.id], - ) - - # B (a different framework) authors one message that mentions A and carries the - # marker, then stops. Mentioning A is what lands it in A's agent-scoped /context. - async with ( - peer.run_as(speaker, prompt=_relay_prompt(recaller, marker)), - reply_capture(room_id) as capture, - ): - probe = capture.messages.snapshot() - mid = await user_ops.send_message( - room_id, - f"Please pass a note to {recaller.name}.", - mention_id=speaker.id, - mention_name=speaker.name, - ) - replies = await capture.wait_for_reply(mid, speaker.id, since=probe) - # Setup precondition (fail loud): B's marker-bearing message must actually - # mention A — else it never reaches A's context and a later recall miss would - # look like a rehydration bug rather than a setup failure. - replies.mentioning(recaller.id).assert_contains_any([marker]) - context = await agent_rest_client( - recaller, baseline_settings - ).agent_api_context.get_agent_chat_context(chat_id=room_id) - assert any(marker in (item.content or "") for item in context.data or []), ( - f"agent-scoped bootstrap context lost the exact foreign token {marker!r}" +class TestForeignPeerRehydration: + @per_adapter( + lane=Lane.CORE, + exclude=[ + ExcludedAdapter( + Adapter.LANGGRAPH, + "the fixed foreign peer here, so it can't also be the fanned cell", + ) + ], + peer=Adapter.LANGGRAPH, + prompt=REPLY_PROMPT, ) - - # A boots fresh under its own identity — no in-memory history — and is asked what the - # other participant told it. A correct recall can only come from the platform - # rehydrating B's (foreign-framework) message into A's context on bootstrap. - async with cell.run_as(recaller), reply_capture(room_id) as capture: - mark = capture.messages.snapshot() # scope strictly to the recall turn - mid = await user_ops.send_message( - room_id, - RECALL_TOKEN_REQUEST, - mention_id=recaller.id, - mention_name=recaller.name, + @pytest.mark.timeout( + extra=300 + ) # peer boot + relay turn + fresh A boot + recall turn + @pytest.mark.asyncio(loop_scope="session") + async def test_rehydrates_foreign_peer_message( + self, + cell: AdapterCell, + peer: AdapterCell, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + baseline_settings: BaselineSettings, + ) -> None: + await rehydrate_foreign_peer( + cell, peer, resource_manager, user_ops, reply_capture, baseline_settings ) - replies = await capture.wait_for_reply(mid, recaller.id, since=mark) - replies.assert_contains_exact(marker) diff --git a/tests/e2e/baseline/smoke/matrix/test_strands_rehydration.py b/tests/e2e/baseline/smoke/matrix/test_strands_rehydration.py new file mode 100644 index 000000000..4797e85a2 --- /dev/null +++ b/tests/e2e/baseline/smoke/matrix/test_strands_rehydration.py @@ -0,0 +1,32 @@ +"""A cold adapter recalls a message authored by its LangGraph peer.""" + +from __future__ import annotations + +import pytest + +from tests.e2e.baseline.agents import Adapter, Lane, lane, per_adapter +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.rehydration import rehydrate_foreign_peer +from tests.e2e.baseline.smoke.samples.sample_agents import REPLY_PROMPT +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import AdapterCell, ResourceManager +from tests.e2e.baseline.toolkit.user_ops import UserOps + + +class TestStrandsForeignPeerRehydration: + @lane(Lane.STRANDS) + @per_adapter(Adapter.STRANDS, peer=Adapter.LANGGRAPH, prompt=REPLY_PROMPT) + @pytest.mark.timeout(extra=300) + @pytest.mark.asyncio(loop_scope="session") + async def test_rehydrates_foreign_peer_message( + self, + cell: AdapterCell, + peer: AdapterCell, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + baseline_settings: BaselineSettings, + ) -> None: + await rehydrate_foreign_peer( + cell, peer, resource_manager, user_ops, reply_capture, baseline_settings + ) diff --git a/tests/e2e/baseline/smoke/samples/provider.py b/tests/e2e/baseline/smoke/samples/provider.py new file mode 100644 index 000000000..7f60ddeee --- /dev/null +++ b/tests/e2e/baseline/smoke/samples/provider.py @@ -0,0 +1,11 @@ +"""Input for a provider tool round trip.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class RecordInput(BaseModel): + """Record a note.""" + + note: str diff --git a/tests/e2e/baseline/smoke/samples/rehydration.py b/tests/e2e/baseline/smoke/samples/rehydration.py new file mode 100644 index 000000000..be0f60cbf --- /dev/null +++ b/tests/e2e/baseline/smoke/samples/rehydration.py @@ -0,0 +1,94 @@ +"""Foreign-framework context recall scenario shared across execution lanes.""" + +from __future__ import annotations + +from tests.e2e.baseline.settings import BaselineSettings +from tests.e2e.baseline.smoke.samples.sample_agents import REPLY_PROMPT, unique_marker +from tests.e2e.baseline.toolkit.capture import CaptureFactory +from tests.e2e.baseline.toolkit.provisioning import ( + AdapterCell, + ProvisionedAgent, + ResourceManager, + agent_rest_client, +) +from tests.e2e.baseline.toolkit.user_ops import UserOps + +# Never names the token, so A can only answer from rehydrated context. +RECALL_TOKEN_REQUEST = ( + "Earlier the other participant sent you a short note with a token. " + "Call band_send_message to reply with exactly that complete token string " + "and nothing else." +) + + +def _relay_prompt(target: ProvisionedAgent, marker: str) -> str: + """A prompt that drives B to send one message mentioning A and carrying the marker.""" + return ( + REPLY_PROMPT + f" When asked, send exactly one message that mentions the " + f"participant '{target.name}' and contains this exact token: {marker}. Address " + "it to them and include nothing else of substance." + ) + + +async def rehydrate_foreign_peer( + cell: AdapterCell, + peer: AdapterCell, + resource_manager: ResourceManager, + user_ops: UserOps, + reply_capture: CaptureFactory, + baseline_settings: BaselineSettings, +) -> None: + """A recalls a marker a *different-framework* peer stated, via agent-scoped ``/context``. + + Requests ``cell`` (A's lifecycle) and ``peer`` (B, a different framework). B authors a + message mentioning A that carries the marker (so it enters A's agent-scoped context), + then stops; A cold-boots and must recall the marker from bootstrap rehydration. The + per-cell ``@requires`` gate (folded with the peer's) rides on the parametrization. + """ + marker = unique_marker("note") + recaller = await cell.provision(label=f"recaller-{cell.adapter_id}") + speaker = await peer.provision(label=f"speaker-{peer.adapter_id}") + room_id = await resource_manager.provision_room( + title=f"e2e-xframework-{cell.adapter_id}", + participants=[recaller.id, speaker.id], + ) + + # B (a different framework) authors one message that mentions A and carries the + # marker, then stops. Mentioning A is what lands it in A's agent-scoped /context. + async with ( + peer.run_as(speaker, prompt=_relay_prompt(recaller, marker)), + reply_capture(room_id) as capture, + ): + probe = capture.messages.snapshot() + mid = await user_ops.send_message( + room_id, + f"Please pass a note to {recaller.name}.", + mention_id=speaker.id, + mention_name=speaker.name, + ) + replies = await capture.wait_for_reply(mid, speaker.id, since=probe) + # Setup precondition (fail loud): B's marker-bearing message must actually + # mention A — else it never reaches A's context and a later recall miss would + # look like a rehydration bug rather than a setup failure. + replies.mentioning(recaller.id).assert_contains_any([marker]) + + context = await agent_rest_client( + recaller, baseline_settings + ).agent_api_context.get_agent_chat_context(chat_id=room_id) + assert any(marker in (item.content or "") for item in context.data or []), ( + f"agent-scoped bootstrap context lost the exact foreign token {marker!r}" + ) + + # A boots fresh under its own identity — no in-memory history — and is asked what the + # other participant told it. A correct recall can only come from the platform + # rehydrating B's (foreign-framework) message into A's context on bootstrap. + async with cell.run_as(recaller), reply_capture(room_id) as capture: + mark = capture.messages.snapshot() # scope strictly to the recall turn + mid = await user_ops.send_message( + room_id, + RECALL_TOKEN_REQUEST, + mention_id=recaller.id, + mention_name=recaller.name, + ) + replies = await capture.wait_for_reply(mid, recaller.id, since=mark) + replies.assert_contains_exact(marker) diff --git a/tests/e2e/baseline/toolkit/builders.py b/tests/e2e/baseline/toolkit/builders.py index 647ed3c92..186ad83d2 100644 --- a/tests/e2e/baseline/toolkit/builders.py +++ b/tests/e2e/baseline/toolkit/builders.py @@ -20,7 +20,7 @@ import os import tempfile import weakref -from typing import Any +from typing import TYPE_CHECKING, Any from band import create_room_workspace_resolver from band.adapters.codex import CodexSandboxMode @@ -44,6 +44,9 @@ ) from tests.e2e.baseline.toolkit.tools import ToolSpec +if TYPE_CHECKING: + from band.adapters.copilot_sdk import CopilotSDKAdapter + # Spelled out rather than derived from the Capability enum: an adapter's # `supports` is a claim the capability-scoped matrices select on, so a newly # added capability should be a deliberate per-adapter decision, not one every @@ -123,9 +126,29 @@ def _build_copilot_sdk( features: AdapterFeatures | None, tools: list[ToolSpec] | None = None, ) -> SimpleAdapter[Any]: - # The generic matrix builder is BYOK-on-Anthropic, matching claude_sdk's model; - # ask_user / a shared client are bespoke knobs exercised by - # tests/e2e/baseline/smoke/adapters/test_copilot_sdk.py, not by this builder. + sandbox = tempfile.TemporaryDirectory(prefix="band-e2e-copilot-sdk-") + adapter = build_copilot_sdk_adapter( + s, + base_directory=copilot_home_dir(sandbox.name), + custom_section=prompt or "", + features=features, + tools=tools, + ) + weakref.finalize(adapter, sandbox.cleanup) + return adapter + + +def build_copilot_sdk_adapter( + s: BaselineSettings, + *, + base_directory: str, + custom_section: str = "Keep responses short and concise.", + client: Any | None = None, + ask_user: Any | None = None, + features: AdapterFeatures | None = None, + tools: list[ToolSpec] | None = None, +) -> CopilotSDKAdapter: + """Build BYOK sessions with a caller-owned, isolated Copilot runtime home.""" from copilot import ( # noqa: PLC0415 -- isolates the copilot_sdk extra from the other frameworks this file builds ProviderConfig, ) @@ -135,16 +158,15 @@ def _build_copilot_sdk( CopilotSDKAdapterConfig, ) - # A per-cell COPILOT_HOME, as copilot_acp gets: host hooks and extensions - # in ~/.copilot otherwise steer or deny the turn under test. - sandbox = tempfile.TemporaryDirectory(prefix="band-e2e-copilot-sdk-") - adapter = CopilotSDKAdapter( + return CopilotSDKAdapter( CopilotSDKAdapterConfig( model=s.llm_models.anthropic_model, use_logged_in_user=False, - base_directory=copilot_home_dir(sandbox.name), - custom_section=prompt or "", + base_directory=base_directory, + custom_section=custom_section, ), + client=client, + ask_user=ask_user, additional_tools=_custom_tool_defs(tools), provider=ProviderConfig( type="anthropic", @@ -153,8 +175,6 @@ def _build_copilot_sdk( ), **feature_kwargs(features), ) - weakref.finalize(adapter, sandbox.cleanup) - return adapter @adapter(Adapter.LANGGRAPH, requires=[Dep.OPENAI], supports=_EVERY_CAPABILITY) @@ -225,7 +245,9 @@ def _build_pydantic_ai( ) -@adapter(Adapter.STRANDS, requires=[Dep.OPENAI], supports=_EVERY_CAPABILITY) +@adapter( + Adapter.STRANDS, requires=[Dep.OPENAI, Dep.STRANDS], supports=_EVERY_CAPABILITY +) def _build_strands( s: BaselineSettings, *, diff --git a/tests/e2e/baseline/toolkit/ci_lanes.py b/tests/e2e/baseline/toolkit/ci_lanes.py index f6844f0eb..76942eb06 100644 --- a/tests/e2e/baseline/toolkit/ci_lanes.py +++ b/tests/e2e/baseline/toolkit/ci_lanes.py @@ -118,25 +118,27 @@ def adapter_home_lanes() -> dict[str, str]: return {str(adapter): str(cl.id) for cl in ci_lanes() for adapter in cl.adapters} -def hosting_lanes(home_lanes: frozenset[str]) -> frozenset[str]: - """CI lane ids whose ``uv`` extra can install *every* framework whose home lane is - in ``home_lanes`` — empty if none can (the frameworks need incompatible extras, e.g. - crewai + a ``dev``-extra framework); empty ``home_lanes`` → every lane. - - A lane *hosts* a framework iff they share a ``uv`` extra - (``lane_extra(lane) == lane_extra(home)``): every ``dev`` lane installs every - ``dev`` framework, and ``dev-crewai`` installs only the crewai stack. Hosting is thus - broader than — and distinct from — an adapter's single *home* lane. Conservative and - correct given crewai is the only venv conflict, and derived purely from - ``lane_extra`` + the home-lane partition (no hand-maintained table). - """ - if not home_lanes: - return known_lane_ids() - extras = {lane_extra(Lane(home)) for home in home_lanes} - if len(extras) != 1: # frameworks need incompatible extras — no lane hosts them all - return frozenset() - (extra,) = extras - return frozenset(str(cl.id) for cl in ci_lanes() if cl.extra == extra) +# Extra hosts beyond its adapters' home extras; legacy needs this foreign peer. +EXTRA_HOSTS: dict[Extra, frozenset[Adapter]] = { + Extra.DEV_STRANDS_NOOA: frozenset({Adapter.LANGGRAPH}), +} + + +def hosting_lanes(frameworks: frozenset[str]) -> frozenset[str]: + """Lanes whose installed dependencies host every requested adapter.""" + homes = adapter_home_lanes() + unknown = frameworks - homes.keys() + if unknown: + raise ValueError(f"Unknown adapter ids: {sorted(unknown)}") + return frozenset( + str(cl.id) + for cl in ci_lanes() + if all( + lane_extra(Lane(homes[framework])) == cl.extra + or framework in EXTRA_HOSTS.get(cl.extra, frozenset()) + for framework in frameworks + ) + ) # The e2e workflow (REPO_ROOT is the single source of the checkout-depth assumption). diff --git a/tests/e2e/baseline/toolkit/deps.py b/tests/e2e/baseline/toolkit/deps.py index f9ed1a7f3..94d36f996 100644 --- a/tests/e2e/baseline/toolkit/deps.py +++ b/tests/e2e/baseline/toolkit/deps.py @@ -84,6 +84,7 @@ class Lane(StrEnum): GOOGLE = "google" LETTA = "letta" NOOA = "nooa" + STRANDS = "strands" PARLANT = "parlant" @@ -103,6 +104,7 @@ class Extra(StrEnum): DEV = "dev" DEV_CREWAI = "dev-crewai" DEV_PARLANT = "dev-parlant" + DEV_STRANDS_NOOA = "dev-strands-nooa" # Lane -> the ``uv`` extra a lane's job installs. Lane id and extra are separate: @@ -124,8 +126,8 @@ class Extra(StrEnum): # (same import path, two distributions), so it needs its own extra entirely — # `dev` can no longer install parlant at all (see pyproject [tool.uv] conflicts). Lane.PARLANT: Extra.DEV_PARLANT, - # NOOA rides the ``dev`` extra, which installs it only where NOOA runs. - Lane.NOOA: Extra.DEV, + Lane.NOOA: Extra.DEV_STRANDS_NOOA, + Lane.STRANDS: Extra.DEV_STRANDS_NOOA, } @@ -140,7 +142,7 @@ class Extra(StrEnum): # (https://docs.letta.com/guides/server/source). # # NOOA imports ``fcntl`` unconditionally, so it cannot be imported on Windows and -# the ``dev`` extra does not install it there. Every other lane runs on every OS +# the ``dev-strands-nooa`` extra does not install it there. Every other lane runs on every OS # the e2e matrix offers. LINUX_ONLY_LANES: frozenset[Lane] = frozenset({Lane.LETTA, Lane.NOOA}) @@ -165,6 +167,7 @@ class Dep(Enum): OPENCODE_BASH_ASKS = "opencode_bash_asks" # that serve gates `bash` to `ask` LETTA = "letta" # a self-hosted LETTA_BASE_URL (or a Letta Cloud key) CREWAI = "crewai" # the crewai package is importable (the dev-crewai lane) + STRANDS = "strands" NOOA = "nooa" # the nooa package is importable (Python 3.12-3.13, not Windows) COPILOT_CLI = "copilot_cli" # the `copilot` CLI reachable on PATH (ACP backend) CURSOR_CLI = "cursor_cli" # the `agent` CLI and noninteractive auth for Cursor ACP @@ -421,9 +424,14 @@ def _letta_available(settings: BaselineSettings) -> bool: "crewai is not importable (install the dev-crewai lane)", lane=Lane.CREWAI, ), + Dep.STRANDS: DepSpec( + lambda _s: importlib.util.find_spec("strands") is not None, + "strands is not importable (install dev-strands-nooa)", + lane=Lane.STRANDS, + ), Dep.NOOA: DepSpec( lambda _s: importlib.util.find_spec("nooa") is not None, - "nooa is not importable (the dev extra installs it on Python 3.12-3.13, " + "nooa is not importable (dev-strands-nooa installs it on Python 3.12-3.13, " "off Windows)", lane=Lane.NOOA, ), diff --git a/tests/e2e/baseline/toolkit/logs.py b/tests/e2e/baseline/toolkit/logs.py index ec8532839..4c70fbd0a 100644 --- a/tests/e2e/baseline/toolkit/logs.py +++ b/tests/e2e/baseline/toolkit/logs.py @@ -11,11 +11,13 @@ @contextmanager -def sdk_logs_at(package: ModuleType, level: int) -> Iterator[None]: +def sdk_logs_at(package: ModuleType | str, level: int) -> Iterator[None]: """Let ``package``'s records at ``level`` reach pytest's captured log, which a failing test prints; the root logger's WARNING default otherwise drops them. Never raises the threshold, so a run at ``--log-level=DEBUG`` keeps its DEBUG.""" - sdk_logger = logging.getLogger(package.__name__) + sdk_logger = logging.getLogger( + package if isinstance(package, str) else package.__name__ + ) previous_level = sdk_logger.level sdk_logger.setLevel(more_verbose(level, sdk_logger.getEffectiveLevel())) try: diff --git a/tests/framework_configs/adapters.py b/tests/framework_configs/adapters.py index 5d838509d..50352a3dc 100644 --- a/tests/framework_configs/adapters.py +++ b/tests/framework_configs/adapters.py @@ -44,7 +44,8 @@ from band.integrations.crewai.tools import NoopReporter, build_band_crewai_tools from tests.framework_configs.sentinel import MISSING, STRICT_CI, MissingSentinel -# NOOA supports Python 3.12 and 3.13 on POSIX only, so the dev extra skips it elsewhere. +# Legacy dependencies are intentionally absent from modern dev. +_HAS_STRANDS = importlib.util.find_spec("strands") is not None _HAS_NOOA = importlib.util.find_spec("nooa") is not None __all__ = [ @@ -590,7 +591,9 @@ def _build_pydantic_ai_config() -> AdapterConfig: ) -def _build_strands_config() -> AdapterConfig: +def _build_strands_config() -> AdapterConfig | None: + if not _HAS_STRANDS: + return None from band.adapters.strands import ( # noqa: PLC0415 -- isolates the strands extra from the other frameworks this file configures StrandsAdapterConfig, ) @@ -821,6 +824,8 @@ def _build_gemini_config() -> AdapterConfig: _excluded = _excluded | {"claude_sdk"} if not _HAS_COPILOT_SDK: _excluded = _excluded | {"copilot_sdk"} +if not _HAS_STRANDS: + _excluded = _excluded | {"strands"} if not _HAS_NOOA: _excluded = _excluded | {"nooa"} ADAPTER_EXCLUDED_MODULES: frozenset[str] = frozenset(_excluded) diff --git a/tests/framework_configs/converters.py b/tests/framework_configs/converters.py index 87f103f5a..98b85b956 100644 --- a/tests/framework_configs/converters.py +++ b/tests/framework_configs/converters.py @@ -8,6 +8,7 @@ from __future__ import annotations import functools +import importlib.util import logging from collections.abc import Callable from dataclasses import dataclass @@ -42,6 +43,8 @@ ) from tests.framework_configs.sentinel import STRICT_CI +_HAS_STRANDS = importlib.util.find_spec("strands") is not None + __all__ = [ "CONVERTER_CONFIGS", "CONVERTER_EXCLUDED_MODULES", @@ -339,6 +342,9 @@ def _build_gemini_config() -> ConverterConfig: } ) +if not _HAS_STRANDS: + CONVERTER_EXCLUDED_MODULES |= {"strands"} + def _build_google_adk_config() -> ConverterConfig: return ConverterConfig( @@ -356,7 +362,9 @@ def _build_google_adk_config() -> ConverterConfig: ) -def _build_strands_config() -> ConverterConfig: +def _build_strands_config() -> ConverterConfig | None: + if not _HAS_STRANDS: + return None return ConverterConfig( framework_id="strands", display_name="Strands", @@ -389,7 +397,7 @@ def _build_nooa_config() -> ConverterConfig: ) -_CONVERTER_CONFIG_BUILDERS: list[Callable[[], ConverterConfig]] = [ +_CONVERTER_CONFIG_BUILDERS: list[Callable[[], ConverterConfig | None]] = [ _build_anthropic_config, _build_langchain_config, _build_crewai_config, @@ -417,7 +425,9 @@ def _build_converter_configs() -> list[ConverterConfig]: configs: list[ConverterConfig] = [] for builder in _CONVERTER_CONFIG_BUILDERS: try: - configs.append(builder()) + result = builder() + if result is not None: + configs.append(result) except Exception as exc: if STRICT_CI: raise RuntimeError( diff --git a/tests/framework_conformance/imageprobes.py b/tests/framework_conformance/imageprobes.py new file mode 100644 index 000000000..f70c82dc1 --- /dev/null +++ b/tests/framework_conformance/imageprobes.py @@ -0,0 +1,436 @@ +"""Live matrix proving each IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS entry +actually passes an image band_read_room_file result through as real +vision/image content -- not just that the bookkeeping constant lists it. + +Each probe drives the framework's real tool-dispatch code path with a fake +tools object whose read_room_file returns the exact MCP-image-content shape +AgentTools.read_room_file produces for a small previewable image, and asserts +the framework's own outgoing tool-result shape is a real image block rather +than a json.dumps'd text blob. Deeper framework-specific coverage (the +non-image degrade-to-text path, error handling, custom-tool interaction) +stays in each framework's own test file -- this matrix exists to answer one +question, uniformly, for every framework that claims support: does a real +image actually get through. + +To add a framework here: write a probe, add it to IMAGE_PASSTHROUGH_PROBES, +and add the framework_id to IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS in +test_adapter_conformance.py -- test_probe_registry_matches_supported_framework_ids +fails loudly if the two ever name different frameworks. +""" + +from __future__ import annotations + +import base64 +import importlib.util +from collections.abc import Awaitable, Callable, Iterable +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from band.core.types import AdapterFeatures, Capability +from band.integrations.mcp import BandMCPTransport +from band.runtime.tools import TOOL_DEFINITIONS, TOOL_MODELS, BandTool, ToolCallOutcome + +try: + import crewai # noqa: F401 + + _CREWAI_AVAILABLE = True +except ImportError: + _CREWAI_AVAILABLE = False + +try: + import pydantic_ai # noqa: F401 + + _PYDANTIC_AI_AVAILABLE = True +except ImportError: + _PYDANTIC_AI_AVAILABLE = False + +try: + import nooa # noqa: F401 + + _NOOA_AVAILABLE = True +except ImportError: + _NOOA_AVAILABLE = False + +# crewai and pydantic-ai aren't both installed in every lane's venv (a +# three-way conflict group with parlant -- see `conflicts` in pyproject.toml): +# dev-crewai lacks pydantic-ai, dev-parlant lacks both. These framework_ids +# need a per-lane skip the other probes (all in every lane's `dev` baseline) +# don't. +_SOMETIMES_MISSING: dict[str, bool] = { + "crewai": _CREWAI_AVAILABLE, + "crewai_flow": _CREWAI_AVAILABLE, + "pydantic_ai": _PYDANTIC_AI_AVAILABLE, + # NOOA supports Python 3.12 and 3.13 on POSIX only. + "nooa": _NOOA_AVAILABLE, + "strands": importlib.util.find_spec("strands") is not None, +} + +_IMAGE_RESULT: dict[str, Any] = { + "content": [{"type": "image", "data": "ZmFrZQ==", "mimeType": "image/png"}] +} + +# Derived, never re-typed: a probe asserting on a hand-written "ZmFrZQ==" would +# still pass if _IMAGE_RESULT changed underneath it. +_EXPECTED_BASE64: str = _IMAGE_RESULT["content"][0]["data"] +_EXPECTED_MIME_TYPE: str = _IMAGE_RESULT["content"][0]["mimeType"] +_EXPECTED_BYTES: bytes = base64.b64decode(_EXPECTED_BASE64) + + +def _is_expected_image(data: bytes | str, mime_type: str) -> bool: + """Whether a framework's outgoing image block carries the probe's image. + + ``data`` arrives in whichever encoding that framework's SDK uses, so a + probe passes what it found rather than normalising first. + """ + expected = _EXPECTED_BYTES if isinstance(data, bytes) else _EXPECTED_BASE64 + return data == expected and mime_type == _EXPECTED_MIME_TYPE + + +def _tool_named(tools: Iterable[Any], name: str) -> Any: + """The one tool a framework built for ``name``.""" + return next(tool for tool in tools if tool.name == name) + + +class ReadRoomFileTools: + """Minimal AgentToolsProtocol double whose read_room_file/execute_tool_call + always return the fixed image result -- only what each probe's dispatch + path calls (MCP-based probes call read_room_file; generic-dispatch probes + like agno call execute_tool_call).""" + + async def read_room_file(self, file_id: str) -> dict[str, Any]: + del file_id + return _IMAGE_RESULT + + async def execute_tool_call( + self, tool_name: str, arguments: dict[str, Any] + ) -> dict[str, Any]: + del tool_name, arguments + return _IMAGE_RESULT + + +async def _probe_claude_sdk() -> bool: + """claude_sdk's tools are its room-bound Band MCP backend, dialed over HTTP.""" + from tests.mcpclient import ( # noqa: PLC0415 -- imports mcp, a claude_sdk extra absent from the standard dev-crewai/dev-parlant lane venvs + mcp_session, + started_backend, + ) + + async with ( + started_backend( + room_bound=True, + tool_definitions=[TOOL_DEFINITIONS[BandTool.READ_ROOM_FILE]], + get_tools={"room-1": ReadRoomFileTools()}.get, + ) as backend, + mcp_session(backend.endpoint(BandMCPTransport.HTTP, "room-1")) as session, + ): + result = await session.call_tool(BandTool.READ_ROOM_FILE, {"file_id": "file-1"}) + + blocks = [block.model_dump(exclude_none=True) for block in result.content] + return not result.isError and {"content": blocks} == _IMAGE_RESULT + + +async def _probe_anthropic() -> bool: + + from anthropic.types import ( # noqa: PLC0415 -- anthropic extra, absent from the standard dev-crewai/dev-parlant lane venvs + ToolUseBlock, + ) + + from band.adapters.anthropic import ( # noqa: PLC0415 -- anthropic extra, absent from the standard dev-crewai/dev-parlant lane venvs + AnthropicAdapter, + ) + + adapter = AnthropicAdapter(emit=()) + tools = MagicMock() + tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) + response = MagicMock() + response.content = [ + ToolUseBlock( + type="tool_use", + id="tool-1", + name=BandTool.READ_ROOM_FILE, + input={"file_id": "file-1"}, + ) + ] + + results = await adapter._process_tool_calls(response, tools) + + content = results[0]["content"] + if not isinstance(content, list) or len(content) != 1: + return False + block = content[0] + return block["type"] == "image" and _is_expected_image( + block["source"]["data"], block["source"]["media_type"] + ) + + +async def _probe_opencode() -> bool: + from mcp.shared.memory import ( # noqa: PLC0415 -- opencode extra, absent from the standard dev-crewai/dev-parlant lane venvs + create_connected_server_and_client_session, + ) + + from band.integrations.mcp.engine import ( # noqa: PLC0415 -- opencode extra, absent from the standard dev-crewai/dev-parlant lane venvs + EmbeddedResolver, + EngineSpec, + build_engine, + build_tool_registration, + extend_with_chat_id, + ) + + resolver = EmbeddedResolver(get_tools=lambda _chat_id: ReadRoomFileTools()) + definition = TOOL_DEFINITIONS[BandTool.READ_ROOM_FILE] + registration = build_tool_registration( + definition, + extend_with_chat_id(definition.input_model, None), + resolver=resolver, + strip_chat_id=True, + ) + mcp = build_engine(EngineSpec(name="probe-opencode", tools=(registration,))) + + async with create_connected_server_and_client_session(mcp) as session: + result = await session.call_tool( + BandTool.READ_ROOM_FILE, {"chat_id": "room-1", "file_id": "file-1"} + ) + + if result.isError or len(result.content) != 1: + return False + block = result.content[0] + return block.type == "image" and _is_expected_image(block.data, block.mimeType) + + +async def _probe_gemini() -> bool: + + from google.genai import ( # noqa: PLC0415 -- gemini extra, absent from the standard dev-crewai/dev-parlant lane venvs + types, + ) + + from band.adapters.gemini import ( # noqa: PLC0415 -- gemini extra, absent from the standard dev-crewai/dev-parlant lane venvs + GeminiAdapter, + ) + + adapter = GeminiAdapter() + tools = MagicMock() + tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) + function_calls = [ + types.FunctionCall( + name=BandTool.READ_ROOM_FILE, args={"file_id": "file-1"}, id="c1" + ) + ] + + parts = await adapter._process_function_calls(function_calls, tools) + + function_response = parts[0].function_response + if function_response is None or not function_response.parts: + return False + inline_data = function_response.parts[0].inline_data + return inline_data is not None and _is_expected_image( + inline_data.data, inline_data.mime_type + ) + + +async def _probe_langgraph() -> bool: + + from band.integrations.langgraph.langchain_tools import ( # noqa: PLC0415 -- langgraph extra, absent from the standard dev-crewai/dev-parlant lane venvs + agent_tools_to_langchain, + ) + + tools = MagicMock() + tools.is_hub_room = False + tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) + wrapped = { + tool.name: tool + for tool in agent_tools_to_langchain( + tools, + features=AdapterFeatures(capabilities=frozenset({Capability.FILES})), + ) + } + + result = await wrapped[BandTool.READ_ROOM_FILE].ainvoke({"file_id": "file-1"}) + + if not isinstance(result, list) or len(result) != 1: + return False + block = result[0] + return block["type"] == "image" and _is_expected_image( + block["base64"], block["mime_type"] + ) + + +async def _probe_agno() -> bool: + from agno.tools.function import ( # noqa: PLC0415 -- agno extra, absent from the standard dev-crewai/dev-parlant lane venvs + ToolResult, + ) + + from band.adapters.agno import ( # noqa: PLC0415 -- agno extra, absent from the standard dev-crewai/dev-parlant lane venvs + _bind_room_tools, + _make_band_entrypoint, + ) + + entry = _make_band_entrypoint(BandTool.READ_ROOM_FILE) + with _bind_room_tools(ReadRoomFileTools()): + result = await entry(file_id="file-1") + + if not isinstance(result, ToolResult) or not result.images: + return False + image = result.images[0] + return len(result.images) == 1 and _is_expected_image( + image.content, image.mime_type + ) + + +async def _probe_strands() -> bool: + from band.adapters.strands import ( # noqa: PLC0415 -- strands extra, absent from the standard dev-crewai/dev-parlant lane venvs + _tool_result, + ) + + tool_use = {"toolUseId": "t1", "name": BandTool.READ_ROOM_FILE, "input": {}} + + result = _tool_result(tool_use, value=_IMAGE_RESULT, ok=True) + + # Strands names the format bare ("png"), not as a mime type. + expected_format = _EXPECTED_MIME_TYPE.removeprefix("image/") + return result["content"] == [ + {"image": {"format": expected_format, "source": {"bytes": _EXPECTED_BYTES}}} + ] + + +async def _probe_nooa() -> bool: + from band.adapters.nooa.tools import ( # noqa: PLC0415 -- nooa extra, absent outside Python 3.12-3.13 on POSIX + RoomToolSpec, + room_tools_class, + ) + + async def read(tools: Any, arguments: dict[str, Any]) -> object: + return _IMAGE_RESULT + + spec = RoomToolSpec( + BandTool.READ_ROOM_FILE, TOOL_MODELS[BandTool.READ_ROOM_FILE], read + ) + room = room_tools_class([spec])(emit_tool_calls=False) + async with room.bound_to(MagicMock()): + result = await getattr(room, BandTool.READ_ROOM_FILE)(file_id="f1") + + [image] = result + return image.modality == "image" and _is_expected_image( + image._base64_data(), image.media_type + ) + + +async def _probe_copilot_sdk() -> bool: + + from copilot import ( # noqa: PLC0415 -- copilot_sdk extra, absent from the standard dev-crewai/dev-parlant lane venvs + ToolInvocation, + ) + + from band.adapters.copilot_sdk import ( # noqa: PLC0415 -- copilot_sdk extra, absent from the standard dev-crewai/dev-parlant lane venvs + CopilotSDKAdapter, + ) + + room_tools = MagicMock() + room_tools.execute_tool_call_structured = AsyncMock( + return_value=ToolCallOutcome(value=_IMAGE_RESULT, ok=True) + ) + adapter = CopilotSDKAdapter.__new__(CopilotSDKAdapter) + adapter.features = SimpleNamespace(emit=()) + adapter._custom_tools = [] + adapter._custom_effects = {} + adapter._turn_state = {} + adapter._room_tools = {"room-1": room_tools} + + result = await adapter._execute_bridged_tool( + "room-1", + ToolInvocation( + tool_call_id="c1", tool_name=BandTool.READ_ROOM_FILE, arguments={} + ), + ) + + binary = result.binary_results_for_llm + if not binary or len(binary) != 1: + return False + return binary[0].type == "image" and _is_expected_image( + binary[0].data, binary[0].mime_type + ) + + +async def _probe_codex() -> bool: + from band.adapters.codex import ( # noqa: PLC0415 -- codex extra, absent from the standard dev-crewai/dev-parlant lane venvs + _image_content_items, + ) + + content_items = _image_content_items(_IMAGE_RESULT) + + data_uri = f"data:{_EXPECTED_MIME_TYPE};base64,{_EXPECTED_BASE64}" + return content_items == [{"type": "inputImage", "imageUrl": data_uri}] + + +async def _probe_pydantic_ai() -> bool: + from band.adapters.pydantic_ai import ( # noqa: PLC0415 -- pydantic_ai extra, absent from the standard dev-crewai/dev-parlant lane venvs + PydanticAIAdapter, + PydanticAIAdapterConfig, + ) + + adapter = PydanticAIAdapter( + PydanticAIAdapterConfig(model="test"), capabilities=Capability.FILES + ) + await adapter.on_started(agent_name="Probe", agent_description="probe") + read_room_file = adapter._agent._function_toolset.tools[BandTool.READ_ROOM_FILE] + + result = await read_room_file.function( + SimpleNamespace(deps=ReadRoomFileTools(), tool_call_id="probe"), + file_id="file-1", + ) + + if not isinstance(result, list) or len(result) != 1: + return False + binary = result[0] + return _is_expected_image(binary.data, binary.media_type) + + +async def _probe_crewai() -> bool: + from band.integrations.crewai.tools import ( # noqa: PLC0415 -- crewai extra, absent from the standard dev-crewai/dev-parlant lane venvs + CrewAIToolContext, + NoopReporter, + build_band_crewai_tools, + vision_sentinel, + ) + + context = CrewAIToolContext(room_id="room-1", tools=ReadRoomFileTools()) + tools = build_band_crewai_tools( + get_context=lambda: context, + reporter=NoopReporter(), + capabilities=frozenset({Capability.FILES}), + ) + read_room_file = _tool_named(tools, BandTool.READ_ROOM_FILE) + + result = read_room_file._run(file_id="file-1") + + return result == vision_sentinel(_IMAGE_RESULT) + + +IMAGE_PASSTHROUGH_PROBES: dict[str, Callable[[], Awaitable[bool]]] = { + "claude_sdk": _probe_claude_sdk, + "anthropic": _probe_anthropic, + "opencode": _probe_opencode, + "gemini": _probe_gemini, + "langgraph": _probe_langgraph, + "agno": _probe_agno, + "strands": _probe_strands, + "nooa": _probe_nooa, + "copilot_sdk": _probe_copilot_sdk, + "codex": _probe_codex, + "pydantic_ai": _probe_pydantic_ai, + "crewai": _probe_crewai, + # crewai_flow shares integrations/crewai/tools.py with crewai -- same probe. + "crewai_flow": _probe_crewai, +} + + +def _framework_param(framework_id: str) -> Any: + available = _SOMETIMES_MISSING.get(framework_id, True) + return pytest.param( + framework_id, + marks=pytest.mark.skipif( + not available, reason=f"{framework_id} not installed in this venv" + ), + ) diff --git a/tests/framework_conformance/lanekit.py b/tests/framework_conformance/lanekit.py new file mode 100644 index 000000000..0e7e34bfa --- /dev/null +++ b/tests/framework_conformance/lanekit.py @@ -0,0 +1,66 @@ +"""Pytest item doubles for baseline lane-scheduling guards.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.e2e.baseline.agents import ( + LANE_MARKER, + PER_ADAPTER_MARKER, + WITH_ADAPTERS_MARKER, + Adapter, + Lane, + PerAdapter, + WithAdapters, +) +from tests.e2e.baseline.toolkit.ci_lanes import ci_lanes + + +class FakeItem: + """Minimal ``pytest.Item`` stand-in for the collection hooks. + + Models only what ``item_frameworks`` / ``_override_lane`` read (callspec params, + the per-adapter / with-adapters / lane markers) and records ``add_marker`` skips so + a test can assert whether ``apply_lane_skips`` skipped it. + """ + + def __init__( + self, + nodeid: str, + *, + agents: tuple[Adapter, ...] | None = None, + adapter_id: str | None = None, + peer: Adapter | None = None, + assigned_lane: Lane | None = None, + ) -> None: + self.nodeid = nodeid + self._markers: dict[str, SimpleNamespace] = {} + self._added: list[object] = [] + if agents is not None: + req = WithAdapters(adapters=agents, prompt=None, features=None, tools=None) + self._markers[WITH_ADAPTERS_MARKER] = SimpleNamespace(args=(req,)) + if adapter_id is not None: + self.callspec = SimpleNamespace(params={"adapter_id": adapter_id}) + build = PerAdapter(prompt=None, features=None, tools=None, peer=peer) + self._markers[PER_ADAPTER_MARKER] = SimpleNamespace(args=(build,)) + if assigned_lane is not None: + self._markers[LANE_MARKER] = SimpleNamespace(args=(assigned_lane,)) + + def get_closest_marker(self, name: str) -> SimpleNamespace | None: + return self._markers.get(name) + + def add_marker(self, marker: object) -> None: + self._added.append(marker) + + @property + def skipped(self) -> bool: + return any(getattr(m, "name", None) == "skip" for m in self._added) + + +def _two_adapters_in_different_lanes() -> tuple[Adapter, Adapter]: + populated = [lane for lane in ci_lanes() if lane.adapters] + if len(populated) < 2: + pytest.skip("need >=2 populated CI lanes to exercise the schedulability guard") + return populated[0].adapters[0], populated[1].adapters[0] diff --git a/tests/framework_conformance/outcomeprobes.py b/tests/framework_conformance/outcomeprobes.py new file mode 100644 index 000000000..cfe8d896d --- /dev/null +++ b/tests/framework_conformance/outcomeprobes.py @@ -0,0 +1,150 @@ +"""Every judged adapter's turn ends with core's verdict, not its own. + +Each probe (see ``turnprobes``) drives one scripted turn through the adapter's +real ``on_event``. A turn that replied, declined, did real work or was settled +by the adapter completes silently; a turn that did nothing is reported once +with core's missing-reply text and raised as ``TurnResultAlreadyReported``, so +the runtime marks it FAILED. The relaying adapters post the model's final text +unless a tool already replied; the rest discard it. + +To add an adapter: write its probe in ``turnprobes/.py`` and register it +in ``TURN_OUTCOME_PROBES``. ``test_every_judged_adapter_has_a_probe`` fails +when a judged adapter has none. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +from dataclasses import dataclass + +from band_sdk_core import TurnVerdict + +import band +from band.core.protocols import TurnResultAlreadyReported +from band.core.simple_adapter import SimpleAdapter +from band.runtime.tools import BandTool +from band.testing import failure_reports +from band.testing.fake_tools import FakeAgentTools +from tests.framework_conformance.turnprobes import ( + ToolCall, + TurnOutcomeProbe, + TurnScript, + acp, + claudesdk, + codex, + copilotsdk, + crewai, + letta, + opencode, + toolloop, + turn_tools, +) + +TURN_OUTCOME_PROBES: dict[str, TurnOutcomeProbe] = { + **acp.PROBES, + **claudesdk.PROBES, + **codex.PROBES, + **copilotsdk.PROBES, + **crewai.PROBES, + **letta.PROBES, + **opencode.PROBES, + **toolloop.PROBES, +} + +ANSWER = "The vault code is 4471-ECHO." +REPLY = ToolCall(BandTool.SEND_MESSAGE, {"content": ANSWER, "mentions": ["@alice"]}) + + +@dataclass(frozen=True) +class TurnResult: + verdict: TurnVerdict + messages: list[str] + failures: list[tuple[str, str]] + + +async def run_turn(probe: TurnOutcomeProbe, script: TurnScript) -> TurnResult: + tools = turn_tools() + try: + await probe.run(script, tools) + verdict = TurnVerdict.Complete + except TurnResultAlreadyReported: + verdict = TurnVerdict.MissingReply + return observed(tools, verdict) + + +def observed(tools: FakeAgentTools, verdict: TurnVerdict) -> TurnResult: + return TurnResult( + verdict=verdict, + messages=tools.chat, + failures=failure_reports(tools), + ) + + +COMPLETING_SCRIPTS = { + "decline": TurnScript((ToolCall(BandTool.NO_REPLY, {"reason": "FYI only"}),)), + "reply": TurnScript((REPLY,)), + "act": TurnScript((ToolCall(BandTool.CREATE_CHATROOM),)), +} + + +def probe_params() -> list[str]: + return sorted(TURN_OUTCOME_PROBES) + + +def relaying_probe_params() -> list[str]: + return [name for name in probe_params() if TURN_OUTCOME_PROBES[name].relays] + + +#: Registered adapters whose turns are not the model's to answer through Band tools. +UNJUDGED_FRAMEWORK_IDS = frozenset({"crewai_flow", "parlant"}) + + +#: Judged adapters outside ADAPTER_CONFIGS, by the probe that runs their rows. +#: A subclass that inherits one of these ``on_message`` methods is covered too. +PROBED_OUTSIDE_CONFIGS = {"ACPClientAdapter": "acp", "CursorACPAdapter": "cursor_acp"} + +#: Adapters outside ADAPTER_CONFIGS whose verdict their own tests pin. +PINNED_OUTSIDE_CONFIGS = { + "SlackAdapter": "its brain's verdict (tests/integrations/slack)", + "A2AAdapter": "unjudged (tests/integrations/a2a)", + "A2AGatewayAdapter": "unjudged (tests/integrations/a2a/gateway)", + "BandACPServerAdapter": "unjudged (tests/integrations/acp)", +} + + +def band_adapters() -> dict[str, type[SimpleAdapter]]: + """Every concrete SimpleAdapter defined in band whose module imports here.""" + for module in pkgutil.walk_packages(band.__path__, "band."): + if module.name.endswith("__main__"): + continue + try: + importlib.import_module(module.name) + except ImportError as error: + missing = ( + error if isinstance(error, ModuleNotFoundError) else error.__cause__ + ) + if not isinstance(missing, ModuleNotFoundError) or ( + missing.name or "" + ).startswith("band"): + raise + continue # an absent optional package is covered by its owning lane + pending, adapters = [SimpleAdapter], {} + while pending: + for cls in pending.pop().__subclasses__(): + pending.append(cls) + if cls.__module__.startswith("band.") and not inspect.isabstract(cls): + adapters[cls.__name__] = cls + return adapters + + +def probe_running( + cls: type[SimpleAdapter], adapters: dict[str, type[SimpleAdapter]] +) -> str | None: + """The probe that runs ``cls``'s turn, via the ``on_message`` it uses.""" + for name, probe in PROBED_OUTSIDE_CONFIGS.items(): + owner = adapters.get(name) + if owner and issubclass(cls, owner) and cls.on_message is owner.on_message: + return probe + return None diff --git a/tests/framework_conformance/scorecardkit.py b/tests/framework_conformance/scorecardkit.py new file mode 100644 index 000000000..8142437e4 --- /dev/null +++ b/tests/framework_conformance/scorecardkit.py @@ -0,0 +1,84 @@ +"""Pytest marker, item, and report doubles for scorecard guards.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from tests.e2e.baseline.agents import ( + LANE_MARKER, + PER_ADAPTER_MARKER, + ExcludedAdapter, + PerAdapter, +) +from tests.e2e.baseline.toolkit.ci_lanes import ci_lanes + + +def _per_adapter_marker(fn: object) -> PerAdapter: + """The ``PerAdapter`` payload a decorated function carries.""" + for mark in fn.pytestmark: # type: ignore[attr-defined] + if mark.name == PER_ADAPTER_MARKER: + return mark.args[0] + raise AssertionError("no per_adapter marker on the decorated function") + + +def _parametrized_adapter_ids(fn: object) -> list[str]: + for mark in fn.pytestmark: # type: ignore[attr-defined] + if mark.name == "parametrize": + return [param.id for param in mark.args[1]] + raise AssertionError("no parametrize marker on the decorated function") + + +class ScorecardItem: + """A ``pytest.Item`` stand-in exposing only what ``na_rows`` reads.""" + + def __init__(self, nodeid: str, exclude: tuple[ExcludedAdapter, ...] = ()) -> None: + self.nodeid = nodeid + build = PerAdapter(prompt=None, features=None, tools=None, exclude=exclude) + self._marker = SimpleNamespace(args=(build,)) + + def get_closest_marker(self, name: str) -> object | None: + return self._marker if name == PER_ADAPTER_MARKER else None + + +def _report( + nodeid: str, + when: str, + *, + outcome: str, + reason: str | None = None, + keywords: tuple[str, ...] = (), +) -> object: + longrepr = ("m.py", 1, f"Skipped: {reason}") if reason is not None else None + return SimpleNamespace( + nodeid=nodeid, + when=when, + skipped=outcome == "skipped", + failed=outcome == "failed", + passed=outcome == "passed", + longrepr=longrepr, + keywords=frozenset(keywords), + ) + + +# Two distinct lanes with at least one adapter each, read off the real registry rather +# than hand-picked ids — a gate case only needs "two different homes", not which ones. +_LANE_A, _LANE_B = [lane for lane in ci_lanes() if lane.adapters][:2] +_ADAPTER_A = str(_LANE_A.adapters[0]) +_ADAPTER_B = str(_LANE_B.adapters[0]) + + +class FakeMatrixItem: + """A ``pytest.Item`` stand-in exposing what ``lane_selection.expected_lane`` reads: + its ``adapter_id`` callspec param and an optional ``@lane`` override marker.""" + + def __init__( + self, nodeid: str, adapter: str, override_lane: str | None = None + ) -> None: + self.nodeid = nodeid + self.callspec = SimpleNamespace(params={"adapter_id": adapter}) + self._override = override_lane + + def get_closest_marker(self, name: str) -> object | None: + if name == LANE_MARKER and self._override is not None: + return SimpleNamespace(args=(self._override,)) + return None diff --git a/tests/framework_conformance/test_collected_coverage.py b/tests/framework_conformance/test_collected_coverage.py new file mode 100644 index 000000000..8c416952c --- /dev/null +++ b/tests/framework_conformance/test_collected_coverage.py @@ -0,0 +1,41 @@ +"""Collection coverage follows pytest's actual keyword selection and framework bindings.""" + +from __future__ import annotations + +from pathlib import Path + +from tests.framework_conformance.venv_job_coverage import ( + collected_nodes, + selected_nodes, +) + + +class TestCollectedCoverage: + def test_keyword_filter_does_not_cover_an_explicitly_named_file( + self, tmp_path: Path + ) -> None: + test = tmp_path / "test_selection.py" + test.write_text( + "class TestSelection:\n" + " def test_retained(self): pass\n" + " def test_omitted(self): pass\n" + ) + full = selected_nodes([str(test)]) + selected = selected_nodes([str(test), "-k", "retained"]) + assert {node.rsplit("::", 1)[1] for node in full - selected} == {"test_omitted"} + + def test_framework_binding_does_not_depend_on_the_test_name( + self, tmp_path: Path + ) -> None: + test = tmp_path / "test_bindings.py" + test.write_text( + "import pytest\n" + "class TestBindings:\n" + ' @pytest.mark.parametrize("framework_id", ["strands", "pydantic_ai"])\n' + " def test_behavior(self, framework_id): pass\n" + ) + nodes = collected_nodes([str(test)]) + assert {node.frameworks for node in nodes} == { + frozenset({"strands"}), + frozenset({"pydantic_ai"}), + } diff --git a/tests/framework_conformance/test_files_image_passthrough_matrix.py b/tests/framework_conformance/test_files_image_passthrough_matrix.py index 1a460b103..afad4f485 100644 --- a/tests/framework_conformance/test_files_image_passthrough_matrix.py +++ b/tests/framework_conformance/test_files_image_passthrough_matrix.py @@ -1,481 +1,65 @@ -"""Live matrix proving each IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS entry -actually passes an image band_read_room_file result through as real -vision/image content -- not just that the bookkeeping constant lists it. - -Each probe drives the framework's real tool-dispatch code path with a fake -tools object whose read_room_file returns the exact MCP-image-content shape -AgentTools.read_room_file produces for a small previewable image, and asserts -the framework's own outgoing tool-result shape is a real image block rather -than a json.dumps'd text blob. Deeper framework-specific coverage (the -non-image degrade-to-text path, error handling, custom-tool interaction) -stays in each framework's own test file -- this matrix exists to answer one -question, uniformly, for every framework that claims support: does a real -image actually get through. - -To add a framework here: write a probe, add it to IMAGE_PASSTHROUGH_PROBES, -and add the framework_id to IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS in -test_adapter_conformance.py -- test_probe_registry_matches_supported_framework_ids -fails loudly if the two ever name different frameworks. -""" +"""ImagePassthrough behavior.""" from __future__ import annotations -import base64 -from collections.abc import Awaitable, Callable, Iterable -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock, MagicMock - import pytest -from band.core.types import AdapterFeatures, Capability -from band.integrations.mcp import BandMCPTransport -from band.runtime.tools import TOOL_DEFINITIONS, TOOL_MODELS, BandTool, ToolCallOutcome +from band.core.types import Capability +from tests.framework_conformance.imageprobes import ( + _CREWAI_AVAILABLE, + IMAGE_PASSTHROUGH_PROBES, + ReadRoomFileTools, + _framework_param, +) from tests.framework_conformance.test_adapter_conformance import ( IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS, ) -try: - import crewai # noqa: F401 - - _CREWAI_AVAILABLE = True -except ImportError: - _CREWAI_AVAILABLE = False - -try: - import pydantic_ai # noqa: F401 - - _PYDANTIC_AI_AVAILABLE = True -except ImportError: - _PYDANTIC_AI_AVAILABLE = False - -try: - import nooa # noqa: F401 - - _NOOA_AVAILABLE = True -except ImportError: - _NOOA_AVAILABLE = False - -# crewai and pydantic-ai aren't both installed in every lane's venv (a -# three-way conflict group with parlant -- see `conflicts` in pyproject.toml): -# dev-crewai lacks pydantic-ai, dev-parlant lacks both. These framework_ids -# need a per-lane skip the other probes (all in every lane's `dev` baseline) -# don't. -_SOMETIMES_MISSING: dict[str, bool] = { - "crewai": _CREWAI_AVAILABLE, - "crewai_flow": _CREWAI_AVAILABLE, - "pydantic_ai": _PYDANTIC_AI_AVAILABLE, - # NOOA supports Python 3.12 and 3.13 on POSIX only. - "nooa": _NOOA_AVAILABLE, -} - -_IMAGE_RESULT: dict[str, Any] = { - "content": [{"type": "image", "data": "ZmFrZQ==", "mimeType": "image/png"}] -} - -# Derived, never re-typed: a probe asserting on a hand-written "ZmFrZQ==" would -# still pass if _IMAGE_RESULT changed underneath it. -_EXPECTED_BASE64: str = _IMAGE_RESULT["content"][0]["data"] -_EXPECTED_MIME_TYPE: str = _IMAGE_RESULT["content"][0]["mimeType"] -_EXPECTED_BYTES: bytes = base64.b64decode(_EXPECTED_BASE64) - - -def _is_expected_image(data: bytes | str, mime_type: str) -> bool: - """Whether a framework's outgoing image block carries the probe's image. - - ``data`` arrives in whichever encoding that framework's SDK uses, so a - probe passes what it found rather than normalising first. - """ - expected = _EXPECTED_BYTES if isinstance(data, bytes) else _EXPECTED_BASE64 - return data == expected and mime_type == _EXPECTED_MIME_TYPE - - -def _tool_named(tools: Iterable[Any], name: str) -> Any: - """The one tool a framework built for ``name``.""" - return next(tool for tool in tools if tool.name == name) - -class _StubReadRoomFileTools: - """Minimal AgentToolsProtocol double whose read_room_file/execute_tool_call - always return the fixed image result -- only what each probe's dispatch - path calls (MCP-based probes call read_room_file; generic-dispatch probes - like agno call execute_tool_call).""" - - async def read_room_file(self, file_id: str) -> dict[str, Any]: - del file_id - return _IMAGE_RESULT - - async def execute_tool_call( - self, tool_name: str, arguments: dict[str, Any] - ) -> dict[str, Any]: - del tool_name, arguments - return _IMAGE_RESULT - - -async def _probe_claude_sdk() -> bool: - """claude_sdk's tools are its room-bound Band MCP backend, dialed over HTTP.""" - from tests.mcpclient import ( # noqa: PLC0415 -- imports mcp, a claude_sdk extra absent from the standard dev-crewai/dev-parlant lane venvs - mcp_session, - started_backend, - ) - - async with ( - started_backend( - room_bound=True, - tool_definitions=[TOOL_DEFINITIONS[BandTool.READ_ROOM_FILE]], - get_tools={"room-1": _StubReadRoomFileTools()}.get, - ) as backend, - mcp_session(backend.endpoint(BandMCPTransport.HTTP, "room-1")) as session, - ): - result = await session.call_tool(BandTool.READ_ROOM_FILE, {"file_id": "file-1"}) - - blocks = [block.model_dump(exclude_none=True) for block in result.content] - return not result.isError and {"content": blocks} == _IMAGE_RESULT - - -async def _probe_anthropic() -> bool: - - from anthropic.types import ( # noqa: PLC0415 -- anthropic extra, absent from the standard dev-crewai/dev-parlant lane venvs - ToolUseBlock, - ) - - from band.adapters.anthropic import ( # noqa: PLC0415 -- anthropic extra, absent from the standard dev-crewai/dev-parlant lane venvs - AnthropicAdapter, - ) - - adapter = AnthropicAdapter(emit=()) - tools = MagicMock() - tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) - response = MagicMock() - response.content = [ - ToolUseBlock( - type="tool_use", - id="tool-1", - name=BandTool.READ_ROOM_FILE, - input={"file_id": "file-1"}, +class TestImagePassthrough: + def test_probe_registry_matches_supported_framework_ids(self) -> None: + """The probes are the live proof behind the supported-framework set.""" + assert ( + set(IMAGE_PASSTHROUGH_PROBES) == IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS ) - ] - - results = await adapter._process_tool_calls(response, tools) - content = results[0]["content"] - if not isinstance(content, list) or len(content) != 1: - return False - block = content[0] - return block["type"] == "image" and _is_expected_image( - block["source"]["data"], block["source"]["media_type"] - ) - - -async def _probe_opencode() -> bool: - from mcp.shared.memory import ( # noqa: PLC0415 -- opencode extra, absent from the standard dev-crewai/dev-parlant lane venvs - create_connected_server_and_client_session, - ) - - from band.integrations.mcp.engine import ( # noqa: PLC0415 -- opencode extra, absent from the standard dev-crewai/dev-parlant lane venvs - EmbeddedResolver, - EngineSpec, - build_engine, - build_tool_registration, - extend_with_chat_id, - ) - - resolver = EmbeddedResolver(get_tools=lambda _chat_id: _StubReadRoomFileTools()) - definition = TOOL_DEFINITIONS[BandTool.READ_ROOM_FILE] - registration = build_tool_registration( - definition, - extend_with_chat_id(definition.input_model, None), - resolver=resolver, - strip_chat_id=True, - ) - mcp = build_engine(EngineSpec(name="probe-opencode", tools=(registration,))) - - async with create_connected_server_and_client_session(mcp) as session: - result = await session.call_tool( - BandTool.READ_ROOM_FILE, {"chat_id": "room-1", "file_id": "file-1"} + @pytest.mark.skipif( + not _CREWAI_AVAILABLE, reason="crewai not installed in this venv" + ) + def test_crewai_platform_tool_name_is_plain_str(self) -> None: + """CrewAI's real BaseTool doesn't validate field defaults, so a bare + BandTool (StrEnum) default would leave tool.name a BandTool instance at + runtime instead of the str the field is typed as -- str(spec.name) at + the PlatformTool definition must keep it a plain str.""" + from band.integrations.crewai.tools import ( # noqa: PLC0415 -- crewai extra, absent from the standard dev-crewai/dev-parlant lane venvs + CrewAIToolContext, + NoopReporter, + build_band_crewai_tools, ) - if result.isError or len(result.content) != 1: - return False - block = result.content[0] - return block.type == "image" and _is_expected_image(block.data, block.mimeType) - - -async def _probe_gemini() -> bool: - - from google.genai import ( # noqa: PLC0415 -- gemini extra, absent from the standard dev-crewai/dev-parlant lane venvs - types, - ) - - from band.adapters.gemini import ( # noqa: PLC0415 -- gemini extra, absent from the standard dev-crewai/dev-parlant lane venvs - GeminiAdapter, - ) - - adapter = GeminiAdapter() - tools = MagicMock() - tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) - function_calls = [ - types.FunctionCall( - name=BandTool.READ_ROOM_FILE, args={"file_id": "file-1"}, id="c1" + context = CrewAIToolContext(room_id="room-1", tools=ReadRoomFileTools()) + tools = build_band_crewai_tools( + get_context=lambda: context, + reporter=NoopReporter(), + capabilities=frozenset({Capability.FILES}), ) - ] - parts = await adapter._process_function_calls(function_calls, tools) + for tool in tools: + assert type(tool.name) is str, ( + f"{tool.name!r} is {type(tool.name)}, not plain str" + ) - function_response = parts[0].function_response - if function_response is None or not function_response.parts: - return False - inline_data = function_response.parts[0].inline_data - return inline_data is not None and _is_expected_image( - inline_data.data, inline_data.mime_type + @pytest.mark.asyncio + @pytest.mark.parametrize( + "framework_id", + [_framework_param(fid) for fid in sorted(IMAGE_PASSTHROUGH_PROBES)], ) + async def test_image_result_passes_through_as_real_content( + self, framework_id: str + ) -> None: + probe = IMAGE_PASSTHROUGH_PROBES[framework_id] - -async def _probe_langgraph() -> bool: - - from band.integrations.langgraph.langchain_tools import ( # noqa: PLC0415 -- langgraph extra, absent from the standard dev-crewai/dev-parlant lane venvs - agent_tools_to_langchain, - ) - - tools = MagicMock() - tools.is_hub_room = False - tools.execute_tool_call = AsyncMock(return_value=_IMAGE_RESULT) - wrapped = { - tool.name: tool - for tool in agent_tools_to_langchain( - tools, - features=AdapterFeatures(capabilities=frozenset({Capability.FILES})), + assert await probe(), ( + f"{framework_id} did not pass an image band_read_room_file result " + "through as real image content" ) - } - - result = await wrapped[BandTool.READ_ROOM_FILE].ainvoke({"file_id": "file-1"}) - - if not isinstance(result, list) or len(result) != 1: - return False - block = result[0] - return block["type"] == "image" and _is_expected_image( - block["base64"], block["mime_type"] - ) - - -async def _probe_agno() -> bool: - from agno.tools.function import ( # noqa: PLC0415 -- agno extra, absent from the standard dev-crewai/dev-parlant lane venvs - ToolResult, - ) - - from band.adapters.agno import ( # noqa: PLC0415 -- agno extra, absent from the standard dev-crewai/dev-parlant lane venvs - _bind_room_tools, - _make_band_entrypoint, - ) - - entry = _make_band_entrypoint(BandTool.READ_ROOM_FILE) - with _bind_room_tools(_StubReadRoomFileTools()): - result = await entry(file_id="file-1") - - if not isinstance(result, ToolResult) or not result.images: - return False - image = result.images[0] - return len(result.images) == 1 and _is_expected_image( - image.content, image.mime_type - ) - - -async def _probe_strands() -> bool: - from band.adapters.strands import ( # noqa: PLC0415 -- strands extra, absent from the standard dev-crewai/dev-parlant lane venvs - _tool_result, - ) - - tool_use = {"toolUseId": "t1", "name": BandTool.READ_ROOM_FILE, "input": {}} - - result = _tool_result(tool_use, value=_IMAGE_RESULT, ok=True) - - # Strands names the format bare ("png"), not as a mime type. - expected_format = _EXPECTED_MIME_TYPE.removeprefix("image/") - return result["content"] == [ - {"image": {"format": expected_format, "source": {"bytes": _EXPECTED_BYTES}}} - ] - - -async def _probe_nooa() -> bool: - from band.adapters.nooa.tools import ( # noqa: PLC0415 -- nooa extra, absent outside Python 3.12-3.13 on POSIX - RoomToolSpec, - room_tools_class, - ) - - async def read(tools: Any, arguments: dict[str, Any]) -> object: - return _IMAGE_RESULT - - spec = RoomToolSpec( - BandTool.READ_ROOM_FILE, TOOL_MODELS[BandTool.READ_ROOM_FILE], read - ) - room = room_tools_class([spec])(emit_tool_calls=False) - async with room.bound_to(MagicMock()): - result = await getattr(room, BandTool.READ_ROOM_FILE)(file_id="f1") - - [image] = result - return image.modality == "image" and _is_expected_image( - image._base64_data(), image.media_type - ) - - -async def _probe_copilot_sdk() -> bool: - - from copilot import ( # noqa: PLC0415 -- copilot_sdk extra, absent from the standard dev-crewai/dev-parlant lane venvs - ToolInvocation, - ) - - from band.adapters.copilot_sdk import ( # noqa: PLC0415 -- copilot_sdk extra, absent from the standard dev-crewai/dev-parlant lane venvs - CopilotSDKAdapter, - ) - - room_tools = MagicMock() - room_tools.execute_tool_call_structured = AsyncMock( - return_value=ToolCallOutcome(value=_IMAGE_RESULT, ok=True) - ) - adapter = CopilotSDKAdapter.__new__(CopilotSDKAdapter) - adapter.features = SimpleNamespace(emit=()) - adapter._custom_tools = [] - adapter._custom_effects = {} - adapter._turn_state = {} - adapter._room_tools = {"room-1": room_tools} - - result = await adapter._execute_bridged_tool( - "room-1", - ToolInvocation( - tool_call_id="c1", tool_name=BandTool.READ_ROOM_FILE, arguments={} - ), - ) - - binary = result.binary_results_for_llm - if not binary or len(binary) != 1: - return False - return binary[0].type == "image" and _is_expected_image( - binary[0].data, binary[0].mime_type - ) - - -async def _probe_codex() -> bool: - from band.adapters.codex import ( # noqa: PLC0415 -- codex extra, absent from the standard dev-crewai/dev-parlant lane venvs - _image_content_items, - ) - - content_items = _image_content_items(_IMAGE_RESULT) - - data_uri = f"data:{_EXPECTED_MIME_TYPE};base64,{_EXPECTED_BASE64}" - return content_items == [{"type": "inputImage", "imageUrl": data_uri}] - - -async def _probe_pydantic_ai() -> bool: - from band.adapters.pydantic_ai import ( # noqa: PLC0415 -- pydantic_ai extra, absent from the standard dev-crewai/dev-parlant lane venvs - PydanticAIAdapter, - PydanticAIAdapterConfig, - ) - - adapter = PydanticAIAdapter( - PydanticAIAdapterConfig(model="test"), capabilities=Capability.FILES - ) - await adapter.on_started(agent_name="Probe", agent_description="probe") - read_room_file = adapter._agent._function_toolset.tools[BandTool.READ_ROOM_FILE] - - result = await read_room_file.function( - SimpleNamespace(deps=_StubReadRoomFileTools(), tool_call_id="probe"), - file_id="file-1", - ) - - if not isinstance(result, list) or len(result) != 1: - return False - binary = result[0] - return _is_expected_image(binary.data, binary.media_type) - - -async def _probe_crewai() -> bool: - from band.integrations.crewai.tools import ( # noqa: PLC0415 -- crewai extra, absent from the standard dev-crewai/dev-parlant lane venvs - CrewAIToolContext, - NoopReporter, - build_band_crewai_tools, - vision_sentinel, - ) - - context = CrewAIToolContext(room_id="room-1", tools=_StubReadRoomFileTools()) - tools = build_band_crewai_tools( - get_context=lambda: context, - reporter=NoopReporter(), - capabilities=frozenset({Capability.FILES}), - ) - read_room_file = _tool_named(tools, BandTool.READ_ROOM_FILE) - - result = read_room_file._run(file_id="file-1") - - return result == vision_sentinel(_IMAGE_RESULT) - - -IMAGE_PASSTHROUGH_PROBES: dict[str, Callable[[], Awaitable[bool]]] = { - "claude_sdk": _probe_claude_sdk, - "anthropic": _probe_anthropic, - "opencode": _probe_opencode, - "gemini": _probe_gemini, - "langgraph": _probe_langgraph, - "agno": _probe_agno, - "strands": _probe_strands, - "nooa": _probe_nooa, - "copilot_sdk": _probe_copilot_sdk, - "codex": _probe_codex, - "pydantic_ai": _probe_pydantic_ai, - "crewai": _probe_crewai, - # crewai_flow shares integrations/crewai/tools.py with crewai -- same probe. - "crewai_flow": _probe_crewai, -} - - -def test_probe_registry_matches_supported_framework_ids() -> None: - """The probes are the live proof behind the supported-framework set.""" - assert set(IMAGE_PASSTHROUGH_PROBES) == IMAGE_PASSTHROUGH_SUPPORTED_FRAMEWORK_IDS - - -@pytest.mark.skipif(not _CREWAI_AVAILABLE, reason="crewai not installed in this venv") -def test_crewai_platform_tool_name_is_plain_str() -> None: - """CrewAI's real BaseTool doesn't validate field defaults, so a bare - BandTool (StrEnum) default would leave tool.name a BandTool instance at - runtime instead of the str the field is typed as -- str(spec.name) at - the PlatformTool definition must keep it a plain str.""" - from band.integrations.crewai.tools import ( # noqa: PLC0415 -- crewai extra, absent from the standard dev-crewai/dev-parlant lane venvs - CrewAIToolContext, - NoopReporter, - build_band_crewai_tools, - ) - - context = CrewAIToolContext(room_id="room-1", tools=_StubReadRoomFileTools()) - tools = build_band_crewai_tools( - get_context=lambda: context, - reporter=NoopReporter(), - capabilities=frozenset({Capability.FILES}), - ) - - for tool in tools: - assert type(tool.name) is str, ( - f"{tool.name!r} is {type(tool.name)}, not plain str" - ) - - -def _framework_param(framework_id: str) -> Any: - available = _SOMETIMES_MISSING.get(framework_id, True) - return pytest.param( - framework_id, - marks=pytest.mark.skipif( - not available, reason=f"{framework_id} not installed in this venv" - ), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "framework_id", - [_framework_param(fid) for fid in sorted(IMAGE_PASSTHROUGH_PROBES)], -) -async def test_image_result_passes_through_as_real_content(framework_id: str) -> None: - probe = IMAGE_PASSTHROUGH_PROBES[framework_id] - - assert await probe(), ( - f"{framework_id} did not pass an image band_read_room_file result " - "through as real image content" - ) diff --git a/tests/framework_conformance/test_lane_scheduling.py b/tests/framework_conformance/test_lane_scheduling.py index caa442d74..5aa79245a 100644 --- a/tests/framework_conformance/test_lane_scheduling.py +++ b/tests/framework_conformance/test_lane_scheduling.py @@ -17,18 +17,14 @@ from __future__ import annotations -from types import SimpleNamespace +import tomllib import pytest +from packaging.requirements import Requirement from tests.e2e.baseline.agents import ( - LANE_MARKER, - PER_ADAPTER_MARKER, - WITH_ADAPTERS_MARKER, Adapter, Lane, - PerAdapter, - WithAdapters, adapter_params, ) from tests.e2e.baseline.lane_selection import ( @@ -42,214 +38,185 @@ spec_for, specs, ) -from tests.e2e.baseline.toolkit.ci_lanes import ci_lanes, hosting_lanes +from tests.e2e.baseline.toolkit.ci_lanes import EXTRA_HOSTS, ci_lanes, hosting_lanes from tests.e2e.baseline.toolkit.deps import Extra - - -class _FakeItem: - """Minimal ``pytest.Item`` stand-in for the collection hooks. - - Models only what ``item_frameworks`` / ``_override_lane`` read (callspec params, - the per-adapter / with-adapters / lane markers) and records ``add_marker`` skips so - a test can assert whether ``apply_lane_skips`` skipped it. - """ - - def __init__( - self, - nodeid: str, - *, - agents: tuple[Adapter, ...] | None = None, - adapter_id: str | None = None, - peer: Adapter | None = None, - assigned_lane: Lane | None = None, - ) -> None: - self.nodeid = nodeid - self._markers: dict[str, SimpleNamespace] = {} - self._added: list[object] = [] - if agents is not None: - req = WithAdapters(adapters=agents, prompt=None, features=None, tools=None) - self._markers[WITH_ADAPTERS_MARKER] = SimpleNamespace(args=(req,)) - if adapter_id is not None: - self.callspec = SimpleNamespace(params={"adapter_id": adapter_id}) - build = PerAdapter(prompt=None, features=None, tools=None, peer=peer) - self._markers[PER_ADAPTER_MARKER] = SimpleNamespace(args=(build,)) - if assigned_lane is not None: - self._markers[LANE_MARKER] = SimpleNamespace(args=(assigned_lane,)) - - def get_closest_marker(self, name: str) -> SimpleNamespace | None: - return self._markers.get(name) - - def add_marker(self, marker: object) -> None: - self._added.append(marker) - - @property - def skipped(self) -> bool: - return any(getattr(m, "name", None) == "skip" for m in self._added) - - -def _two_adapters_in_different_lanes() -> tuple[Adapter, Adapter]: - populated = [lane for lane in ci_lanes() if lane.adapters] - if len(populated) < 2: - pytest.skip("need >=2 populated CI lanes to exercise the schedulability guard") - return populated[0].adapters[0], populated[1].adapters[0] - - -# --- matrix-invariance: single-adapter cells shard exactly by their home lane -------- - - -def test_single_adapter_cells_schedule_by_home_lane() -> None: - """For every registered adapter and every lane, a lone cell runs iff active==home.""" - lane_ids = [str(cl.id) for cl in ci_lanes()] - for spec in specs(include_pending=True): - home = str(adapter_lane(spec)) - for active in lane_ids: - item = _FakeItem(f"m.py::test[{spec.id}]", adapter_id=str(spec.id)) - apply_lane_skips(active, [item]) - assert item.skipped == (home != active), ( - f"{spec.id} active={active} home={home}: skipped={item.skipped}" +from tests.framework_conformance.lanekit import ( + FakeItem, + _two_adapters_in_different_lanes, +) +from tests.paths import REPO_ROOT + + +class TestLaneScheduling: + def test_single_adapter_cells_schedule_by_home_lane(self) -> None: + """For every registered adapter and every lane, a lone cell runs iff active==home.""" + lane_ids = [str(cl.id) for cl in ci_lanes()] + for spec in specs(include_pending=True): + home = str(adapter_lane(spec)) + for active in lane_ids: + item = FakeItem(f"m.py::test[{spec.id}]", adapter_id=str(spec.id)) + apply_lane_skips(active, [item]) + assert item.skipped == (home != active), ( + f"{spec.id} active={active} home={home}: skipped={item.skipped}" + ) + + def test_adapter_agnostic_item_runs_in_every_lane(self) -> None: + for cl in ci_lanes(): + item = FakeItem("p.py::test_agnostic") + apply_lane_skips(str(cl.id), [item]) + assert not item.skipped + + def test_unknown_active_lane_is_a_usage_error(self) -> None: + with pytest.raises(pytest.UsageError): + apply_lane_skips( + "no-such-lane", [FakeItem("x.py::t", adapter_id="anthropic")] ) - -def test_adapter_agnostic_item_runs_in_every_lane() -> None: - for cl in ci_lanes(): - item = _FakeItem("p.py::test_agnostic") - apply_lane_skips(str(cl.id), [item]) - assert not item.skipped - - -def test_unknown_active_lane_is_a_usage_error() -> None: - with pytest.raises(pytest.UsageError): - apply_lane_skips("no-such-lane", [_FakeItem("x.py::t", adapter_id="anthropic")]) - - -# --- schedulability guard: cross-lane spans fail unless assigned -------------------- - - -def test_cross_lane_with_adapters_fails_collection() -> None: - a, b = _two_adapters_in_different_lanes() - item = _FakeItem("x.py::test_cross", agents=(a, b)) - with pytest.raises(pytest.UsageError, match="unschedulable"): - assert_every_item_is_schedulable([item]) - - -def test_cross_lane_peer_fails_collection() -> None: - """Peer-aware: a cell whose peer lives in another lane trips the guard too.""" - a, b = _two_adapters_in_different_lanes() - item = _FakeItem("x.py::test_peer", adapter_id=str(a), peer=b) - with pytest.raises(pytest.UsageError, match="unschedulable"): - assert_every_item_is_schedulable([item]) - - -def test_lane_override_makes_cross_lane_schedulable() -> None: - a, b = _two_adapters_in_different_lanes() - item = _FakeItem( - "x.py::test_ok", - adapter_id=str(a), - peer=b, - assigned_lane=adapter_lane(spec_for(a)), + def test_cross_lane_with_adapters_fails_collection(self) -> None: + a, b = _two_adapters_in_different_lanes() + item = FakeItem("x.py::test_cross", agents=(a, b)) + with pytest.raises(pytest.UsageError, match="unschedulable"): + assert_every_item_is_schedulable([item]) + + def test_cross_lane_peer_fails_collection(self) -> None: + """Peer-aware: a cell whose peer lives in another lane trips the guard too.""" + a, b = _two_adapters_in_different_lanes() + item = FakeItem("x.py::test_peer", adapter_id=str(a), peer=b) + with pytest.raises(pytest.UsageError, match="unschedulable"): + assert_every_item_is_schedulable([item]) + + def test_lane_override_makes_cross_lane_schedulable(self) -> None: + a, b = _two_adapters_in_different_lanes() + item = FakeItem( + "x.py::test_ok", + adapter_id=str(a), + peer=b, + assigned_lane=adapter_lane(spec_for(a)), + ) + assert_every_item_is_schedulable([item]) # no raise + + def test_same_lane_peer_is_schedulable(self) -> None: + """A peer in the cell's own lane never spans (the cross-framework/same-lane case).""" + item = FakeItem( + "x.py::test_same", adapter_id="anthropic", peer=Adapter.CLAUDE_SDK + ) + assert_every_item_is_schedulable([item]) # both core + # ...and it runs in core, skips elsewhere. + core = FakeItem("x.py::t", adapter_id="anthropic", peer=Adapter.CLAUDE_SDK) + apply_lane_skips("core", [core]) + assert not core.skipped + + def test_lane_override_runs_only_in_assigned_lane(self) -> None: + a, b = _two_adapters_in_different_lanes() + home_a = adapter_lane(spec_for(a)) + run = FakeItem("x.py::t", adapter_id=str(a), peer=b, assigned_lane=home_a) + apply_lane_skips(str(home_a), [run]) + assert not run.skipped + other = next(str(cl.id) for cl in ci_lanes() if str(cl.id) != str(home_a)) + skip = FakeItem("x.py::t", adapter_id=str(a), peer=b, assigned_lane=home_a) + apply_lane_skips(other, [skip]) + assert skip.skipped + + def test_hosting_lanes_truth_table(self) -> None: + """A dev home hosts in every dev lane; incompatible extras host nowhere; empty→all.""" + dev_lanes = frozenset(str(cl.id) for cl in ci_lanes() if cl.extra == Extra.DEV) + all_lanes = frozenset(str(cl.id) for cl in ci_lanes()) + assert hosting_lanes(frozenset({str(Adapter.ANTHROPIC)})) == dev_lanes + assert hosting_lanes(frozenset()) == all_lanes + assert ( + hosting_lanes(frozenset({str(Adapter.ANTHROPIC), str(Adapter.CREWAI)})) + == frozenset() + ) + + def test_lane_override_must_name_a_hosting_lane(self) -> None: + """A real-but-non-hosting @lane target (wrong extra) fails the guard — finding 1. + + agno (core) + gemini (google) share the ``dev`` extra; ``crewai`` is ``dev-crewai`` + and cannot host them, so pinning @lane(crewai) is a wrong-but-real lane. + """ + item = FakeItem( + "x.py::t", adapter_id="agno", peer=Adapter.GEMINI, assigned_lane=Lane.CREWAI + ) + with pytest.raises(pytest.UsageError, match="no lane hosts all its frameworks"): + assert_every_item_is_schedulable([item]) + + def test_incompatible_extras_are_unschedulable(self) -> None: + """crewai + a dev framework need different extras — no lane hosts them; no @lane helps.""" + item = FakeItem("x.py::t", adapter_id="anthropic", peer=Adapter.CREWAI) + with pytest.raises(pytest.UsageError, match="incompatible uv extras"): + assert_every_item_is_schedulable([item]) + pinned = FakeItem( + "x.py::t", + adapter_id="anthropic", + peer=Adapter.CREWAI, + assigned_lane=Lane.CORE, + ) + with pytest.raises(pytest.UsageError, match="no lane hosts all its frameworks"): + assert_every_item_is_schedulable([pinned]) + + def test_same_extra_cross_home_needs_lane_then_passes(self) -> None: + """agno (core) + gemini (google) share the dev extra: ambiguous → needs @lane, which fixes it.""" + bare = FakeItem("x.py::t", adapter_id="agno", peer=Adapter.GEMINI) + with pytest.raises(pytest.UsageError, match="add @lane"): + assert_every_item_is_schedulable([bare]) + pinned = FakeItem( + "x.py::t", adapter_id="agno", peer=Adapter.GEMINI, assigned_lane=Lane.GOOGLE + ) + assert_every_item_is_schedulable([pinned]) # google hosts both dev frameworks + + def test_peer_deps_fold_into_single_requires_mark(self) -> None: + """adapter_params(peer=X) → exactly one requires mark == dedup(cell ∪ peer) deps.""" + params = adapter_params(include={Adapter.ANTHROPIC}, peer=Adapter.LANGGRAPH) + (param,) = params + requires_marks = [m for m in param.marks if m.name == REQUIRES_MARKER] + assert len(requires_marks) == 1, ( + "peer deps must ride on ONE mark, not a second one" + ) + got = set(requires_marks[0].args[0]) + expected = set(spec_for(Adapter.ANTHROPIC).requires) | set( + spec_for(Adapter.LANGGRAPH).requires + ) + assert got == expected + + def test_no_peer_leaves_requires_mark_unchanged(self) -> None: + (param,) = adapter_params(include={Adapter.ANTHROPIC}) + requires_marks = [m for m in param.marks if m.name == REQUIRES_MARKER] + assert len(requires_marks) == 1 + assert set(requires_marks[0].args[0]) == set( + spec_for(Adapter.ANTHROPIC).requires + ) + + def test_empty_tools_builds_no_custom_tools(self) -> None: + """tools=[] must resolve to no custom tools (withholding a tool from A must stick).""" + assert _custom_tool_defs([]) is None + assert _custom_tool_defs(None) is None + + @pytest.mark.parametrize( + ("frameworks", "expected"), + [ + ({Adapter.STRANDS, Adapter.LANGGRAPH}, {Lane.STRANDS, Lane.NOOA}), + ({Adapter.STRANDS, Adapter.NOOA}, {Lane.STRANDS, Lane.NOOA}), + ({Adapter.STRANDS, Adapter.PYDANTIC_AI}, set()), + ], ) - assert_every_item_is_schedulable([item]) # no raise - - -def test_same_lane_peer_is_schedulable() -> None: - """A peer in the cell's own lane never spans (the cross-framework/same-lane case).""" - item = _FakeItem("x.py::test_same", adapter_id="anthropic", peer=Adapter.CLAUDE_SDK) - assert_every_item_is_schedulable([item]) # both core - # ...and it runs in core, skips elsewhere. - core = _FakeItem("x.py::t", adapter_id="anthropic", peer=Adapter.CLAUDE_SDK) - apply_lane_skips("core", [core]) - assert not core.skipped - - -def test_lane_override_runs_only_in_assigned_lane() -> None: - a, b = _two_adapters_in_different_lanes() - home_a = adapter_lane(spec_for(a)) - run = _FakeItem("x.py::t", adapter_id=str(a), peer=b, assigned_lane=home_a) - apply_lane_skips(str(home_a), [run]) - assert not run.skipped - other = next(str(cl.id) for cl in ci_lanes() if str(cl.id) != str(home_a)) - skip = _FakeItem("x.py::t", adapter_id=str(a), peer=b, assigned_lane=home_a) - apply_lane_skips(other, [skip]) - assert skip.skipped - - -# --- hosting: which lanes a framework set can run in, and @lane validation ----------- - - -def test_hosting_lanes_truth_table() -> None: - """A dev home hosts in every dev lane; incompatible extras host nowhere; empty→all.""" - dev_lanes = frozenset(str(cl.id) for cl in ci_lanes() if cl.extra == Extra.DEV) - all_lanes = frozenset(str(cl.id) for cl in ci_lanes()) - core_home = str(adapter_lane(spec_for(Adapter.ANTHROPIC))) # 'core' (dev extra) - crewai_home = str(adapter_lane(spec_for(Adapter.CREWAI))) # 'crewai' (dev-crewai) - assert hosting_lanes(frozenset({core_home})) == dev_lanes - assert hosting_lanes(frozenset()) == all_lanes - assert hosting_lanes(frozenset({core_home, crewai_home})) == frozenset() - - -def test_lane_override_must_name_a_hosting_lane() -> None: - """A real-but-non-hosting @lane target (wrong extra) fails the guard — finding 1. - - agno (core) + gemini (google) share the ``dev`` extra; ``crewai`` is ``dev-crewai`` - and cannot host them, so pinning @lane(crewai) is a wrong-but-real lane. - """ - item = _FakeItem( - "x.py::t", adapter_id="agno", peer=Adapter.GEMINI, assigned_lane=Lane.CREWAI - ) - with pytest.raises(pytest.UsageError, match="no lane hosts all its frameworks"): - assert_every_item_is_schedulable([item]) - - -def test_incompatible_extras_are_unschedulable() -> None: - """crewai + a dev framework need different extras — no lane hosts them; no @lane helps.""" - item = _FakeItem("x.py::t", adapter_id="anthropic", peer=Adapter.CREWAI) - with pytest.raises(pytest.UsageError, match="incompatible uv extras"): - assert_every_item_is_schedulable([item]) - pinned = _FakeItem( - "x.py::t", adapter_id="anthropic", peer=Adapter.CREWAI, assigned_lane=Lane.CORE - ) - with pytest.raises(pytest.UsageError, match="no lane hosts all its frameworks"): - assert_every_item_is_schedulable([pinned]) - - -def test_same_extra_cross_home_needs_lane_then_passes() -> None: - """agno (core) + gemini (google) share the dev extra: ambiguous → needs @lane, which fixes it.""" - bare = _FakeItem("x.py::t", adapter_id="agno", peer=Adapter.GEMINI) - with pytest.raises(pytest.UsageError, match="add @lane"): - assert_every_item_is_schedulable([bare]) - pinned = _FakeItem( - "x.py::t", adapter_id="agno", peer=Adapter.GEMINI, assigned_lane=Lane.GOOGLE - ) - assert_every_item_is_schedulable([pinned]) # google hosts both dev frameworks - - -# --- dep gating: peer requirements fold into ONE merged mark ------------------------- - - -def test_peer_deps_fold_into_single_requires_mark() -> None: - """adapter_params(peer=X) → exactly one requires mark == dedup(cell ∪ peer) deps.""" - params = adapter_params(include={Adapter.ANTHROPIC}, peer=Adapter.LANGGRAPH) - (param,) = params - requires_marks = [m for m in param.marks if m.name == REQUIRES_MARKER] - assert len(requires_marks) == 1, "peer deps must ride on ONE mark, not a second one" - got = set(requires_marks[0].args[0]) - expected = set(spec_for(Adapter.ANTHROPIC).requires) | set( - spec_for(Adapter.LANGGRAPH).requires - ) - assert got == expected - - -def test_no_peer_leaves_requires_mark_unchanged() -> None: - (param,) = adapter_params(include={Adapter.ANTHROPIC}) - requires_marks = [m for m in param.marks if m.name == REQUIRES_MARKER] - assert len(requires_marks) == 1 - assert set(requires_marks[0].args[0]) == set(spec_for(Adapter.ANTHROPIC).requires) - - -# --- withholding a tool sticks ------------------------------------------------------ - - -def test_empty_tools_builds_no_custom_tools() -> None: - """tools=[] must resolve to no custom tools (withholding a tool from A must stick).""" - assert _custom_tool_defs([]) is None - assert _custom_tool_defs(None) is None + def test_strands_nooa_hosting_is_framework_specific( + self, frameworks, expected + ) -> None: + assert hosting_lanes(frozenset(str(value) for value in frameworks)) == expected + + def test_strands_cannot_be_assigned_to_core(self) -> None: + item = FakeItem( + "x.py::t", adapter_id=str(Adapter.STRANDS), assigned_lane=Lane.CORE + ) + with pytest.raises(pytest.UsageError, match="no lane hosts all its frameworks"): + assert_every_item_is_schedulable([item]) + + def test_extra_hosts_have_their_public_dependencies(self) -> None: + extras = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())["project"][ + "optional-dependencies" + ] + for extra, adapters in EXTRA_HOSTS.items(): + installed = {Requirement(value).name for value in extras[extra]} + for adapter in adapters: + needed = {Requirement(value).name for value in extras[str(adapter)]} + assert needed <= installed, sorted(needed - installed) diff --git a/tests/framework_conformance/test_scorecard.py b/tests/framework_conformance/test_scorecard.py index 8c6fccfca..1b5aa2cd0 100644 --- a/tests/framework_conformance/test_scorecard.py +++ b/tests/framework_conformance/test_scorecard.py @@ -1,22 +1,8 @@ -"""Unit guards for the adapter×test scorecard (``ExcludedAdapter`` + ``scorecard``). - -Pure-function tests (no live platform), so they run in the ordinary unit suite on every -PR rather than only in the manually-triggered E2E job — the scorecard is what keeps an -excluded adapter from vanishing from the matrix, and its reasons feed CI gating, so a -regression here silently drops rows or loses the N/A explanations. - -Covered: -* ``ExcludedAdapter`` requires a non-empty reason at construction (so ``@per_adapter`` - cannot exclude an adapter without saying why); -* ``@per_adapter`` drops the excluded adapters from the fanned cells yet carries their - reasons on the ``PerAdapter`` marker, and rejects an unregistered exclusion; -* ``na_rows`` turns those marker records into N/A rows; ``outcome_row`` maps a test - report to pass / fail / skip; ``merge`` unions per-lane cards (a real outcome beats a - skip, an N/A is never clobbered). -""" +"""Scorecard behavior.""" from __future__ import annotations +import importlib.util import json import subprocess import sys @@ -26,11 +12,8 @@ import pytest from tests.e2e.baseline.agents import ( - LANE_MARKER, - PER_ADAPTER_MARKER, Adapter, ExcludedAdapter, - PerAdapter, per_adapter, ) from tests.e2e.baseline.scorecard import ( @@ -51,499 +34,426 @@ outcome_row, to_markdown, ) -from tests.e2e.baseline.toolkit.ci_lanes import ci_lanes +from tests.e2e.baseline.toolkit.deps import Lane +from tests.framework_conformance.scorecardkit import ( + _ADAPTER_A, + _ADAPTER_B, + _LANE_A, + _LANE_B, + FakeMatrixItem, + ScorecardItem, + _parametrized_adapter_ids, + _per_adapter_marker, + _report, +) from tests.paths import REPO_ROOT -# --- ExcludedAdapter: a reason is mandatory ----------------------------------------- - - -def test_excluded_adapter_keeps_adapter_and_reason() -> None: - excluded = ExcludedAdapter(Adapter.CREWAI, "no per-turn usage") - assert excluded.adapter is Adapter.CREWAI - assert excluded.reason == "no per-turn usage" - - -@pytest.mark.parametrize("reason", ["", " ", "\n\t"]) -def test_excluded_adapter_rejects_empty_reason(reason: str) -> None: - with pytest.raises(ValueError, match="non-empty reason"): - ExcludedAdapter(Adapter.CREWAI, reason) - - -# --- @per_adapter: excluded cells drop out, but their reasons ride the marker -------- - - -def _per_adapter_marker(fn: object) -> PerAdapter: - """The ``PerAdapter`` payload a decorated function carries.""" - for mark in fn.pytestmark: # type: ignore[attr-defined] - if mark.name == PER_ADAPTER_MARKER: - return mark.args[0] - raise AssertionError("no per_adapter marker on the decorated function") - - -def _parametrized_adapter_ids(fn: object) -> list[str]: - for mark in fn.pytestmark: # type: ignore[attr-defined] - if mark.name == "parametrize": - return [param.id for param in mark.args[1]] - raise AssertionError("no parametrize marker on the decorated function") - - -def test_per_adapter_excludes_cell_but_carries_reason() -> None: - @per_adapter(exclude=[ExcludedAdapter(Adapter.CREWAI, "cumulative usage")]) - def fn() -> None: ... - - assert str(Adapter.CREWAI) not in _parametrized_adapter_ids(fn) - payload = _per_adapter_marker(fn) - assert payload.exclude == (ExcludedAdapter(Adapter.CREWAI, "cumulative usage"),) - - -def test_per_adapter_rejects_unregistered_exclusion() -> None: - # Adapter is a StrEnum, so a plain unregistered string is an unknown id. - with pytest.raises(ValueError, match="unregistered adapters"): - per_adapter(exclude=[ExcludedAdapter("no-such-adapter", "typo")])(lambda: None) # type: ignore[arg-type] +class TestScorecard: + def test_excluded_adapter_keeps_adapter_and_reason(self) -> None: + excluded = ExcludedAdapter(Adapter.CREWAI, "no per-turn usage") + assert excluded.adapter is Adapter.CREWAI + assert excluded.reason == "no per-turn usage" + + @pytest.mark.parametrize("reason", ["", " ", "\n\t"]) + def test_excluded_adapter_rejects_empty_reason(self, reason: str) -> None: + with pytest.raises(ValueError, match="non-empty reason"): + ExcludedAdapter(Adapter.CREWAI, reason) + + def test_per_adapter_excludes_cell_but_carries_reason(self) -> None: + @per_adapter(exclude=[ExcludedAdapter(Adapter.CREWAI, "cumulative usage")]) + def fn() -> None: ... + + assert str(Adapter.CREWAI) not in _parametrized_adapter_ids(fn) + payload = _per_adapter_marker(fn) + assert payload.exclude == (ExcludedAdapter(Adapter.CREWAI, "cumulative usage"),) + + def test_per_adapter_rejects_unregistered_exclusion(self) -> None: + # Adapter is a StrEnum, so a plain unregistered string is an unknown id. + with pytest.raises(ValueError, match="unregistered adapters"): + per_adapter(exclude=[ExcludedAdapter("no-such-adapter", "typo")])( + lambda: None + ) # type: ignore[arg-type] + + def test_na_rows_from_marker_exclusions(self) -> None: + exclude = ( + ExcludedAdapter(Adapter.CREWAI, "cumulative usage"), + ExcludedAdapter(Adapter.CREWAI_FLOW, "flow internals"), + ) + # Two collected cells of the same test share the marker; the reasons dedupe by key. + items = [ + ScorecardItem("m.py::test_x[anthropic]", exclude), + ScorecardItem("m.py::test_x[agno]", exclude), + ] + rows = na_rows(items) + assert rows[("m.py::test_x", "crewai")] == ScorecardRow( + "m.py::test_x", "crewai", "na", "cumulative usage" + ) + assert rows[("m.py::test_x", "crewai_flow")].reason == "flow internals" + assert len(rows) == 2 -# --- na_rows: marker exclusions become N/A rows ------------------------------------- - - -class _FakeItem: - """A ``pytest.Item`` stand-in exposing only what ``na_rows`` reads.""" - - def __init__(self, nodeid: str, exclude: tuple[ExcludedAdapter, ...] = ()) -> None: - self.nodeid = nodeid - build = PerAdapter(prompt=None, features=None, tools=None, exclude=exclude) - self._marker = SimpleNamespace(args=(build,)) - - def get_closest_marker(self, name: str) -> object | None: - return self._marker if name == PER_ADAPTER_MARKER else None - - -def test_na_rows_from_marker_exclusions() -> None: - exclude = ( - ExcludedAdapter(Adapter.CREWAI, "cumulative usage"), - ExcludedAdapter(Adapter.CREWAI_FLOW, "flow internals"), - ) - # Two collected cells of the same test share the marker; the reasons dedupe by key. - items = [ - _FakeItem("m.py::test_x[anthropic]", exclude), - _FakeItem("m.py::test_x[agno]", exclude), - ] - rows = na_rows(items) - assert rows[("m.py::test_x", "crewai")] == ScorecardRow( - "m.py::test_x", "crewai", "na", "cumulative usage" - ) - assert rows[("m.py::test_x", "crewai_flow")].reason == "flow internals" - assert len(rows) == 2 - - -def test_na_rows_ignores_items_without_per_adapter_marker() -> None: - class _Bare: - nodeid = "p.py::test_provisioning" - - def get_closest_marker(self, name: str) -> None: - return None - - assert na_rows([_Bare()]) == {} - - -# --- outcome_row: a test report -> a cell verdict ----------------------------------- - - -def _report( - nodeid: str, - when: str, - *, - outcome: str, - reason: str | None = None, - keywords: tuple[str, ...] = (), -) -> object: - longrepr = ("m.py", 1, f"Skipped: {reason}") if reason is not None else None - return SimpleNamespace( - nodeid=nodeid, - when=when, - skipped=outcome == "skipped", - failed=outcome == "failed", - passed=outcome == "passed", - longrepr=longrepr, - keywords=frozenset(keywords), - ) - + def test_na_rows_ignores_items_without_per_adapter_marker(self) -> None: + class _Bare: + nodeid = "p.py::test_provisioning" -def test_outcome_row_call_pass_and_fail() -> None: - key, row = outcome_row(_report("m.py::t[anthropic]", "call", outcome="passed")) - assert (key, row.status) == (("m.py::t", "anthropic"), "pass") - _, fail = outcome_row(_report("m.py::t[crewai]", "call", outcome="failed")) - assert fail.status == "fail" + def get_closest_marker(self, name: str) -> None: + return None + assert na_rows([_Bare()]) == {} -def test_outcome_row_setup_skip_captures_reason() -> None: - _, row = outcome_row( - _report("m.py::t[agno]", "setup", outcome="skipped", reason="lane 'core'") - ) - assert (row.status, row.reason) == ("skip", "lane 'core'") + def test_outcome_row_call_pass_and_fail(self) -> None: + key, row = outcome_row(_report("m.py::t[anthropic]", "call", outcome="passed")) + assert (key, row.status) == (("m.py::t", "anthropic"), "pass") + _, fail = outcome_row(_report("m.py::t[crewai]", "call", outcome="failed")) + assert fail.status == "fail" + def test_outcome_row_setup_skip_captures_reason(self) -> None: + _, row = outcome_row( + _report("m.py::t[agno]", "setup", outcome="skipped", reason="lane 'core'") + ) + assert (row.status, row.reason) == ("skip", "lane 'core'") + + def test_env_gated_skip_applies_both_skipif_and_marker(self) -> None: + @env_gated_skip(True, reason="flag is off") + def fn() -> None: ... + + marks = {mark.name for mark in fn.pytestmark} # type: ignore[attr-defined] + assert marks == {"skipif", ENV_GATED_MARKER} + + def test_outcome_row_env_gated_skip_reports_na_not_missing(self) -> None: + # A deployment flag that is permanently off in this environment (e.g. the SaaS + # E2E lanes' ff_file_transfer) must never read as "missing coverage" -- see + # ENV_GATED_MARKER. + _, row = outcome_row( + _report( + "m.py::t[anthropic]", + "setup", + outcome="skipped", + reason="E2E_FILE_TRANSFER is not true", + keywords=(ENV_GATED_MARKER,), + ) + ) + assert (row.status, row.reason) == ("na", "E2E_FILE_TRANSFER is not true") + + def test_outcome_row_setup_error_is_a_fail(self) -> None: + _, row = outcome_row(_report("m.py::t[agno]", "setup", outcome="failed")) + assert row.status == "fail" + + def test_outcome_row_records_bespoke_and_ignores_passing_setup(self) -> None: + key, row = outcome_row(_report("p.py::test_no_param", "call", outcome="passed")) + assert key == ("p.py::test_no_param", SUITE_ADAPTER) + assert row.status == "pass" + assert outcome_row(_report("m.py::t[agno]", "setup", outcome="passed")) is None + assert ( + outcome_row(_report("m.py::t[agno]", "teardown", outcome="passed")) is None + ) -def test_env_gated_skip_applies_both_skipif_and_marker() -> None: - @env_gated_skip(True, reason="flag is off") - def fn() -> None: ... + def test_outcome_row_keeps_non_adapter_parametrization_distinct(self) -> None: + key, row = outcome_row( + _report("e.py::test_send_event[thought]", "call", outcome="passed") + ) + assert key == ("e.py::test_send_event[thought]", SUITE_ADAPTER) + assert row.status == "pass" - marks = {mark.name for mark in fn.pytestmark} # type: ignore[attr-defined] - assert marks == {"skipif", ENV_GATED_MARKER} + def test_collector_combines_outcomes_and_na(self) -> None: + collector = ScorecardCollector(path="unused") + collector.pytest_runtest_logreport( + _report("m.py::t[anthropic]", "call", outcome="passed") + ) + item = ScorecardItem( + "m.py::t[anthropic]", (ExcludedAdapter(Adapter.CREWAI, "no usage"),) + ) + rows = {(r.test, r.adapter): r for r in collector.scorecard([item])} + assert rows[("m.py::t", "anthropic")].status == "pass" + assert rows[("m.py::t", "crewai")].status == "na" + + def test_collector_keeps_a_failed_first_attempt(self) -> None: + collector = ScorecardCollector(path="unused") + nodeid = "m.py::t[anthropic]" + collector.pytest_runtest_logreport(_report(nodeid, "call", outcome="failed")) + collector.pytest_runtest_logreport(_report(nodeid, "call", outcome="passed")) + rows = collector.scorecard([ScorecardItem(nodeid)]) + assert rows == [ScorecardRow("m.py::t", "anthropic", "fail")] + + def test_merge_prefers_real_outcome_over_skip_and_keeps_na(self) -> None: + lane_a = [ + ScorecardRow("t", "anthropic", "pass"), + ScorecardRow("t", "crewai", "skip", "lane"), + ScorecardRow("t", "crewai_flow", "na", "no usage"), + ] + lane_b = [ + ScorecardRow("t", "anthropic", "skip", "lane"), + ScorecardRow("t", "crewai", "fail"), + ScorecardRow("t", "crewai_flow", "na", "no usage"), + ] + merged = {(r.test, r.adapter): r for r in merge([lane_a, lane_b])} + assert merged[("t", "anthropic")].status == "pass" + assert merged[("t", "crewai")].status == "fail" + assert merged[("t", "crewai_flow")].status == "na" + + def test_merge_leaves_a_never_run_cell_visible_as_skip(self) -> None: + merged = merge([[ScorecardRow("t", "letta", "skip", "lane")]]) + assert merged[0].status == "skip" + + def test_gate_fails_on_a_fail_cell(self) -> None: + row = ScorecardRow("t", _ADAPTER_A, "fail") + lane = str(_LANE_A.id) + result = gate_manifest( + {(lane, "ubuntu"): [row]}, + [ExpectedCell("t", _ADAPTER_A, (lane,), "pass")], + {lane: ("ubuntu",)}, + lane, + "ubuntu", + ) + assert result.ok is False + assert result.failing == ( + ScorecardRow("t", _ADAPTER_A, "fail", lane=lane, os="ubuntu"), + ) + assert result.missing == () + + def test_gate_ignores_a_skip_cell_whose_lane_is_out_of_scope(self) -> None: + lane = str(_LANE_A.id) + row = ScorecardRow("t", _ADAPTER_B, "skip", "other lane") + result = gate_manifest( + {(lane, "ubuntu"): [row]}, + [ExpectedCell("t", _ADAPTER_B, (str(_LANE_B.id),), "pass")], + {lane: ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, + lane, + "ubuntu", + ) + assert result.ok is True + + def test_gate_fails_a_skip_cell_whose_lane_was_expected_to_run(self) -> None: + lane = str(_LANE_A.id) + row = ScorecardRow("t", _ADAPTER_A, "skip", "missing key") + result = gate_manifest( + {(lane, "ubuntu"): [row]}, + [ExpectedCell("t", _ADAPTER_A, (lane,), "pass")], + {lane: ("ubuntu",)}, + lane, + "ubuntu", + ) + assert result.ok is False + assert result.missing == ( + ScorecardRow( + "t", _ADAPTER_A, "skip", "missing key", lane=lane, os="ubuntu" + ), + ) + @pytest.mark.parametrize(("status", "ok"), [("pass", True), ("fail", False)]) + def test_a_selector_run_gates_only_the_cells_it_reported( + self, status: Status, ok: bool + ) -> None: + lane = str(_LANE_A.id) + selected = ExpectedCell("t_selected", _ADAPTER_A, (lane,), "pass") + deselected = ExpectedCell("t_deselected", _ADAPTER_A, (lane,), "pass") + result = gate_manifest( + {(lane, "ubuntu"): [ScorecardRow(selected.test, selected.adapter, status)]}, + [selected, deselected], + {lane: ("ubuntu",)}, + lane, + "ubuntu", + selected_tests_only=True, + ) + assert result.ok is ok + assert result.missing == () + + def test_manifest_gate_requires_bespoke_results_on_each_os(self) -> None: + cell = ExpectedCell("smoke.py::test_control", SUITE_ADAPTER, ("core",), "pass") + pass_row = ScorecardRow(cell.test, cell.adapter, "pass") + result = gate_manifest( + {("core", "ubuntu"): [pass_row]}, + [cell], + {"core": ("ubuntu", "windows")}, + "core", + "all", + ) + assert result.ok is False + assert [(row.lane, row.os, row.test) for row in result.missing] == [ + ("core", "windows", cell.test) + ] -def test_outcome_row_env_gated_skip_reports_na_not_missing() -> None: - # A deployment flag that is permanently off in this environment (e.g. the SaaS - # E2E lanes' ff_file_transfer) must never read as "missing coverage" -- see - # ENV_GATED_MARKER. - _, row = outcome_row( - _report( - "m.py::t[anthropic]", - "setup", - outcome="skipped", - reason="E2E_FILE_TRANSFER is not true", - keywords=(ENV_GATED_MARKER,), + def test_manifest_gate_rejects_skip_and_respects_explicit_na(self) -> None: + required = ExpectedCell("smoke.py::test_live", "anthropic", ("core",), "pass") + unsupported = ExpectedCell("smoke.py::test_file", "anthropic", ("core",), "na") + result = gate_manifest( + { + ("core", "ubuntu"): [ + ScorecardRow( + required.test, required.adapter, "skip", "missing key" + ), + ScorecardRow( + unsupported.test, unsupported.adapter, "na", "flag off" + ), + ] + }, + [required, unsupported], + {"core": ("ubuntu",)}, + "core", + "ubuntu", ) - ) - assert (row.status, row.reason) == ("na", "E2E_FILE_TRANSFER is not true") - - -def test_outcome_row_setup_error_is_a_fail() -> None: - _, row = outcome_row(_report("m.py::t[agno]", "setup", outcome="failed")) - assert row.status == "fail" - - -def test_outcome_row_records_bespoke_and_ignores_passing_setup() -> None: - key, row = outcome_row(_report("p.py::test_no_param", "call", outcome="passed")) - assert key == ("p.py::test_no_param", SUITE_ADAPTER) - assert row.status == "pass" - assert outcome_row(_report("m.py::t[agno]", "setup", outcome="passed")) is None - assert outcome_row(_report("m.py::t[agno]", "teardown", outcome="passed")) is None - - -def test_outcome_row_keeps_non_adapter_parametrization_distinct() -> None: - key, row = outcome_row( - _report("e.py::test_send_event[thought]", "call", outcome="passed") - ) - assert key == ("e.py::test_send_event[thought]", SUITE_ADAPTER) - assert row.status == "pass" - - -# --- collector + merge: a run's rows, then the cross-lane union ---------------------- - - -def test_collector_combines_outcomes_and_na() -> None: - collector = ScorecardCollector(path="unused") - collector.pytest_runtest_logreport( - _report("m.py::t[anthropic]", "call", outcome="passed") - ) - item = _FakeItem( - "m.py::t[anthropic]", (ExcludedAdapter(Adapter.CREWAI, "no usage"),) - ) - rows = {(r.test, r.adapter): r for r in collector.scorecard([item])} - assert rows[("m.py::t", "anthropic")].status == "pass" - assert rows[("m.py::t", "crewai")].status == "na" - - -def test_collector_keeps_a_failed_first_attempt() -> None: - collector = ScorecardCollector(path="unused") - nodeid = "m.py::t[anthropic]" - collector.pytest_runtest_logreport(_report(nodeid, "call", outcome="failed")) - collector.pytest_runtest_logreport(_report(nodeid, "call", outcome="passed")) - rows = collector.scorecard([_FakeItem(nodeid)]) - assert rows == [ScorecardRow("m.py::t", "anthropic", "fail")] - - -def test_merge_prefers_real_outcome_over_skip_and_keeps_na() -> None: - lane_a = [ - ScorecardRow("t", "anthropic", "pass"), - ScorecardRow("t", "crewai", "skip", "lane"), - ScorecardRow("t", "crewai_flow", "na", "no usage"), - ] - lane_b = [ - ScorecardRow("t", "anthropic", "skip", "lane"), - ScorecardRow("t", "crewai", "fail"), - ScorecardRow("t", "crewai_flow", "na", "no usage"), - ] - merged = {(r.test, r.adapter): r for r in merge([lane_a, lane_b])} - assert merged[("t", "anthropic")].status == "pass" - assert merged[("t", "crewai")].status == "fail" - assert merged[("t", "crewai_flow")].status == "na" - - -def test_merge_leaves_a_never_run_cell_visible_as_skip() -> None: - merged = merge([[ScorecardRow("t", "letta", "skip", "lane")]]) - assert merged[0].status == "skip" - - -# --- gate: required lane x OS cells --------------------------------------------------- - -# Two distinct lanes with at least one adapter each, read off the real registry rather -# than hand-picked ids — a gate case only needs "two different homes", not which ones. -_LANE_A, _LANE_B = [lane for lane in ci_lanes() if lane.adapters][:2] -_ADAPTER_A = str(_LANE_A.adapters[0]) -_ADAPTER_B = str(_LANE_B.adapters[0]) - - -def test_gate_fails_on_a_fail_cell() -> None: - row = ScorecardRow("t", _ADAPTER_A, "fail") - lane = str(_LANE_A.id) - result = gate_manifest( - {(lane, "ubuntu"): [row]}, - [ExpectedCell("t", _ADAPTER_A, (lane,), "pass")], - {lane: ("ubuntu",)}, - lane, - "ubuntu", - ) - assert result.ok is False - assert result.failing == ( - ScorecardRow("t", _ADAPTER_A, "fail", lane=lane, os="ubuntu"), - ) - assert result.missing == () - - -def test_gate_ignores_a_skip_cell_whose_lane_is_out_of_scope() -> None: - lane = str(_LANE_A.id) - row = ScorecardRow("t", _ADAPTER_B, "skip", "other lane") - result = gate_manifest( - {(lane, "ubuntu"): [row]}, - [ExpectedCell("t", _ADAPTER_B, (str(_LANE_B.id),), "pass")], - {lane: ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, - lane, - "ubuntu", - ) - assert result.ok is True - - -def test_gate_fails_a_skip_cell_whose_lane_was_expected_to_run() -> None: - lane = str(_LANE_A.id) - row = ScorecardRow("t", _ADAPTER_A, "skip", "missing key") - result = gate_manifest( - {(lane, "ubuntu"): [row]}, - [ExpectedCell("t", _ADAPTER_A, (lane,), "pass")], - {lane: ("ubuntu",)}, - lane, - "ubuntu", - ) - assert result.ok is False - assert result.missing == ( - ScorecardRow("t", _ADAPTER_A, "skip", "missing key", lane=lane, os="ubuntu"), - ) - - -@pytest.mark.parametrize(("status", "ok"), [("pass", True), ("fail", False)]) -def test_a_selector_run_gates_only_the_cells_it_reported( - status: Status, ok: bool -) -> None: - lane = str(_LANE_A.id) - selected = ExpectedCell("t_selected", _ADAPTER_A, (lane,), "pass") - deselected = ExpectedCell("t_deselected", _ADAPTER_A, (lane,), "pass") - result = gate_manifest( - {(lane, "ubuntu"): [ScorecardRow(selected.test, selected.adapter, status)]}, - [selected, deselected], - {lane: ("ubuntu",)}, - lane, - "ubuntu", - selected_tests_only=True, - ) - assert result.ok is ok - assert result.missing == () - - -def test_manifest_gate_requires_bespoke_results_on_each_os() -> None: - cell = ExpectedCell("smoke.py::test_control", SUITE_ADAPTER, ("core",), "pass") - pass_row = ScorecardRow(cell.test, cell.adapter, "pass") - result = gate_manifest( - {("core", "ubuntu"): [pass_row]}, - [cell], - {"core": ("ubuntu", "windows")}, - "core", - "all", - ) - assert result.ok is False - assert [(row.lane, row.os, row.test) for row in result.missing] == [ - ("core", "windows", cell.test) - ] - - -def test_manifest_gate_rejects_skip_and_respects_explicit_na() -> None: - required = ExpectedCell("smoke.py::test_live", "anthropic", ("core",), "pass") - unsupported = ExpectedCell("smoke.py::test_file", "anthropic", ("core",), "na") - result = gate_manifest( - { - ("core", "ubuntu"): [ - ScorecardRow(required.test, required.adapter, "skip", "missing key"), - ScorecardRow(unsupported.test, unsupported.adapter, "na", "flag off"), - ] - }, - [required, unsupported], - {"core": ("ubuntu",)}, - "core", - "ubuntu", - ) - assert result.ok is False - assert [row.test for row in result.missing] == [required.test] - - -def test_expected_manifest_matches_collected_baseline(tmp_path: Path) -> None: - generated = tmp_path / "expected-cells.json" - result = subprocess.run( - [ - sys.executable, - "-m", - "tests.e2e.baseline.scorecard", - "manifest", - "--out", - str(generated), - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stdout + result.stderr - checked_in = REPO_ROOT / "tests/e2e/baseline/expected-cells.json" - fresh = json.loads(generated.read_text()) - committed = json.loads(checked_in.read_text()) - assert fresh["supported_os"] == committed["supported_os"] - current = {(cell["test"], cell["adapter"]): cell for cell in fresh["cells"]} - expected = {(cell["test"], cell["adapter"]): cell for cell in committed["cells"]} - assert all(expected.get(key) == cell for key, cell in current.items()) - extra = [cell for key, cell in expected.items() if key not in current] - assert extra and all( - cell["test"].startswith("tests/e2e/baseline/smoke/adapters/test_parlant.py::") - for cell in extra - ) - - -class FakeMatrixItem: - """A ``pytest.Item`` stand-in exposing what ``lane_selection.expected_lane`` reads: - its ``adapter_id`` callspec param and an optional ``@lane`` override marker.""" - - def __init__( - self, nodeid: str, adapter: str, override_lane: str | None = None - ) -> None: - self.nodeid = nodeid - self.callspec = SimpleNamespace(params={"adapter_id": adapter}) - self._override = override_lane - - def get_closest_marker(self, name: str) -> object | None: - if name == LANE_MARKER and self._override is not None: - return SimpleNamespace(args=(self._override,)) - return None - - -def test_gate_honors_a_lane_pin_over_the_adapters_home_lane() -> None: - """A cell pinned by ``@lane`` cross-lane must gate on the pin, not the home lane. - - Reproduces the scenario ``ADDING_AN_ADAPTER.md`` prescribes: a ``@per_adapter`` - cell whose adapter's home lane is ``_LANE_A`` but is pinned to ``_LANE_B`` (e.g. to - share an extra with a cross-lane peer). Using the home lane alone here would - report this cell "missing" the moment its home lane's job runs — a legitimate - lane-scoped skip, not a silent failure. - """ - item = FakeMatrixItem(f"m.py::t[{_ADAPTER_A}]", _ADAPTER_A, str(_LANE_B.id)) - cell = next(c for c in expected_cells([item]) if c.adapter == _ADAPTER_A) - assert cell.lanes == (str(_LANE_B.id),) - - # The adapter's home lane (_LANE_A) ran this invocation; the cell is pinned - # elsewhere, so it must NOT be reported missing here. - home_only = gate_manifest( - {(str(_LANE_A.id), "ubuntu"): []}, - [cell], - {str(_LANE_A.id): ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, - str(_LANE_A.id), - "ubuntu", - ) - assert home_only.ok is True - assert home_only.missing == () - - # The pinned lane (_LANE_B) ran and reported nothing for this cell — that is a - # real gap. - pinned_ran = gate_manifest( - {(str(_LANE_B.id), "ubuntu"): []}, - [cell], - {str(_LANE_A.id): ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, - str(_LANE_B.id), - "ubuntu", - ) - assert pinned_ran.ok is False - assert pinned_ran.missing[0].test == cell.test - - -def test_environment_gated_na_is_expected_in_every_lane() -> None: - class FileCell(FakeMatrixItem): - def get_closest_marker(self, name: str) -> object | None: - if name == ENV_GATED_MARKER: - return SimpleNamespace() - if name == "skipif": - return SimpleNamespace(kwargs={"reason": "file transfer unavailable"}) - return super().get_closest_marker(name) - - item = FileCell(f"m.py::t[{_ADAPTER_A}]", _ADAPTER_A) - cell = next(c for c in expected_cells([item]) if c.adapter == _ADAPTER_A) - assert cell.status == "na" - assert str(_LANE_B.id) in cell.lanes - result = gate_manifest( - { - (str(_LANE_B.id), "ubuntu"): [ - ScorecardRow(cell.test, cell.adapter, "na", cell.reason) + assert result.ok is False + assert [row.test for row in result.missing] == [required.test] + + def test_expected_manifest_matches_collected_baseline(self, tmp_path: Path) -> None: + generated = tmp_path / "expected-cells.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "tests.e2e.baseline.scorecard", + "manifest", + "--out", + str(generated), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + checked_in = REPO_ROOT / "tests/e2e/baseline/expected-cells.json" + fresh = json.loads(generated.read_text()) + committed = json.loads(checked_in.read_text()) + assert fresh["supported_os"] == committed["supported_os"] + current = {(cell["test"], cell["adapter"]): cell for cell in fresh["cells"]} + expected = { + (cell["test"], cell["adapter"]): cell for cell in committed["cells"] + } + assert all(expected.get(key) == cell for key, cell in current.items()) + for path in ( + "guards/test_strands_provider.py", + "smoke/matrix/test_strands_rehydration.py", + ): + owned = [ + cell + for cell in current.values() + if f"tests/e2e/baseline/{path}::" in cell["test"] ] - }, - [cell], - {str(_LANE_B.id): ("ubuntu",)}, - str(_LANE_B.id), - "ubuntu", - ) - assert result.ok is True - - -def test_gate_summary_reports_totals_and_names_the_culprit() -> None: - rows = [ - ScorecardRow("t", _ADAPTER_A, "pass"), - ScorecardRow("t", _ADAPTER_B, "fail"), - ] - result = GateResult(False, (rows[1],), ()) - summary = gate_summary(result, rows) - assert "GATE: FAIL" in summary - assert _ADAPTER_B in summary - - -# --- digest_body: the email-safe half of gate_summary (no header, no grid) ---------- - - -def test_digest_body_all_clear_has_no_problem_sections() -> None: - rows = [ScorecardRow("t", _ADAPTER_A, "pass"), ScorecardRow("t", _ADAPTER_B, "na")] - result = GateResult(True, (), ()) - body = digest_body(result, rows) - assert "| Passed | Failed | N/A | Skipped |" in body - assert "| 1 | 0 | 1 | 0 |" in body - assert "Failing" not in body - assert "Missing" not in body - - -def test_digest_body_lists_failing_and_missing_separately() -> None: - failing = ScorecardRow("t", _ADAPTER_A, "fail") - missing = ScorecardRow("t", _ADAPTER_B, "skip", "lane 'core'") - result = GateResult(False, (failing,), (missing,)) - body = digest_body(result, [failing, missing]) - assert "**Failing**" in body - assert f"`{_ADAPTER_A}`" in body.split("**Missing**")[0] - assert "**Missing** (lane ran, no result)" in body - assert f"`{_ADAPTER_B}`" in body.split("**Missing**")[1] - - -def test_digest_body_never_renders_a_grid() -> None: - rows = [ScorecardRow("t", _ADAPTER_A, "fail")] - result = GateResult(False, (rows[0],), ()) - body = digest_body(result, rows) - assert "| test |" not in body - - -def test_to_markdown_renders_grid_and_na_reasons() -> None: - md = to_markdown( - [ - ScorecardRow("m.py::test_x", "anthropic", "pass"), - ScorecardRow("m.py::test_x", "crewai", "na", "no per-turn usage"), + assert len(owned) == 1, f"missing or duplicated Strands cell: {path}" + assert owned[0]["lanes"] == [str(Lane.STRANDS)] + + extra = [cell for key, cell in expected.items() if key not in current] + if importlib.util.find_spec("parlant") is not None: + assert not extra + else: + assert extra + assert all( + cell["test"].startswith( + "tests/e2e/baseline/smoke/adapters/test_parlant.py::" + ) + for cell in extra + ) + + def test_gate_honors_a_lane_pin_over_the_adapters_home_lane(self) -> None: + """A cell pinned by ``@lane`` cross-lane must gate on the pin, not the home lane. + + Reproduces the scenario ``ADDING_AN_ADAPTER.md`` prescribes: a ``@per_adapter`` + cell whose adapter's home lane is ``_LANE_A`` but is pinned to ``_LANE_B`` (e.g. to + share an extra with a cross-lane peer). Using the home lane alone here would + report this cell "missing" the moment its home lane's job runs — a legitimate + lane-scoped skip, not a silent failure. + """ + item = FakeMatrixItem(f"m.py::t[{_ADAPTER_A}]", _ADAPTER_A, str(_LANE_B.id)) + cell = next(c for c in expected_cells([item]) if c.adapter == _ADAPTER_A) + assert cell.lanes == (str(_LANE_B.id),) + + # The adapter's home lane (_LANE_A) ran this invocation; the cell is pinned + # elsewhere, so it must NOT be reported missing here. + home_only = gate_manifest( + {(str(_LANE_A.id), "ubuntu"): []}, + [cell], + {str(_LANE_A.id): ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, + str(_LANE_A.id), + "ubuntu", + ) + assert home_only.ok is True + assert home_only.missing == () + + # The pinned lane (_LANE_B) ran and reported nothing for this cell — that is a + # real gap. + pinned_ran = gate_manifest( + {(str(_LANE_B.id), "ubuntu"): []}, + [cell], + {str(_LANE_A.id): ("ubuntu",), str(_LANE_B.id): ("ubuntu",)}, + str(_LANE_B.id), + "ubuntu", + ) + assert pinned_ran.ok is False + assert pinned_ran.missing[0].test == cell.test + + def test_environment_gated_na_is_expected_in_every_lane(self) -> None: + class FileCell(FakeMatrixItem): + def get_closest_marker(self, name: str) -> object | None: + if name == ENV_GATED_MARKER: + return SimpleNamespace() + if name == "skipif": + return SimpleNamespace( + kwargs={"reason": "file transfer unavailable"} + ) + return super().get_closest_marker(name) + + item = FileCell(f"m.py::t[{_ADAPTER_A}]", _ADAPTER_A) + cell = next(c for c in expected_cells([item]) if c.adapter == _ADAPTER_A) + assert cell.status == "na" + assert str(_LANE_B.id) in cell.lanes + result = gate_manifest( + { + (str(_LANE_B.id), "ubuntu"): [ + ScorecardRow(cell.test, cell.adapter, "na", cell.reason) + ] + }, + [cell], + {str(_LANE_B.id): ("ubuntu",)}, + str(_LANE_B.id), + "ubuntu", + ) + assert result.ok is True + + def test_gate_summary_reports_totals_and_names_the_culprit(self) -> None: + rows = [ + ScorecardRow("t", _ADAPTER_A, "pass"), + ScorecardRow("t", _ADAPTER_B, "fail"), ] - ) - assert "| test | anthropic | crewai |" in md - assert "no per-turn usage" in md + result = GateResult(False, (rows[1],), ()) + summary = gate_summary(result, rows) + assert "GATE: FAIL" in summary + assert _ADAPTER_B in summary + + def test_digest_body_all_clear_has_no_problem_sections(self) -> None: + rows = [ + ScorecardRow("t", _ADAPTER_A, "pass"), + ScorecardRow("t", _ADAPTER_B, "na"), + ] + result = GateResult(True, (), ()) + body = digest_body(result, rows) + assert "| Passed | Failed | N/A | Skipped |" in body + assert "| 1 | 0 | 1 | 0 |" in body + assert "Failing" not in body + assert "Missing" not in body + + def test_digest_body_lists_failing_and_missing_separately(self) -> None: + failing = ScorecardRow("t", _ADAPTER_A, "fail") + missing = ScorecardRow("t", _ADAPTER_B, "skip", "lane 'core'") + result = GateResult(False, (failing,), (missing,)) + body = digest_body(result, [failing, missing]) + assert "**Failing**" in body + assert f"`{_ADAPTER_A}`" in body.split("**Missing**")[0] + assert "**Missing** (lane ran, no result)" in body + assert f"`{_ADAPTER_B}`" in body.split("**Missing**")[1] + + def test_digest_body_never_renders_a_grid(self) -> None: + rows = [ScorecardRow("t", _ADAPTER_A, "fail")] + result = GateResult(False, (rows[0],), ()) + body = digest_body(result, rows) + assert "| test |" not in body + + def test_to_markdown_renders_grid_and_na_reasons(self) -> None: + md = to_markdown( + [ + ScorecardRow("m.py::test_x", "anthropic", "pass"), + ScorecardRow("m.py::test_x", "crewai", "na", "no per-turn usage"), + ] + ) + assert "| test | anthropic | crewai |" in md + assert "no per-turn usage" in md diff --git a/tests/framework_conformance/test_strands_nooa_job_coverage.py b/tests/framework_conformance/test_strands_nooa_job_coverage.py new file mode 100644 index 000000000..d639dd29e --- /dev/null +++ b/tests/framework_conformance/test_strands_nooa_job_coverage.py @@ -0,0 +1,73 @@ +"""The owning legacy job must collect every real legacy package consumer.""" + +from __future__ import annotations + +import importlib.util +import sys + +import pytest + +from tests.e2e.baseline.toolkit.deps import Extra +from tests.framework_configs.adapters import ADAPTER_CONFIGS +from tests.framework_configs.converters import CONVERTER_CONFIGS +from tests.framework_conformance import venv_job_coverage as coverage +from tests.paths import REPO_ROOT + + +class TestStrandsNooaJobCoverage: + @pytest.fixture + def framework_environment(self) -> None: + if ( + coverage.CoverageSettings().BAND_TEST_EXTRA != Extra.DEV_STRANDS_NOOA + and importlib.util.find_spec("strands") is None + ): + pytest.skip("covered by dev-strands-nooa") + + def test_expected_configs_are_present(self, framework_environment: None) -> None: + assert importlib.util.find_spec("strands") is not None + expected = {"strands"} + if sys.version_info[:2] in {(3, 12), (3, 13)} and sys.platform != "win32": + assert importlib.util.find_spec("nooa") is not None + expected.add("nooa") + assert expected <= {config.framework_id for config in ADAPTER_CONFIGS} + assert expected <= {config.framework_id for config in CONVERTER_CONFIGS} + + def test_job_selects_every_required_node(self, framework_environment: None) -> None: + needed = coverage.tests_needing_venv( + coverage.needs_venv_pattern(frozenset({"strands", "nooa"})) + ) + assert needed, "legacy dependency detection found no consumers" + assert all((REPO_ROOT / path).exists() for path in needed) + arguments = coverage.pytest_arguments( + coverage.job_command("test-strands-nooa", "Run Strands and NOOA tests") + ) + selected = coverage.selected_nodes(arguments) + # Directory collection honors NOOA's supported-platform conftest gate. + nooa_tests = REPO_ROOT / "tests/adapters/nooa" + targets = { + str(nooa_tests) if (REPO_ROOT / path).parent == nooa_tests else str(path) + for path in needed + } + baseline = coverage.collected_nodes( + [*sorted(targets), "tests/framework_conformance/"] + ) + needed_files = {path.as_posix() for path in needed} + required = { + node.nodeid + for node in baseline + if node.frameworks & {"strands", "nooa"} + or ( + not node.frameworks + and not node.skipped + and node.nodeid.split("::", 1)[0] in needed_files + ) + } + assert required, "no legacy nodes collected" + assert not required - selected, sorted(required - selected) + assert any("test_strands_injection_spike.py" in node for node in selected) + + def test_job_paths_exist(self) -> None: + command = coverage.job_command( + "test-strands-nooa", "Run Strands and NOOA tests" + ) + assert all((REPO_ROOT / path).exists() for path in coverage.job_paths(command)) diff --git a/tests/framework_conformance/test_turn_outcome.py b/tests/framework_conformance/test_turn_outcome.py index 6203fbf3b..4dd1e142f 100644 --- a/tests/framework_conformance/test_turn_outcome.py +++ b/tests/framework_conformance/test_turn_outcome.py @@ -1,262 +1,148 @@ -"""Every judged adapter's turn ends with core's verdict, not its own. - -Each probe (see ``turnprobes``) drives one scripted turn through the adapter's -real ``on_event``. A turn that replied, declined, did real work or was settled -by the adapter completes silently; a turn that did nothing is reported once -with core's missing-reply text and raised as ``TurnResultAlreadyReported``, so -the runtime marks it FAILED. The relaying adapters post the model's final text -unless a tool already replied; the rest discard it. - -To add an adapter: write its probe in ``turnprobes/.py`` and register it -in ``TURN_OUTCOME_PROBES``. ``test_every_judged_adapter_has_a_probe`` fails -when a judged adapter has none. -""" +"""TurnOutcome behavior.""" from __future__ import annotations -import importlib -import inspect -import pkgutil -from dataclasses import dataclass - import pytest from band_sdk_core import TurnVerdict -import band -from band.core.protocols import TurnResultAlreadyReported -from band.core.simple_adapter import SimpleAdapter -from band.runtime.tools import BandTool -from band.testing import MISSING_REPLY_FAILURE, failure_reports -from band.testing.fake_tools import FakeAgentTools, reported_failures +from band.testing import MISSING_REPLY_FAILURE +from band.testing.fake_tools import reported_failures from tests.framework_configs.adapters import ADAPTER_CONFIGS +from tests.framework_conformance.outcomeprobes import ( + ANSWER, + COMPLETING_SCRIPTS, + PINNED_OUTSIDE_CONFIGS, + REPLY, + TURN_OUTCOME_PROBES, + UNJUDGED_FRAMEWORK_IDS, + TurnResult, + band_adapters, + probe_params, + probe_running, + relaying_probe_params, + run_turn, +) from tests.framework_conformance.turnprobes import ( - ToolCall, - TurnOutcomeProbe, TurnScript, - acp, - claudesdk, - codex, - copilotsdk, - crewai, - letta, - opencode, - toolloop, turn_tools, ) -TURN_OUTCOME_PROBES: dict[str, TurnOutcomeProbe] = { - **acp.PROBES, - **claudesdk.PROBES, - **codex.PROBES, - **copilotsdk.PROBES, - **crewai.PROBES, - **letta.PROBES, - **opencode.PROBES, - **toolloop.PROBES, -} - -ANSWER = "The vault code is 4471-ECHO." -REPLY = ToolCall(BandTool.SEND_MESSAGE, {"content": ANSWER, "mentions": ["@alice"]}) - - -@dataclass(frozen=True) -class TurnResult: - verdict: TurnVerdict - messages: list[str] - failures: list[tuple[str, str]] - - -async def run_turn(probe: TurnOutcomeProbe, script: TurnScript) -> TurnResult: - tools = turn_tools() - try: - await probe.run(script, tools) - verdict = TurnVerdict.Complete - except TurnResultAlreadyReported: - verdict = TurnVerdict.MissingReply - return observed(tools, verdict) - - -def observed(tools: FakeAgentTools, verdict: TurnVerdict) -> TurnResult: - return TurnResult( - verdict=verdict, - messages=tools.chat, - failures=failure_reports(tools), - ) - - -COMPLETING_SCRIPTS = { - "decline": TurnScript((ToolCall(BandTool.NO_REPLY, {"reason": "FYI only"}),)), - "reply": TurnScript((REPLY,)), - "act": TurnScript((ToolCall(BandTool.CREATE_CHATROOM),)), -} - - -def probe_params() -> list[str]: - return sorted(TURN_OUTCOME_PROBES) - - -def relaying_probe_params() -> list[str]: - return [name for name in probe_params() if TURN_OUTCOME_PROBES[name].relays] - - -@pytest.mark.parametrize("framework_id", probe_params()) -@pytest.mark.parametrize("script_name", sorted(COMPLETING_SCRIPTS)) -async def test_a_turn_that_answered_or_worked_completes( - framework_id: str, script_name: str -) -> None: - result = await run_turn( - TURN_OUTCOME_PROBES[framework_id], COMPLETING_SCRIPTS[script_name] - ) - - assert result.verdict == TurnVerdict.Complete - assert result.failures == [] - - -@pytest.mark.parametrize("framework_id", probe_params()) -async def test_a_turn_that_did_nothing_is_reported_once(framework_id: str) -> None: - result = await run_turn(TURN_OUTCOME_PROBES[framework_id], TurnScript()) - - assert result.verdict == TurnVerdict.MissingReply - assert result.failures == [MISSING_REPLY_FAILURE] - assert result.messages == [] - - -@pytest.mark.parametrize( - "framework_id", - [name for name in probe_params() if TURN_OUTCOME_PROBES[name].settle], -) -async def test_an_adapter_settled_turn_completes(framework_id: str) -> None: - settle = TURN_OUTCOME_PROBES[framework_id].settle - assert settle is not None - tools = turn_tools() - - await settle(tools) - - assert tools.turn.complete - assert reported_failures(tools) == [] - -@pytest.mark.parametrize("framework_id", relaying_probe_params()) -async def test_a_relaying_adapter_posts_the_final_text_once(framework_id: str) -> None: - result = await run_turn( - TURN_OUTCOME_PROBES[framework_id], TurnScript(final_text=ANSWER) +class TestTurnOutcome: + @pytest.mark.parametrize("framework_id", probe_params()) + @pytest.mark.parametrize("script_name", sorted(COMPLETING_SCRIPTS)) + async def test_a_turn_that_answered_or_worked_completes( + self, framework_id: str, script_name: str + ) -> None: + result = await run_turn( + TURN_OUTCOME_PROBES[framework_id], COMPLETING_SCRIPTS[script_name] + ) + + assert result.verdict == TurnVerdict.Complete + assert result.failures == [] + + @pytest.mark.parametrize("framework_id", probe_params()) + async def test_a_turn_that_did_nothing_is_reported_once( + self, framework_id: str + ) -> None: + result = await run_turn(TURN_OUTCOME_PROBES[framework_id], TurnScript()) + + assert result.verdict == TurnVerdict.MissingReply + assert result.failures == [MISSING_REPLY_FAILURE] + assert result.messages == [] + + @pytest.mark.parametrize( + "framework_id", + [name for name in probe_params() if TURN_OUTCOME_PROBES[name].settle], ) - - assert result == TurnResult(TurnVerdict.Complete, messages=[ANSWER], failures=[]) - - -@pytest.mark.parametrize("framework_id", relaying_probe_params()) -async def test_a_tool_reply_suppresses_the_closing_text(framework_id: str) -> None: - result = await run_turn( - TURN_OUTCOME_PROBES[framework_id], - TurnScript((REPLY,), final_text="Anything else?"), - ) - - assert result == TurnResult(TurnVerdict.Complete, messages=[ANSWER], failures=[]) - - -@pytest.mark.parametrize("framework_id", ["acp", "cursor_acp", "codex"]) -async def test_native_text_cannot_complete_a_tool_authoritative_turn( - framework_id: str, -) -> None: - result = await run_turn( - TURN_OUTCOME_PROBES[framework_id], TurnScript(final_text=ANSWER) - ) - assert result == TurnResult(TurnVerdict.MissingReply, [], [MISSING_REPLY_FAILURE]) - - -@pytest.mark.parametrize("framework_id", ["acp", "cursor_acp", "codex"]) -@pytest.mark.parametrize("script_name", sorted(COMPLETING_SCRIPTS)) -async def test_native_text_preserves_successful_tool_outcomes( - framework_id: str, script_name: str -) -> None: - script = COMPLETING_SCRIPTS[script_name] - result = await run_turn( - TURN_OUTCOME_PROBES[framework_id], - TurnScript(script.tool_calls, final_text=ANSWER), - ) - assert result == TurnResult( - TurnVerdict.Complete, [ANSWER] if script_name == "reply" else [], [] - ) - - -#: Registered adapters whose turns are not the model's to answer through Band tools. -UNJUDGED_FRAMEWORK_IDS = frozenset({"crewai_flow", "parlant"}) - - -def test_only_the_declared_adapters_go_unjudged() -> None: - unjudged = { - cfg.framework_id - for cfg in ADAPTER_CONFIGS - if not cfg.adapter_factory().judges_turns - } - - assert unjudged == UNJUDGED_FRAMEWORK_IDS - - -#: Judged adapters outside ADAPTER_CONFIGS, by the probe that runs their rows. -#: A subclass that inherits one of these ``on_message`` methods is covered too. -PROBED_OUTSIDE_CONFIGS = {"ACPClientAdapter": "acp", "CursorACPAdapter": "cursor_acp"} - -#: Adapters outside ADAPTER_CONFIGS whose verdict their own tests pin. -PINNED_OUTSIDE_CONFIGS = { - "SlackAdapter": "its brain's verdict (tests/integrations/slack)", - "A2AAdapter": "unjudged (tests/integrations/a2a)", - "A2AGatewayAdapter": "unjudged (tests/integrations/a2a/gateway)", - "BandACPServerAdapter": "unjudged (tests/integrations/acp)", -} - - -def band_adapters() -> dict[str, type[SimpleAdapter]]: - """Every concrete SimpleAdapter defined in band whose module imports here.""" - for module in pkgutil.walk_packages(band.__path__, "band."): - if module.name.endswith("__main__"): - continue - try: - importlib.import_module(module.name) - except ModuleNotFoundError as missing: - if (missing.name or "").startswith("band"): - raise - continue # an extra this venv lacks; its own lane covers it - pending, adapters = [SimpleAdapter], {} - while pending: - for cls in pending.pop().__subclasses__(): - pending.append(cls) - if cls.__module__.startswith("band.") and not inspect.isabstract(cls): - adapters[cls.__name__] = cls - return adapters - - -def probe_running( - cls: type[SimpleAdapter], adapters: dict[str, type[SimpleAdapter]] -) -> str | None: - """The probe that runs ``cls``'s turn, via the ``on_message`` it uses.""" - for name, probe in PROBED_OUTSIDE_CONFIGS.items(): - owner = adapters.get(name) - if owner and issubclass(cls, owner) and cls.on_message is owner.on_message: - return probe - return None - - -def test_every_band_adapter_is_registered_probed_or_pinned() -> None: - registered = {type(cfg.adapter_factory()).__name__ for cfg in ADAPTER_CONFIGS} - adapters = band_adapters() - unprobed = { - name - for name, cls in adapters.items() - if name not in registered - and probe_running(cls, adapters) not in TURN_OUTCOME_PROBES - } - - assert unprobed == set(PINNED_OUTSIDE_CONFIGS) - - -def test_every_judged_adapter_has_a_probe() -> None: - unprobed = sorted( - cfg.framework_id - for cfg in ADAPTER_CONFIGS - if cfg.adapter_factory().judges_turns - and cfg.framework_id not in TURN_OUTCOME_PROBES - ) - - assert unprobed == [] + async def test_an_adapter_settled_turn_completes(self, framework_id: str) -> None: + settle = TURN_OUTCOME_PROBES[framework_id].settle + assert settle is not None + tools = turn_tools() + + await settle(tools) + + assert tools.turn.complete + assert reported_failures(tools) == [] + + @pytest.mark.parametrize("framework_id", relaying_probe_params()) + async def test_a_relaying_adapter_posts_the_final_text_once( + self, framework_id: str + ) -> None: + result = await run_turn( + TURN_OUTCOME_PROBES[framework_id], TurnScript(final_text=ANSWER) + ) + + assert result == TurnResult( + TurnVerdict.Complete, messages=[ANSWER], failures=[] + ) + + @pytest.mark.parametrize("framework_id", relaying_probe_params()) + async def test_a_tool_reply_suppresses_the_closing_text( + self, framework_id: str + ) -> None: + result = await run_turn( + TURN_OUTCOME_PROBES[framework_id], + TurnScript((REPLY,), final_text="Anything else?"), + ) + + assert result == TurnResult( + TurnVerdict.Complete, messages=[ANSWER], failures=[] + ) + + @pytest.mark.parametrize("framework_id", ["acp", "cursor_acp", "codex"]) + async def test_native_text_cannot_complete_a_tool_authoritative_turn( + self, + framework_id: str, + ) -> None: + result = await run_turn( + TURN_OUTCOME_PROBES[framework_id], TurnScript(final_text=ANSWER) + ) + assert result == TurnResult( + TurnVerdict.MissingReply, [], [MISSING_REPLY_FAILURE] + ) + + @pytest.mark.parametrize("framework_id", ["acp", "cursor_acp", "codex"]) + @pytest.mark.parametrize("script_name", sorted(COMPLETING_SCRIPTS)) + async def test_native_text_preserves_successful_tool_outcomes( + self, framework_id: str, script_name: str + ) -> None: + script = COMPLETING_SCRIPTS[script_name] + result = await run_turn( + TURN_OUTCOME_PROBES[framework_id], + TurnScript(script.tool_calls, final_text=ANSWER), + ) + assert result == TurnResult( + TurnVerdict.Complete, [ANSWER] if script_name == "reply" else [], [] + ) + + def test_only_the_declared_adapters_go_unjudged(self) -> None: + unjudged = { + cfg.framework_id + for cfg in ADAPTER_CONFIGS + if not cfg.adapter_factory().judges_turns + } + + assert unjudged == UNJUDGED_FRAMEWORK_IDS + + def test_every_band_adapter_is_registered_probed_or_pinned(self) -> None: + registered = {type(cfg.adapter_factory()).__name__ for cfg in ADAPTER_CONFIGS} + adapters = band_adapters() + unprobed = { + name + for name, cls in adapters.items() + if name not in registered + and probe_running(cls, adapters) not in TURN_OUTCOME_PROBES + } + + assert unprobed == set(PINNED_OUTSIDE_CONFIGS) + + def test_every_judged_adapter_has_a_probe(self) -> None: + unprobed = sorted( + cfg.framework_id + for cfg in ADAPTER_CONFIGS + if cfg.adapter_factory().judges_turns + and cfg.framework_id not in TURN_OUTCOME_PROBES + ) + + assert unprobed == [] diff --git a/tests/framework_conformance/venv_job_coverage.py b/tests/framework_conformance/venv_job_coverage.py index de8db2020..9b70097f6 100644 --- a/tests/framework_conformance/venv_job_coverage.py +++ b/tests/framework_conformance/venv_job_coverage.py @@ -1,31 +1,33 @@ -"""Shared machinery for the `test__job_coverage.py` drift guards. - -A framework whose deps conflict with the rest of `dev` (crewai, parlant) lives in -its own venv (`dev-crewai`, `dev-parlant`), which cannot import the other -frameworks — so its CI job cannot just run `pytest`, it names the test files one -by one. That list is the only thing standing between one of its tests and never -running anywhere: the default `test` job skips whatever needs the isolated venv's -deps, and the isolated job only collects what its list names. A file dropped from -it goes silently uncovered (this happened once, to the crewai cases in -`test_capability_gating_e2e.py`). - -So each per-framework guard derives the set that *needs* its venv from the tests -themselves — via the deps only that venv has — and asserts the workflow covers -every one. This module holds the generic mechanics; `test_crewai_job_coverage.py` -and `test_parlant_job_coverage.py` supply the framework-specific module names and -job/step identifiers. -""" +"""Coverage of isolated jobs, derived from real consumers and pytest selection.""" from __future__ import annotations +import ast +import json import re +import shlex +import subprocess +import sys +import tempfile import tomllib +from dataclasses import dataclass from pathlib import Path +import pytest import yaml +from pydantic_settings import BaseSettings, SettingsConfigDict +from tests.e2e.baseline.toolkit.deps import Extra from tests.paths import REPO_ROOT + +class CoverageSettings(BaseSettings): + model_config = SettingsConfigDict( + extra="ignore", case_sensitive=False, env_ignore_empty=True + ) + BAND_TEST_EXTRA: Extra | None = None + + CI_WORKFLOW = REPO_ROOT / ".github/workflows/ci.yml" _TESTS_ROOT = REPO_ROOT / "tests" @@ -59,17 +61,38 @@ def name(requirement: str) -> str: return {name(r) for r in extras[extra]} - {name(r) for r in extras[baseline_extra]} -def tests_needing_venv(pattern: re.Pattern[str]) -> set[Path]: - """Repo-relative unit-test files that only a venv matching ``pattern`` can run. +def consumer_source(path: Path, visited: set[Path] | None = None) -> str: + """Include same-suite helpers so extracting a probe cannot hide its consumers.""" + seen = visited if visited is not None else set() + if path in seen: + return "" + seen.add(path) + source = path.read_text(encoding="utf-8") + helpers = { + REPO_ROOT.joinpath(*node.module.split(".")).with_suffix(".py") + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.ImportFrom) + and node.module is not None + and node.module.startswith("tests.") + } + return ( + source + + "\n" + + "\n".join( + consumer_source(helper, seen) + for helper in sorted(helpers) + if helper.parent == path.parent and helper.exists() + ) + ) - `tests/e2e/**` is excluded: those run from the e2e workflow's own lane, not a - ci.yml job. - """ + +def tests_needing_venv(pattern: re.Pattern[str]) -> set[Path]: + """Unit-test consumers of a real dependency, including their shared probes.""" return { path.relative_to(REPO_ROOT) for path in _TESTS_ROOT.rglob("test_*.py") if "e2e" not in path.relative_to(_TESTS_ROOT).parts - and pattern.search(path.read_text(encoding="utf-8")) + and pattern.search(consumer_source(path)) } @@ -98,3 +121,87 @@ def covered_by(target: Path, listed: set[Path], command: str) -> bool: return not _KEYWORD_FILTER.search(command) and any( parent in listed for parent in target.parents ) + + +@dataclass(frozen=True) +class CollectedNode: + nodeid: str + frameworks: frozenset[str] + skipped: bool + + +def collected_nodes(arguments: list[str]) -> tuple[CollectedNode, ...]: + """Collect nodes and framework bindings after pytest applies the job selectors.""" + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "nodes.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "tests.framework_conformance.venv_job_coverage", + str(output), + *arguments, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(result.stdout + result.stderr) + return tuple( + CollectedNode(row["nodeid"], frozenset(row["frameworks"]), row["skipped"]) + for row in json.loads(output.read_text(encoding="utf-8")) + ) + + +def selected_nodes(arguments: list[str]) -> set[str]: + """The exact node IDs surviving a job's pytest selectors.""" + return {node.nodeid for node in collected_nodes(arguments)} + + +def framework_bindings(item: pytest.Item) -> set[str]: + """Read actual parametrized framework identities, independent of node names.""" + params = getattr(getattr(item, "callspec", None), "params", {}) + return { + str(value.framework_id) if hasattr(value, "framework_id") else str(value) + for name, value in params.items() + if name in {"framework_id", "adapter_id", "adapter_config", "converter_config"} + } + + +def collection_row(item: pytest.Item) -> dict[str, object]: + skipped = item.get_closest_marker("skip") is not None or any( + marker.args and marker.args[0] is True for marker in item.iter_markers("skipif") + ) + return { + "nodeid": item.nodeid, + "frameworks": sorted(framework_bindings(item)), + "skipped": skipped, + } + + +def pytest_arguments(command: str) -> list[str]: + """Read selectors after the workflow's pytest executable.""" + tokens = shlex.split(command) + return tokens[tokens.index("pytest") + 1 :] + + +class NodeCollector: + def __init__(self, output: Path) -> None: + self.output = output + + def pytest_collection_finish(self, session: pytest.Session) -> None: + self.output.write_text( + json.dumps([collection_row(item) for item in session.items]), + encoding="utf-8", + ) + + +if __name__ == "__main__": + raise SystemExit( + pytest.main( + [*sys.argv[2:], "--collect-only", "--no-cov"], + plugins=[NodeCollector(Path(sys.argv[1]))], + ) + ) diff --git a/tests/integrations/langgraph/declinekit.py b/tests/integrations/langgraph/declinekit.py new file mode 100644 index 000000000..a679b5330 --- /dev/null +++ b/tests/integrations/langgraph/declinekit.py @@ -0,0 +1,55 @@ +"""Native chat model and tool outcomes for explicit-decline regressions.""" + +from __future__ import annotations + +from collections.abc import Sequence +from enum import StrEnum +from typing import Any + +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.outputs import ChatResult + +from band.runtime.tools import BandTool +from band.testing import FakeAgentTools + + +class DeclineFailure(StrEnum): + INVALID_ARGUMENTS = "invalid-decline" + RAISED_DECLINE = "raised-decline" + STRUCTURED_DECLINE = "structured-decline" + FAILED_SIBLING = "failed-sibling" + + +class ScriptedModel(GenericFakeChatModel): + requests: int = 0 + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> ScriptedModel: + return self + + def _generate(self, *args: Any, **kwargs: Any) -> ChatResult: + self.requests += 1 + return super()._generate(*args, **kwargs) + + +class DeclineTools(FakeAgentTools): + def __init__(self, failure: DeclineFailure | None = None) -> None: + super().__init__() + self.failure = failure + + @property + def replies(self) -> list[str]: + return [ + call["arguments"]["content"] + for call in self.tool_calls + if call["tool_name"] == BandTool.SEND_MESSAGE + ] + + async def execute_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> Any: + match (self.failure, tool_name): + case (DeclineFailure.RAISED_DECLINE, BandTool.NO_REPLY): + raise RuntimeError("decline rejected") + case (DeclineFailure.STRUCTURED_DECLINE, BandTool.NO_REPLY): + return {"ok": False} + case (DeclineFailure.FAILED_SIBLING, BandTool.GET_PARTICIPANTS): + raise RuntimeError("sibling rejected") + return await super().execute_tool_call(tool_name, arguments) diff --git a/tests/integrations/langgraph/test_decline.py b/tests/integrations/langgraph/test_decline.py new file mode 100644 index 000000000..316c9e860 --- /dev/null +++ b/tests/integrations/langgraph/test_decline.py @@ -0,0 +1,147 @@ +"""A successful decline ends the native graph without another model request.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from langchain_core.messages import AIMessage + +from band.adapters.langgraph import LangGraphAdapter +from band.core.types import PlatformMessage +from band.runtime.tools import BandTool +from tests.integrations.langgraph.declinekit import ( + DeclineFailure, + DeclineTools, + ScriptedModel, +) + + +class TestExplicitDecline: + @pytest.fixture + def message(self) -> PlatformMessage: + return PlatformMessage( + id="message", + room_id="room-fake", + content="FYI", + sender_id="user", + sender_type="User", + sender_name="User", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ) + + @pytest.mark.parametrize( + "failure", + [None, *DeclineFailure], + ) + async def test_only_a_successful_batch_is_terminal( + self, message: PlatformMessage, failure: DeclineFailure | None + ) -> None: + calls = [ + { + "name": BandTool.NO_REPLY, + "args": { + "reason": 42 + if failure == DeclineFailure.INVALID_ARGUMENTS + else "FYI" + }, + "id": "decline", + } + ] + if failure == DeclineFailure.FAILED_SIBLING: + calls.append( + {"name": BandTool.GET_PARTICIPANTS, "args": {}, "id": "sibling"} + ) + model = ScriptedModel( + messages=iter( + [ + AIMessage(content="", tool_calls=calls), + AIMessage( + content="", + tool_calls=[ + { + "name": BandTool.SEND_MESSAGE, + "args": { + "content": "fresh reply", + "mentions": [ + "00000000-0000-0000-0000-000000000001" + ], + }, + "id": "reply", + } + ], + ), + AIMessage(content="done"), + ] + ), + disable_streaming=True, + ) + adapter = LangGraphAdapter(llm=model) + tools = DeclineTools(failure) + await adapter.on_started("Bot", "Bot") + await adapter.run_judged_turn( + msg=message, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=tools.room_id, + ) + assert model.requests == (1 if failure is None else 3) + assert tools.turn.replied + assert tools.replies == ([] if failure is None else ["fresh reply"]) + + async def test_a_previous_decline_does_not_end_the_next_turn( + self, message: PlatformMessage + ) -> None: + model = ScriptedModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": BandTool.NO_REPLY, + "args": {"reason": "FYI"}, + "id": "decline", + } + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "name": BandTool.SEND_MESSAGE, + "args": { + "content": "fresh reply", + "mentions": [ + "00000000-0000-0000-0000-000000000001" + ], + }, + "id": "reply", + } + ], + ), + AIMessage(content="done"), + ] + ), + disable_streaming=True, + ) + adapter = LangGraphAdapter(llm=model) + await adapter.on_started("Bot", "Bot") + for bootstrap in (True, False): + tools = DeclineTools() + await adapter.run_judged_turn( + msg=message, + tools=tools, + history=[], + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=bootstrap, + room_id=tools.room_id, + ) + assert model.requests == 3 + assert tools.replies == ["fresh reply"] diff --git a/uv.lock b/uv.lock index 73cca6e45..fb5df03d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,15 +1,77 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] required-markers = [ "platform_machine == 'arm64' and sys_platform == 'darwin'", @@ -53,6 +115,33 @@ conflicts = [[ ], [ { package = "band-sdk", extra = "crewai" }, { package = "band-sdk", extra = "dev-parlant" }, +], [ + { package = "band-sdk", extra = "dev" }, + { package = "band-sdk", extra = "strands" }, +], [ + { package = "band-sdk", extra = "dev" }, + { package = "band-sdk", extra = "nooa" }, +], [ + { package = "band-sdk", extra = "dev" }, + { package = "band-sdk", extra = "dev-strands-nooa" }, +], [ + { package = "band-sdk", extra = "pydantic-ai" }, + { package = "band-sdk", extra = "strands" }, +], [ + { package = "band-sdk", extra = "nooa" }, + { package = "band-sdk", extra = "pydantic-ai" }, +], [ + { package = "band-sdk", extra = "dev-strands-nooa" }, + { package = "band-sdk", extra = "pydantic-ai" }, +], [ + { package = "band-sdk", extra = "dev-parlant" }, + { package = "band-sdk", extra = "strands" }, +], [ + { package = "band-sdk", extra = "dev-parlant" }, + { package = "band-sdk", extra = "nooa" }, +], [ + { package = "band-sdk", extra = "dev-parlant" }, + { package = "band-sdk", extra = "dev-strands-nooa" }, ]] [manifest] @@ -70,7 +159,7 @@ name = "a2a-sdk" version = "1.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "culsans", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "culsans", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "google-api-core" }, { name = "googleapis-common-protos" }, { name = "httpx" }, @@ -124,7 +213,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyyaml" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "typing-extensions" }, ] @@ -140,7 +229,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "questionary" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "typer" }, ] @@ -154,7 +243,7 @@ name = "aiofile" version = "3.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "caio" }, + { name = "caio", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ @@ -168,11 +257,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } @@ -185,14 +273,27 @@ name = "aiofiles" version = "25.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", ] sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } wheels = [ @@ -219,7 +320,7 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "yarl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/2f/6a91adaa2dc26877d6ed2f54c0370c8910f019db7d77c5c6a194611e93ea/aiohttp-3.14.4.tar.gz", hash = "sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9", size = 8077839, upload-time = "2026-10-05T17:43:48.309Z" } @@ -373,9 +474,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "wrapt", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "sniffio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "wrapt", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -387,13 +488,13 @@ name = "aiopenapi3" version = "0.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "email-validator" }, - { name = "httpx" }, - { name = "jmespath" }, - { name = "more-itertools" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "yarl" }, + { name = "email-validator", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jmespath", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "more-itertools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyyaml", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "yarl", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/bc/b5be54133ebc97d46e2c50dc2b788e6146bde85a3f681608cd850381ea43/aiopenapi3-0.8.1.tar.gz", hash = "sha256:ef9a84b6a45f72e3016b74621025e5b1c6cb9a3c5da5b77998ddf16e2decd4c4", size = 68670, upload-time = "2025-04-19T07:44:28.016Z" } wheels = [ @@ -415,7 +516,7 @@ version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ @@ -460,8 +561,8 @@ dependencies = [ { name = "anyio" }, { name = "docstring-parser" }, { name = "httpx2" }, - { name = "jiter", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "jiter", version = "0.14.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-dev-parlant') 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-dev-parlant') 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 = "jiter", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jiter", version = "0.17.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-dev-parlant') 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-dev-parlant') 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 = "sniffio" }, { name = "typing-extensions" }, @@ -477,7 +578,7 @@ version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ @@ -576,7 +677,7 @@ source = { editable = "packages/band-mcp" } dependencies = [ { name = "band-client-rest" }, { name = "band-sdk" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, extra = ["cli"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, extra = ["cli"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, extra = ["cli"], marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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-settings" }, { name = "typer" }, @@ -602,8 +703,8 @@ dependencies = [ { name = "band-client-rest" }, { name = "band-sdk-core" }, { name = "cryptography" }, - { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' or (python_full_version >= '3.12' and 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') or (python_full_version >= '3.12' and 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-parlant') or (python_full_version >= '3.12' and 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-strands-nooa') or (python_full_version >= '3.12' and 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-nooa') or (python_full_version >= '3.12' and 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-parlant') or (python_full_version >= '3.12' and 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-strands') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32') or (python_full_version < '3.14' and sys_platform == 'win32') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "phoenix-channels-python-client" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -632,7 +733,7 @@ a2a-gateway-demo = [ ] acp = [ { name = "agent-client-protocol" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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 = "starlette" }, { name = "uvicorn" }, @@ -660,7 +761,7 @@ bridge-agentcore = [ ] claude-sdk = [ { name = "claude-agent-sdk" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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')" }, ] codex = [ @@ -672,11 +773,11 @@ copilot-sdk = [ crewai = [ { name = "crewai" }, { name = "nest-asyncio" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, { name = "pillow" }, ] desktop = [ - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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')" }, ] dev = [ @@ -693,8 +794,8 @@ dev = [ { name = "commitizen" }, { name = "github-copilot-sdk" }, { name = "google-adk" }, - { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "httpx" }, { name = "langchain" }, { name = "langchain-anthropic" }, @@ -706,14 +807,13 @@ dev = [ { name = "letta-client" }, { name = "looptime" }, { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" } }, - { name = "nooa", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'" }, - { name = "openai" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" } }, { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pre-commit" }, - { name = "pydantic-ai-slim" }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"], marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "pydantic-settings" }, { name = "pyrefly" }, { name = "pytest" }, @@ -730,7 +830,6 @@ 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-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "tenacity" }, { name = "uvicorn" }, ] @@ -742,7 +841,7 @@ dev-crewai = [ { name = "httpx" }, { name = "looptime" }, { name = "nest-asyncio" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, { name = "pillow" }, { name = "pydantic-settings" }, { name = "pyrefly" }, @@ -762,7 +861,7 @@ dev-parlant = [ { name = "band-testing-python" }, { name = "httpx" }, { name = "looptime" }, - { name = "openai" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" } }, { name = "parlant" }, { name = "pydantic-settings" }, { name = "pyrefly" }, @@ -777,14 +876,45 @@ dev-parlant = [ { name = "tenacity" }, { name = "werkzeug" }, ] +dev-strands-nooa = [ + { name = "a2a-sdk" }, + { name = "anthropic" }, + { name = "band-testing-python" }, + { name = "beautifulsoup4" }, + { name = "httpx" }, + { name = "langchain" }, + { name = "langchain-anthropic" }, + { name = "langchain-community" }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "langchain-text-splitters" }, + { name = "langgraph" }, + { name = "looptime" }, + { name = "nooa", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic-settings" }, + { name = "pyrefly" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-markdown-docs" }, + { name = "pytest-mock" }, + { name = "pytest-rerunfailures" }, + { name = "pytest-timeout" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "ruff" }, + { name = "strands-agents", extra = ["openai"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tenacity" }, +] gemini = [ - { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] google-adk = [ { name = "google-adk" }, - { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "opentelemetry-resourcedetector-gcp" }, ] langgraph = [ @@ -796,34 +926,37 @@ langgraph = [ { name = "langchain-openai" }, { name = "langchain-text-splitters" }, { name = "langgraph" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] letta = [ { name = "letta-client" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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')" }, ] logging = [ { name = "python-json-logger" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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')" }, ] nooa = [ - { name = "nooa", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "nooa", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] opencode = [ { name = "httpx" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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')" }, ] parlant = [ - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "parlant" }, { name = "werkzeug" }, ] pydantic-ai = [ - { name = "openai" }, - { name = "pydantic-ai-slim", extra = ["anthropic"], marker = "extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"], marker = "extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands')" }, ] slack = [ { name = "aiohttp" }, @@ -832,8 +965,8 @@ slack = [ { name = "uvicorn" }, ] strands = [ - { name = "openai" }, - { name = "strands-agents", extra = ["openai"] }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, + { name = "strands-agents", extra = ["openai"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, ] [package.metadata] @@ -842,6 +975,7 @@ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'dev'", specifier = ">=1.1.2,<2" }, { name = "a2a-sdk", marker = "extra == 'dev-crewai'", specifier = ">=1.1.2,<2" }, { name = "a2a-sdk", marker = "extra == 'dev-parlant'", specifier = ">=1.1.2,<2" }, + { name = "a2a-sdk", marker = "extra == 'dev-strands-nooa'", specifier = ">=1.1.2,<2" }, { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a-gateway'", specifier = ">=1.1.2,<2" }, { name = "a2a-sdk", extras = ["http-server"], marker = "extra == 'a2a-gateway-demo'", specifier = ">=1.1.2,<2" }, { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = ">=0.11.0" }, @@ -856,13 +990,16 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'dev'", specifier = ">=0.75.0,<2" }, { name = "anthropic", marker = "extra == 'dev-crewai'", specifier = ">=0.75.0,<2" }, { name = "anthropic", marker = "extra == 'dev-parlant'", specifier = ">=0.75.0,<2" }, + { name = "anthropic", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.75.0,<2" }, { name = "async-lru", specifier = ">=2.3.0" }, { name = "band-client-rest", specifier = "==0.0.46" }, { name = "band-sdk-core", specifier = "==2.8.0" }, { name = "band-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" }, { name = "band-testing-python", marker = "extra == 'dev-parlant'", specifier = "==0.1.4" }, + { name = "band-testing-python", marker = "extra == 'dev-strands-nooa'", specifier = "==0.1.4" }, { name = "beautifulsoup4", marker = "extra == 'dev'", specifier = ">=4.12.0" }, + { name = "beautifulsoup4", marker = "extra == 'dev-strands-nooa'", specifier = ">=4.12.0" }, { name = "beautifulsoup4", marker = "extra == 'langgraph'", specifier = ">=4.12.0" }, { name = "boto3", marker = "extra == 'bridge-agentcore'", specifier = ">=1.35.0" }, { name = "boto3", marker = "extra == 'dev'", specifier = ">=1.35.0" }, @@ -888,28 +1025,37 @@ requires-dist = [ { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'dev-crewai'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'dev-parlant'", specifier = ">=0.24.0" }, + { name = "httpx", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.24.0" }, { name = "httpx", marker = "extra == 'opencode'", specifier = ">=0.24.0" }, { name = "langchain", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "langchain", marker = "extra == 'dev-strands-nooa'", specifier = ">=1.0.0" }, { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "langchain-anthropic", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "langchain-anthropic", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.3.0" }, { name = "langchain-anthropic", marker = "extra == 'langgraph'", specifier = ">=0.3.0" }, { name = "langchain-community", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "langchain-community", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.4.0" }, { name = "langchain-community", marker = "extra == 'langgraph'", specifier = ">=0.4.0" }, { name = "langchain-core", marker = "extra == 'dev'", specifier = ">=1.2.11" }, + { name = "langchain-core", marker = "extra == 'dev-strands-nooa'", specifier = ">=1.2.11" }, { name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.2.11" }, - { name = "langchain-openai", marker = "extra == 'a2a-gateway-demo'", specifier = ">=0.3.0" }, - { name = "langchain-openai", marker = "extra == 'dev'", specifier = ">=0.3.0" }, - { name = "langchain-openai", marker = "extra == 'langgraph'", specifier = ">=0.3.0" }, + { name = "langchain-openai", marker = "extra == 'a2a-gateway-demo'", specifier = ">=1.5.0" }, + { name = "langchain-openai", marker = "extra == 'dev'", specifier = ">=1.5.0" }, + { name = "langchain-openai", marker = "extra == 'dev-strands-nooa'", specifier = ">=1.5.0" }, + { name = "langchain-openai", marker = "extra == 'langgraph'", specifier = ">=1.5.0" }, { name = "langchain-text-splitters", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "langchain-text-splitters", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.3.0" }, { name = "langchain-text-splitters", marker = "extra == 'langgraph'", specifier = ">=0.3.0" }, { name = "langgraph", marker = "extra == 'a2a-gateway-demo'", specifier = ">=1.0.0" }, { name = "langgraph", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "langgraph", marker = "extra == 'dev-strands-nooa'", specifier = ">=1.0.0" }, { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "letta-client", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "letta-client", marker = "extra == 'letta'", specifier = ">=1.0.0" }, { name = "looptime", marker = "extra == 'dev'", specifier = ">=0.7" }, { name = "looptime", marker = "extra == 'dev-crewai'", specifier = ">=0.7" }, { name = "looptime", marker = "extra == 'dev-parlant'", specifier = ">=0.7" }, + { name = "looptime", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.7" }, { name = "mcp", marker = "extra == 'acp'", specifier = ">=1.28.1,<2" }, { name = "mcp", marker = "extra == 'claude-sdk'", specifier = ">=1.28.1,<2" }, { name = "mcp", marker = "extra == 'desktop'", specifier = ">=1.28.1,<2" }, @@ -918,16 +1064,18 @@ requires-dist = [ { name = "mcp", marker = "extra == 'opencode'", specifier = ">=1.28.1,<2" }, { name = "nest-asyncio", marker = "extra == 'crewai'", specifier = ">=1.6.0" }, { name = "nest-asyncio", marker = "extra == 'dev-crewai'", specifier = ">=1.6.0" }, - { name = "nooa", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev'", specifier = ">=0.0.10,<0.1" }, + { name = "nooa", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev-strands-nooa'", specifier = ">=0.0.10,<0.1" }, { name = "nooa", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'nooa'", specifier = ">=0.0.10,<0.1" }, - { name = "openai", marker = "extra == 'crewai'", specifier = ">=2.0.0" }, - { name = "openai", marker = "extra == 'dev'", specifier = ">=2.0.0" }, - { name = "openai", marker = "extra == 'dev-crewai'", specifier = ">=2.0.0" }, - { name = "openai", marker = "extra == 'dev-parlant'", specifier = ">=2.0.0" }, + { name = "openai", marker = "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'nooa'", specifier = ">=2.0.0,<3" }, + { name = "openai", marker = "extra == 'crewai'", specifier = ">=2.0.0,<3" }, + { name = "openai", marker = "extra == 'dev'", specifier = ">=3.26.0,<4" }, + { name = "openai", marker = "extra == 'dev-crewai'", specifier = ">=2.0.0,<3" }, + { name = "openai", marker = "extra == 'dev-parlant'", specifier = ">=3.26.0,<4" }, + { name = "openai", marker = "extra == 'dev-strands-nooa'", specifier = ">=2.0.0,<3" }, { name = "openai", marker = "extra == 'langgraph'", specifier = ">=2.0.0" }, { name = "openai", marker = "extra == 'parlant'", specifier = ">=2.0.0" }, - { name = "openai", marker = "extra == 'pydantic-ai'", specifier = ">=2.0.0" }, - { name = "openai", marker = "extra == 'strands'", specifier = ">=2.0.0" }, + { name = "openai", marker = "extra == 'pydantic-ai'", specifier = ">=3.26.0,<4" }, + { name = "openai", marker = "extra == 'strands'", specifier = ">=2.0.0,<3" }, { name = "opentelemetry-instrumentation-logging", marker = "extra == 'dev'", specifier = ">=0.65b0" }, { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'dev'", specifier = ">=1.12.0a0,!=1.13.0" }, { name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'google-adk'", specifier = ">=1.12.0a0,!=1.13.0" }, @@ -940,35 +1088,44 @@ requires-dist = [ { name = "pillow", marker = "extra == 'dev-crewai'", specifier = ">=12.1.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0" }, - { name = "pydantic-ai-slim", marker = "extra == 'dev'", specifier = ">=2.18.0" }, - { name = "pydantic-ai-slim", extras = ["anthropic"], marker = "extra == 'pydantic-ai'", specifier = ">=2.18.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'dev'", specifier = ">=2.55.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], marker = "extra == 'pydantic-ai'", specifier = ">=2.55.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pydantic-settings", marker = "extra == 'dev'", specifier = ">=2.0.0" }, { name = "pydantic-settings", marker = "extra == 'dev-crewai'", specifier = ">=2.0.0" }, { name = "pydantic-settings", marker = "extra == 'dev-parlant'", specifier = ">=2.0.0" }, + { name = "pydantic-settings", marker = "extra == 'dev-strands-nooa'", specifier = ">=2.0.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.18.0" }, { name = "pyrefly", marker = "extra == 'dev-crewai'", specifier = ">=0.18.0" }, { name = "pyrefly", marker = "extra == 'dev-parlant'", specifier = ">=0.18.0" }, + { name = "pyrefly", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.18.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev-crewai'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev-parlant'", specifier = ">=7.0.0" }, + { name = "pytest", marker = "extra == 'dev-strands-nooa'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-asyncio", marker = "extra == 'dev-crewai'", specifier = ">=0.21.0" }, { name = "pytest-asyncio", marker = "extra == 'dev-parlant'", specifier = ">=0.21.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pytest-cov", marker = "extra == 'dev-crewai'", specifier = ">=4.0.0" }, { name = "pytest-cov", marker = "extra == 'dev-parlant'", specifier = ">=4.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev-strands-nooa'", specifier = ">=4.0.0" }, { name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.35.0" }, { name = "pytest-markdown-docs", marker = "extra == 'dev'", specifier = ">=0.9.2" }, + { name = "pytest-markdown-docs", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.9.2" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" }, { name = "pytest-mock", marker = "extra == 'dev-crewai'", specifier = ">=3.10.0" }, { name = "pytest-mock", marker = "extra == 'dev-parlant'", specifier = ">=3.10.0" }, + { name = "pytest-mock", marker = "extra == 'dev-strands-nooa'", specifier = ">=3.10.0" }, { name = "pytest-rerunfailures", marker = "extra == 'dev'", specifier = ">=14.0.0" }, { name = "pytest-rerunfailures", marker = "extra == 'dev-crewai'", specifier = ">=14.0.0" }, { name = "pytest-rerunfailures", marker = "extra == 'dev-parlant'", specifier = ">=14.0.0" }, + { name = "pytest-rerunfailures", marker = "extra == 'dev-strands-nooa'", specifier = ">=14.0.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4.0" }, { name = "pytest-timeout", marker = "extra == 'dev-crewai'", specifier = ">=2.4.0" }, { name = "pytest-timeout", marker = "extra == 'dev-parlant'", specifier = ">=2.4.0" }, + { name = "pytest-timeout", marker = "extra == 'dev-strands-nooa'", specifier = ">=2.4.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", marker = "extra == 'bridge'", specifier = ">=1.2.2" }, { name = "python-dotenv", marker = "extra == 'bridge-agentcore'", specifier = ">=1.2.2" }, @@ -980,11 +1137,13 @@ requires-dist = [ { name = "rich", marker = "extra == 'dev'", specifier = ">=13.0.0" }, { name = "rich", marker = "extra == 'dev-crewai'", specifier = ">=13.0.0" }, { name = "rich", marker = "extra == 'dev-parlant'", specifier = ">=13.0.0" }, + { name = "rich", marker = "extra == 'dev-strands-nooa'", specifier = ">=13.0.0" }, { name = "rich", marker = "extra == 'logging'", specifier = ">=14.0.0" }, { name = "ruamel-yaml", specifier = ">=0.18" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev-crewai'", specifier = ">=0.8.0" }, { name = "ruff", marker = "extra == 'dev-parlant'", specifier = ">=0.8.0" }, + { name = "ruff", marker = "extra == 'dev-strands-nooa'", specifier = ">=0.8.0" }, { name = "slack-sdk", marker = "extra == 'dev'", specifier = ">=3.27.0" }, { name = "slack-sdk", marker = "extra == 'slack'", specifier = ">=3.27.0" }, { name = "starlette", marker = "extra == 'a2a-gateway'", specifier = ">=0.40.0" }, @@ -992,11 +1151,12 @@ 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.50.1,<2" }, + { name = "strands-agents", extras = ["openai"], marker = "extra == 'dev-strands-nooa'", specifier = ">=1.50.1,<2" }, { name = "strands-agents", extras = ["openai"], marker = "extra == 'strands'", specifier = ">=1.50.1,<2" }, { name = "tenacity", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "tenacity", marker = "extra == 'dev-crewai'", specifier = ">=8.0.0" }, { name = "tenacity", marker = "extra == 'dev-parlant'", specifier = ">=8.0.0" }, + { name = "tenacity", marker = "extra == 'dev-strands-nooa'", specifier = ">=8.0.0" }, { name = "uvicorn", marker = "extra == 'a2a-gateway'", specifier = ">=0.29" }, { name = "uvicorn", marker = "extra == 'a2a-gateway-demo'", specifier = ">=0.29" }, { name = "uvicorn", marker = "extra == 'acp'", specifier = ">=0.29" }, @@ -1007,7 +1167,7 @@ requires-dist = [ { name = "werkzeug", marker = "extra == 'dev-parlant'", specifier = ">=3.1.6" }, { name = "werkzeug", marker = "extra == 'parlant'", specifier = ">=3.1.6" }, ] -provides-extras = ["logging", "desktop", "codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "copilot-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "agno", "strands", "nooa", "dev", "dev-crewai", "dev-parlant"] +provides-extras = ["logging", "desktop", "codex", "opencode", "letta", "pydantic-ai", "anthropic", "langgraph", "claude-sdk", "copilot-sdk", "parlant", "crewai", "gemini", "a2a", "a2a-gateway", "a2a-gateway-demo", "acp", "slack", "bridge", "bridge-agentcore", "agentcore-runtime", "google-adk", "agno", "strands", "nooa", "dev", "dev-crewai", "dev-parlant", "dev-strands-nooa"] [[package]] name = "band-sdk-core" @@ -1029,10 +1189,10 @@ name = "band-testing-python" version = "0.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pytest-asyncio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/2b/47b6a5afca2f6c41f5c75557164654512af96b2a06f09fe50fac35e86d1d/band_testing_python-0.1.4.tar.gz", hash = "sha256:3a4e3bd3b8a37bda5d587a6290cc7d1a8c4b4ecbe7bb348716d632de314d87f7", size = 61988, upload-time = "2026-04-28T13:24:46.244Z" } wheels = [ @@ -1237,7 +1397,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -1468,7 +1628,7 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, { name = "typing-extensions" }, - { name = "uvicorn", extra = ["standard"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "uvicorn", extra = ["standard"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/92/d1/5e33b26985f0c7046a0be1cee2158ada1748ee700d2545057fde1468d74d/chromadb-1.5.9.tar.gz", hash = "sha256:5c20e62a455c28bacac927f26116a73fd8e1799e0d908be8e8a4f02197a54731", size = 2595635, upload-time = "2026-05-05T05:54:51.713Z" } wheels = [ @@ -1486,7 +1646,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "jsonschema" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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 = "sniffio" }, ] @@ -1531,7 +1691,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly" }, + { name = "humanfriendly", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -1566,7 +1726,7 @@ name = "contextvars" version = "2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "immutables" }, + { name = "immutables", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/83/96/55b82d9f13763be9d672622e1b8106c85acb83edd7cc2fa5bc67cd9877e9/contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e", size = 9570, upload-time = "2019-04-01T14:42:11.953Z" } @@ -1575,7 +1735,7 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1753,8 +1913,8 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.11' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version > '3.11' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version > '3.11' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version > '3.11' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and 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' and extra == 'extra-8-band-sdk-dev-parlant') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.11' and extra == 'extra-8-band-sdk-dev') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-crewai' and 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') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tomli", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.11' and extra == 'extra-8-band-sdk-crewai') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.11' and extra == 'extra-8-band-sdk-dev') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-nooa') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (python_full_version <= '3.11' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -1778,7 +1938,7 @@ dependencies = [ { name = "lancedb" }, { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" } }, { name = "oauthlib" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, { name = "openpyxl" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -1855,7 +2015,7 @@ name = "croniter" version = "6.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-dateutil" }, + { name = "python-dateutil", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } wheels = [ @@ -1867,7 +2027,7 @@ name = "cryptography" version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } wheels = [ @@ -1936,8 +2096,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "aiologic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -1958,10 +2118,10 @@ name = "cyclopts" version = "4.10.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "rich-rst" }, + { name = "attrs", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "docstring-parser", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich-rst", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" } wheels = [ @@ -1982,7 +2142,7 @@ name = "deprecated" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wrapt" }, + { name = "wrapt", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ @@ -2060,8 +2220,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython" }, - { name = "idna" }, + { name = "dnspython", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "idna", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -2082,7 +2242,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.13' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.13' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2111,27 +2271,27 @@ name = "fastmcp" version = "3.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "authlib" }, - { name = "cyclopts" }, - { name = "exceptiongroup" }, - { name = "httpx" }, - { name = "jsonref" }, - { name = "jsonschema-path" }, - { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" } }, - { name = "openapi-pydantic" }, - { name = "opentelemetry-api" }, - { name = "packaging" }, - { name = "platformdirs" }, - { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", extra = ["email"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyperclip" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "uncalled-for" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "websockets" }, + { name = "authlib", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "cyclopts", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "exceptiongroup", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonref", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonschema-path", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openapi-pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-api", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "platformdirs", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", extra = ["email"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyperclip", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-dotenv", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyyaml", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "uncalled-for", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "uvicorn", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "watchfiles", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "websockets", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/42/7eed0a38e3b7a386805fecacf8a5a9353a2b3040395ef9e30e585d8549ac/fastmcp-3.2.3.tar.gz", hash = "sha256:4f02ae8b00227285a0cf6544dea1db29b022c8cdd8d3dfdec7118540210ae60a", size = 26328743, upload-time = "2026-04-09T22:05:03.402Z" } wheels = [ @@ -2197,9 +2357,7 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } @@ -2212,8 +2370,46 @@ name = "filelock" version = "4.0.9" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] sdist = { url = "https://files.pythonhosted.org/packages/70/51/2bc9e529f154fad99b6cd0073e609291eb32fd23581b32362d33d164d316/filelock-4.0.9.tar.gz", hash = "sha256:635e7d67fa92654eed444e75e9ca18426d34e77ad9c469bf4373f75a932f7b22", size = 251200, upload-time = "2026-10-01T23:09:14.029Z" } wheels = [ @@ -2428,15 +2624,15 @@ dependencies = [ { name = "click" }, { name = "fastapi" }, { name = "google-api-python-client" }, - { name = "google-cloud-aiplatform", version = "1.148.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-aiplatform", version = "1.165.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-cloud-aiplatform", version = "1.148.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-aiplatform", version = "1.165.1", source = { registry = "https://pypi.org/simple" }, extra = ["agent-engines"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "google-cloud-secret-manager" }, { name = "google-cloud-speech" }, { name = "google-cloud-storage" }, - { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "graphviz" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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" }, { name = "opentelemetry-exporter-gcp-trace" }, @@ -2540,18 +2736,18 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "docstring-parser", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-api-core", extra = ["grpc"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-bigquery", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-resource-manager", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-storage", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "docstring-parser", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-api-core", extra = ["grpc"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-auth", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-bigquery", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-resource-manager", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-storage", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "1.75.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } wheels = [ @@ -2560,18 +2756,18 @@ wheels = [ [package.optional-dependencies] agent-engines = [ - { name = "aiohttp", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "cloudpickle", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-iam", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-gcp-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-gcp-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "aiohttp", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "cloudpickle", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-iam", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-gcp-logging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-gcp-trace", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-sdk", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -2579,25 +2775,51 @@ name = "google-cloud-aiplatform" version = "1.165.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "docstring-parser", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-api-core", extra = ["grpc"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-bigquery", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-resource-manager", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-storage", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "proto-plus", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "docstring-parser", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-api-core", extra = ["grpc"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-auth", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-bigquery", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-resource-manager", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-storage", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-genai", version = "2.25.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "proto-plus", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "protobuf", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/13/19/45df8264bf80dcc133c777822390c503aa75b878a57e9286d6f520b08c87/google_cloud_aiplatform-1.165.1.tar.gz", hash = "sha256:bd62ba7590255cacd66f9d0439eb731060af460d35bb48dbbeeefce9dfc0a359", size = 11325730, upload-time = "2026-08-19T21:12:30.858Z" } wheels = [ @@ -2606,15 +2828,15 @@ wheels = [ [package.optional-dependencies] agent-engines = [ - { name = "aiohttp", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "cloudpickle", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-cloud-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-gcp-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "aiohttp", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "cloudpickle", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-cloud-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-gcp-logging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-sdk", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -2682,12 +2904,12 @@ name = "google-cloud-iam" version = "2.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "google-api-core", extra = ["grpc"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "grpc-google-iam-v1", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "grpcio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "google-api-core", extra = ["grpc"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-auth", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "grpc-google-iam-v1", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "grpcio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "proto-plus", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "protobuf", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/08/4f88586e9e1cc65754200fc60adf25c2ca7b7da0407a6dcf13362e69e63a/google_cloud_iam-2.26.0.tar.gz", hash = "sha256:d44a86e5cb0bbc904c459b1367bd0124eef802fe0af72ac378f87f7f8e5b2d42", size = 613465, upload-time = "2026-10-01T18:16:49.524Z" } wheels = [ @@ -2839,16 +3061,16 @@ resolution-markers = [ "python_full_version == '3.13.*' and sys_platform != 'win32'", ] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "distro", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "requests", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "sniffio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tenacity", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "websockets", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "anyio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "distro", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "requests", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "sniffio", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tenacity", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "websockets", marker = "python_full_version >= '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } wheels = [ @@ -2860,22 +3082,48 @@ name = "google-genai" version = "2.25.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] -dependencies = [ - { name = "anyio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "distro", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "google-auth", extra = ["requests"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "requests", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "sniffio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tenacity", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "websockets", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "distro", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "google-auth", extra = ["requests"], marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "requests", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "sniffio", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tenacity", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "websockets", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/62/0a/a3b7856ca840031d4393dbdd97b67fe811b20061315ed68b67b5c85ca80d/google_genai-2.25.0.tar.gz", hash = "sha256:ab603baa5eee0205926ad0f8d7f93e0400df6d67650e99c33e01ab228ea16ad6", size = 699564, upload-time = "2026-09-22T17:23:01.238Z" } wheels = [ @@ -3034,7 +3282,7 @@ name = "griffe" version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama" }, + { name = "colorama", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } wheels = [ @@ -3207,15 +3455,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.9.1" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "truststore", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten' or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] @@ -3304,18 +3552,28 @@ wheels = [ [[package]] name = "httpx2" -version = "2.9.1" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx2-jsfetch", marker = "(python_full_version >= '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "idna" }, - { name = "truststore" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "truststore", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -3323,16 +3581,16 @@ name = "huggingface-hub" version = "1.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (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') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, - { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "fsspec", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "hf-xet", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform != 'win32') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform != 'win32') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform != 'win32') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform != 'win32') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform != 'win32') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyyaml", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tqdm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typer", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and 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') or (python_full_version >= '3.12' and 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-parlant') or (python_full_version >= '3.12' and 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-strands-nooa') or (python_full_version >= '3.12' and 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-nooa') or (python_full_version >= '3.12' and 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-parlant') or (python_full_version >= '3.12' and 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-strands') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.14' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (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') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (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-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (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-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "fsspec", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "hf-xet", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'amd64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'arm64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine != 'AMD64' and platform_machine != 'aarch64' and platform_machine != 'amd64' and platform_machine != 'arm64' and platform_machine != 'x86_64' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'aarch64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'amd64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'arm64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'x86_64' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'AMD64' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'aarch64' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'amd64' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'amd64' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'arm64' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'arm64' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-crewai') or (platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-crewai') or (platform_machine == 'x86_64' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_machine == 'AMD64' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'aarch64' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'amd64' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'arm64' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (platform_machine == 'x86_64' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyyaml", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tqdm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typer", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/89/e7aa12d8a6b9259bed10671abb25ae6fa437c0f88a86ecbf59617bae7759/huggingface_hub-1.11.0.tar.gz", hash = "sha256:15fb3713c7f9cdff7b808a94fd91664f661ab142796bb48c9cd9493e8d166278", size = 761749, upload-time = "2026-04-16T13:07:39.73Z" } wheels = [ @@ -3344,7 +3602,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -3415,7 +3673,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "zipp", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.13' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.13' and sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.14' and sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (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') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -3449,7 +3707,7 @@ dependencies = [ { name = "docstring-parser" }, { name = "jinja2" }, { name = "jiter", version = "0.13.0", source = { registry = "https://pypi.org/simple" } }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, { name = "pydantic" }, { name = "pydantic-core" }, { name = "requests" }, @@ -3467,7 +3725,7 @@ name = "jaraco-classes" version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ @@ -3479,7 +3737,7 @@ name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, + { name = "backports-tarfile", marker = "(python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ @@ -3491,7 +3749,7 @@ name = "jaraco-functools" version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools" }, + { name = "more-itertools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ @@ -3512,7 +3770,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "markupsafe", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ @@ -3526,11 +3784,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } @@ -3616,102 +3873,166 @@ wheels = [ [[package]] name = "jiter" -version = "0.14.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/50/17afdaffcc8af4bf4fddf2b6c26d066553aa2221983f2affcde435fc2532/jiter-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1", size = 290699, upload-time = "2026-09-12T15:11:30.085Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/c185d32d5b3657ad84da26c84a9eb15f00aa1b39d6882fcc0052dba2d7c2/jiter-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406", size = 324246, upload-time = "2026-09-12T15:11:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/24/7a/8b8903bfe91a90a8fa1ec9b45d9fda5b6287a386693b69d720a882d73f3c/jiter-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7", size = 343275, upload-time = "2026-09-12T15:11:32.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5d/6821fae2abc71a3c3a84bef8598d31fc4f27d9edfb55bd8f6c08afb8ef93/jiter-0.17.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e", size = 328034, upload-time = "2026-09-12T15:11:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/8e7a963b1c2dfdc01d6228b004f50a2a3d7c46f0549d7b096a5d15ef81d5/jiter-0.17.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a", size = 342457, upload-time = "2026-09-12T15:11:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/8b2a267e3bda45d9298331cacfe3521e761f0a2b05ad10a23c6548d08358/jiter-0.17.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153", size = 351271, upload-time = "2026-09-12T15:11:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8f/5c74e5e142a6736833d7a991ab04d5c0738038dc44db05af0bb3cd2559e8/jiter-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9", size = 346052, upload-time = "2026-09-12T15:11:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/9c/f54920f06d1696e80b1be841d56412871c6856b1b1e3b541b1ed35346554/jiter-0.17.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04", size = 328915, upload-time = "2026-09-12T15:11:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/21/53/080f126863bceb055db9f1fd5431485eb493b35545a3c35e961ac18cd924/jiter-0.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233", size = 337240, upload-time = "2026-09-12T15:11:42.356Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/3ab742823a0e0e70e143c6c90a482d9d90396ac2445e7b9483eb7245d3b5/jiter-0.17.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599", size = 484917, upload-time = "2026-09-12T15:11:43.549Z" }, + { url = "https://files.pythonhosted.org/packages/14/e0/8ca71bc8b9cc9ed96c9da565863e00f3bb875a8fb82abcf03e975e067902/jiter-0.17.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c", size = 521868, upload-time = "2026-09-12T15:11:44.704Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/81f8719dcedb75713082a2048405376c81f6546b75af8167f3bba01a1ed0/jiter-0.17.0-cp311-cp311-win32.whl", hash = "sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80", size = 187844, upload-time = "2026-09-12T15:11:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/59693f348488f01ed12d862e99ab8da14961152d3e9c39b9b1ef363f3572/jiter-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb", size = 231402, upload-time = "2026-09-12T15:11:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/fe/89/fb35e286463cb9f01edc2c4e47df6e5477ee36bca5096414e9ea87985588/jiter-0.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3", size = 187006, upload-time = "2026-09-12T15:11:48.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" }, + { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" }, + { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" }, + { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2c4a8075ed5ea02b56911e9375d4c8d7784572ff4af32e5a99ae0d071044/jiter-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa", size = 290991, upload-time = "2026-09-12T15:12:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b1/34bfa29599d420423baac6ff7cada6674fe63d5a7a2ccb3900b904678783/jiter-0.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3", size = 321425, upload-time = "2026-09-12T15:12:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/11/71/a5ac64a62a04aebd556afadab14a6b730001e16df87266ded943a100a1d9/jiter-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b", size = 343138, upload-time = "2026-09-12T15:12:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/f761e320ea473314cb68612bc6a435393464dbd198051399b36848b4ebf3/jiter-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b", size = 325805, upload-time = "2026-09-12T15:12:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/19/1a/27d8e40f0fb29bbc7a5adf30907144396a115dbe93d5d8976c054a6dfe96/jiter-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a", size = 340230, upload-time = "2026-09-12T15:12:17.682Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c0/30bcde78a28155461f965d16b7aca4ffca6d17494d905f7a0bb072e6c64e/jiter-0.17.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807", size = 351343, upload-time = "2026-09-12T15:12:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/91420b156315ae22732f5ee1a7b5725a030aab9dc8fd7dcdacfb4aa588d3/jiter-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5", size = 344990, upload-time = "2026-09-12T15:12:20.705Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a2/ae6d5672644cc11127970277c9aeb0fa6fae376845587f5b0a8e8828167c/jiter-0.17.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca", size = 328624, upload-time = "2026-09-12T15:12:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/45cb1162f6aa586536e4a973fc339d72dc6b08cca030d70a838a307aa778/jiter-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8", size = 334731, upload-time = "2026-09-12T15:12:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/45c1574b644da7deda0b7591c349520dcf83ce45b24d7ca19922dab1fc27/jiter-0.17.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9", size = 483649, upload-time = "2026-09-12T15:12:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d3/ebea1ecb5b241c519f192b30215c79a8e47f42f1621acbcd6f8830728416/jiter-0.17.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8", size = 520758, upload-time = "2026-09-12T15:12:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/682b641ff0765ea9bdc349dbc7d223de5c8af8ec1abda0db3406992f92fe/jiter-0.17.0-cp313-cp313-win32.whl", hash = "sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7", size = 184334, upload-time = "2026-09-12T15:12:30.813Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d2/9a49aac2b27af4cc5015e368c0cc3588491a532f717a668ffce1f1ac57da/jiter-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd", size = 226601, upload-time = "2026-09-12T15:12:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ce/9a43e9f614608eafa78de22aedcff54cd21324467b5d442d5c9b00244145/jiter-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac", size = 183103, upload-time = "2026-09-12T15:12:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/23065f8e2c7a4c372c1b6f6622e4cfab4dc786cb5150052b1527e6a6a840/jiter-0.17.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5", size = 292210, upload-time = "2026-09-12T15:12:35.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/81/67b58647560bc82a4490d722caa8561d7a86a9f45d4fa620b7e5fe282c7a/jiter-0.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76", size = 321512, upload-time = "2026-09-12T15:12:36.907Z" }, + { url = "https://files.pythonhosted.org/packages/c7/07/6658359a25f55927f7f8bf0e16465dee2ccd0b2a1a5208acc0df8972e074/jiter-0.17.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64", size = 343897, upload-time = "2026-09-12T15:12:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/5d50a9f0319cbdc37fd53c27f8c313d46afc34f1b048219ae6d8ea068da4/jiter-0.17.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199", size = 326519, upload-time = "2026-09-12T15:12:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c7/d02517832b29eb8275fdd0f4ce0f17b80f58cc4c3ebecd4d9ace990d633d/jiter-0.17.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71", size = 341369, upload-time = "2026-09-12T15:12:41.486Z" }, + { url = "https://files.pythonhosted.org/packages/3b/07/499b5f5603501cdd93a73a6a176dfad9c96555a3ae58ca9f8e3acba63dc9/jiter-0.17.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef", size = 352160, upload-time = "2026-09-12T15:12:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/f5/75/b04013c7743269d4533ef4e746fc0ed678a143968dd7448658e3f51daad2/jiter-0.17.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb", size = 345018, upload-time = "2026-09-12T15:12:44.192Z" }, + { url = "https://files.pythonhosted.org/packages/1d/96/cbb6fd1e42a77c8412ec4643db95059b30cdfc635e387cc9193e098ce268/jiter-0.17.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b", size = 329244, upload-time = "2026-09-12T15:12:45.491Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/d3be402f398566a379bf40ae65be5c3505b14d9e95e0802a597ddde7ddee/jiter-0.17.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b", size = 335693, upload-time = "2026-09-12T15:12:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/98e2c4130b93d64f1d67c89060b928d04102549bf05e64451c9e6024f9ca/jiter-0.17.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9", size = 484329, upload-time = "2026-09-12T15:12:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/78/5e/8da91e49f0fbca37c3489fb4cf3ad6676d4965f00ae5468bca3a2513737a/jiter-0.17.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f", size = 521358, upload-time = "2026-09-12T15:12:49.856Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/5388684a5a38af3557cd9c2424b9827c71809cff24373c75ef9d0d3dfba9/jiter-0.17.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03", size = 110459, upload-time = "2026-09-12T15:12:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/58b3a93525d2ffca7f54d9dee441381990082bd1172fbeb8d6a3f72a4dc3/jiter-0.17.0-cp314-cp314-win32.whl", hash = "sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8", size = 185043, upload-time = "2026-09-12T15:12:54.477Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/1aa520eb6c359b262c14ff995ca7283837208ddfb1202082ce9d73cf214d/jiter-0.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312", size = 227163, upload-time = "2026-09-12T15:12:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/5997f648794bd9b499491d0ff480b096cc9a9c65bdba29f57568e6aa1705/jiter-0.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744", size = 183505, upload-time = "2026-09-12T15:12:58.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/84a5ec271d09f7590b6073af5ee4abb44eab4ccace453b7e2c5ce45234ca/jiter-0.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0", size = 321527, upload-time = "2026-09-12T15:12:59.394Z" }, + { url = "https://files.pythonhosted.org/packages/39/71/9e1fd0045f5920b4c36be35c3f0f0dfd123668684f8ad352619d7aa44183/jiter-0.17.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388", size = 340865, upload-time = "2026-09-12T15:13:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/14627fd2bc377f3dd09491bcace6b90e34b4d7fea2f1f3295031ff91f528/jiter-0.17.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489", size = 325412, upload-time = "2026-09-12T15:13:02.152Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/8d5808f7bf0f456bde79e6393587183a0cee5f83d179fe1f7f1eff2ba067/jiter-0.17.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165", size = 340473, upload-time = "2026-09-12T15:13:03.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/da/1d8c7c6c4ae6b2423b94a81b6b907d37b28f87664e077427b531bf1b5313/jiter-0.17.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9", size = 350757, upload-time = "2026-09-12T15:13:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/eb/96/c1813dcca15c5a370145a448aaea7d1f83f6f0228a5f1130e79340ee385f/jiter-0.17.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478", size = 345203, upload-time = "2026-09-12T15:13:06.131Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/fc61cbcf2992d169ede13648fc3fd8e2d3171a3669dde43cd4db556549ac/jiter-0.17.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768", size = 328322, upload-time = "2026-09-12T15:13:07.392Z" }, + { url = "https://files.pythonhosted.org/packages/8f/88/46418a3abbdffb7dc41b314200360f24f75faaeb35573e81c92de322cce9/jiter-0.17.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718", size = 336570, upload-time = "2026-09-12T15:13:08.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/28/b8a55b949be6306df8888e365a8df05441de8a7b11289f6957004302e41e/jiter-0.17.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59", size = 482879, upload-time = "2026-09-12T15:13:10.037Z" }, + { url = "https://files.pythonhosted.org/packages/75/3b/21d0afa53ba0680962c39f3eb95ed2946f8793369ed44b0c82b490723081/jiter-0.17.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1", size = 520406, upload-time = "2026-09-12T15:13:11.456Z" }, + { url = "https://files.pythonhosted.org/packages/ef/03/bcbaf8b6b9ea23c2c074411f8ecfbb02d820abac5d0cb8f4e280209174a2/jiter-0.17.0-cp314-cp314t-win32.whl", hash = "sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06", size = 184434, upload-time = "2026-09-12T15:13:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b5/5d6ce2c93ef6fe1241b37a9005547f9b6d58db1f07f39fe95807d4b98f51/jiter-0.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9", size = 227392, upload-time = "2026-09-12T15:13:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4b/1e52baf90187606e33a7b8cfa8f96f5829acd7f01870077eb01059ab76d0/jiter-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc", size = 182776, upload-time = "2026-09-12T15:13:16.239Z" }, + { url = "https://files.pythonhosted.org/packages/05/fc/efe3ac75564ab10f53517958f5ccdc231fc7334af66c76776cb554a88967/jiter-0.17.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925", size = 292143, upload-time = "2026-09-12T15:13:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4c/46982118d91f9ffe9714319d21ec4f98d9b7e0cfd9062826c524a54de24e/jiter-0.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656", size = 321341, upload-time = "2026-09-12T15:13:19.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/12/9b1ac6ecc6307049913db54839ddba1c11c1ef72c5a8bbb5514bc3b50d1b/jiter-0.17.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1", size = 344383, upload-time = "2026-09-12T15:13:20.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b6/527cc72af836d824e9d4d666e64f0a1ca7eafd662a8da9657b78592172ba/jiter-0.17.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0", size = 326841, upload-time = "2026-09-12T15:13:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/d1/41/567f98617e88005b249503b933803f633ec6ba2d427cf4cc35e5c832125c/jiter-0.17.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca", size = 341354, upload-time = "2026-09-12T15:13:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/40/da/b29cda895b785f7d426e224638a885b6145a08ce853b381f34afe3e88c5d/jiter-0.17.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9", size = 351985, upload-time = "2026-09-12T15:13:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/8a73829e7389e72ea298a450f2b3cb58e71a3e464b45f6d8753740f1c4f5/jiter-0.17.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826", size = 346052, upload-time = "2026-09-12T15:13:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/98d6001026932c095ba440925570123043bed29f5ff56158dfe729a9e81b/jiter-0.17.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87", size = 329159, upload-time = "2026-09-12T15:13:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/708dc1d2678f092c31c12754e860cd8353e6a85ecbdb1010157edca0da9e/jiter-0.17.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2", size = 336001, upload-time = "2026-09-12T15:13:32.846Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/75a5ae38862f4eaf0fe2f8a9fbf6484c4890df04c06dcdffc45e36bca61a/jiter-0.17.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575", size = 484281, upload-time = "2026-09-12T15:13:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/6636fae811c27c7f93e1b11fb5800de6a5c9e4269a27cf718e0b31218ad1/jiter-0.17.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688", size = 521300, upload-time = "2026-09-12T15:13:37.101Z" }, + { url = "https://files.pythonhosted.org/packages/61/aa/12df7e0b0b1a2602e3d5a5a7104d7d9700f254b400f134a9b50955c4d231/jiter-0.17.0-cp315-cp315-win32.whl", hash = "sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec", size = 185138, upload-time = "2026-09-12T15:13:38.901Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ec/3dd2e495032cddde05723c1f4c743b67a23e55d2af244692a7f58f0cdae3/jiter-0.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1", size = 226950, upload-time = "2026-09-12T15:13:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c7/ef85704e0a57e9cadb2babc05f6d7c5df4a1c75da1a6ee31e1986b0099a5/jiter-0.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1", size = 183618, upload-time = "2026-09-12T15:13:41.432Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/a4b348349de68762b58d6713973d363ad80a1c741d0bf8def7975f0ecb26/jiter-0.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20", size = 321155, upload-time = "2026-09-12T15:13:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/aebd6d0b5f0677de3a3d0bdc4a05fac949b97c4ede454c8809f180ac7b17/jiter-0.17.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab", size = 340985, upload-time = "2026-09-12T15:13:44.115Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/4c3b49796b5eb62f3f5046f957683f4ba0135fe1a60957c11180512460df/jiter-0.17.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2", size = 325670, upload-time = "2026-09-12T15:13:45.901Z" }, + { url = "https://files.pythonhosted.org/packages/bc/43/f6341ecb4872202a4ef150486fcee0e1ace4aa3da39b71b82061452cdd3a/jiter-0.17.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a", size = 340339, upload-time = "2026-09-12T15:13:47.442Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c4/bc2c86e08fa065e03cb2fbc53b367c3640a7d257ef9d877b29118ea636b7/jiter-0.17.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675", size = 350705, upload-time = "2026-09-12T15:13:48.848Z" }, + { url = "https://files.pythonhosted.org/packages/9d/67/91f12aa111cca6e3a197c3e36bf60a034bf9f122f6d41112a639e44217d8/jiter-0.17.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574", size = 345011, upload-time = "2026-09-12T15:13:50.215Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/9f5556e8f6ec89755fb5a709d8eb8270c9a324e31079eda0dfbeca451b6e/jiter-0.17.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b", size = 328268, upload-time = "2026-09-12T15:13:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/153f20680fb75781a490fb849940e2b00f95035c7aa054df592f36ed33fc/jiter-0.17.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d", size = 337024, upload-time = "2026-09-12T15:13:53.095Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b16c9be3a5035df4466cc72e888188c027562de90a723d290ab6814cb9d4/jiter-0.17.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb", size = 482766, upload-time = "2026-09-12T15:13:55.713Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/667dea313094024bef082175d6bfe8976f90d1c00c926af9df1d8e0eab48/jiter-0.17.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a", size = 520367, upload-time = "2026-09-12T15:13:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/21/e3/4b1a43501fb9ed17b01d137e380cb0e8fdcb39a254ce31aa2ab95bc861ac/jiter-0.17.0-cp315-cp315t-win32.whl", hash = "sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d", size = 184603, upload-time = "2026-09-12T15:13:59.27Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f2/b8ee0372b6ebdf1bde5cc44495d5291d17f961065f5b48f8616cc67cac2e/jiter-0.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174", size = 227936, upload-time = "2026-09-12T15:14:00.472Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b4/923a1215daba959aed8355973315cb3f81f53e0d01c5b211870a27b41f45/jiter-0.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23", size = 182977, upload-time = "2026-09-12T15:14:01.799Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3b/05a917204413e2e09906dfa35240c1021227aeb56c7305abea9562c598b4/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33", size = 288143, upload-time = "2026-09-12T15:14:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/a1cd3c0cf8f79945939e4f8caae9990529f67d7f77f671769f73956329b9/jiter-0.17.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2", size = 281847, upload-time = "2026-09-12T15:14:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/72/b4/9b797679e09f4a46c32986aeb3670bd9bc562fc0b373c7a0ee5c5dce1206/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39", size = 304797, upload-time = "2026-09-12T15:14:05.733Z" }, + { url = "https://files.pythonhosted.org/packages/25/4a/0d77415b27a00d970e4e710f7c1de62e96a11c4cab3ed1add0015af04626/jiter-0.17.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe", size = 307815, upload-time = "2026-09-12T15:14:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" }, ] [[package]] @@ -3818,9 +4139,9 @@ name = "jsonschema-path" version = "0.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pathable" }, - { name = "pyyaml" }, - { name = "referencing" }, + { name = "pathable", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyyaml", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "referencing", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/8a/7e6102f2b8bdc6705a9eb5294f8f6f9ccd3a8420e8e8e19671d1dd773251/jsonschema_path-0.4.5.tar.gz", hash = "sha256:c6cd7d577ae290c7defd4f4029e86fdb248ca1bd41a07557795b3c95e5144918", size = 15113, upload-time = "2026-03-03T09:56:46.87Z" } wheels = [ @@ -3844,13 +4165,13 @@ name = "keyring" version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, + { name = "importlib-metadata", marker = "(python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jaraco-classes", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jaraco-context", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jaraco-functools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jeepney", marker = "(sys_platform == 'linux' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'linux' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'linux' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'linux' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pywin32-ctypes", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "secretstorage", marker = "(sys_platform == 'linux' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'linux' and extra == 'extra-8-band-sdk-parlant') or (sys_platform != 'linux' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'linux' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ @@ -4152,7 +4473,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "langchain-core" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, { name = "tiktoken" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/68/dd7f7ef76efe58222d37f1b1a5595b1bb368fc9165f71526db26e4c5759e/langchain_openai-1.7.0.tar.gz", hash = "sha256:7df2db16d32320ba563b7e4291a71ec6bf7b0ea8a5369ed2a0501d74928ea6d0", size = 3404806, upload-time = "2026-10-08T16:07:35.135Z" } @@ -4249,7 +4571,7 @@ version = "0.7.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, - { name = "orjson", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, @@ -4406,9 +4728,9 @@ name = "limits" version = "5.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "deprecated" }, - { name = "packaging" }, - { name = "typing-extensions" }, + { name = "deprecated", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } wheels = [ @@ -4432,24 +4754,24 @@ name = "litellm" version = "1.105.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "boto3", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "click", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "fastuuid", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx", extra = ["http2"], marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "huggingface-hub", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "importlib-metadata", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "jinja2", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "jsonschema", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "openai", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "packaging", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic-settings", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "python-dotenv", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyyaml", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tiktoken", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "tokenizers", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "aiohttp", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "boto3", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "click", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "fastuuid", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "httpx", extra = ["http2"], marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "huggingface-hub", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "importlib-metadata", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "jinja2", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "jsonschema", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "packaging", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pydantic", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pydantic-settings", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "python-dotenv", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pyyaml", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "tiktoken", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "tokenizers", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3e/35/f326093937d03fbca3e25c42f42708854b9321af841db29ce28235f6237a/litellm-1.105.0.tar.gz", hash = "sha256:f4af675e480ba7cf1f43c2bf5e480564b547e0e58d31e88f724daa0a0967b065", size = 20320691, upload-time = "2026-10-11T09:48:15.603Z" } wheels = [ @@ -4485,23 +4807,69 @@ name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] -dependencies = [ - { name = "mdurl", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", +] +dependencies = [ + { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -4511,24 +4879,18 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] dependencies = [ - { name = "mdurl", marker = "extra == 'extra-8-band-sdk-crewai' or extra != 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "mdurl", marker = "extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-pydantic-ai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -4608,15 +4970,15 @@ name = "matplotlib" version = "3.10.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "contourpy" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, + { name = "contourpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "cycler", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "fonttools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "kiwisolver", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pillow", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyparsing", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-dateutil", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } wheels = [ @@ -4674,28 +5036,27 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] dependencies = [ - { name = "anyio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "httpx-sse", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "jsonschema", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "python-multipart", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pywin32", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "sse-starlette", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "starlette", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typing-inspection", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "uvicorn", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai') or (sys_platform != 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "anyio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx-sse", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonschema", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-multipart", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pywin32", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "sse-starlette", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "starlette", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-inspection", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "uvicorn", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-crewai') or (sys_platform != 'emscripten' and extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ @@ -4704,8 +5065,8 @@ wheels = [ [package.optional-dependencies] cli = [ - { name = "python-dotenv", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "typer", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "python-dotenv", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typer", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -4713,14 +5074,55 @@ name = "mcp" version = "1.29.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] dependencies = [ { name = "anyio", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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')" }, @@ -4731,12 +5133,12 @@ dependencies = [ { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "pyjwt", extra = ["crypto"], marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "python-multipart", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "pywin32", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "pywin32", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "sse-starlette", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "starlette", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "typing-inspection", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') 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 = "uvicorn", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev') or (sys_platform != 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "uvicorn", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-band-sdk-dev') or (sys_platform != 'emscripten' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } wheels = [ @@ -4754,7 +5156,7 @@ name = "mdit-py-plugins" version = "0.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } wheels = [ @@ -5008,10 +5410,10 @@ name = "mypy" version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, + { name = "librt", marker = "(platform_python_implementation != 'PyPy' and extra == 'extra-8-band-sdk-dev-parlant') or (platform_python_implementation != 'PyPy' and extra == 'extra-8-band-sdk-parlant') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "mypy-extensions", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pathspec", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" } wheels = [ @@ -5067,7 +5469,7 @@ name = "nano-vectordb" version = "0.0.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/ff/ed9ff1c4e5b0418687c17d02fdc453c212e7550c62622914ba0243c106bc/nano_vectordb-0.0.4.3.tar.gz", hash = "sha256:3d13074476f2b739e51261974ed44aa467725579966219734c03502c929ed3b5", size = 6332, upload-time = "2024-11-11T12:50:50.584Z" } wheels = [ @@ -5103,10 +5505,10 @@ wheels = [ [package.optional-dependencies] default = [ - { name = "matplotlib" }, - { name = "numpy" }, - { name = "pandas" }, - { name = "scipy" }, + { name = "matplotlib", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pandas", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "scipy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -5123,10 +5525,10 @@ name = "nooa" version = "0.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "litellm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "openinference-instrumentation-litellm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pydantic", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "httpx", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "litellm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openinference-instrumentation-litellm", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pydantic", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/27/90/f07e44b57d10e3687caba9b18a1b2064e7c9602f85fdeb9c9b0a4843aeba/nooa-0.0.10.tar.gz", hash = "sha256:91d44a48610b116561d2ddc42ed80a7c4011c7324a67236300d2fe78182d3c78", size = 3504802, upload-time = "2026-09-04T06:04:36.719Z" } wheels = [ @@ -5263,28 +5665,63 @@ wheels = [ name = "openai" version = "2.46.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "jiter", version = "0.14.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-dev-parlant') 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-dev-parlant') 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 = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, + { name = "anyio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "distro", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "jiter", version = "0.13.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jiter", version = "0.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "sniffio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tqdm", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/ac/f725c4efbda8657d02be684607e5a2e5ce362e4790fdbcbdfb7c15018647/openai-2.46.0.tar.gz", hash = "sha256:0421e0735ac41451cad894af4cddf0435bfbf8cbc538ac0e15b3c062f2ddc06a", size = 1114628, upload-time = "2026-07-17T02:48:06.05Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ea/7b/206238ebcb50b235942b1c66dba4974776f2057402a8d91c399be587d66a/openai-2.46.0-py3-none-any.whl", hash = "sha256:672381db55efb3a1e2610f29304c130cccdd0b319bace4d492b2443cb64c1e7c", size = 1637556, upload-time = "2026-07-17T02:48:03.695Z" }, ] +[[package]] +name = "openai" +version = "3.31.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32')", +] +dependencies = [ + { name = "anyio", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "httpx2", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "jiter", version = "0.17.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "sniffio", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/99/d0c5b9e60eb131bfe5f6f818e9b25650b64004ff84512c0f7e00a246b826/openai-3.31.0.tar.gz", hash = "sha256:58110edba9acaf29cb2a675a1cbcccdd9f2fdb4932baed474dc847a7639e5d02", size = 2117207, upload-time = "2026-10-14T05:39:33.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/22/66014c8fede0aa2b3381cf80c5102219b78657b3e61776cdab8f0b78675a/openai-3.31.0-py3-none-any.whl", hash = "sha256:e5839f6670483f368de40ce3422f524c1afaf12ff8660539f53489e200db8d78", size = 2235454, upload-time = "2026-10-14T05:39:31.197Z" }, +] + [[package]] name = "openapi-pydantic" version = "0.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } wheels = [ @@ -5296,12 +5733,12 @@ name = "openapi-schema-validator" version = "0.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-specifications" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "referencing" }, - { name = "rfc3339-validator" }, + { name = "jsonschema", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonschema-specifications", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "referencing", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rfc3339-validator", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/4b/67b24b2b23d96ea862be2cca3632a546f67a22461200831213e80c3c6011/openapi_schema_validator-0.8.1.tar.gz", hash = "sha256:4c57266ce8cbfa37bb4eb4d62cdb7d19356c3a468e3535743c4562863e1790da", size = 23134, upload-time = "2026-03-02T08:46:29.807Z" } wheels = [ @@ -5313,12 +5750,12 @@ name = "openapi-spec-validator" version = "0.8.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonschema" }, - { name = "jsonschema-path" }, - { name = "lazy-object-proxy" }, - { name = "openapi-schema-validator" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, + { name = "jsonschema", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonschema-path", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "lazy-object-proxy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openapi-schema-validator", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic-settings", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/de/0199b15f5dde3ca61df6e6b3987420bfd424db077998f0162e8ffe12e4f5/openapi_spec_validator-0.8.4.tar.gz", hash = "sha256:8bb324b9b08b9b368b1359dec14610c60a8f3a3dd63237184eb04456d4546f49", size = 1756847, upload-time = "2026-03-01T15:48:19.499Z" } wheels = [ @@ -5330,8 +5767,8 @@ name = "openapi3-parser" version = "1.1.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "openapi-spec-validator" }, - { name = "prance" }, + { name = "openapi-spec-validator", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "prance", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/af/93/0fd92e1d9d4e5432e1d206e1648b02d125dc9d2551a8f525d8c433cb08b7/openapi3_parser-1.1.21.tar.gz", hash = "sha256:6ad5a4c307fdabb371c3191dc608968b68db433238f218591ff825c8d28aaa75", size = 15208, upload-time = "2025-04-24T20:18:19.42Z" } wheels = [ @@ -5343,10 +5780,10 @@ name = "openinference-instrumentation" version = "0.1.71" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "openinference-semantic-conventions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "wrapt", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "openinference-semantic-conventions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "opentelemetry-api", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "opentelemetry-sdk", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "wrapt", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/ec/ef1ca7e409ee6bddb7278b9760e0941f65911748f5dc84d17c115f6648f1/openinference_instrumentation-0.1.71.tar.gz", hash = "sha256:4d267b6ce8e067572a740d6a0dbf6b4d4c6e386c5a52a7e7f61e3a714a49849d", size = 46600, upload-time = "2026-10-07T20:26:41.084Z" } wheels = [ @@ -5358,13 +5795,13 @@ name = "openinference-instrumentation-litellm" version = "0.1.50" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "openinference-instrumentation", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "openinference-semantic-conventions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-api", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-instrumentation", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "opentelemetry-sdk", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "setuptools", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "wrapt", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "openinference-instrumentation", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "openinference-semantic-conventions", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "opentelemetry-api", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "opentelemetry-instrumentation", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "opentelemetry-sdk", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "setuptools", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, + { name = "wrapt", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/06/8c3b91c2399726c9286e1b1894b48898a19f7a49c1435adf232054766522/openinference_instrumentation_litellm-0.1.50.tar.gz", hash = "sha256:ab483a9455313cae3d572ff21b65e665ad9f2964f7d8ccaaecee06605851f174", size = 115691, upload-time = "2026-10-09T18:29:10.739Z" } wheels = [ @@ -5456,8 +5893,8 @@ name = "opentelemetry-exporter-otlp" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e3/6f/5a561048ea372894f22f58f70e8478e4cd97d9a4e2d6f014559584a29bc5/opentelemetry_exporter_otlp-1.45.1.tar.gz", hash = "sha256:d0ac35592e77663a9fabf2740b4818c57196b1b764e0c8449d0d7bb2c7b2bc67", size = 6094, upload-time = "2026-10-06T17:33:00.966Z" } wheels = [ @@ -5493,14 +5930,14 @@ name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.45.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-common" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, + { name = "googleapis-common-protos", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "grpcio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-api", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp-common", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp-proto-common", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-proto", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-sdk", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d6/00/a82af0be959dc58495740b169c6669a86e0811f6cd353a01eda34d255db3/opentelemetry_exporter_otlp_proto_grpc-1.45.1.tar.gz", hash = "sha256:3b3dcfbfdcb4e35149fcf309972282054b45228f5c10547d0095d6578510a9a0", size = 25997, upload-time = "2026-10-06T17:33:05.114Z" } wheels = [ @@ -5532,10 +5969,10 @@ name = "opentelemetry-instrumentation" version = "0.66b1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, + { name = "opentelemetry-api", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-semantic-conventions", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "wrapt", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } wheels = [ @@ -5762,9 +6199,9 @@ name = "pandas" version = "3.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-dateutil", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tzdata", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } wheels = [ @@ -5822,56 +6259,57 @@ name = "parlant" version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiofiles", version = "25.1.0", source = { registry = "https://pypi.org/simple" } }, - { name = "aiopenapi3" }, - { name = "aiorwlock" }, - { name = "authlib" }, - { name = "boto3" }, - { name = "cachetools" }, - { name = "click" }, - { name = "colorama" }, - { name = "coloredlogs" }, - { name = "contextvars" }, - { name = "croniter" }, - { name = "fastapi" }, - { name = "fastmcp" }, - { name = "griffe" }, - { name = "httpx" }, - { name = "jinja2" }, - { name = "jsonfinder" }, - { name = "jsonschema" }, - { name = "lagom" }, - { name = "limits" }, - { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" } }, - { name = "more-itertools" }, - { name = "nano-vectordb" }, - { name = "nanoid" }, - { name = "networkx", extra = ["default"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "openai" }, - { name = "openapi3-parser" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "parlant-client" }, - { name = "python-dateutil" }, - { name = "python-dotenv" }, - { name = "requests" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "semver" }, - { name = "starlette" }, - { name = "structlog" }, - { name = "tabulate" }, - { name = "tiktoken" }, - { name = "tokenizers" }, - { name = "toml" }, - { name = "types-aiofiles" }, - { name = "types-cachetools" }, - { name = "types-croniter" }, - { name = "types-jsonschema" }, - { name = "uvicorn" }, - { name = "websocket-client" }, - { name = "wsproto" }, + { name = "aiofiles", version = "25.1.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "aiopenapi3", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "aiorwlock", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "authlib", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "boto3", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "cachetools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "click", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "colorama", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "coloredlogs", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "contextvars", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "croniter", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "fastapi", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "fastmcp", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "griffe", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jinja2", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonfinder", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jsonschema", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "lagom", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "limits", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "more-itertools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "nano-vectordb", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "nanoid", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "networkx", extra = ["default"], marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "openapi3-parser", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-api", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-exporter-otlp", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-instrumentation", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "opentelemetry-sdk", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "parlant-client", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-dateutil", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "python-dotenv", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "requests", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "semver", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "starlette", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "structlog", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tabulate", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tiktoken", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "tokenizers", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "toml", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "types-aiofiles", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "types-cachetools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "types-croniter", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "types-jsonschema", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "uvicorn", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "websocket-client", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "wsproto", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/a3/047bec94bf6b01812766a68b660bad68819c01adf5a75958ed54cb23f1f0/parlant-3.3.2.tar.gz", hash = "sha256:d4fa34b4f58ccfdacaf88dbe855b96f1d4e8c1ad7e2f2239946ba6c17a7ea6fc", size = 95261377, upload-time = "2026-04-28T06:29:43.471Z" } wheels = [ @@ -5883,10 +6321,10 @@ name = "parlant-client" version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "httpx" }, - { name = "mypy" }, - { name = "nanoid" }, - { name = "pydantic" }, + { name = "httpx", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "mypy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "nanoid", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pydantic", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/31/57/4a7f82db584d5a02d270a1723ae96b8bc04ae927731b9c4f8bccb58f05a8/parlant_client-3.3.1.tar.gz", hash = "sha256:c8bbdaaac6718a71376fe2d539254b6f1ef4a82002278d0bd6dc5c9843732f9b", size = 55113, upload-time = "2026-04-14T14:15:18.45Z" } wheels = [ @@ -6130,10 +6568,10 @@ name = "prance" version = "25.4.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "chardet" }, - { name = "packaging" }, - { name = "requests" }, - { name = "ruamel-yaml" }, + { name = "chardet", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "requests", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "ruamel-yaml", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/5c/afa384b91354f0dbc194dfbea89bbd3e07dbe47d933a0a2c4fb989fc63af/prance-25.4.8.0.tar.gz", hash = "sha256:2f72d2983d0474b6f53fd604eb21690c1ebdb00d79a6331b7ec95fb4f25a1f65", size = 2808091, upload-time = "2025-04-07T22:22:36.739Z" } wheels = [ @@ -6299,8 +6737,8 @@ name = "py-key-value-aio" version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, + { name = "beartype", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" } wheels = [ @@ -6309,14 +6747,14 @@ wheels = [ [package.optional-dependencies] filetree = [ - { name = "aiofile" }, - { name = "anyio" }, + { name = "aiofile", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "anyio", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] keyring = [ - { name = "keyring" }, + { name = "keyring", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] memory = [ - { name = "cachetools" }, + { name = "cachetools", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -6564,7 +7002,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator" }, + { name = "email-validator", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] [[package]] @@ -6590,6 +7028,10 @@ wheels = [ anthropic = [ { name = "anthropic" }, ] +openai = [ + { name = "openai", version = "3.31.0", source = { registry = "https://pypi.org/simple" } }, + { name = "tiktoken" }, +] [[package]] name = "pydantic-core" @@ -6836,11 +7278,11 @@ name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, + { name = "colorama", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "iniconfig", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pluggy", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pygments", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -6852,8 +7294,8 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-nooa') or (python_full_version < '3.13' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.13' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -6865,9 +7307,9 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"], marker = "extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pluggy" }, - { name = "pytest" }, + { name = "coverage", extra = ["toml"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pluggy", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ @@ -6892,8 +7334,8 @@ name = "pytest-markdown-docs" version = "0.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, - { name = "pytest" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/c7/6953e9e25d90d038600e65b05265693aef7ae2a2b67e0f170d65f4f16c24/pytest_markdown_docs-0.9.2.tar.gz", hash = "sha256:bf7510fdeec90d3e98ac924561c01de8dbaf4c3ad34f2418ed2b0b12e5a3f4f5", size = 10929, upload-time = "2026-03-23T12:35:04.804Z" } wheels = [ @@ -6905,7 +7347,7 @@ name = "pytest-mock" version = "3.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" } wheels = [ @@ -6917,8 +7359,8 @@ name = "pytest-rerunfailures" version = "16.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, - { name = "pytest" }, + { name = "packaging", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/b0/6b5337f9d59b26b0069ea3d5e863c31dc04b69e51bbfb1cf2ea6328fba87/pytest_rerunfailures-16.7.tar.gz", hash = "sha256:6956ddfb65ca1d07e7e3d99e2c5359d82300f4cb062e6049563bd2c106f72d5c", size = 59666, upload-time = "2026-09-17T07:08:48.871Z" } wheels = [ @@ -6930,7 +7372,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest" }, + { name = "pytest", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-dev-strands-nooa' or extra == 'extra-8-band-sdk-nooa' or extra == 'extra-8-band-sdk-parlant' or extra != 'extra-8-band-sdk-pydantic-ai' or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ @@ -6954,8 +7396,8 @@ name = "python-discovery" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ @@ -7091,7 +7533,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -7105,11 +7547,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } @@ -7217,14 +7658,55 @@ name = "regex" version = "2026.4.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } wheels = [ @@ -7371,7 +7853,7 @@ name = "rfc3339-validator" version = "0.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } wheels = [ @@ -7385,16 +7867,15 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] dependencies = [ - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "pygments", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "pygments", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } wheels = [ @@ -7406,18 +7887,59 @@ name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", - "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", -] -dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai')" }, + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "python_full_version == '3.12.*' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-pydantic-ai')" }, { name = "pygments", marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } @@ -7430,8 +7952,8 @@ name = "rich-rst" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils" }, - { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "docutils", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } wheels = [ @@ -7597,7 +8119,7 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy" }, + { name = "numpy", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -7668,8 +8190,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography", marker = "(python_full_version != '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "jeepney", marker = "(python_full_version != '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform != 'emscripten' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'emscripten' and extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -7744,7 +8266,7 @@ name = "sqlalchemy" version = "2.0.49" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } @@ -7811,7 +8333,7 @@ version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } wheels = [ @@ -7828,8 +8350,8 @@ dependencies = [ { name = "docstring-parser" }, { name = "httpx" }, { name = "jsonschema" }, - { name = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "mcp", version = "1.29.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-dev-parlant') 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-dev-parlant') 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 = "mcp", version = "1.28.1", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "mcp", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-nooa') or (extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-dev-crewai' and extra != 'extra-8-band-sdk-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai')" }, { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation-threading" }, { name = "opentelemetry-sdk" }, @@ -7846,7 +8368,7 @@ wheels = [ [package.optional-dependencies] openai = [ { name = "aws-bedrock-token-generator" }, - { name = "openai" }, + { name = "openai", version = "2.46.0", source = { registry = "https://pypi.org/simple" } }, ] [[package]] @@ -7902,7 +8424,7 @@ name = "textual" version = "8.2.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "mdit-py-plugins" }, { name = "platformdirs" }, { name = "pygments" }, @@ -7919,7 +8441,7 @@ name = "tiktoken" version = "0.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "regex", version = "2026.1.15", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "regex", version = "2026.1.15", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "regex", version = "2026.4.4", 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-dev-parlant') 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-dev-parlant') 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 = "requests" }, ] @@ -7974,7 +8496,7 @@ name = "tokenizers" version = "0.22.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "huggingface-hub", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-crewai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai')" }, + { name = "huggingface-hub", marker = "(python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'extra-8-band-sdk-nooa') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-parlant') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-parlant') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version < '3.12' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (python_full_version >= '3.14' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant') or (extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } wheels = [ @@ -8011,11 +8533,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", - "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.13.*' and sys_platform != 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096, upload-time = "2024-10-02T10:46:13.208Z" } @@ -8028,8 +8549,18 @@ name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform != 'win32'", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra == 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands'", + "(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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands') or (python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-dev-strands-nooa' and extra != 'extra-8-band-sdk-nooa' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai' and extra != 'extra-8-band-sdk-strands')", + "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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", + "python_full_version < '3.12' 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-dev-parlant' and extra != 'extra-8-band-sdk-parlant' and extra != 'extra-8-band-sdk-pydantic-ai'", ] sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ @@ -8104,7 +8635,7 @@ name = "tqdm" version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "colorama", marker = "(sys_platform == 'win32' and extra == 'extra-8-band-sdk-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-crewai') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-parlant') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-nooa') or (sys_platform == 'win32' and extra == 'extra-8-band-sdk-parlant') or (sys_platform == 'win32' and extra != 'extra-8-band-sdk-dev' and extra != 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ @@ -8126,8 +8657,8 @@ version = "0.27.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "rich", version = "14.3.4", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "rich", version = "15.0.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-8-band-sdk-dev' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra != 'extra-8-band-sdk-crewai' and extra != 'extra-8-band-sdk-dev-crewai')" }, { name = "shellingham" }, ] @@ -8168,7 +8699,7 @@ name = "types-jsonschema" version = "4.26.0.20260408" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "referencing" }, + { name = "referencing", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/4c/2ef5483ef81e7b6e1b901eb2994fd280cb19860134a20ff99e72748f3ccb/types_jsonschema-4.26.0.20260408.tar.gz", hash = "sha256:82b75a976ed1507c473b8dee2d4841bd65926758c6d672bb93d08bf5e16f1b3f", size = 16553, upload-time = "2026-04-08T04:36:00.543Z" } wheels = [ @@ -8210,7 +8741,7 @@ name = "tzlocal" version = "5.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "tzdata", marker = "sys_platform == 'win32' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } wheels = [ @@ -8375,8 +8906,8 @@ version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, - { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, - { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.12' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai')" }, + { name = "filelock", version = "3.32.7", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.14' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') 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-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, + { name = "filelock", version = "4.0.9", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.14' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-dev-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-parlant') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-parlant' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-parlant' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, @@ -8418,7 +8949,7 @@ name = "watchfiles" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "anyio", marker = "extra == 'extra-8-band-sdk-crewai' or extra == 'extra-8-band-sdk-dev-crewai' or extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ @@ -8622,7 +9153,7 @@ name = "werkzeug" version = "3.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe" }, + { name = "markupsafe", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } wheels = [ @@ -8693,7 +9224,7 @@ name = "wsproto" version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, + { name = "h11", marker = "extra == 'extra-8-band-sdk-dev-parlant' or extra == 'extra-8-band-sdk-parlant' or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-dev') or (extra == 'extra-8-band-sdk-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-crewai') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-dev-strands-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-nooa') or (extra == 'extra-8-band-sdk-dev' and extra == 'extra-8-band-sdk-strands') or (extra == 'extra-8-band-sdk-dev-crewai' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-dev-strands-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-nooa' and extra == 'extra-8-band-sdk-pydantic-ai') or (extra == 'extra-8-band-sdk-pydantic-ai' and extra == 'extra-8-band-sdk-strands')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } wheels = [ From e38e7ffb63dfd2daeeb415c9da3a6550d8ef9ed0 Mon Sep 17 00:00:00 2001 From: Natan Zalkin Date: Thu, 15 Oct 2026 05:34:49 +0300 Subject: [PATCH 10/13] feat(codex): resolve ambient servers inside owned room startup --- docs/adapters/codex.md | 67 ++ src/band/adapters/codex.py | 362 ++++++++-- src/band/core/retention/room.py | 19 +- src/band/core/turn_lifecycle.py | 55 +- src/band/integrations/codex/mcp.py | 512 ++++++++++++++ src/band/integrations/codex/rpc_base.py | 34 +- src/band/integrations/codex/stdio_client.py | 80 ++- tests/adapters/codex/kit.py | 2 +- tests/adapters/codex/lifecycleprofile.py | 4 +- tests/adapters/codex/mcpkit.py | 130 ++++ tests/adapters/codex/test_mcp_acquisition.py | 38 + tests/adapters/codex/test_mcp_cancellation.py | 204 ++++++ tests/adapters/codex/test_mcp_launch.py | 659 ++++++++++++++++++ .../codex/test_mcp_owner_lifecycle.py | 150 ++++ tests/adapters/codex/test_mcp_recovery.py | 367 ++++++++++ tests/adapters/codex/test_mcp_startup.py | 649 +++++++++++++++++ tests/adapters/codexturns.py | 2 +- tests/adapters/opencode/helpers.py | 8 +- tests/adapters/opencode/test_waiting.py | 44 ++ tests/core/test_turn_lifecycle.py | 96 +++ tests/integrations/codex/test_cleanup.py | 18 + .../integrations/codex/test_mcp_redaction.py | 252 +++++++ .../codex/test_process_group_cleanup.py | 510 ++++++++++++++ 23 files changed, 4182 insertions(+), 80 deletions(-) create mode 100644 src/band/integrations/codex/mcp.py create mode 100644 tests/adapters/codex/mcpkit.py create mode 100644 tests/adapters/codex/test_mcp_acquisition.py create mode 100644 tests/adapters/codex/test_mcp_cancellation.py create mode 100644 tests/adapters/codex/test_mcp_launch.py create mode 100644 tests/adapters/codex/test_mcp_owner_lifecycle.py create mode 100644 tests/adapters/codex/test_mcp_recovery.py create mode 100644 tests/adapters/codex/test_mcp_startup.py create mode 100644 tests/adapters/opencode/test_waiting.py create mode 100644 tests/integrations/codex/test_mcp_redaction.py create mode 100644 tests/integrations/codex/test_process_group_cleanup.py diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index 1377ce05d..a6ae71a34 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -35,6 +35,15 @@ For existing integrations, follow the [SDK 4.x to 5.x migration](../acp.md#upgra close errors and close timeouts retain the claim for retry. A timed-out or cancelled caller does not abandon subprocess cleanup; retry room cleanup to finish releasing ownership. Shutdown attempts every room and reports failures. +- **POSIX cleanup settles the original owned process group.** After normal leader + exit or an exit timeout, close sends TERM, waits up to one second, then sends + KILL and waits up to one more second for `ESRCH`. Unconfirmed cleanup retains + the client and workspace for retry. The group ID comes from the original + subprocess handle, never a lookup of a possibly recycled PID. POSIX groups + lack generation-bound handles: reuse between the group emptying and a final + probe or signal remains a residual race. Descendants that leave the group are + outside this guarantee. Windows retains best-effort `taskkill /F /T` on leader + timeout, not confirmation of orphaned or escaped descendants. - **Leaving is final.** Leaving a room stops it admitting work at once. A leave that fails or runs over keeps the process, thread and workspace claim, and the room refuses work until a retried leave succeeds; a later rejoin @@ -105,3 +114,61 @@ Approval commands use the token displayed in the room prompt. Native Codex request and approval IDs stay internal; a fresh request gets a new chat token after completion or room cleanup, even when Codex reuses its native ID. A redelivery of a still-pending request keeps its displayed token. + +## Runtime MCP servers + +Pass the keyword-only `resolve_mcp_servers` callback to `CodexAdapter`, not +`CodexAdapterConfig`. It receives the room's absolute workspace and returns a +`Mapping[str, CodexMcpServer]`. Import the descriptors and startup error from +`band.integrations.codex.mcp`. + +- `CodexMcpStdioServer(command, args=(), env={}, cwd=None)` freezes arguments + and child-only environment values. Relative commands and arguments use the + server's `cwd`. On POSIX, nonempty `env` uses the SDK's interpreter to restore + generated aliases before replacing itself with the server process; native + protected environment names are refused. On Windows, nonempty stdio `env` + fails before client construction with stage `unsupported-platform`: + `secret-bearing stdio MCP servers require POSIX exec`. Env-free Windows stdio + and all HTTP servers remain supported; no Windows supervisor is included. +- `CodexMcpHttpServer(url, bearer_token=None, headers={})` describes streamable + HTTP. Other descriptor types, including SSE, are refused. Names must match + `[A-Za-z0-9_-]+`, be unique, and not be the reserved native name `codex_apps`. +- Resolution runs inside the retained room's acquisition, once per new process + after any predecessor is confirmed closed. Reusing a process does not resolve + again. To rotate a running grant, clean up the room first. An empty result + adds no servers or readiness requests; callback failures never fall back. +- The snapshot is frozen before launch without changing configuration or + `codex_env`. Validated TOML overrides are appended after existing arguments, + including a relay's `--` boundary. Supplied servers are enabled, required and + use connection readiness; other native servers are unchanged. +- Bearer tokens, headers and stdio environment values use generated child-env + names, never secret values in generated TOML or argv. Names reserve inherited + env names too. Secret fields are excluded from repr, equality and supported + Pydantic serialization; these objects are runtime-only, not persistence data. +- Before the process becomes resident or restores a thread, readiness pages + `mcpServerStatus/list` under one 30-second deadline. Every supplied server must + report information and capabilities, no discovery error, and null pre-thread + or `connected` runtime status. Tool-less servers are supported. Auth settings + or an empty catalog alone are not readiness proof. Malformed pages, duplicate + names, repeated cursors, negative statuses and consumed timeout cancellation + fail startup. +- Cancellation and retirement fence each stage and every discovery request. + A resolver may request its own room cleanup without self-waiting. Cleanup + disarms readiness deadlines and joins an already-cancelling acquisition + without a second cancellation. Settlement beyond the reporting bound retains + acquisition, child, workspace and diagnostic scope until cleanup succeeds. + Failed registration unwinds the gated worker before resolution can start. + Invocation-only cancellation timers end with their owner, never later work. +- Thread start/resume errors with adopted servers trigger bounded readiness + rather than error-text classification. Failed readiness refuses history + fallback and retires the process; the next acquisition resolves a fresh grant. + Positive readiness preserves genuine missing-history fallback and unrelated + errors. Recovery still strictly restores retained threads as described above. + +`CodexMcpStartupError.stage` is a secret-free startup or validation category. +Transport diagnostics omit RPC methods, wire contents and process-stderr text. +Unknown frames report transport, type and character length; stderr reports byte +length; full queues report only the dropped event kind; cleanup reports only +exception type. Forwarded RPC errors remain scoped-redacted and are frozen +before teardown clears the secret scope. + diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index b9115e676..2444bb6c5 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -21,6 +21,7 @@ from pydantic import ( AliasChoices, BaseModel, + ConfigDict, Field, ValidationError, field_validator, @@ -43,7 +44,7 @@ deliver_notice, reraise_delivery_cause, ) -from band.core.exceptions import InputNotAccepted +from band.core.exceptions import CleanupIncompleteError, InputNotAccepted from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -68,7 +69,6 @@ ApprovalInterruptMixin, TurnSettleTimeout, TurnTask, - bind_turn_task, start_releasable_turn, ) from band.core.types import ( @@ -87,6 +87,13 @@ CodexStdioClient, RpcEvent, ) +from band.integrations.codex.mcp import ( + CodexMcpLaunch, + CodexMcpResolver, + CodexMcpSnapshot, + CodexMcpStartupError, + freeze_mcp_servers, +) from band.integrations.codex.types import ( CODEX_APPROVAL_METHODS, CODEX_PROVIDER, @@ -617,6 +624,35 @@ def changes_in(self, restored: ThreadSettings) -> list[str]: ] +class McpServerInfo(BaseModel): + model_config = ConfigDict(strict=True) + + name: str + version: str + + +class McpServerStatus(BaseModel): + model_config = ConfigDict(strict=True) + + name: str = Field(min_length=1) + server_info: McpServerInfo | None = Field(default=None, alias="serverInfo") + server_capabilities: dict[str, Any] | None = Field( + default=None, alias="serverCapabilities" + ) + tools_error: str | None = Field(default=None, alias="toolsError") + runtime_status: str | None = Field(default=None, alias="runtimeStatus") + + +class McpStatusPage(BaseModel): + model_config = ConfigDict(strict=True) + + data: list[McpServerStatus] + next_cursor: str | None = Field(default=None, alias="nextCursor") + + +_MCP_READINESS_TIMEOUT_S = 30.0 + + @dataclass class RoomCodexClient: """One Band room's Codex process and what its thread was started with. @@ -633,6 +669,8 @@ class RoomCodexClient: client: CodexClientProtocol | None = None reader: asyncio.Task[None] | None = None turn: NativeTurn | None = None + mcp_snapshot: CodexMcpSnapshot | None = dataclass_field(default=None, repr=False) + startup_owner: TurnTask | None = dataclass_field(default=None, repr=False) selected_model: str | None = None reasoning_effort: str | None = None reasoning_summary: str | None = None @@ -909,6 +947,7 @@ def __init__( *, additional_tools: list[CustomToolDef] | None = None, history_converter: CodexHistoryConverter | None = None, + resolve_mcp_servers: CodexMcpResolver | None = None, client_factory: Callable[[CodexAdapterConfig], CodexClientProtocol] | None = None, **features: Unpack[FeatureKwargs], @@ -935,6 +974,7 @@ def __init__( raise ValueError( "custom Codex clients cannot guarantee room process isolation" ) + self._resolve_mcp_servers = resolve_mcp_servers self._room_clients: dict[str, RoomCodexClient] = {} # Final disposal gives a room's turn ``turn_settle_timeout_s`` to wind # down before cancelling it. @@ -1180,16 +1220,23 @@ async def on_message( is_session_bootstrap: bool, room_id: str, ) -> None: + task = asyncio.current_task() + if task is not None and task.cancelling(): + raise asyncio.CancelledError self._admit_turn(room_id, "Codex") room = self._room_client(room_id) self._active_room.set(room_id) command = self._extract_local_command(msg.content) release: asyncio.Future[None] = asyncio.get_running_loop().create_future() + owner_ready: asyncio.Future[TurnTask] = ( + asyncio.get_running_loop().create_future() + ) turn = start_releasable_turn( room.retained, partial( self._run_turn, + owner_ready=owner_ready, room=room, msg=msg, tools=tools, @@ -1203,15 +1250,35 @@ async def on_message( room_id=room_id, ) self._turn_release[room_id] = release - self._turn_tasks[room_id] = bind_turn_task(tools, turn) + owner = self._turn_tasks[room_id] = TurnTask(turn) turn.add_done_callback(lambda task: self._forget_turn(room_id, task)) + try: + register = getattr(tools, "register_turn_task", None) + if callable(register): + register(owner) + if owner.task.cancelling(): + raise asyncio.CancelledError + owner_ready.set_result(owner) + except BaseException: + owner_ready.cancel() + owner.terminate() + try: + await owner.drain() + finally: + owner.close() + self._forget_turn(room_id, turn) + raise try: await asyncio.wait({release, turn}, return_when=asyncio.FIRST_COMPLETED) except asyncio.CancelledError: await self._cancel_turn(room_id) raise finally: - if self._turn_release.get(room_id) is release: + if ( + turn.done() + and room.startup_owner is None + and self._turn_release.get(room_id) is release + ): del self._turn_release[room_id] if turn.done(): # No human was asked: the turn's outcome is on_message's own. @@ -1220,6 +1287,7 @@ async def on_message( async def _run_turn( self, *, + owner_ready: asyncio.Future[TurnTask], room: RoomCodexClient, msg: PlatformMessage, tools: AgentToolsProtocol, @@ -1230,6 +1298,7 @@ async def _run_turn( command: tuple[CodexCommand, str] | None, ) -> None: """Run one turn to completion, leasing its room's process throughout.""" + await owner_ready async with room.rpc_lock: try: if command is not None and await self._run_local_command( @@ -1252,12 +1321,13 @@ async def _run_turn( # A lease refused by a closing room is no failed turn: the # host keeps the message. raise - except (CodexJsonRpcError, CodexContinuityError) as e: - # A structured RPC error from the app-server (e.g. "model not - # available") or a refused restore is safe, curated text -- - # unlike an arbitrary caught exception, worth showing verbatim. - await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(e))) + except (CodexMcpStartupError, CodexContinuityError) as error: + await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(error))) raise + except CodexJsonRpcError as error: + safe = self._safe_rpc_error(room, error) + await tools.send_failure(AgentFailure(CODEX_PROVIDER, str(safe))) + raise safe from None except TimeoutError as timeout: # Only waiting on the room's process start or stop raises # this; an abandoned start stays owned until recovery closes it. @@ -1479,6 +1549,9 @@ def _forget_turn(self, room_id: str, task: asyncio.Task[None]) -> None: owner = self._turn_tasks.get(room_id) if owner is not None and owner.task is task: del self._turn_tasks[room_id] + room = self._room_clients.get(room_id) + if room is None or room.startup_owner is None: + self._turn_release.pop(room_id, None) async def _emit_failed_turn_outcome( self, @@ -1866,7 +1939,20 @@ async def on_cleanup(self, room_id: str) -> None: if room is None: return self._clear_pending_approvals_for_room(room_id) - await room.retained.dispose() + if room.startup_owner is not None: + room.startup_owner.terminate( + deadline=asyncio.get_running_loop().time() + + self.config.turn_settle_timeout_s, + cooperative=True, + ) + try: + await room.retained.dispose() + except CleanupIncompleteError: + if ( + room.startup_owner is None + or room.startup_owner.task is not asyncio.current_task() + ): + raise async def cleanup_all(self) -> None: """Stop the residency owner: admission closes at once, every room's @@ -1878,38 +1964,167 @@ async def cleanup_all(self) -> None: workspace. Success stays sealed until ``on_started``. """ for room_id in list(self._room_clients): + room = self._room_clients[room_id] + if room.startup_owner is not None: + room.startup_owner.terminate( + deadline=asyncio.get_running_loop().time() + + self.config.turn_settle_timeout_s, + cooperative=True, + ) self._clear_pending_approvals_for_room(room_id) await self._retention.stop() + @staticmethod + def _check_startup_current(room: RoomCodexClient, generation: int) -> None: + task = asyncio.current_task() + if task is not None and task.cancelling(): + raise asyncio.CancelledError + room.retained.require_admission() + if ( + not room.retained.has_authority(generation) + or (room.startup_owner is not None and room.startup_owner.task.cancelling()) + or ( + room.startup_owner is not None + and room.startup_owner.deadline is not None + ) + ): + raise asyncio.CancelledError + async def _acquire( self, room: RoomCodexClient, generation: int, resume: bool ) -> None: - """Start the room's process and strictly restore the thread it retains. - - A crash keeps the thread id and the room's model, so ``resume`` - changes nothing: a retained thread is always restored on its model. - The reader runs before ``thread/resume``, which can start native work. - """ - room.client = client = self._build_client(room.workspace) - await client.connect() - room.reader = spawn_owned( - self._read_native_events(room, client, generation), - label=f"Room {room.room_id} Codex event reader", - ) - await client.initialize( - client_name=self.config.client_name, - client_title=self.config.client_title, - client_version=self.config.client_version, - experimental_api=self.config.experimental_api, - ) - await self._register_skill_roots(client) - if room.selected_model is None: - room.selected_model = await self._select_model(client) - logger.info( - "Room %s: Codex model selected: %s", room.room_id, room.selected_model + """Resolve and ready one process under the retained acquisition owner.""" + task = asyncio.current_task() + assert task is not None + owner = room.startup_owner = TurnTask(task) + stage = "resolution" + try: + self._check_startup_current(room, generation) + launch = None + if self._resolve_mcp_servers is not None: + resolved = await self._resolve_mcp_servers(room.workspace) + self._check_startup_current(room, generation) + stage = "snapshot" + room.mcp_snapshot = freeze_mcp_servers(resolved) + self._check_startup_current(room, generation) + stage = "launch" + launch = room.mcp_snapshot.launch( + self.config.codex_command + or CodexStdioClient._default_codex_command(), + self.config.codex_env, + ) + self._check_startup_current(room, generation) + room.client = client = self._build_client(room, launch) + stage = "connect" + await client.connect() + self._check_startup_current(room, generation) + room.reader = spawn_owned( + self._read_native_events(room, client, generation), + label=f"Room {room.room_id} Codex event reader", + ) + stage = "initialize" + await client.initialize( + client_name=self.config.client_name, + client_title=self.config.client_title, + client_version=self.config.client_version, + experimental_api=self.config.experimental_api, ) - if (thread_id := self._room_threads.get(room.room_id)) is not None: - await self._restore_thread(room, client, thread_id) + self._check_startup_current(room, generation) + stage = "skills" + await self._register_skill_roots(client) + self._check_startup_current(room, generation) + stage = "model" + selected_model = room.selected_model or await self._select_model(client) + self._check_startup_current(room, generation) + stage = "readiness" + await self._check_mcp_readiness(client, room, generation, owner) + self._check_startup_current(room, generation) + room.selected_model = selected_model + if (thread_id := self._room_threads.get(room.room_id)) is not None: + stage = "thread/resume" + await self._restore_thread(room, client, thread_id) + self._check_startup_current(room, generation) + except asyncio.CancelledError: + raise asyncio.CancelledError from None + except InputNotAccepted: + raise + except Exception as error: + if self._resolve_mcp_servers is None: + raise + if ( + stage == "snapshot" + and type(error) is CodexMcpStartupError + and error.stage + in { + "unsupported-transport", + "unsupported-platform", + "invalid-name", + "duplicate-name", + "reserved-name", + "invalid-descriptor", + } + ): + stage = error.stage + raise CodexMcpStartupError(stage) from None + finally: + owner.close() + room.startup_owner = None + if not self._turn_running(room.room_id): + self._turn_release.pop(room.room_id, None) + + @staticmethod + def _check_mcp_deadline(readiness: asyncio.Timeout) -> None: + deadline = readiness.when() + if readiness.expired() or ( + deadline is not None and asyncio.get_running_loop().time() >= deadline + ): + raise CodexMcpStartupError("readiness") + + async def _check_mcp_readiness( + self, + client: CodexClientProtocol, + room: RoomCodexClient, + generation: int, + owner: TurnTask, + ) -> None: + snapshot = room.mcp_snapshot + if snapshot is None or not snapshot.expected_names: + return + seen_names: set[str] = set() + cursors: set[str] = set() + cursor = None + async with owner.timed(_MCP_READINESS_TIMEOUT_S) as readiness: + while True: + self._check_startup_current(room, generation) + raw_page = await client.request( + "mcpServerStatus/list", + {"cursor": cursor, "limit": 100, "detail": "full"}, + ) + self._check_mcp_deadline(readiness) + self._check_startup_current(room, generation) + page = McpStatusPage.model_validate(raw_page) + for row in page.data: + if row.name in seen_names: + raise CodexMcpStartupError("readiness") + seen_names.add(row.name) + if row.name in snapshot.expected_names and ( + row.server_info is None + or row.server_capabilities is None + or "tools_error" not in row.model_fields_set + or row.tools_error is not None + or "runtime_status" not in row.model_fields_set + or row.runtime_status not in (None, "connected") + ): + raise CodexMcpStartupError("readiness") + cursor = page.next_cursor + if cursor is None: + break + if not cursor or cursor in cursors: + raise CodexMcpStartupError("readiness") + cursors.add(cursor) + if not snapshot.expected_names <= seen_names: + raise CodexMcpStartupError("readiness") + self._check_mcp_deadline(readiness) def _resume_params(self, room: RoomCodexClient, thread_id: str) -> dict[str, Any]: """``thread/resume`` overrides pinning the thread to the room's workspace. @@ -2115,7 +2330,14 @@ async def _release( f"Codex process for room {room.room_id} did not close " f"within {timeout}s" ) from error + except Exception: + if room.mcp_snapshot is not None: + raise CodexMcpStartupError("retirement") from None + raise room.client = None + if room.mcp_snapshot is not None: + room.mcp_snapshot.redactor.clear() + room.mcp_snapshot = None room.restored = False return ReleaseOutcome.RELEASED @@ -2153,11 +2375,16 @@ async def _register_skill_roots(self, client: CodexClientProtocol) -> None: except CodexJsonRpcError as exc: raise RuntimeError(f"Codex rejected skill_roots {roots}: {exc}") from exc - def _build_client(self, workspace: str) -> CodexClientProtocol: + def _build_client( + self, room: RoomCodexClient, launch: CodexMcpLaunch | None + ) -> CodexClientProtocol: return CodexStdioClient( - command=self.config.codex_command, - cwd=workspace, - env=self.config.codex_env, + command=launch.command if launch is not None else self.config.codex_command, + cwd=room.workspace, + env=launch.env if launch is not None else self.config.codex_env, + redactor=room.mcp_snapshot.redactor + if room.mcp_snapshot is not None + else None, ) async def _select_model(self, client: CodexClientProtocol) -> str: @@ -2179,6 +2406,52 @@ async def _select_model(self, client: CodexClientProtocol) -> str: logger.warning("model/list has no visible models; using default Codex model") return OPENAI_MODEL + @staticmethod + def _safe_rpc_error( + room: RoomCodexClient, error: CodexJsonRpcError + ) -> CodexJsonRpcError: + if room.mcp_snapshot is None: + return error + redactor = room.mcp_snapshot.redactor + return CodexJsonRpcError( + code=error.code, + message=redactor.text(error.message), + data=redactor.value(error.data), + ) + + async def _verify_mcp_after_thread_error( + self, room: RoomCodexClient, stage: str + ) -> None: + if room.mcp_snapshot is None or not room.mcp_snapshot.expected_names: + return + task = asyncio.current_task() + assert task is not None + owner = self._turn_tasks.get(room.room_id) + temporary = owner is None + if owner is None: + owner = TurnTask(task) + try: + await self._check_mcp_readiness( + room.require_client(), room, room.generation, owner + ) + except asyncio.CancelledError: + room.retained.recover( + generation=room.generation, reason="MCP readiness cancelled" + ) + raise asyncio.CancelledError from None + except Exception as error: # noqa: BLE001 -- discovery errors may carry credentials + logger.debug( + "Codex MCP readiness failed after a thread error (type=%s)", + type(error).__name__, + ) + room.retained.recover( + generation=room.generation, reason="MCP readiness failed" + ) + raise CodexMcpStartupError(stage) from None + finally: + if temporary: + owner.close() + async def _ensure_thread( self, *, @@ -2241,11 +2514,12 @@ async def _resume_from_history( self._resume_params(room, named), ) except CodexJsonRpcError as exc: + await self._verify_mcp_after_thread_error(room, "thread/resume") logger.warning( "thread/resume failed for room %s thread %s: %s", room_id, named, - exc, + self._safe_rpc_error(room, exc), ) if self.config.inject_history_on_resume_failure: self._needs_history_injection.add(room_id) @@ -2293,7 +2567,13 @@ async def _start_thread( } self._apply_thread_sandbox(start_params, room_id=room_id) - started = await client.request(CodexRequestMethod.THREAD_START, start_params) + try: + started = await client.request( + CodexRequestMethod.THREAD_START, start_params + ) + except CodexJsonRpcError as error: + await self._verify_mcp_after_thread_error(room, "thread/start") + raise self._safe_rpc_error(room, error) from None thread_id = _result_id(started, "thread") if not thread_id: raise RuntimeError("Codex thread/start returned no thread id") diff --git a/src/band/core/retention/room.py b/src/band/core/retention/room.py index fef3c34b8..e8b65e7e4 100644 --- a/src/band/core/retention/room.py +++ b/src/band/core/retention/room.py @@ -245,6 +245,8 @@ async def use( """ self.require_admission() holder = asyncio.current_task() + if holder is not None and holder.cancelling(): + raise asyncio.CancelledError self._holders[holder] += 1 self._schedule() try: @@ -329,6 +331,8 @@ async def _join( f"Room {self.room_id} transition still running after {timeout}s" ) if transition.cancelled(): + if initiator: + raise asyncio.CancelledError from None # Closed admission means disposal cancelled it; otherwise recovery # did, and the caller re-evaluates the room. self.require_admission() @@ -354,7 +358,8 @@ def _abandon_acquisition(self, transition: asyncio.Task[None]) -> None: self.room_id, self._generation, ) - transition.cancel(f"Room {self.room_id} acquisition was abandoned") + if not transition.cancelling(): + transition.cancel(f"Room {self.room_id} acquisition was abandoned") def _request(self, cause: ReleaseCause) -> None: self._cause = stronger_cause(self._cause, cause) @@ -531,7 +536,11 @@ def _start_idle_release(self) -> None: def enrolls(self, task: asyncio.Task[Any] | None) -> bool: """Whether ``task`` is accepted work this room's disposal would await.""" - return task in self._owned or task in self._holders + return ( + task in self._owned + or task in self._holders + or (task is self._transition and self._state == Residency.ACQUIRING) + ) def _spare_requester(self) -> bool: """Exempt enrolled work requesting cleanup from cancellation; it is @@ -602,7 +611,11 @@ async def _settle_accepted(self) -> None: self._work_cancelled = True for task in self._uncancelled_work(): task.cancel() - if self._state == Residency.ACQUIRING and self._transition is not None: + if ( + self._state == Residency.ACQUIRING + and self._transition is not None + and not self._transition.cancelling() + ): self._transition.cancel() await self._drained.wait() diff --git a/src/band/core/turn_lifecycle.py b/src/band/core/turn_lifecycle.py index 58657f596..7c8afab97 100644 --- a/src/band/core/turn_lifecycle.py +++ b/src/band/core/turn_lifecycle.py @@ -54,31 +54,58 @@ class TurnTask: def __init__(self, task: asyncio.Task[None]) -> None: self.task = task - if task.done(): - self._retrieve_result(task) - else: - task.add_done_callback(self._retrieve_result) + self._cancel_baseline = task.cancelling() self.deadline: float | None = None self._cancel_delivered = False self._grace_expired = False self._timer: asyncio.Timeout | None = None self._cleanup_timer = False + self._expiry_timer: asyncio.TimerHandle | None = None + self._closed = False + if task.done(): + self._finish(task) + else: + task.add_done_callback(self._finish) @staticmethod def _retrieve_result(task: asyncio.Task[None]) -> None: if not task.cancelled(): task.exception() + def _finish(self, task: asyncio.Task[None]) -> None: + self.close() + self._retrieve_result(task) + + def close(self) -> None: + """End this owner's lifetime without cancelling its task.""" + if self._closed: + return + self._closed = True + if self._expiry_timer is not None: + self._expiry_timer.cancel() + self._expiry_timer = None + if self._timer is not None and not self._timer.expired(): + self._timer.reschedule(None) + self.task.remove_done_callback(self._finish) + + def _schedule_expiry(self, deadline: float) -> None: + if self._expiry_timer is not None: + self._expiry_timer.cancel() + self._expiry_timer = asyncio.get_running_loop().call_at(deadline, self._expire) + def terminate( self, *, deadline: float | None = None, cooperative: bool = False ) -> None: """Start the cleanup grace once; later requests never renew it.""" + if self._closed: + return + self._adopt_cancellation() if self.deadline is not None: return bound = deadline if deadline is not None else terminal_deadline() self.deadline = self._release_timer(bound) # The owner enforces its own deadline: a detached worker may have no drainer. - asyncio.get_running_loop().call_at(self.deadline, self._expire) + self._schedule_expiry(self.deadline) if not cooperative: self._cancel() @@ -88,19 +115,24 @@ def retry(self, *, deadline: float) -> None: A task that swallowed its cancel would otherwise stay unfinished on every cleanup retry; within an unexpired grace this is ``terminate``. """ + if self._closed: + return if not self._grace_expired: self.terminate(deadline=deadline) return self.deadline = deadline self._grace_expired = False self._cancel_delivered = False - asyncio.get_running_loop().call_at(deadline, self._expire) + self._schedule_expiry(deadline) self._cancel() def _expire(self) -> None: + if self._closed: + return # The loop's own timer marks expiry: it can fire up to one clock # resolution before loop.time() reaches the deadline. self._grace_expired = True + self._adopt_cancellation() self._cancel() def _release_timer(self, bound: float) -> float: @@ -116,8 +148,13 @@ def _release_timer(self, bound: float) -> float: timer.reschedule(None) return bound + def _adopt_cancellation(self) -> None: + # Counts predating this owner belong to a previous cancellation lifetime. + if self.task.cancelling() > self._cancel_baseline: + self._cancel_delivered = True + def _cancel(self) -> None: - if not self._cancel_delivered and not self.task.done(): + if not self._closed and not self._cancel_delivered and not self.task.done(): self._cancel_delivered = True self.task.cancel() @@ -125,6 +162,8 @@ async def drain(self) -> bool: """Wait within the original terminal grace without cancelling a waiter.""" if self.task.done(): return True + if self._closed: + return False if self.deadline is None: raise RuntimeError("Turn draining requires termination") remaining = max(0.0, self.deadline - asyncio.get_running_loop().time()) @@ -136,7 +175,7 @@ async def timed( self, timeout: float | None, *, cleanup: bool = False ) -> AsyncIterator[asyncio.Timeout]: """Coordinate an existing provider timer with terminal cancellation.""" - if self.deadline is not None: + if self._closed or self.deadline is not None: raise asyncio.CancelledError if self._timer is not None or asyncio.current_task() is not self.task: raise RuntimeError("A turn timer must run in its owner, without nesting") diff --git a/src/band/integrations/codex/mcp.py b/src/band/integrations/codex/mcp.py new file mode 100644 index 000000000..3d539c605 --- /dev/null +++ b/src/band/integrations/codex/mcp.py @@ -0,0 +1,512 @@ +"""Immutable room-scoped MCP descriptions and secret-safe native Codex launches. + +These are live runtime objects, not adapter configuration. Pydantic serialization +excludes secret fields; stdlib dataclass serialization refuses their frozen maps. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import tomllib +from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field +from heapq import heapify, heappop, heappush +from types import MappingProxyType +from typing import Annotated, Any, NoReturn, SupportsIndex, TypeAlias +from uuid import uuid4 + +from pydantic import Field + +logger = logging.getLogger(__name__) + +__all__ = [ + "CodexMcpHttpServer", + "CodexMcpLaunch", + "CodexMcpRedactor", + "CodexMcpResolver", + "CodexMcpServer", + "CodexMcpSnapshot", + "CodexMcpStartupError", + "CodexMcpStdioServer", + "freeze_mcp_servers", +] + +_SERVER_NAME = re.compile(r"[A-Za-z0-9_-]+") +# Codex manages this server separately from caller-configured transports. +# Native source: codex-rs/codex-mcp/src/mcp/mod.rs, CODEX_APPS_MCP_SERVER_NAME. +_RESERVED_SERVER_NAMES = frozenset({"codex_apps"}) +# Preserve native stdio filtering even after alias remapping. This is the union +# in protocol/src/shell_environment.rs for Codex 0.160.0 and 0.162.1. +_NON_INHERITABLE_STDIO_ENV = frozenset( + { + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", + "NODE_REPL_AUTH_TOKEN", + "CODEX_GUARDIAN_DECISIONS_API_KEY", + "OPENAI_FEDERATION_RULE_ID", + "OPENAI_IDENTITY_TOKEN_FILE", + "OPENAI_WORKLOAD_IDENTITY_CONTEXT", + } +) +_REDACTED = "[REDACTED]" +_STARTUP_STAGES = frozenset( + { + "startup", + "retirement", + "resolution", + "snapshot", + "launch", + "connect", + "initialize", + "skills", + "model", + "readiness", + "thread/start", + "thread/resume", + } +) +_VALIDATION_MESSAGES = { + "unsupported-transport": "unsupported transport (only stdio and streamable HTTP are supported)", + "unsupported-platform": "secret-bearing stdio MCP servers require POSIX exec", + "invalid-name": "invalid server name", + "duplicate-name": "duplicate server name", + "reserved-name": "reserved server name", + "invalid-descriptor": "invalid server descriptor", +} +_TOML_ESCAPES = { + '"': '\\"', + "\\": "\\\\", + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", +} +# Native env_vars forwards same-named values, never aliases (rmcp-client/src/utils.rs). +# POSIX exec preserves native ownership while removing consumed secret aliases. +_STDIO_ENV_EXEC = ( + "import json,os,sys; " + "aliases=json.loads(sys.argv[1]); env=os.environ.copy(); " + "values={name:env.pop(source) for name,source in aliases.items()}; " + "env.update(values); argv=sys.argv[2:]; os.execvpe(argv[0],argv,env)" +) + + +class CodexMcpStartupError(RuntimeError): + """A startup stage or owned validation category, never callback diagnostics.""" + + def __init__(self, stage: str) -> None: + known = type(stage) is str and ( + stage in _STARTUP_STAGES or stage in _VALIDATION_MESSAGES + ) + self.stage = stage if known else "startup" + category = _VALIDATION_MESSAGES.get(self.stage) + message = ( + f"Codex MCP startup failed: {category}." + if category is not None + else f"Codex MCP startup failed during {self.stage}." + ) + super().__init__(message) + + +def _mapping_entries( + mapping: Mapping[Any, Any], stage: str +) -> tuple[tuple[Any, Any], ...]: + if not isinstance(mapping, Mapping): + raise CodexMcpStartupError(stage) + try: + return tuple((name, value) for name, value in mapping.items()) + except Exception: + # Mapping iteration and access are caller code, not owned validation. + safe = CodexMcpStartupError(stage) + logger.debug("Codex MCP mapping refused", exc_info=(type(safe), safe, None)) + raise safe from None + + +def _valid_text(value: Any, *, nonempty: bool = False) -> bool: + return ( + type(value) is str + and (bool(value) or not nonempty) + and "\0" not in value + and not any(0xD800 <= ord(char) <= 0xDFFF for char in value) + ) + + +def _string_mapping( + mapping: Mapping[str, str], stage: str, *, environment: bool = False +) -> dict[str, str]: + copied: dict[str, str] = {} + for name, value in _mapping_entries(mapping, stage): + if ( + not _valid_text(name, nonempty=True) + or not _valid_text(value) + or (environment and "=" in name) + ): + raise CodexMcpStartupError(stage) + copied[name] = value + return copied + + +def _argv( + sequence: Sequence[str], stage: str, *, nonempty: bool = False +) -> tuple[str, ...]: + if not isinstance(sequence, Sequence) or isinstance(sequence, str | bytes): + raise CodexMcpStartupError(stage) + try: + copied = tuple(sequence) + except Exception: + safe = CodexMcpStartupError(stage) + logger.debug("Codex MCP arguments refused", exc_info=(type(safe), safe, None)) + raise safe from None + if (nonempty and not copied) or any(not _valid_text(item) for item in copied): + raise CodexMcpStartupError(stage) + return copied + + +@dataclass(frozen=True, slots=True) +class CodexMcpStdioServer: + """A native stdio server; env is a frozen copy of child-only secret values.""" + + command: str + args: tuple[str, ...] = () + env: Annotated[Mapping[str, str], Field(exclude=True)] = field( + default_factory=dict, repr=False, compare=False + ) + cwd: str | None = None + + def __post_init__(self) -> None: + if not _valid_text(self.command, nonempty=True) or ( + self.cwd is not None and not _valid_text(self.cwd) + ): + raise CodexMcpStartupError("invalid-descriptor") + object.__setattr__(self, "args", _argv(self.args, "invalid-descriptor")) + child_env = _string_mapping(self.env, "invalid-descriptor", environment=True) + if any(name.upper() in _NON_INHERITABLE_STDIO_ENV for name in child_env): + raise CodexMcpStartupError("invalid-descriptor") + object.__setattr__(self, "env", MappingProxyType(child_env)) + + +@dataclass(frozen=True, slots=True) +class CodexMcpHttpServer: + """A streamable HTTP server; bearer and header values stay out of diagnostics.""" + + url: str + bearer_token: Annotated[str | None, Field(exclude=True)] = field( + default=None, repr=False, compare=False + ) + headers: Annotated[Mapping[str, str], Field(exclude=True)] = field( + default_factory=dict, repr=False, compare=False + ) + + def __post_init__(self) -> None: + if not _valid_text(self.url, nonempty=True) or ( + self.bearer_token is not None and not _valid_text(self.bearer_token) + ): + raise CodexMcpStartupError("invalid-descriptor") + object.__setattr__( + self, + "headers", + MappingProxyType(_string_mapping(self.headers, "invalid-descriptor")), + ) + + +CodexMcpServer: TypeAlias = CodexMcpStdioServer | CodexMcpHttpServer +CodexMcpResolver: TypeAlias = Callable[[str], Awaitable[Mapping[str, CodexMcpServer]]] + + +def _escaped_secrets(secret: str) -> set[str]: + variants = {secret} + frontier = {secret} + # JSON payloads can themselves appear as escaped strings in RPC diagnostics. + for _ in range(2): + escaped: set[str] = set() + for value in frontier: + for rendered in ( + json.dumps(value, ensure_ascii=True), + json.dumps(value, ensure_ascii=False), + repr(value), + ascii(value), + ): + escaped.add(rendered) + escaped.add(rendered[1:-1]) + escaped.add(value.encode("unicode_escape").decode("ascii")) + escaped.add(json.dumps(value, ensure_ascii=False)[1:-1].replace("/", "\\/")) + frontier = escaped - variants + variants.update(escaped) + return variants + + +class CodexMcpRedactor: + """Exact-value redaction scoped to one snapshot and its owned child launches.""" + + __slots__ = ("_secrets", "_variants") + + def __reduce_ex__(self, protocol: SupportsIndex, /) -> NoReturn: + raise TypeError( + "Codex MCP redactors are runtime-only and cannot be serialized." + ) + + def __init__(self, secrets: Iterable[str] = ()) -> None: + self._secrets: set[str] = set() + self._variants: tuple[str, ...] = () + self._register(secrets) + + def _register(self, secrets: Iterable[str]) -> None: + previous_count = len(self._secrets) + for secret in secrets: + if type(secret) is not str: + raise CodexMcpStartupError("snapshot") + if secret: + self._secrets.add(secret) + if len(self._secrets) == previous_count: + return + variants = { + variant for secret in self._secrets for variant in _escaped_secrets(secret) + } + # Secret-bearing regexes would outlive clear() in Python's global regex cache. + self._variants = tuple(sorted(variants, key=len, reverse=True)) + + def text(self, text: str) -> str: + matches = [ + (start, -len(secret), secret) + for secret in self._variants + if (start := text.find(secret)) >= 0 + ] + if not matches: + return text + heapify(matches) + parts: list[str] = [] + cursor = 0 + while matches: + start, negative_length, secret = heappop(matches) + if start >= cursor: + parts.extend((text[cursor:start], _REDACTED)) + cursor = start - negative_length + next_start = text.find(secret, cursor) + if next_start >= 0: + heappush(matches, (next_start, negative_length, secret)) + parts.append(text[cursor:]) + return "".join(parts) + + def value(self, value: Any) -> Any: + """Copy nested diagnostic data, including keys, without formatting unknown objects.""" + if isinstance(value, str): + return self.text(value) + if isinstance(value, Mapping): + redacted: dict[Any, Any] = {} + for key, item in value.items(): + safe_key = self.value(key) + unique_key = safe_key + suffix = 2 + while unique_key in redacted: + unique_key = f"{safe_key}#{suffix}" + suffix += 1 + redacted[unique_key] = self.value(item) + return redacted + if isinstance(value, list): + return [self.value(item) for item in value] + if isinstance(value, tuple): + return tuple(self.value(item) for item in value) + if ( + value is None + or type(value) is bool + or type(value) is int + or type(value) is float + ): + return value + return _REDACTED + + def clear(self) -> None: + """Release tracked values only after the owner has torn down its children.""" + self._secrets.clear() + self._variants = () + + +@dataclass(frozen=True, slots=True) +class CodexMcpLaunch: + """A private command/env copy for one native child, with no secret serialization.""" + + command: tuple[str, ...] + env: Annotated[Mapping[str, str] | None, Field(exclude=True)] = field( + default=None, repr=False, compare=False + ) + + def __post_init__(self) -> None: + object.__setattr__( + self, "command", _argv(self.command, "launch", nonempty=True) + ) + if self.env is not None: + object.__setattr__( + self, + "env", + MappingProxyType(_string_mapping(self.env, "launch", environment=True)), + ) + + +def _toml_string(value: str) -> str: + return ( + '"' + + "".join( + _TOML_ESCAPES.get(char, f"\\u{ord(char):04x}") + if ord(char) < 0x20 or ord(char) == 0x7F or char in _TOML_ESCAPES + else char + for char in value + ) + + '"' + ) + + +def _toml_value(value: Any) -> str: + match value: + case str(): + return _toml_string(value) + case bool(): + return "true" if value else "false" + case Mapping(): + return ( + "{" + + ", ".join( + f"{_toml_string(key)} = {_toml_value(item)}" + for key, item in value.items() + ) + + "}" + ) + case tuple() | list(): + return "[" + ", ".join(_toml_value(item) for item in value) + "]" + case _: + raise CodexMcpStartupError("launch") + + +def _server_secret_values(servers: Mapping[str, CodexMcpServer]) -> Iterable[str]: + for server in servers.values(): + match server: + case CodexMcpStdioServer(): + yield from server.env.values() + case CodexMcpHttpServer(): + if server.bearer_token is not None: + yield server.bearer_token + yield from server.headers.values() + + +@dataclass(frozen=True, slots=True) +class CodexMcpSnapshot: + """Frozen servers, expected native names, and a pre-registered diagnostic boundary.""" + + servers: Mapping[str, CodexMcpServer] + expected_names: frozenset[str] = field(init=False) + redactor: CodexMcpRedactor = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + entries = _mapping_entries(self.servers, "snapshot") + frozen: dict[str, CodexMcpServer] = {} + for name, server in entries: + if type(name) is not str or _SERVER_NAME.fullmatch(name) is None: + raise CodexMcpStartupError("invalid-name") + if name in _RESERVED_SERVER_NAMES: + raise CodexMcpStartupError("reserved-name") + if name in frozen: + raise CodexMcpStartupError("duplicate-name") + if ( + type(server) is not CodexMcpStdioServer + and type(server) is not CodexMcpHttpServer + ): + raise CodexMcpStartupError("unsupported-transport") + match server: + case CodexMcpStdioServer(): + if server.env and os.name != "posix": + raise CodexMcpStartupError("unsupported-platform") + frozen[name] = CodexMcpStdioServer( + server.command, server.args, server.env, server.cwd + ) + case CodexMcpHttpServer(): + frozen[name] = CodexMcpHttpServer( + server.url, server.bearer_token, server.headers + ) + object.__setattr__(self, "servers", MappingProxyType(frozen)) + object.__setattr__(self, "expected_names", frozenset(frozen)) + object.__setattr__( + self, "redactor", CodexMcpRedactor(_server_secret_values(frozen)) + ) + + def launch( + self, command: Sequence[str], env: Mapping[str, str] | None = None + ) -> CodexMcpLaunch: + """Build private overrides with aliases reserved against the full child env.""" + argv = list(_argv(command, "launch", nonempty=True)) + child_env = ( + _string_mapping(env, "launch", environment=True) if env is not None else {} + ) + self.redactor._register(child_env.values()) + namespace = uuid4().hex.upper() + used_names = {name.upper() for name in os.environ} + used_names.update(name.upper() for name in child_env) + counter = 0 + + def secret_ref(value: str) -> str: + nonlocal counter + while True: + name = f"BAND_CODEX_MCP_{namespace}_{counter}" + counter += 1 + if name not in used_names: + break + used_names.add(name) + child_env[name] = value + return name + + for name, server in self.servers.items(): + settings: dict[str, Any] = { + "enabled": True, + "required": True, + "startup_readiness": "connection", + } + match server: + case CodexMcpStdioServer(): + settings.update( + command=server.command, args=server.args, env_vars=[] + ) + if server.cwd is not None: + settings["cwd"] = server.cwd + if server.env: + aliases = { + key: secret_ref(value) for key, value in server.env.items() + } + settings.update( + command=sys.executable, + args=( + "-I", + "-c", + _STDIO_ENV_EXEC, + json.dumps(aliases, ensure_ascii=False), + server.command, + *server.args, + ), + env_vars=list(aliases.values()), + ) + case CodexMcpHttpServer(): + settings["url"] = server.url + settings["env_http_headers"] = { + key: secret_ref(value) for key, value in server.headers.items() + } + if server.bearer_token is not None: + settings["bearer_token_env_var"] = secret_ref( + server.bearer_token + ) + override = f"mcp_servers.{name}={_toml_value(settings)}" + try: + parsed = tomllib.loads(override) + except (tomllib.TOMLDecodeError, ValueError): + raise CodexMcpStartupError("launch") from None + if parsed["mcp_servers"][name] != json.loads(json.dumps(settings)): + raise CodexMcpStartupError("launch") + argv.extend(("-c", override)) + return CodexMcpLaunch( + tuple(argv), child_env if env is not None or child_env else None + ) + + +def freeze_mcp_servers(servers: Mapping[str, CodexMcpServer]) -> CodexMcpSnapshot: + """Validate and snapshot one resolver result before constructing a native client.""" + return CodexMcpSnapshot(servers) diff --git a/src/band/integrations/codex/rpc_base.py b/src/band/integrations/codex/rpc_base.py index 2fd379ff3..3261a3839 100644 --- a/src/band/integrations/codex/rpc_base.py +++ b/src/band/integrations/codex/rpc_base.py @@ -10,6 +10,7 @@ from dataclasses import dataclass from typing import Any, Literal +from band.integrations.codex.mcp import CodexMcpRedactor from band.integrations.codex.types import CodexRequestMethod logger = logging.getLogger(__name__) @@ -82,9 +83,11 @@ def __init__( *, retry_policy: OverloadRetryPolicy | None = None, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + redactor: CodexMcpRedactor | None = None, ) -> None: self.retry_policy = retry_policy or OverloadRetryPolicy() self._sleep = sleep + self._redactor = redactor self._pending: dict[JsonRpcId, asyncio.Future[dict[str, Any]]] = {} self._events: asyncio.Queue[RpcEvent] = asyncio.Queue(maxsize=10_000) @@ -92,6 +95,9 @@ def __init__( self._connected = False self._transport_label = "payload" # overridden by subclasses for diagnostics + def _redact_value(self, value: Any) -> Any: + return self._redactor.value(value) if self._redactor is not None else value + # ------------------------------------------------------------------ # Transport methods # ------------------------------------------------------------------ @@ -263,11 +269,21 @@ async def _dispatch_rpc_message(self, text: str) -> None: try: message = json.loads(text) except json.JSONDecodeError: - logger.warning("Skipping non-JSON %s: %s", self._transport_label, text) + logger.warning( + "Skipping non-JSON %s (type=%s, length=%d)", + self._transport_label, + type(text).__name__, + len(text), + ) return if not isinstance(message, dict): - logger.warning("Skipping non-object JSON-RPC payload: %s", message) + logger.warning( + "Skipping non-object JSON-RPC %s (type=%s, length=%d)", + self._transport_label, + type(message).__name__, + len(text), + ) return msg_id = message.get("id") @@ -285,7 +301,7 @@ async def _dispatch_rpc_message(self, text: str) -> None: ) ) except asyncio.QueueFull: - logger.warning("Event queue full; dropping server request %s", method) + logger.warning("Event queue full; dropping server request") return if method: @@ -300,16 +316,24 @@ async def _dispatch_rpc_message(self, text: str) -> None: ) ) except asyncio.QueueFull: - logger.warning("Event queue full; dropping notification %s", method) + logger.warning("Event queue full; dropping notification") return if msg_id is not None: future = self._pending.get(msg_id) if future is not None and not future.done(): + if "error" in message: + # Freeze diagnostics before teardown releases the secret scope. + message["error"] = self._redact_value(message["error"]) future.set_result(message) return - logger.debug("Ignoring unknown JSON-RPC payload: %s", message) + logger.debug( + "Ignoring unknown JSON-RPC %s (type=%s, length=%d)", + self._transport_label, + type(message).__name__, + len(text), + ) def _next_request_id(self) -> int: self._request_id += 1 diff --git a/src/band/integrations/codex/stdio_client.py b/src/band/integrations/codex/stdio_client.py index 6fb3602c4..d755613e8 100644 --- a/src/band/integrations/codex/stdio_client.py +++ b/src/band/integrations/codex/stdio_client.py @@ -12,26 +12,64 @@ from contextlib import suppress from typing import Any -from band.core.exceptions import BandConnectionError +from band.core.exceptions import BandConnectionError, CleanupIncompleteError +from band.integrations.codex.mcp import CodexMcpRedactor from band.integrations.codex.rpc_base import BaseJsonRpcClient, OverloadRetryPolicy logger = logging.getLogger(__name__) _PROCESS_EXIT_TIMEOUT_S = 1.0 +_PROCESS_GROUP_EXIT_TIMEOUT_S = 1.0 +_PROCESS_GROUP_POLL_S = 0.02 + + +async def _wait_for_process_group_exit(pgid: int) -> bool: + loop = asyncio.get_running_loop() + deadline = loop.time() + _PROCESS_GROUP_EXIT_TIMEOUT_S + last_error: OSError | None = None + while True: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return True + except OSError as error: + last_error = error + else: + last_error = None + remaining = deadline - loop.time() + if remaining <= 0: + if last_error is not None: + raise CleanupIncompleteError( + "Codex owned process-group cleanup could not be confirmed" + ) from last_error + return False + await asyncio.sleep(min(_PROCESS_GROUP_POLL_S, remaining)) async def kill_process_tree(process: asyncio.subprocess.Process) -> None: - """Force-stop ``process`` and everything it spawned. + """Settle the original owned POSIX group; Windows tree kill is best effort. - The ``codex`` launcher is a shim (``codex.cmd`` on Windows, a node script - elsewhere) whose real CLI is its child; killing only the shim leaves that - child running, and on Windows its open workdir cannot be removed. + ``start_new_session`` makes the original process handle's PID its group ID. + Never rediscover a group from a current PID, which may already be recycled. + POSIX has no generation-bound group handle: reuse after the last member exits + and before the final probe remains a residual race. """ if os.name != "nt": - # The child leads its own session, so its pid is the group id. - with suppress(ProcessLookupError): - os.killpg(process.pid, signal.SIGKILL) - return + pgid = process.pid + for signum in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(pgid, signum) + except ProcessLookupError: + return + except OSError as error: + raise CleanupIncompleteError( + "Codex owned process group could not be signalled" + ) from error + if await _wait_for_process_group_exit(pgid): + return + raise CleanupIncompleteError( + "Codex owned process group remained unsettled after TERM and KILL" + ) taskkill = await asyncio.create_subprocess_exec( "taskkill", "/F", @@ -57,8 +95,9 @@ def __init__( env: Mapping[str, str] | None = None, retry_policy: OverloadRetryPolicy | None = None, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + redactor: CodexMcpRedactor | None = None, ) -> None: - super().__init__(retry_policy=retry_policy, sleep=sleep) + super().__init__(retry_policy=retry_policy, sleep=sleep, redactor=redactor) self._transport_label = "codex output line" self.command = tuple(command) if command else self._default_codex_command() self.cwd = cwd @@ -131,10 +170,11 @@ async def close(self) -> None: self._close_task.add_done_callback(self._log_close_failure) await asyncio.shield(self._close_task) - @staticmethod - def _log_close_failure(task: asyncio.Task[None]) -> None: + def _log_close_failure(self, task: asyncio.Task[None]) -> None: if not task.cancelled() and (error := task.exception()) is not None: - logger.error("Codex subprocess cleanup failed", exc_info=error) + logger.error( + "Codex subprocess cleanup failed (type=%s)", type(error).__name__ + ) async def _close_process(self) -> None: readers = [ @@ -151,9 +191,18 @@ async def _close_process(self) -> None: await asyncio.wait_for(process.wait(), timeout=_PROCESS_EXIT_TIMEOUT_S) except TimeoutError: await kill_process_tree(process) + # The kill is forced, so the leader does exit; a loaded loop can + # report it late. Callers are bounded by the room owner's budget, + # which keeps this close owned for a retry to join. await process.wait() + else: + if os.name != "nt": + await kill_process_tree(process) self._fail_pending("Codex stdio client closed") + if self._redactor is not None: + self._redactor.clear() + self.env = None async def _send_json(self, payload: dict[str, Any]) -> None: if not self._proc or not self._proc.stdin: @@ -190,9 +239,8 @@ async def _read_stderr_loop(self) -> None: line = await self._proc.stderr.readline() if not line: return - text = line.decode("utf-8", errors="replace").rstrip() - if text: - logger.debug("codex stderr: %s", text) + if line.rstrip(): + logger.debug("codex stderr (type=bytes, length=%d)", len(line)) except asyncio.CancelledError: raise except Exception: diff --git a/tests/adapters/codex/kit.py b/tests/adapters/codex/kit.py index cda37d249..b89ab4b6f 100644 --- a/tests/adapters/codex/kit.py +++ b/tests/adapters/codex/kit.py @@ -176,7 +176,7 @@ def patch_codex_clients_by_room( ) -> None: """Give each room its own fake app-server, as the real adapter does.""" - def _build(_workspace: str) -> FakeCodexClient: + def _build(_room: object, _launch: object) -> FakeCodexClient: room_id = adapter._active_room.get() assert room_id is not None return clients[room_id] diff --git a/tests/adapters/codex/lifecycleprofile.py b/tests/adapters/codex/lifecycleprofile.py index 34ca58b5e..ca01ea35f 100644 --- a/tests/adapters/codex/lifecycleprofile.py +++ b/tests/adapters/codex/lifecycleprofile.py @@ -12,6 +12,7 @@ from band.adapters.codex import CodexAdapter, CodexAdapterConfig from band.core.protocols import TurnResultAlreadyReported from band.integrations.codex import CodexJsonRpcError, CodexStdioClient +from band.integrations.codex.mcp import CodexMcpRedactor from tests.adapters.codex.kit import residency_of, thread_of from tests.adapters.hold import Hold from tests.adapters.lifecycle.kit import ( @@ -75,8 +76,9 @@ def child( command: Sequence[str] | None, cwd: str | None, env: Mapping[str, str] | None, + redactor: CodexMcpRedactor | None, ) -> CodexPeer: - peer = CodexPeer(command=command, cwd=cwd, env=env) + peer = CodexPeer(command=command, cwd=cwd, env=env, redactor=redactor) host.spawned(peer, cwd) return peer diff --git a/tests/adapters/codex/mcpkit.py b/tests/adapters/codex/mcpkit.py new file mode 100644 index 000000000..7a8660449 --- /dev/null +++ b/tests/adapters/codex/mcpkit.py @@ -0,0 +1,130 @@ +"""Shared room-startup doubles for native Codex MCP tests.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Iterator, Mapping +from contextlib import suppress +from pathlib import Path +from typing import Any + +from band.adapters.codex import CodexAdapter, CodexAdapterConfig +from band.integrations.codex.mcp import ( + CodexMcpHttpServer, + CodexMcpLaunch, + CodexMcpServer, +) +from band.integrations.codex.types import CodexRequestMethod +from tests.adapters.codexturns import FakeCodexClient + + +def ready_row(name: str = "private") -> dict[str, Any]: + return { + "name": name, + "serverInfo": {"name": "fixture", "version": "1"}, + "serverCapabilities": {}, + "toolsError": None, + "runtimeStatus": None, + "tools": {}, + "authStatus": "bearerToken", + } + + +class StatusClient(FakeCodexClient): + def __init__( + self, + pages: list[dict[str, Any]] | None = None, + checkpoint: Callable[[str], Awaitable[None]] | None = None, + ) -> None: + super().__init__() + self.pages = list(pages if pages is not None else [{"data": [ready_row()]}]) + self.checkpoint = checkpoint + + async def pause(self, stage: str) -> None: + if self.checkpoint is not None: + await self.checkpoint(stage) + + async def connect(self) -> None: + await self.pause("connect") + await super().connect() + + async def initialize(self, **kwargs: Any) -> dict[str, Any]: + await self.pause("initialize") + return await super().initialize(**kwargs) + + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == "mcpServerStatus/list": + await self.pause("readiness") + self.requests.append((method, params or {})) + return self.pages.pop(0) + await self.pause( + "skills" if method == CodexRequestMethod.SKILLS_EXTRA_ROOTS_SET else "model" + ) + return await super().request(method, params, **kwargs) + + +def mcp_adapter( + tmp_path: Path, + clients: list[FakeCodexClient], + resolver: Callable[[str], Awaitable[Mapping[str, CodexMcpServer]]], + **config: Any, +) -> tuple[CodexAdapter, list[CodexMcpLaunch | None]]: + adapter = CodexAdapter( + CodexAdapterConfig( + workspace_for_room=lambda room: str(tmp_path / room), **config + ), + resolve_mcp_servers=resolver, + ) + launches: list[CodexMcpLaunch | None] = [] + + def build(_room: object, launch: CodexMcpLaunch | None) -> FakeCodexClient: + launches.append(launch) + return clients[len(launches) - 1] + + adapter._build_client = build + return adapter, launches + + +async def start(adapter: CodexAdapter, room_id: str = "a") -> None: + room = adapter._room_client(room_id) + adapter._active_room.set(room_id) + try: + async with room.retained.use(): + pass + except BaseException: + # A direct helper caller observes the owned recovery, not just its waiter. + if room.retained._transition is not None: + await asyncio.wait({room.retained._transition}) + with suppress(RuntimeError): + await room.retained._settle() + raise + + +async def private_servers(_workspace: str) -> Mapping[str, CodexMcpServer]: + return { + "private": CodexMcpHttpServer( + "http://localhost/mcp", bearer_token="secret-sentinel" + ) + } + + +class DuplicateServers(Mapping[str, Any]): + """A caller mapping can yield the same name twice from items().""" + + def __getitem__(self, key: str) -> Any: + if key != "private": + raise KeyError(key) + return CodexMcpHttpServer("http://localhost/mcp") + + def __iter__(self) -> Iterator[str]: + return iter(("private", "private")) + + def __len__(self) -> int: + return 2 + + +class SseServer: + transport = "sse" + url = "http://localhost/sse" diff --git a/tests/adapters/codex/test_mcp_acquisition.py b/tests/adapters/codex/test_mcp_acquisition.py new file mode 100644 index 000000000..3eb9cb35f --- /dev/null +++ b/tests/adapters/codex/test_mcp_acquisition.py @@ -0,0 +1,38 @@ +"""External MCP resolution follows the retained room's acquisition boundary.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from band.adapters.codex import CodexAdapter, CodexAdapterConfig +from band.integrations.codex.mcp import CodexMcpHttpServer, CodexMcpStartupError +from tests.adapters.codexturns import FakeCodexClient + + +class TestMcpAcquisition: + async def test_resolves_once_per_process_before_readiness( + self, tmp_path: Path + ) -> None: + resolved: list[str] = [] + + async def resolve(workspace: str): + resolved.append(workspace) + return {"private": CodexMcpHttpServer("http://localhost/mcp")} + + adapter = CodexAdapter( + CodexAdapterConfig(workspace_for_room=lambda _: str(tmp_path)), + resolve_mcp_servers=resolve, + ) + client = FakeCodexClient() + adapter._build_client = lambda _room, _launch: client + room = adapter._room_client("a") + try: + with pytest.raises(CodexMcpStartupError): + async with room.retained.use(): + pass + assert resolved == [str(tmp_path)] + assert "mcpServerStatus/list" in client.request_methods + finally: + await adapter.cleanup_all() diff --git a/tests/adapters/codex/test_mcp_cancellation.py b/tests/adapters/codex/test_mcp_cancellation.py new file mode 100644 index 000000000..a89b3f915 --- /dev/null +++ b/tests/adapters/codex/test_mcp_cancellation.py @@ -0,0 +1,204 @@ +"""Public invocation cancellation keeps settlement and discovery custody.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +import pytest + +from band.core.exceptions import CleanupIncompleteError +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex.mcp import CodexMcpServer +from band.integrations.codex.types import CodexRequestMethod, CodexSessionState +from band.testing import FakeAgentTools +from tests.adapters.codex.kit import make_platform_message +from tests.adapters.codex.mcpkit import ( + StatusClient, + mcp_adapter, + private_servers, +) + + +def message_task(adapter) -> asyncio.Task[None]: + return asyncio.create_task( + adapter.on_message( + msg=make_platform_message(room_id="a"), + tools=FakeAgentTools(), + history=CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="a", + ) + ) + + +class TestMcpCancellation: + @pytest.mark.looptime + async def test_external_startup_cancel_outlasting_grace_retains_custody( + self, + tmp_path, + ) -> None: + entered, settling, release, settled = (asyncio.Event() for _ in range(4)) + + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + settling.set() + await release.wait() + settled.set() + raise + + adapter, launches = mcp_adapter( + tmp_path, [StatusClient()], resolve, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Cancellation custody") + running = message_task(adapter) + try: + await entered.wait() + room = adapter._room_clients["a"] + invocation = room.startup_owner.task + assert invocation is not None + turn_release = adapter._turn_release["a"] + invocation.cancel() + await settling.wait() + outcome = await asyncio.gather( + adapter.on_cleanup("a"), return_exceptions=True + ) + assert isinstance(outcome[0], CleanupIncompleteError) + assert invocation.cancelling() == 1 + assert not invocation.done() + assert not settled.is_set() + assert not turn_release.done() + assert adapter._turn_release["a"] is turn_release + with pytest.raises(ValueError): + adapter._workspaces.claim("b", room.workspace) + assert launches == [] + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.on_cleanup("a") + assert settled.is_set() + assert "a" not in adapter._workspaces.rooms + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.cleanup_all() + adapter._workspaces.release("b") + + @pytest.mark.looptime + async def test_cleanup_disarms_readiness_deadline_during_settlement( + self, tmp_path, monkeypatch + ) -> None: + entered, settling, release, settled = (asyncio.Event() for _ in range(4)) + monkeypatch.setattr("band.adapters.codex._MCP_READINESS_TIMEOUT_S", 0.1) + + class SettlingClient(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == "mcpServerStatus/list": + self.requests.append((method, params or {})) + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + settling.set() + await release.wait() + settled.set() + raise + return await super().request(method, params, **kwargs) + + client = SettlingClient() + adapter, _ = mcp_adapter( + tmp_path, [client], private_servers, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Readiness custody") + running = message_task(adapter) + cleanup = None + try: + await entered.wait() + room = adapter._room_clients["a"] + invocation = room.startup_owner.task + assert invocation is not None + turn_release = adapter._turn_release["a"] + cleanup = asyncio.create_task(adapter.on_cleanup("a")) + await settling.wait() + await asyncio.sleep( + 0.2 + ) # Cross the original readiness deadline while settling. + assert invocation.cancelling() == 1 + assert not invocation.done() + assert not settled.is_set() + assert not client.closed + assert room.client is client + assert not turn_release.done() + assert adapter._turn_release["a"] is turn_release + with pytest.raises(ValueError): + adapter._workspaces.claim("b", room.workspace) + outcome = await asyncio.gather(cleanup, return_exceptions=True) + assert isinstance(outcome[0], CleanupIncompleteError) + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.on_cleanup("a") + assert settled.is_set() + assert client.closed + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + if cleanup is not None: + await asyncio.gather(cleanup, return_exceptions=True) + await adapter.cleanup_all() + adapter._workspaces.release("b") + + @pytest.mark.looptime + async def test_thread_error_after_cleanup_issues_no_discovery( + self, tmp_path + ) -> None: + entered, cancelling, release = (asyncio.Event() for _ in range(3)) + + class LateThreadError(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == CodexRequestMethod.THREAD_START: + self.requests.append((method, params or {})) + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelling.set() + await release.wait() + raise CodexJsonRpcError(code=-32603, message="thread failed") + return await super().request(method, params, **kwargs) + + client = LateThreadError() + adapter, _ = mcp_adapter( + tmp_path, [client], private_servers, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Retirement discovery fence") + running = message_task(adapter) + cleanup = None + try: + await entered.wait() + initial_discovery = client.request_methods.count("mcpServerStatus/list") + assert initial_discovery == 1 + cleanup = asyncio.create_task(adapter.on_cleanup("a")) + await cancelling.wait() + release.set() + await asyncio.gather(running, cleanup, return_exceptions=True) + assert ( + client.request_methods.count("mcpServerStatus/list") + == initial_discovery + ) + await adapter.on_cleanup("a") + assert client.closed + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + if cleanup is not None: + await asyncio.gather(cleanup, return_exceptions=True) + await adapter.cleanup_all() diff --git a/tests/adapters/codex/test_mcp_launch.py b/tests/adapters/codex/test_mcp_launch.py new file mode 100644 index 000000000..51b5c7038 --- /dev/null +++ b/tests/adapters/codex/test_mcp_launch.py @@ -0,0 +1,659 @@ +"""Consumer-visible snapshot, launch and diagnostic safety for Codex MCP.""" + +from __future__ import annotations + +import json +import os +import pickle +import subprocess +import sys +import tomllib +import traceback +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import FrozenInstanceError, asdict +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from uuid import UUID + +import pytest +from pydantic import TypeAdapter + +from band.integrations.codex import CodexStdioClient +from band.integrations.codex import mcp as codex_mcp +from band.integrations.codex.mcp import ( + CodexMcpHttpServer, + CodexMcpLaunch, + CodexMcpRedactor, + CodexMcpServer, + CodexMcpStartupError, + CodexMcpStdioServer, + freeze_mcp_servers, +) + + +def launched_servers( + launch: CodexMcpLaunch, original_command: Sequence[str] +) -> dict[str, Any]: + tail = launch.command[len(original_command) :] + assert len(tail) % 2 == 0 + assert all(flag == "-c" for flag in tail[::2]) + parsed = tomllib.loads("\n".join(tail[1::2])) + return parsed.get("mcp_servers", {}) + + +def run_stdio_entry(entry: dict[str, Any], launch: CodexMcpLaunch) -> dict[str, Any]: + assert launch.env is not None + inherited_names = {"SYSTEMROOT", "WINDIR", "PATH", "HOME", "TEMP", "TMP", "TMPDIR"} + child_env = { + name: value + for name, value in os.environ.items() + if name.upper() in inherited_names + } + child_env.update({name: launch.env[name] for name in entry["env_vars"]}) + completed = subprocess.run( + [entry["command"], *entry["args"]], + cwd=entry["cwd"], + env=child_env, + capture_output=True, + text=True, + check=True, + ) + return json.loads(completed.stdout) + + +class TestCodexMcpLaunch: + def test_caller_mutation_cannot_change_snapshot_or_launch( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + codex_mcp, "os", SimpleNamespace(name="posix", environ=os.environ) + ) + args = ["--mode", "original"] + env = {"TOKEN": "stdio-original-grant"} + headers = {"X-Grant": "http-original-grant"} + stdio = CodexMcpStdioServer("relative-runner", args=args, env=env) + http = CodexMcpHttpServer("http://localhost/mcp", headers=headers) + servers: dict[str, CodexMcpServer] = {"stdio": stdio, "http": http} + snapshot = freeze_mcp_servers(servers) + command = ["codex", "app-server", "--listen", "stdio://"] + base_env = {"EXISTING": "existing-value"} + launch = snapshot.launch(command, base_env) + + args.append("mutated") + env["TOKEN"] = "stdio-replacement-grant" + headers["X-Grant"] = "http-replacement-grant" + servers.clear() + command.append("--changed") + base_env["EXISTING"] = "changed" + + assert snapshot.expected_names == frozenset({"stdio", "http"}) + assert snapshot.servers["stdio"].args == ("--mode", "original") + entries = launched_servers(launch, command[:-1]) + assert "mutated" not in entries["stdio"]["args"] + assert launch.env is not None + assert launch.env["EXISTING"] == "existing-value" + assert launch.env[entries["http"]["env_http_headers"]["X-Grant"]] == ( + "http-original-grant" + ) + assert "stdio-original-grant" in launch.env.values() + assert "stdio-replacement-grant" not in launch.env.values() + assert launch.command[:4] == ("codex", "app-server", "--listen", "stdio://") + assert snapshot.redactor.text("stdio-original-grant") == "[REDACTED]" + + with pytest.raises(TypeError): + snapshot.servers["new"] = http + with pytest.raises(TypeError): + stdio.env["TOKEN"] = "changed" + with pytest.raises(TypeError): + http.headers["X-Grant"] = "changed" + with pytest.raises(FrozenInstanceError): + stdio.command = "changed" + with pytest.raises(FrozenInstanceError): + http.url = "changed" + with pytest.raises(TypeError): + launch.env["EXISTING"] = "changed" + + def test_empty_snapshot_does_not_add_overrides_or_child_env(self) -> None: + command = ["codex", "app-server", "--listen", "stdio://"] + snapshot = freeze_mcp_servers({}) + launch = snapshot.launch(command, None) + assert launch.command == tuple(command) + assert launch.env is None + assert snapshot.expected_names == frozenset() + assert snapshot.redactor.text("unregistered value") == "unregistered value" + + base_env = {"BASE": "base-secret"} + launch_with_env = snapshot.launch(command, base_env) + base_env.clear() + assert launch_with_env.env == {"BASE": "base-secret"} + assert "base-secret" not in repr(launch_with_env) + + @pytest.mark.parametrize( + "unsupported", + [ + SimpleNamespace(transport="sse", url="secret-url"), + {"url": "secret-url", "transport": "http"}, + type("secret-callback-type", (), {})(), + type("HttpSubclass", (CodexMcpHttpServer,), {})("http://localhost/mcp"), + ], + ids=["sse", "untyped-mapping", "unknown-type", "descriptor-subclass"], + ) + def test_unsupported_descriptors_have_named_secret_free_errors( + self, unsupported: Any + ) -> None: + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers({"tools": unsupported}) + assert caught.value.stage == "unsupported-transport" + assert "unsupported transport" in str(caught.value).lower() + assert "secret-url" not in str(caught.value) + assert "secret-callback-type" not in repr(caught.value) + + def test_a_callback_type_cannot_spoof_supported_descriptor_identity(self) -> None: + class PretendType(type): + def __eq__(self, other: object) -> bool: + return True + + class PretendDescriptor(metaclass=PretendType): + pass + + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers({"tools": PretendDescriptor()}) + assert caught.value.stage == "unsupported-transport" + + @pytest.mark.parametrize( + "name", ["", "with.dot", "with space", "with/slash", "café", "secret-name\n", 7] + ) + def test_invalid_server_names_have_safe_named_errors(self, name: Any) -> None: + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers({name: CodexMcpHttpServer("http://localhost/mcp")}) + assert caught.value.stage == "invalid-name" + assert "invalid server name" in str(caught.value).lower() + assert "secret-name" not in repr(caught.value) + + def test_native_host_owned_name_is_reserved_without_invented_names(self) -> None: + server = CodexMcpHttpServer("http://localhost/mcp") + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers({"codex_apps": server}) + assert caught.value.stage == "reserved-name" + snapshot = freeze_mcp_servers( + {name: server for name in ("band", "codex", "Codex_apps", "tools-1_v2")} + ) + launch = snapshot.launch(("codex", "app-server"), None) + assert set(launched_servers(launch, ("codex", "app-server"))) == { + "band", + "codex", + "Codex_apps", + "tools-1_v2", + } + + @pytest.mark.parametrize("value", [None, [], "private-nonmapping-data"]) + def test_nonmapping_resolver_results_have_safe_snapshot_errors( + self, value: Any + ) -> None: + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers(value) + assert caught.value.stage == "snapshot" + assert "private-nonmapping-data" not in repr(caught.value) + + @pytest.mark.parametrize( + "make_server", + [ + lambda: CodexMcpStdioServer("runner", args="private-invalid-args"), + lambda: CodexMcpStdioServer("runner", env={"TOKEN": 7}), + lambda: CodexMcpStdioServer( + "runner", env={"BAD=NAME": "private-env-value"} + ), + lambda: CodexMcpHttpServer("http://localhost/mcp", bearer_token=object()), + lambda: CodexMcpHttpServer("http://localhost/mcp", headers={"X-Grant": 7}), + ], + ids=[ + "string-args", + "nonstring-env", + "invalid-env-key", + "nonstring-bearer", + "nonstring-header", + ], + ) + def test_invalid_descriptor_data_is_rejected_without_input_diagnostics( + self, make_server: Any + ) -> None: + with pytest.raises(CodexMcpStartupError) as caught: + make_server() + assert caught.value.stage == "invalid-descriptor" + assert "private-invalid-args" not in repr(caught.value) + assert "private-env-value" not in repr(caught.value) + + @pytest.mark.parametrize( + "name", + [ + "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN", + "node_repl_auth_token", + "CODEX_GUARDIAN_DECISIONS_API_KEY", + "OPENAI_FEDERATION_RULE_ID", + "OPENAI_IDENTITY_TOKEN_FILE", + "OPENAI_WORKLOAD_IDENTITY_CONTEXT", + ], + ) + def test_stdio_alias_remapping_cannot_restore_native_noninheritable_env( + self, name: str + ) -> None: + with pytest.raises(CodexMcpStartupError) as caught: + CodexMcpStdioServer("runner", env={name: "private-native-launch-context"}) + assert caught.value.stage == "invalid-descriptor" + assert "private-native-launch-context" not in repr(caught.value) + + def test_duplicate_mapping_entries_are_not_silently_overwritten(self) -> None: + class RepeatedNames(Mapping[str, CodexMcpServer]): + def __iter__(self) -> Iterator[str]: + return iter(("repeated", "repeated")) + + def __len__(self) -> int: + return 2 + + def __getitem__(self, key: str) -> CodexMcpServer: + return CodexMcpHttpServer("http://localhost/mcp") + + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers(RepeatedNames()) + assert caught.value.stage == "duplicate-name" + assert "duplicate server name" in str(caught.value).lower() + + @pytest.mark.parametrize("failure", ["iteration", "lookup", "malformed-items"]) + def test_caller_mapping_failures_are_normalized_before_validation( + self, failure: str + ) -> None: + secret = "private-callback-error" + + class BrokenMapping(Mapping[str, CodexMcpServer]): + def __iter__(self) -> Iterator[str]: + if failure == "iteration": + raise RuntimeError(secret) + return iter(("tools",)) + + def __len__(self) -> int: + return 1 + + def __getitem__(self, key: str) -> CodexMcpServer: + raise ValueError(secret) + + def items(self) -> Any: + if failure == "malformed-items": + return [ + ("tools", CodexMcpHttpServer("http://localhost/mcp"), secret) + ] + return super().items() + + with pytest.raises(CodexMcpStartupError) as caught: + freeze_mcp_servers(BrokenMapping()) + assert caught.value.stage == "snapshot" + rendered = "".join(traceback.format_exception(caught.value)) + assert secret not in str(caught.value) + assert secret not in repr(caught.value) + assert secret not in rendered + + @pytest.mark.parametrize( + "command", + [ + ["codex", "app-server", "--listen", "stdio://", "-c", 'model="base"'], + [ + "python", + "-m", + "band_agent", + "codex-relay", + "--skills-root", + "skills", + "--", + "codex", + "app-server", + "--listen", + "stdio://", + "--extra-arg", + ], + ], + ids=["default-command-extra-config", "relay-boundary-extra-args"], + ) + def test_native_overrides_follow_existing_default_or_relay_argv( + self, command: list[str] + ) -> None: + original = command.copy() + snapshot = freeze_mcp_servers( + {"tools": CodexMcpHttpServer("http://localhost/mcp")} + ) + launch = snapshot.launch(command, None) + assert command == original + assert launch.command[: len(original)] == tuple(original) + assert launched_servers(launch, original)["tools"] == { + "url": "http://localhost/mcp", + "env_http_headers": {}, + "enabled": True, + "required": True, + "startup_readiness": "connection", + } + + def test_toml_preserves_nonsecret_strings_without_an_unneeded_trampoline( + self, + ) -> None: + args = ('quote" and backslash\\', "line\n\tbreak", "emoji-🦉", "delete-\x7f") + server = CodexMcpStdioServer("./relative-runner", args=args, cwd="relative dir") + command = ("codex", "app-server") + launch = freeze_mcp_servers({"tools": server}).launch(command, None) + entry = launched_servers(launch, command)["tools"] + assert entry["command"] == "./relative-runner" + assert entry["args"] == list(args) + assert entry["cwd"] == "relative dir" + assert entry["env_vars"] == [] + assert entry["required"] is True + assert entry["startup_readiness"] == "connection" + assert launch.env is None + + def test_secrets_only_exist_in_the_private_child_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + codex_mcp, "os", SimpleNamespace(name="posix", environ=os.environ) + ) + bearer = 'bearer-secret-"\\-🦉' + header = "header-secret-with\nnewline" + stdio_value = "stdio-secret-value" + servers = { + "remote": CodexMcpHttpServer( + "http://localhost/mcp", bearer_token=bearer, headers={"X.Grant": header} + ), + "local": CodexMcpStdioServer("runner", env={"TOKEN": stdio_value}), + } + command = ["codex", "app-server"] + base_env = {"BASE": "base-secret-value"} + process_env_before = dict(os.environ) + snapshot = freeze_mcp_servers(servers) + launch = snapshot.launch(command, base_env) + entries = launched_servers(launch, command) + assert launch.env is not None + remote = entries["remote"] + assert launch.env[remote["bearer_token_env_var"]] == bearer + assert launch.env[remote["env_http_headers"]["X.Grant"]] == header + assert [launch.env[name] for name in entries["local"]["env_vars"]] == [ + stdio_value + ] + assert "TOKEN" not in launch.env + assert "bearer_token" not in remote + assert "http_headers" not in remote + assert "env" not in entries["local"] + assert dict(os.environ) == process_env_before + assert base_env == {"BASE": "base-secret-value"} + + diagnostic = repr((snapshot, snapshot.redactor, launch, *servers.values())) + for secret in (bearer, header, stdio_value, "base-secret-value"): + assert secret not in " ".join(launch.command) + assert secret not in diagnostic + assert snapshot.redactor.text(secret) == "[REDACTED]" + + def test_descriptor_equality_and_serialization_do_not_use_secret_fields( + self, + ) -> None: + left = CodexMcpHttpServer( + "http://localhost/mcp", + bearer_token="first-secret", + headers={"X-Grant": "one"}, + ) + right = CodexMcpHttpServer( + "http://localhost/mcp", + bearer_token="second-secret", + headers={"X-Grant": "two"}, + ) + stdio = CodexMcpStdioServer("runner", env={"TOKEN": "stdio-secret"}) + assert left == right + assert hash(left) == hash(right) + assert stdio == CodexMcpStdioServer("runner", env={"TOKEN": "other-secret"}) + assert TypeAdapter(CodexMcpHttpServer).dump_python(left) == { + "url": "http://localhost/mcp" + } + assert TypeAdapter(CodexMcpStdioServer).dump_python(stdio) == { + "command": "runner", + "args": (), + "cwd": None, + } + with pytest.raises(TypeError): + asdict(left) + with pytest.raises(TypeError): + asdict(stdio) + launch = freeze_mcp_servers({"remote": left}).launch( + ("codex", "app-server"), None + ) + assert ( + "first-secret" not in TypeAdapter(CodexMcpLaunch).dump_json(launch).decode() + ) + for runtime in (left, stdio, launch, CodexMcpRedactor(("first-secret",))): + with pytest.raises(TypeError) as caught: + pickle.dumps(runtime) + assert "first-secret" not in repr(caught.value) + + def test_windows_allows_env_free_stdio_and_secret_http( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + codex_mcp, "os", SimpleNamespace(name="nt", environ=os.environ) + ) + probe = ( + "import json,os,sys; " + "print(json.dumps({'argv':sys.argv[1:], 'env_names':list(os.environ)}))" + ) + command = ("codex", "app-server") + snapshot = freeze_mcp_servers( + { + "local": CodexMcpStdioServer( + sys.executable, + args=("-I", "-c", probe, "literal argument"), + cwd=str(tmp_path), + ), + "remote": CodexMcpHttpServer( + "http://localhost/mcp", + bearer_token="private-http-platform-grant", + headers={"X-Grant": "private-http-header-grant"}, + ), + } + ) + launch = snapshot.launch(command, None) + entries = launched_servers(launch, command) + assert set(entries) == {"local", "remote"} + observed = run_stdio_entry(entries["local"], launch) + assert observed["argv"] == ["literal argument"] + assert "TOKEN" not in observed["env_names"] + assert not any( + name.startswith("BAND_CODEX_MCP_") for name in observed["env_names"] + ) + + @pytest.mark.skipif( + os.name != "posix", + reason="secret-bearing stdio alias ownership requires POSIX exec", + ) + def test_stdio_env_aliases_deliver_distinct_same_named_grants_to_relative_targets( + self, + ) -> None: + target = str(Path(".") / Path(sys.executable).name) + if not target.startswith("."): + target = os.path.join(".", target) + cwd = str(Path(sys.executable).parent) + probe = ( + "import json,os,sys; " + "print(json.dumps({'token':os.environ['TOKEN'], 'argv':sys.argv[1:], " + "'cwd':os.getcwd(), 'env_names':list(os.environ)}))" + ) + command = ("codex", "app-server") + snapshot = freeze_mcp_servers( + { + "first": CodexMcpStdioServer( + target, + args=("-I", "-c", probe, "literal argument"), + env={"TOKEN": "first-server-grant"}, + cwd=cwd, + ), + "second": CodexMcpStdioServer( + target, + args=("-I", "-c", probe, "literal argument"), + env={"TOKEN": "second-server-grant"}, + cwd=cwd, + ), + } + ) + launch = snapshot.launch(command, None) + entries = launched_servers(launch, command) + assert set(entries["first"]["env_vars"]).isdisjoint( + entries["second"]["env_vars"] + ) + for name, expected in ( + ("first", "first-server-grant"), + ("second", "second-server-grant"), + ): + observed = run_stdio_entry(entries[name], launch) + assert observed["token"] == expected + assert observed["argv"] == ["literal argument"] + assert Path(observed["cwd"]).samefile(cwd) + assert set(observed["env_names"]).isdisjoint(entries[name]["env_vars"]) + + def test_fresh_launches_do_not_reuse_generated_secret_names(self) -> None: + snapshot = freeze_mcp_servers( + {"tools": CodexMcpHttpServer("http://localhost/mcp", bearer_token="grant")} + ) + command = ("codex", "app-server") + first = snapshot.launch(command, None) + second = snapshot.launch(command, None) + first_ref = launched_servers(first, command)["tools"]["bearer_token_env_var"] + second_ref = launched_servers(second, command)["tools"]["bearer_token_env_var"] + assert first_ref != second_ref + assert first.env[first_ref] == second.env[second_ref] == "grant" + + @pytest.mark.parametrize( + "supplied_env", [False, True], ids=["inherited", "overrides"] + ) + def test_secret_aliases_reserve_effective_stdio_child_environment( + self, monkeypatch: pytest.MonkeyPatch, supplied_env: bool + ) -> None: + namespace = UUID("00000000-0000-0000-0000-000000000005") + inherited_name = "BAND_CODEX_MCP_00000000000000000000000000000005_0" + override_name = "BAND_CODEX_MCP_00000000000000000000000000000005_1" + command = ("codex", "app-server") + snapshot = freeze_mcp_servers( + { + "tools": CodexMcpHttpServer( + "http://localhost/mcp", bearer_token="correct-server-grant" + ) + } + ) + with monkeypatch.context() as scoped: + scoped.setattr("band.integrations.codex.mcp.uuid4", lambda: namespace) + scoped.setenv(inherited_name, "inherited-value") + scoped.setenv(override_name, "inherited-override-value") + env = {override_name: "supplied-override-value"} if supplied_env else None + launch = snapshot.launch(command, env) + client = CodexStdioClient( + command=launch.command, env=launch.env, redactor=snapshot.redactor + ) + reference = launched_servers(launch, command)["tools"]["bearer_token_env_var"] + assert client.env is not None + assert client.env[inherited_name] == "inherited-value" + assert client.env[override_name] == ( + "supplied-override-value" if supplied_env else "inherited-override-value" + ) + assert reference not in {inherited_name, override_name} + assert client.env[reference] == "correct-server-grant" + + @pytest.mark.parametrize( + "stage", + [ + "resolution", + "snapshot", + "launch", + "readiness", + "thread/start", + "thread/resume", + ], + ) + def test_owned_startup_stages_have_fixed_safe_text(self, stage: str) -> None: + error = CodexMcpStartupError(stage) + assert error.stage == stage + assert stage in str(error) + assert str(error) == error.args[0] + + def test_untrusted_startup_stage_text_is_not_reflected(self) -> None: + error = CodexMcpStartupError("secret-callback-message") + assert error.stage == "startup" + assert "secret-callback-message" not in str(error) + assert "secret-callback-message" not in repr(error) + + @pytest.mark.parametrize( + "render", + [ + lambda value: value, + lambda value: json.dumps(value, ensure_ascii=True)[1:-1], + lambda value: json.dumps(value, ensure_ascii=False)[1:-1], + lambda value: repr(value)[1:-1], + lambda value: ascii(value)[1:-1], + lambda value: value.encode("unicode_escape").decode(), + lambda value: json.dumps(json.dumps(value))[1:-1], + ], + ids=[ + "raw", + "json-ascii", + "json-unicode", + "repr", + "ascii-repr", + "unicode-escape", + "nested-json", + ], + ) + def test_redaction_covers_raw_and_escaped_secret_forms(self, render: Any) -> None: + secret = 'private-"quote\\slash\nline-é-🦉' + snapshot = freeze_mcp_servers( + {"tools": CodexMcpHttpServer("http://localhost/mcp", bearer_token=secret)} + ) + message = f"before {render(secret)} after" + assert snapshot.redactor.text(message) == "before [REDACTED] after" + assert secret not in repr(snapshot.redactor) + + def test_redaction_keeps_distinct_fields_when_secret_keys_collide(self) -> None: + redactor = CodexMcpRedactor(("first-grant", "second-grant")) + assert redactor.value( + {"first-grant": "first error", "second-grant": "second error"} + ) == { + "[REDACTED]": "first error", + "[REDACTED]#2": "second error", + } + + def test_redaction_preserves_nested_nonsecret_data_and_does_not_mutate_input( + self, + ) -> None: + secret = "private-nested-grant" + redactor = CodexMcpRedactor((secret,)) + payload = { + "message": f"received {secret}", + secret: [secret, {"nested": (secret, 42, True, None)}], + "safe": "public explanation", + } + assert redactor.value(payload) == { + "message": "received [REDACTED]", + "[REDACTED]": ["[REDACTED]", {"nested": ("[REDACTED]", 42, True, None)}], + "safe": "public explanation", + } + assert payload["message"] == f"received {secret}" + assert payload[secret][0] == secret + assert redactor.value(SimpleNamespace(secret=secret)) == "[REDACTED]" + + def test_redaction_handles_overlapping_values_without_rewriting_its_marker( + self, + ) -> None: + redactor = CodexMcpRedactor(("grant", "grant-longer", "REDACTED")) + assert redactor.text("grant-longer grant REDACTED") == ( + "[REDACTED] [REDACTED] [REDACTED]" + ) + + def test_redaction_scope_clears_only_its_own_secret_references(self) -> None: + first = CodexMcpRedactor(("first-grant", "")) + second = CodexMcpRedactor(("second-grant",)) + assert first.text("first-grant second-grant") == "[REDACTED] second-grant" + assert second.text("first-grant second-grant") == "first-grant [REDACTED]" + first.clear() + first.clear() + assert first.text("first-grant") == "first-grant" + assert first.value({"detail": "first-grant"}) == {"detail": "first-grant"} + assert second.text("second-grant") == "[REDACTED]" diff --git a/tests/adapters/codex/test_mcp_owner_lifecycle.py b/tests/adapters/codex/test_mcp_owner_lifecycle.py new file mode 100644 index 000000000..be1ae6f64 --- /dev/null +++ b/tests/adapters/codex/test_mcp_owner_lifecycle.py @@ -0,0 +1,150 @@ +"""Owner installation and invocation lifetime never strand or cancel callers.""" + +from __future__ import annotations + +import asyncio +from contextlib import nullcontext +from typing import Any + +import pytest + +from band.core.exceptions import InputNotAccepted +from band.core.turn_lifecycle import TurnTask +from band.integrations.codex.types import CodexSessionState +from band.runtime.cycle import TurnScope +from band.testing import FakeAgentTools +from tests.adapters.codex.kit import make_platform_message +from tests.adapters.codex.mcpkit import ( + StatusClient, + mcp_adapter, + private_servers, + start, +) +from tests.conftest import eager_tasks, requires_eager_tasks + + +async def invoke(adapter, tools: FakeAgentTools) -> None: + await adapter.on_message( + msg=make_platform_message(room_id="a"), + tools=tools, + history=CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="a", + ) + + +class TestMcpOwnerLifecycle: + @pytest.mark.parametrize( + "eager", [False, pytest.param(True, marks=requires_eager_tasks)] + ) + async def test_failed_registrar_unwinds_uninstalled_worker( + self, tmp_path, eager: bool + ) -> None: + original = RuntimeError("registrar refused") + workers: list[asyncio.Task[None]] = [] + + class RefusingTools(FakeAgentTools): + def register_turn_task(self, owner: TurnTask) -> None: + workers.append(owner.task) + raise original + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], private_servers) + await adapter.on_started("Fixture", "Registration custody") + try: + with eager_tasks() if eager else nullcontext(): + with pytest.raises(RuntimeError) as error: + await invoke(adapter, RefusingTools()) + assert error.value is original + await adapter.cleanup_all() + assert workers[0].done() + assert adapter._turn_tasks == {} + assert adapter._turn_release == {} + assert adapter._room_clients == {} + assert launches == [] + finally: + for worker in workers: + if not worker.done(): + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "eager", [False, pytest.param(True, marks=requires_eager_tasks)] + ) + async def test_terminating_scope_unwinds_cancelled_installation( + self, tmp_path, eager: bool + ) -> None: + scope = TurnScope(control_revision=0) + scope.terminate() + workers: list[asyncio.Task[None]] = [] + + class TerminatingTools(FakeAgentTools): + def register_turn_task(self, owner: TurnTask) -> None: + workers.append(owner.task) + scope.bind(owner) + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], private_servers) + await adapter.on_started("Fixture", "Terminating registration") + try: + result: BaseException | None = None + with eager_tasks() if eager else nullcontext(): + try: + await invoke(adapter, TerminatingTools()) + except (asyncio.CancelledError, asyncio.InvalidStateError) as error: + result = error + await adapter.cleanup_all() + assert isinstance(result, asyncio.CancelledError) + assert workers[0].done() + assert adapter._turn_tasks == {} + assert adapter._turn_release == {} + assert adapter._room_clients == {} + assert launches == [] + finally: + for worker in workers: + if not worker.done(): + worker.cancel() + await asyncio.gather(*workers, return_exceptions=True) + await adapter.cleanup_all() + + @pytest.mark.looptime + async def test_self_cleanup_temporary_owner_does_not_cancel_continuing_caller( + self, + tmp_path, + ) -> None: + continued = asyncio.Event() + + async def resolve(workspace: str) -> dict[str, Any]: + await adapter.on_cleanup("a") + return dict(await private_servers(workspace)) + + adapter, launches = mcp_adapter( + tmp_path, [StatusClient()], resolve, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Temporary owner lifetime") + + async def caller() -> None: + try: + await start(adapter) + except (asyncio.CancelledError, InputNotAccepted): + pass + await asyncio.sleep( + 0.1 + ) # Continue beyond the invocation's cleanup deadline. + continued.set() + + running = asyncio.create_task(caller()) + try: + await asyncio.gather(running, return_exceptions=True) + assert continued.is_set() + assert running.cancelling() == 0 + assert launches == [] + await adapter.cleanup_all() + assert adapter._room_clients == {} + assert "a" not in adapter._workspaces.rooms + finally: + if not running.done(): + running.cancel() + await asyncio.gather(running, return_exceptions=True) + await adapter.cleanup_all() diff --git a/tests/adapters/codex/test_mcp_recovery.py b/tests/adapters/codex/test_mcp_recovery.py new file mode 100644 index 000000000..b58aa6cb6 --- /dev/null +++ b/tests/adapters/codex/test_mcp_recovery.py @@ -0,0 +1,367 @@ +"""Recovery, settlement and deadline regressions at the actual Codex seams.""" + +from __future__ import annotations + +import asyncio +import tomllib +from collections.abc import Mapping +from typing import Any + +import pytest + +from band.core.retention import Residency +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex.mcp import ( + CodexMcpHttpServer, + CodexMcpLaunch, + CodexMcpServer, + CodexMcpStartupError, +) +from band.integrations.codex.types import CodexRequestMethod, CodexSessionState +from band.testing import FakeAgentTools +from tests.adapters.codex.kit import make_platform_message +from tests.adapters.codex.mcpkit import ( + StatusClient, + mcp_adapter, + private_servers, + ready_row, + start, +) +from tests.adapters.codexturns import tool_call_request, turn_completed + + +class TestMcpRecovery: + @pytest.mark.parametrize( + "method", [CodexRequestMethod.THREAD_START, CodexRequestMethod.THREAD_RESUME] + ) + async def test_thread_failure_next_turn_sends_rotated_grant( + self, tmp_path, method + ) -> None: + grant = {"value": "grant-A"} + receipts: list[tuple[str, int]] = [] + + async def endpoint( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + request = await reader.readuntil(b"\r\n\r\n") + headers = dict( + line.split(b": ", 1) + for line in request.split(b"\r\n")[1:] + if b": " in line + ) + supplied = ( + headers.get(b"Authorization", b"").decode().removeprefix("Bearer ") + ) + status = 200 if supplied == grant["value"] else 401 + receipts.append((supplied, status)) + writer.write( + f"HTTP/1.1 {status} Fixture\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".encode() + ) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + + server = await asyncio.start_server(endpoint, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + resolved: list[str] = [] + clients: list[StatusClient] = [] + + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + resolved.append(grant["value"]) + return { + "private": CodexMcpHttpServer( + f"http://127.0.0.1:{port}/mcp", bearer_token=grant["value"] + ) + } + + class EndpointClient(StatusClient): + def __init__(self, token: str) -> None: + super().__init__() + self.token = token + self._events.extend( + ( + tool_call_request( + 1, + "band_send_message", + {"content": "recovered", "mentions": ["user"]}, + ), + turn_completed(), + ) + ) + + async def authorized(self) -> bool: + reader, writer = await asyncio.open_connection("127.0.0.1", port) + try: + writer.write( + f"GET /mcp HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {self.token}\r\n\r\n".encode() + ) + await writer.drain() + status = await reader.readline() + await reader.read() + return status.split()[1] == b"200" + finally: + writer.close() + await writer.wait_closed() + + async def request( + self, sent: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if sent == "mcpServerStatus/list": + self.requests.append((sent, params or {})) + row = ready_row() + if not await self.authorized(): + row.update( + serverInfo=None, + serverCapabilities=None, + runtimeStatus="authenticationRequired", + ) + return {"data": [row]} + if sent == method: + grant["value"] = "grant-B" + if not await self.authorized(): + self.requests.append((sent, params or {})) + raise CodexJsonRpcError( + code=-32603, message="Required MCP connection failed" + ) + return await super().request(sent, params, **kwargs) + + adapter, _ = mcp_adapter(tmp_path, [], resolve, model="fixture") + + def build(_room: object, launch: CodexMcpLaunch | None) -> EndpointClient: + assert launch is not None + overrides = [ + launch.command[index + 1] + for index, item in enumerate(launch.command) + if item == "-c" + ] + settings = tomllib.loads("\n".join(overrides))["mcp_servers"]["private"] + client = EndpointClient(launch.env[settings["bearer_token_env_var"]]) + clients.append(client) + return client + + adapter._build_client = build + tools = FakeAgentTools() + history = ( + CodexSessionState(thread_id="saved") + if method == CodexRequestMethod.THREAD_RESUME + else CodexSessionState() + ) + arguments = { + "msg": make_platform_message(room_id="a"), + "tools": tools, + "history": history, + "participants_msg": None, + "contacts_msg": None, + "is_session_bootstrap": True, + "room_id": "a", + } + try: + await adapter.on_started("Fixture", "Grant recovery fixture") + with pytest.raises(CodexMcpStartupError): + await adapter.on_message(**arguments) + second_failure = None + try: + await adapter.on_message(**arguments) + except CodexMcpStartupError as error: + second_failure = error + assert resolved == ["grant-A", "grant-B"] + assert len(clients) == 2 + assert clients[0].closed + assert second_failure is None + assert ("grant-A", 401) in receipts + assert receipts[-1] == ("grant-B", 200) + finally: + await adapter.cleanup_all() + server.close() + await server.wait_closed() + + @pytest.mark.parametrize("public_turn", [False, True]) + async def test_cleanup_joins_already_cancelling_resolver( + self, tmp_path, public_turn: bool + ) -> None: + entered, settling, release, settled = (asyncio.Event() for _ in range(4)) + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + settling.set() + await release.wait() + settled.set() + raise + + adapter, launches = mcp_adapter( + tmp_path, [StatusClient()], resolve, turn_settle_timeout_s=2 + ) + await adapter.on_started("Fixture", "Cancellation settlement fixture") + if public_turn: + running = asyncio.create_task( + adapter.on_message( + msg=make_platform_message(room_id="a"), + tools=FakeAgentTools(), + history=CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="a", + ) + ) + else: + running = asyncio.create_task(start(adapter)) + cleanup = None + try: + await asyncio.wait_for(entered.wait(), timeout=1) + room = adapter._room_clients["a"] + invocation = room.startup_owner.task + assert invocation is not None + running.cancel() + await asyncio.wait_for(settling.wait(), timeout=1) + cleanup = asyncio.create_task(adapter.on_cleanup("a")) + await asyncio.sleep(0) + await asyncio.sleep(0) + assert invocation.cancelling() == 1 + assert not cleanup.done() + assert not settled.is_set() + assert not (room.startup_owner is None) + with pytest.raises(ValueError): + adapter._workspaces.claim("b", room.workspace) + release.set() + await asyncio.gather(running, return_exceptions=True) + await asyncio.wait_for(cleanup, timeout=1) + assert settled.is_set() + assert launches == [] + assert "a" not in adapter._room_clients + assert "a" not in adapter._workspaces.rooms + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + if cleanup is not None: + await asyncio.gather(cleanup, return_exceptions=True) + await adapter.cleanup_all() + adapter._workspaces.release("b") + + async def test_resume_failure_without_mcp_word_checks_readiness_before_fallback( + self, + tmp_path, + ) -> None: + class LostRequiredServer(StatusClient): + failed = False + + async def request( + self, sent: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if sent == CodexRequestMethod.THREAD_RESUME: + self.failed = True + self.requests.append((sent, params or {})) + raise CodexJsonRpcError( + code=-32603, + message="error resuming thread: connection failed", + data=None, + ) + if sent == "mcpServerStatus/list": + self.requests.append((sent, params or {})) + return { + "data": [ + {**ready_row(), "runtimeStatus": "failed"} + if self.failed + else ready_row() + ] + } + return await super().request(sent, params, **kwargs) + + client = LostRequiredServer() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + try: + await start(adapter) + with pytest.raises(CodexMcpStartupError) as error: + await adapter._ensure_thread( + room=adapter._room_clients["a"], + history=CodexSessionState(thread_id="saved"), + tools=FakeAgentTools(), + is_session_bootstrap=True, + ) + assert error.value.stage == "thread/resume" + assert CodexRequestMethod.THREAD_START not in client.request_methods + assert "a" not in adapter._needs_history_injection + assert adapter._room_clients["a"].retained.snapshot.pending_cleanup + finally: + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "method", [CodexRequestMethod.THREAD_START, CodexRequestMethod.THREAD_RESUME] + ) + async def test_unrelated_error_mentioning_mcp_is_not_startup_failure( + self, tmp_path, method + ) -> None: + class ModelFailure(StatusClient): + async def request( + self, sent: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if sent == method: + self.requests.append((sent, params or {})) + raise CodexJsonRpcError( + code=-32603, + message="MCP was mentioned in an unrelated model validation error", + ) + if sent == "mcpServerStatus/list": + self.requests.append((sent, params or {})) + return {"data": [ready_row()]} + return await super().request(sent, params, **kwargs) + + client = ModelFailure() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + try: + await start(adapter) + kwargs = { + "room": adapter._room_clients["a"], + "history": CodexSessionState(thread_id="saved") + if method == CodexRequestMethod.THREAD_RESUME + else CodexSessionState(), + "tools": FakeAgentTools(), + "is_session_bootstrap": True, + } + if method == CodexRequestMethod.THREAD_RESUME: + assert await adapter._ensure_thread(**kwargs) == "thr-1" + assert "a" in adapter._needs_history_injection + else: + with pytest.raises(CodexJsonRpcError): + await adapter._ensure_thread(**kwargs) + assert not adapter._room_clients["a"].retained.snapshot.pending_cleanup + finally: + await adapter.cleanup_all() + + @pytest.mark.looptime + async def test_consumed_readiness_timeout_cannot_publish_initialized( + self, tmp_path + ) -> None: + class ConsumingClient(StatusClient): + async def request( + self, sent: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if sent == "mcpServerStatus/list": + self.requests.append((sent, params or {})) + try: + await asyncio.sleep(31) + except asyncio.CancelledError: + task = asyncio.current_task() + assert task is not None + task.uncancel() + return {"data": [ready_row()]} + return await super().request(sent, params, **kwargs) + + client = ConsumingClient() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + try: + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "readiness" + assert ( + adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + ) + assert client.closed + assert adapter._room_threads == {} + finally: + await adapter.cleanup_all() diff --git a/tests/adapters/codex/test_mcp_startup.py b/tests/adapters/codex/test_mcp_startup.py new file mode 100644 index 000000000..ec5828da2 --- /dev/null +++ b/tests/adapters/codex/test_mcp_startup.py @@ -0,0 +1,649 @@ +"""Runtime MCP startup ownership and positive readiness regressions.""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Mapping +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from band.adapters.codex import CodexAdapter +from band.core.exceptions import InputNotAccepted +from band.core.retention import Residency +from band.integrations.codex import CodexJsonRpcError +from band.integrations.codex import mcp as codex_mcp +from band.integrations.codex.mcp import ( + CodexMcpHttpServer, + CodexMcpServer, + CodexMcpStartupError, + CodexMcpStdioServer, +) +from band.integrations.codex.types import CodexRequestMethod, CodexSessionState +from band.testing import FakeAgentTools, reported_failures +from tests.adapters.codex.kit import make_platform_message +from tests.adapters.codex.mcpkit import ( + DuplicateServers, + SseServer, + StatusClient, + mcp_adapter, + private_servers, + ready_row, + start, +) +from tests.adapters.codexturns import FakeCodexClient +from tests.conftest import eager_tasks, requires_eager_tasks + + +class TestMcpStartup: + async def test_reuse_skips_resolution_and_retirement_resolves_fresh( + self, + tmp_path: Path, + ) -> None: + calls = [] + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + calls.append(workspace) + return await private_servers(workspace) + + first, second = StatusClient(), StatusClient() + adapter, launches = mcp_adapter(tmp_path, [first, second], resolve) + await start(adapter) + original = adapter._room_clients["a"].client + await start(adapter) + assert adapter._room_clients["a"].client is original + assert len(calls) == 1 + assert len(launches) == 1 + room = adapter._room_clients["a"] + room.retained.recover(generation=room.generation, reason="test recovery") + await start(adapter) + assert first.closed + assert adapter._room_clients["a"].client is second + assert calls == [str(tmp_path / "a"), str(tmp_path / "a")] + await adapter.cleanup_all() + + async def test_held_resolver_does_not_block_another_room( + self, tmp_path: Path + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + if Path(workspace).name == "a": + entered.set() + await release.wait() + return await private_servers(workspace) + + adapter, launches = mcp_adapter( + tmp_path, [StatusClient(), StatusClient()], resolve + ) + waiting = asyncio.create_task(start(adapter, "a")) + try: + await asyncio.wait_for(entered.wait(), timeout=1) + await asyncio.wait_for(start(adapter, "b"), timeout=1) + assert ( + adapter._room_clients["b"].retained.snapshot.state == Residency.RESIDENT + ) + assert ( + adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + ) + assert adapter._room_clients["a"].client is None + assert not waiting.done() + assert len(launches) == 1 + release.set() + await asyncio.wait_for(waiting, timeout=1) + assert ( + adapter._room_clients["a"].retained.snapshot.state == Residency.RESIDENT + ) + assert len(launches) == 2 + finally: + release.set() + await waiting + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "stage", ["resolution", "connect", "initialize", "skills", "model", "readiness"] + ) + @pytest.mark.parametrize("swallow", [False, True]) + async def test_cancelled_startup_cannot_publish( + self, tmp_path: Path, stage: str, swallow: bool + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + + async def checkpoint(current: str) -> None: + if current == stage: + entered.set() + try: + await release.wait() + except asyncio.CancelledError: + if not swallow: + raise + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + await checkpoint("resolution") + return await private_servers(workspace) + + client = StatusClient(checkpoint=checkpoint) + adapter, launches = mcp_adapter( + tmp_path, [client], resolve, skill_roots=[str(tmp_path)] + ) + running = asyncio.create_task(start(adapter)) + try: + await entered.wait() + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + assert ( + adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + ) + assert adapter._room_clients["a"].client is None + assert adapter._room_threads == {} + if stage == "resolution": + assert launches == [] + else: + assert client.closed + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.cleanup_all() + + async def test_cancelled_at_admission_never_resolves(self, tmp_path: Path) -> None: + calls = [] + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + calls.append(workspace) + return {} + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], resolve) + + async def cancelled() -> None: + task = asyncio.current_task() + assert task is not None + task.cancel() + await start(adapter) + + with pytest.raises(asyncio.CancelledError): + await asyncio.create_task(cancelled()) + assert calls == [] + assert launches == [] + await adapter.cleanup_all() + + async def test_resolver_self_cleanup_unwinds_without_self_wait( + self, tmp_path: Path + ) -> None: + adapter: CodexAdapter + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + await adapter.on_cleanup("a") + return await private_servers(workspace) + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], resolve) + + with pytest.raises(InputNotAccepted): + await asyncio.wait_for(start(adapter), timeout=1) + await asyncio.wait_for(adapter.on_cleanup("a"), timeout=1) + assert launches == [] + assert "a" not in adapter._room_clients + + async def test_failed_close_retains_secret_scope_claim_and_primary_error( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + class FailedClient(StatusClient): + refuse_close = True + + async def initialize(self, **kwargs: Any) -> dict[str, Any]: + raise RuntimeError("primary secret-sentinel") + + async def close(self) -> None: + if self.refuse_close: + raise RuntimeError("cleanup secret-sentinel") + await super().close() + + first = FailedClient() + adapter, _ = mcp_adapter(tmp_path, [first, StatusClient()], private_servers) + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "initialize" + assert "secret-sentinel" not in str(error.value) + assert "secret-sentinel" not in caplog.text + room = adapter._room_clients["a"] + assert room.client is first + assert room.mcp_snapshot is not None + assert room.retained.snapshot.pending_cleanup + assert adapter._workspaces.claim("a", room.workspace) == room.workspace + first.refuse_close = False + await start(adapter) + assert first.closed + assert room.retained.snapshot.state == Residency.RESIDENT + assert room.client is not first + await adapter.cleanup_all() + + async def test_status_pagination_requires_all_expected_names( + self, tmp_path: Path + ) -> None: + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + return { + "one": CodexMcpHttpServer("http://localhost/one"), + "two": CodexMcpHttpServer("http://localhost/two"), + } + + client = StatusClient( + [ + {"data": [ready_row("one")], "nextCursor": "next"}, + {"data": [ready_row("two")], "nextCursor": None}, + ] + ) + adapter, _ = mcp_adapter(tmp_path, [client], resolve) + await start(adapter) + assert adapter._room_clients["a"].retained.snapshot.state == Residency.RESIDENT + assert [ + params + for method, params in client.requests + if method == "mcpServerStatus/list" + ] == [ + {"cursor": None, "limit": 100, "detail": "full"}, + {"cursor": "next", "limit": 100, "detail": "full"}, + ] + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "page", + [ + {}, + {"data": None}, + {"data": {}}, + {"data": [None]}, + {"data": [{"name": 7}]}, + {"data": []}, + {"data": [ready_row(), ready_row()]}, + {"data": [ready_row()], "nextCursor": 1}, + {"data": [ready_row()], "nextCursor": ""}, + {"data": [{"name": "private", "authStatus": "bearerToken", "tools": {}}]}, + {"data": [{**ready_row(), "serverInfo": None}]}, + {"data": [{**ready_row(), "serverInfo": {}}]}, + {"data": [{**ready_row(), "serverCapabilities": None}]}, + {"data": [{**ready_row(), "toolsError": "secret-sentinel"}]}, + ], + ) + async def test_bad_catalog_never_marks_room_ready( + self, tmp_path: Path, page: dict[str, Any] + ) -> None: + client = StatusClient([page]) + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "readiness" + assert "secret-sentinel" not in str(error.value) + assert adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + assert adapter._room_threads == {} + assert client.closed + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "status", + [ + "notStarted", + "starting", + "failed", + "authenticationRequired", + "authRequired", + "disabled", + "cancelled", + "garbage", + False, + ], + ) + async def test_negative_runtime_status_is_not_readiness( + self, tmp_path: Path, status: Any + ) -> None: + client = StatusClient([{"data": [{**ready_row(), "runtimeStatus": status}]}]) + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + with pytest.raises(CodexMcpStartupError): + await start(adapter) + assert client.closed + assert adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + await adapter.cleanup_all() + + async def test_repeated_cursor_is_refused(self, tmp_path: Path) -> None: + client = StatusClient( + [ + {"data": [ready_row()], "nextCursor": "same"}, + {"data": [], "nextCursor": "same"}, + ] + ) + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + with pytest.raises(CodexMcpStartupError): + await start(adapter) + assert client.closed + assert ( + len( + [ + method + for method, _ in client.requests + if method == "mcpServerStatus/list" + ] + ) + == 2 + ) + await adapter.cleanup_all() + + @pytest.mark.parametrize( + "method", [CodexRequestMethod.THREAD_START, CodexRequestMethod.THREAD_RESUME] + ) + async def test_required_mcp_failure_never_starts_history_fallback( + self, tmp_path: Path, method: CodexRequestMethod + ) -> None: + class RequiredFailureClient(StatusClient): + failed = False + + async def request( + self, sent: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if sent == method: + self.failed = True + self.requests.append((sent, params or {})) + raise CodexJsonRpcError( + code=-32002, + message="Required MCP startup failed; thread not found secret-sentinel", + data={"token": "secret-sentinel"}, + ) + if sent == "mcpServerStatus/list" and self.failed: + self.requests.append((sent, params or {})) + return {"data": [{**ready_row(), "runtimeStatus": "failed"}]} + return await super().request(sent, params, **kwargs) + + client = RequiredFailureClient() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + await start(adapter) + history = ( + CodexSessionState(thread_id="saved") + if method == CodexRequestMethod.THREAD_RESUME + else CodexSessionState() + ) + with pytest.raises(CodexMcpStartupError) as error: + await adapter._ensure_thread( + room=adapter._room_clients["a"], + history=history, + tools=FakeAgentTools(), + is_session_bootstrap=True, + ) + assert error.value.stage == method + assert "secret-sentinel" not in str(error.value) + assert "a" not in adapter._needs_history_injection + assert adapter._room_threads == {} + if method == CodexRequestMethod.THREAD_RESUME: + assert CodexRequestMethod.THREAD_START not in client.request_methods + await adapter.cleanup_all() + + async def test_callback_exception_metadata_cannot_forge_startup_stage( + self, + tmp_path: Path, + ) -> None: + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + raise CodexMcpStartupError("connect") + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], resolve) + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "resolution" + assert launches == [] + await adapter.cleanup_all() + + async def test_empty_snapshot_has_no_mcp_readiness_requirement( + self, tmp_path: Path + ) -> None: + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + return {} + + client = FakeCodexClient() + adapter, launches = mcp_adapter(tmp_path, [client], resolve) + await start(adapter) + assert adapter._room_clients["a"].retained.snapshot.state == Residency.RESIDENT + assert "mcpServerStatus/list" not in client.request_methods + assert launches[0] is not None + assert "-c" not in launches[0].command + await adapter.cleanup_all() + + async def test_snapshot_is_stable_while_connect_suspends( + self, tmp_path: Path + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + servers: dict[str, CodexMcpServer] = { + "private": CodexMcpHttpServer( + "http://localhost/original", bearer_token="original-sentinel" + ) + } + + async def checkpoint(stage: str) -> None: + if stage == "connect": + entered.set() + await release.wait() + + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + return servers + + adapter, launches = mcp_adapter( + tmp_path, [StatusClient(checkpoint=checkpoint)], resolve + ) + running = asyncio.create_task(start(adapter)) + try: + await entered.wait() + servers.clear() + servers["replacement"] = CodexMcpHttpServer( + "http://localhost/replacement", bearer_token="replacement-sentinel" + ) + release.set() + await running + room = adapter._room_clients["a"] + assert room.mcp_snapshot is not None + assert room.mcp_snapshot.expected_names == frozenset({"private"}) + launch = launches[0] + assert launch is not None and launch.env is not None + assert "original-sentinel" in launch.env.values() + assert "replacement-sentinel" not in launch.env.values() + assert room.retained.snapshot.state == Residency.RESIDENT + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.cleanup_all() + + @pytest.mark.looptime + async def test_readiness_pages_share_one_total_deadline( + self, tmp_path: Path + ) -> None: + class SlowPages(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == "mcpServerStatus/list": + self.requests.append((method, params or {})) + await asyncio.sleep(20) + cursor = params.get("cursor") if params is not None else None + return { + "data": [ready_row()] if cursor is None else [], + "nextCursor": "next" if cursor is None else None, + } + return await super().request(method, params, **kwargs) + + client = SlowPages() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "readiness" + assert adapter._room_clients["a"].retained.snapshot.state != Residency.RESIDENT + assert client.closed + await adapter.cleanup_all() + + async def test_missing_history_still_resumes_via_fallback_with_mcp( + self, + tmp_path: Path, + ) -> None: + class MissingHistory(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == CodexRequestMethod.THREAD_RESUME: + self.requests.append((method, params or {})) + raise CodexJsonRpcError(code=-32002, message="Thread not found") + if method == "mcpServerStatus/list": + self.requests.append((method, params or {})) + return {"data": [ready_row()]} + return await super().request(method, params, **kwargs) + + client = MissingHistory() + adapter, _ = mcp_adapter(tmp_path, [client], private_servers) + await start(adapter) + thread = await adapter._ensure_thread( + room=adapter._room_clients["a"], + history=CodexSessionState(thread_id="missing"), + tools=FakeAgentTools(), + is_session_bootstrap=True, + ) + assert thread == "thr-1" + assert "a" in adapter._needs_history_injection + params = client.params_of(CodexRequestMethod.THREAD_START)[0] + assert "dynamicTools" in params + assert params["cwd"] == str(tmp_path / "a") + await adapter.cleanup_all() + + @pytest.mark.parametrize( + ("servers", "stage"), + [ + ({"private": SseServer()}, "unsupported-transport"), + ( + {"invalid.name": CodexMcpHttpServer("http://localhost/mcp")}, + "invalid-name", + ), + (DuplicateServers(), "duplicate-name"), + ( + {"codex_apps": CodexMcpHttpServer("http://localhost/mcp")}, + "reserved-name", + ), + ], + ) + async def test_invalid_resolver_result_refuses_before_construction( + self, tmp_path: Path, servers: Mapping[str, Any], stage: str + ) -> None: + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + return servers + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], resolve) + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == stage + assert launches == [] + assert adapter._room_clients["a"].client is None + await adapter.cleanup_all() + + async def test_windows_secret_stdio_refuses_before_client_construction( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr( + codex_mcp, "os", SimpleNamespace(name="nt", environ=os.environ) + ) + secret = "private-stdio-platform-grant" + + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + return {"private": CodexMcpStdioServer("runner", env={"TOKEN": secret})} + + adapter, launches = mcp_adapter(tmp_path, [StatusClient()], resolve) + try: + with pytest.raises(CodexMcpStartupError) as error: + await start(adapter) + assert error.value.stage == "unsupported-platform" + assert secret not in str(error.value) + assert launches == [] + assert adapter._room_clients["a"].client is None + finally: + await adapter.cleanup_all() + + @requires_eager_tasks + async def test_cancelled_admission_refuses_before_eager_worker( + self, tmp_path: Path + ) -> None: + resolved = [] + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + resolved.append(workspace) + return {} + + adapter, launches = mcp_adapter(tmp_path, [], resolve) + + async def cancelled_caller() -> None: + task = asyncio.current_task() + assert task is not None + task.cancel() + try: + await asyncio.sleep(0) + except asyncio.CancelledError: + pass + with pytest.raises(asyncio.CancelledError): + await adapter.on_message( + msg=make_platform_message(room_id="a"), + tools=FakeAgentTools(), + history=CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="a", + ) + + with eager_tasks(): + caller = asyncio.create_task(cancelled_caller()) + await caller + assert resolved == [] + assert launches == [] + assert adapter._room_clients == {} + assert adapter._turn_tasks == {} + + async def test_callback_cancellation_cannot_leak_its_reason( + self, tmp_path: Path + ) -> None: + async def resolve(_workspace: str) -> Mapping[str, CodexMcpServer]: + raise asyncio.CancelledError("private-callback-sentinel") + + adapter, launches = mcp_adapter(tmp_path, [], resolve) + with pytest.raises(asyncio.CancelledError) as error: + await start(adapter) + assert error.value.args == () + assert launches == [] + await adapter.cleanup_all() + + async def test_rpc_failure_forwarded_to_band_is_redacted( + self, tmp_path: Path + ) -> None: + class FailingTurnClient(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method == CodexRequestMethod.TURN_START: + raise CodexJsonRpcError( + code=-32001, + message="Provider rejected secret-sentinel", + data={"nested": ["secret-sentinel"]}, + ) + return await super().request(method, params, **kwargs) + + adapter, _ = mcp_adapter(tmp_path, [FailingTurnClient()], private_servers) + tools = FakeAgentTools() + await adapter.on_started("Fixture", "RPC diagnostic fixture") + with pytest.raises(CodexJsonRpcError) as error: + await adapter.on_message( + msg=make_platform_message(room_id="a"), + tools=tools, + history=CodexSessionState(), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id="a", + ) + assert "secret-sentinel" not in str(error.value) + assert error.value.data == {"nested": ["[REDACTED]"]} + failures = reported_failures(tools) + assert len(failures) == 1 + assert failures[0]["provider"] == "codex" + assert "Provider rejected [REDACTED]" in failures[0]["message"] + assert "secret-sentinel" not in str(failures) + await adapter.cleanup_all() diff --git a/tests/adapters/codexturns.py b/tests/adapters/codexturns.py index 8974badda..baa370adc 100644 --- a/tests/adapters/codexturns.py +++ b/tests/adapters/codexturns.py @@ -257,7 +257,7 @@ def patch_codex_client( to the next process started after a loss; the last one keeps serving.""" processes = deque([client, *replacements]) - def _build(_workspace: str) -> FakeCodexClient: + def _build(_room: object, _launch: object) -> FakeCodexClient: return processes.popleft() if len(processes) > 1 else processes[0] adapter._build_client = _build # type: ignore[method-assign] diff --git a/tests/adapters/opencode/helpers.py b/tests/adapters/opencode/helpers.py index f6cbdab4a..0fe1ecc21 100644 --- a/tests/adapters/opencode/helpers.py +++ b/tests/adapters/opencode/helpers.py @@ -443,12 +443,12 @@ async def time_passes(seconds: float) -> None: async def wait_for(predicate: Callable[[], bool], timeout_s: float = 1.0) -> None: + """Observe readiness before timing out when a delayed poll resumes.""" deadline = asyncio.get_running_loop().time() + timeout_s - while asyncio.get_running_loop().time() < deadline: - if predicate(): - return + while not predicate(): + if asyncio.get_running_loop().time() >= deadline: + pytest.fail("Timed out waiting for condition") await asyncio.sleep(0.01) - pytest.fail("Timed out waiting for condition") async def run_single_turn( diff --git a/tests/adapters/opencode/test_waiting.py b/tests/adapters/opencode/test_waiting.py new file mode 100644 index 000000000..24106528f --- /dev/null +++ b/tests/adapters/opencode/test_waiting.py @@ -0,0 +1,44 @@ +"""OpenCode synchronization observes readiness after a delayed poll wakes.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from tests.adapters.opencode.helpers import wait_for + + +class TestWaitFor: + @pytest.mark.parametrize("wake_at", [1.0, 1.5]) + @pytest.mark.parametrize("completed", [False, True]) + async def test_condition_is_observed_before_expired_deadline( + self, wake_at: float, completed: bool + ) -> None: + now = 0.0 + ready = False + loop = SimpleNamespace(time=lambda: now) + + async def resume_poll(_interval: float) -> None: + nonlocal now, ready + assert now == 0.0, "A late poll must not start another wait" + now = wake_at + ready = completed + + with ( + patch( + "tests.adapters.opencode.helpers.asyncio.get_running_loop", + return_value=loop, + ), + patch( + "tests.adapters.opencode.helpers.asyncio.sleep", side_effect=resume_poll + ), + ): + if completed: + await wait_for(lambda: ready, timeout_s=1.0) + else: + with pytest.raises(pytest.fail.Exception): + await wait_for(lambda: ready, timeout_s=1.0) + assert now == wake_at + assert ready is completed diff --git a/tests/core/test_turn_lifecycle.py b/tests/core/test_turn_lifecycle.py index 50d274235..30cfa0b4e 100644 --- a/tests/core/test_turn_lifecycle.py +++ b/tests/core/test_turn_lifecycle.py @@ -232,3 +232,99 @@ async def swallow_first_cancel() -> None: owner.retry(deadline=terminal_deadline()) assert await owner.drain() assert cancels == 2 + + @pytest.mark.looptime + async def test_new_owner_terminates_task_with_historical_cancellation(self) -> None: + started, handled, release = (asyncio.Event() for _ in range(3)) + cancellations = 0 + + async def work() -> None: + nonlocal cancellations + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellations += 1 + handled.set() + try: + await release.wait() + except asyncio.CancelledError: + cancellations += 1 + raise + + task = asyncio.create_task(work()) + owner = None + try: + await started.wait() + task.cancel() + await handled.wait() + assert task.cancelling() == 1 + owner = TurnTask(task) + owner.terminate() + await owner.drain() + assert cancellations == 2 + assert task.cancelled() + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + @pytest.mark.looptime + async def test_closed_owner_cannot_deliver_pending_or_new_expiry(self) -> None: + release = asyncio.Event() + task = asyncio.create_task(release.wait()) + owner = TurnTask(task) + try: + owner.terminate( + deadline=asyncio.get_running_loop().time() + 0.02, cooperative=True + ) + owner.close() + owner.close() + await asyncio.sleep(0.04) # Cross the scheduled cleanup expiry. + assert task.cancelling() == 0 + assert not task.done() + owner.terminate() + owner.retry(deadline=asyncio.get_running_loop().time() + 0.02) + await asyncio.sleep(0.04) + assert task.cancelling() == 0 + release.set() + await task + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + @pytest.mark.looptime + async def test_external_cancel_during_cooperative_grace_is_adopted(self) -> None: + entered, settling, release = (asyncio.Event() for _ in range(3)) + cancellations = 0 + + async def work() -> None: + nonlocal cancellations + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellations += 1 + settling.set() + try: + await release.wait() + except asyncio.CancelledError: + cancellations += 1 + raise + + task = asyncio.create_task(work()) + owner = TurnTask(task) + try: + await entered.wait() + owner.terminate( + deadline=asyncio.get_running_loop().time() + 0.02, cooperative=True + ) + task.cancel() + await settling.wait() + assert not await owner.drain() + assert cancellations == 1 + assert not task.done() + release.set() + await task + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) diff --git a/tests/integrations/codex/test_cleanup.py b/tests/integrations/codex/test_cleanup.py index ab9d0669c..d3b7b396f 100644 --- a/tests/integrations/codex/test_cleanup.py +++ b/tests/integrations/codex/test_cleanup.py @@ -136,6 +136,24 @@ async def test_overlapping_close_waits_for_the_same_child_to_exit( assert process.returncode is not None await first + async def test_forced_close_waits_for_an_exit_reported_late( + self, + child: tuple[CodexStdioClient, asyncio.subprocess.Process], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A forced kill always ends the leader, but a loaded event loop (as on + Windows CI) can report its exit seconds later. Close must keep waiting + for that report instead of failing the cleanup it already forced.""" + client, process = child + + async def kill_reported_late(target: asyncio.subprocess.Process) -> None: + asyncio.get_running_loop().call_later(1.5, target.kill) + + monkeypatch.setattr(stdio_client, "kill_process_tree", kill_reported_late) + await asyncio.wait_for(client.close(), timeout=CLOSE_BOUND_S) + + assert process.returncode is not None + async def test_reconnect_reaps_the_previous_child_before_spawning( self, child: tuple[CodexStdioClient, asyncio.subprocess.Process] ) -> None: diff --git a/tests/integrations/codex/test_mcp_redaction.py b/tests/integrations/codex/test_mcp_redaction.py new file mode 100644 index 000000000..6c90172b4 --- /dev/null +++ b/tests/integrations/codex/test_mcp_redaction.py @@ -0,0 +1,252 @@ +"""Secret-safe Codex transport diagnostics and forwarded RPC failures.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from types import SimpleNamespace +from typing import Any + +import pytest + +from band.integrations.codex import CodexJsonRpcError, CodexStdioClient, stdio_client +from band.integrations.codex.mcp import CodexMcpHttpServer, freeze_mcp_servers +from band.integrations.codex.rpc_base import BaseJsonRpcClient + +SECRET = 'sentinel-"quoted"-\\escaped-credential' +_SYNTHETIC_PGID = 123_456_789 + + +@pytest.fixture +def exited_process_group(monkeypatch: pytest.MonkeyPatch) -> int: + def group_ended(pgid: int, _signum: int) -> None: + if pgid != _SYNTHETIC_PGID: + raise AssertionError("redaction fixture cannot settle another group") + raise ProcessLookupError("the synthetic owned group has exited") + + monkeypatch.setattr(stdio_client.os, "killpg", group_ended, raising=False) + return _SYNTHETIC_PGID + + +def redactor() -> Any: + return freeze_mcp_servers( + {"private": CodexMcpHttpServer("http://localhost/mcp", bearer_token=SECRET)} + ).redactor + + +class TestMcpRedaction: + async def test_stderr_and_malformed_rpc_log_metadata_without_payloads( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + caplog.set_level(logging.DEBUG, logger="band.integrations.codex.stdio_client") + caplog.set_level(logging.DEBUG, logger="band.integrations.codex.rpc_base") + client = CodexStdioClient(redactor=redactor()) + reader = asyncio.StreamReader() + reader.feed_data(f"connection failed {SECRET}\n".encode()) + reader.feed_eof() + client._proc = SimpleNamespace(stderr=reader) + await client._read_stderr_loop() + await client._dispatch_rpc_message("malformed " + SECRET) + await client._dispatch_rpc_message(json.dumps([SECRET])) + await client._dispatch_rpc_message(json.dumps({"unexpected": SECRET})) + rendered = caplog.text + assert SECRET not in rendered + assert json.dumps(SECRET)[1:-1] not in rendered + assert "connection failed" not in rendered + assert "malformed" not in rendered + assert "unexpected" not in rendered + assert "type=str" in rendered + assert "type=list" in rendered + assert "type=dict" in rendered + for payload in ( + "malformed " + SECRET, + json.dumps([SECRET]), + json.dumps({"unexpected": SECRET}), + ): + assert f"length={len(payload)}" in rendered + + @pytest.mark.parametrize("request_id", [None, 7]) + @pytest.mark.parametrize( + "method", + ["item/tool/call", "FirstServerGrant2026", {"token": SECRET}, 7], + ) + async def test_full_rpc_queue_does_not_log_method_or_parameter_contents( + self, + caplog: pytest.LogCaptureFixture, + request_id: int | None, + method: Any, + ) -> None: + caplog.set_level(logging.WARNING, logger="band.integrations.codex.rpc_base") + private = freeze_mcp_servers( + { + "private": CodexMcpHttpServer( + "http://localhost/mcp", bearer_token="FirstServerGrant2026" + ) + } + ) + client = CodexStdioClient(redactor=private.redactor) + client._events = asyncio.Queue(maxsize=1) + await client._dispatch_rpc_message(json.dumps({"method": "occupied"})) + await client._dispatch_rpc_message( + json.dumps( + {"id": request_id, "method": method, "params": {"token": SECRET}} + ) + ) + assert [record.levelno for record in caplog.records] == [logging.WARNING] + rendered = caplog.text + assert SECRET not in rendered + assert "FirstServerGrant2026" not in rendered + assert "token" not in rendered + if isinstance(method, str): + assert method not in rendered + + async def test_rpc_error_message_and_nested_data_are_redacted( + self, + ) -> None: + class Peer(BaseJsonRpcClient): + async def _send_json(self, payload: dict[str, Any]) -> None: + await self._dispatch_rpc_message( + json.dumps( + { + "id": payload["id"], + "error": { + "code": -32000, + "message": "server denied " + SECRET, + "data": { + SECRET: ["Bearer " + SECRET, {"cause": SECRET}] + }, + }, + } + ) + ) + + peer = Peer(redactor=redactor()) + with pytest.raises(CodexJsonRpcError) as error: + await peer.request("thread/start", {}) + assert error.value.code == -32000 + assert "server denied" in str(error.value) + assert SECRET not in str(error.value) + assert SECRET not in repr(error.value.data) + assert json.dumps(SECRET)[1:-1] not in json.dumps(error.value.data) + + async def test_success_results_and_tool_arguments_are_not_redacted( + self, + ) -> None: + class Peer(BaseJsonRpcClient): + async def _send_json(self, payload: dict[str, Any]) -> None: + assert payload["params"] == {"arguments": {"token": SECRET}} + await self._dispatch_rpc_message( + json.dumps({"id": payload["id"], "result": {"content": [SECRET]}}) + ) + + peer = Peer(redactor=redactor()) + result = await peer.request("tools/call", {"arguments": {"token": SECRET}}) + assert result == {"content": [SECRET]} + await peer._dispatch_rpc_message( + json.dumps( + { + "id": "server-request", + "method": "item/tool/call", + "params": {"arguments": {"token": SECRET}}, + } + ) + ) + event = await peer.recv_event(timeout_s=1) + assert event.params == {"arguments": {"token": SECRET}} + assert event.raw["params"] == {"arguments": {"token": SECRET}} + + async def test_rpc_error_delivered_before_close_is_safe_when_consumed_after( + self, + exited_process_group: int, + ) -> None: + scope = redactor() + client = CodexStdioClient(redactor=scope) + sent = asyncio.Event() + consume = asyncio.Event() + outgoing: list[dict[str, Any]] = [] + + def write_request(data: bytes) -> None: + outgoing.append(json.loads(data)) + sent.set() + + async def drain() -> None: + await consume.wait() + + async def wait() -> None: + return None + + client._proc = SimpleNamespace( + pid=exited_process_group, + stdin=SimpleNamespace(write=write_request, drain=drain, close=lambda: None), + wait=wait, + ) + request = asyncio.create_task(client.request("thread/start", {})) + try: + await asyncio.wait_for(sent.wait(), timeout=1) + await client._dispatch_rpc_message( + json.dumps( + { + "id": outgoing[0]["id"], + "error": { + "code": -32000, + "message": "server denied " + SECRET, + "data": {SECRET: ["Bearer " + SECRET, {"cause": SECRET}]}, + }, + } + ) + ) + assert not request.done() + await client.close() + assert scope.text(SECRET) == SECRET + assert not request.done() + consume.set() + with pytest.raises(CodexJsonRpcError) as error: + await asyncio.wait_for(request, timeout=1) + assert error.value.code == -32000 + assert error.value.message == "server denied [REDACTED]" + assert error.value.data == { + "[REDACTED]": ["Bearer [REDACTED]", {"cause": "[REDACTED]"}] + } + assert SECRET not in str(error.value) + assert json.dumps(SECRET)[1:-1] not in json.dumps(error.value.data) + finally: + consume.set() + await asyncio.gather(request, return_exceptions=True) + await client.close() + + async def test_secret_scope_is_retained_until_physical_teardown( + self, + exited_process_group: int, + ) -> None: + scope = redactor() + client = CodexStdioClient(env={"GENERATED": SECRET}, redactor=scope) + exited = asyncio.Event() + + class Process: + pid = exited_process_group + stdin = None + + async def wait(self) -> None: + await exited.wait() + + client._proc = Process() + closing = asyncio.create_task(client.close()) + try: + while client._close_task is None: + await asyncio.sleep(0) + assert scope.text(SECRET) != SECRET + assert client.env is not None and client.env["GENERATED"] == SECRET + closing.cancel() + with pytest.raises(asyncio.CancelledError): + await closing + assert scope.text(SECRET) != SECRET + exited.set() + await client.close() + assert client.env is None + assert scope.text(SECRET) == SECRET + finally: + exited.set() + await asyncio.gather(closing, return_exceptions=True) + await client.close() diff --git a/tests/integrations/codex/test_process_group_cleanup.py b/tests/integrations/codex/test_process_group_cleanup.py new file mode 100644 index 000000000..61aebe4c3 --- /dev/null +++ b/tests/integrations/codex/test_process_group_cleanup.py @@ -0,0 +1,510 @@ +"""Real local process groups must settle before Codex workspace custody ends. + +Each scenario runs in an isolated Python worker. On Linux that worker is a +subreaper: an orphan zombie still makes killpg(pgid, 0) succeed, and container +PID 1 is not necessarily a reaper. Only the recorded descendant is waitpid'd; +pytest's process-wide child watcher and subreaper state are never changed. +""" + +from __future__ import annotations + +import asyncio +import ctypes +import errno +import logging +import os +import signal +import sys +import textwrap +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager, suppress +from pathlib import Path +from unittest.mock import patch + +import pytest +import pytest_asyncio + +from band.adapters.codex import CodexAdapter, CodexAdapterConfig +from band.core.exceptions import CleanupIncompleteError +from band.integrations.codex import CodexStdioClient +from tests.paths import REPO_ROOT + +logger = logging.getLogger(__name__) + +POLL_S = 0.02 +QUIET_S = 0.2 +READINESS_BOUND_S = 5 +CLOSE_BOUND_S = 8 +CLEANUP_BOUND_S = 4 +WORKER_BOUND_S = 20 +FAILSAFE_LIFETIME_S = 45 +SUBREAPER_UNAVAILABLE_EXIT = 77 +PR_SET_CHILD_SUBREAPER = 36 + +LEADER_READY = "leader-ready" +DESCENDANT_READY = "descendant-ready" +DESCENDANT_PID = "descendant-pid" +LEADER_EOF = "leader-eof" +HEARTBEAT = "heartbeat" +TERM_OBSERVED = "term-observed" +FAILSAFE_EXPIRED = "failsafe-expired" +ROOM_ID = "owned-room" +OTHER_ROOM_ID = "other-room" +NORMAL_CLOSE = "normal-close" +IGNORE_TERM = "ignore-term" +UNKNOWN_EMPTY_PROBE = "unknown-empty-probe" +TRANSIENT_EMPTY_PROBE = "transient-empty-probe" +WORKER_MODULE = "tests.integrations.codex.test_process_group_cleanup" + +pytestmark = pytest.mark.skipif( + os.name != "posix" or not hasattr(os, "fork") or not hasattr(os, "killpg"), + reason="requires POSIX sessions, fork, and process-group signals", +) + +LEADER = textwrap.dedent( + f""" + import os + import signal + import sys + import time + from pathlib import Path + + def publish(name, value): + temporary = Path(name + ".tmp") + temporary.write_text(value, encoding="utf-8") + temporary.replace(name) + + def expire(_signal, _frame): + publish({FAILSAFE_EXPIRED!r}, str(os.getpid())) + os._exit(99) + + signal.signal(signal.SIGALRM, expire) + signal.alarm({FAILSAFE_LIFETIME_S}) + descendant = os.fork() + if descendant == 0: + # Inherited pipes would keep process.wait() pending after normal EOF, + # accidentally exercising the leader-timeout path instead. + null = os.open(os.devnull, os.O_RDWR) + for descriptor in (0, 1, 2): + os.dup2(null, descriptor) + if null > 2: + os.close(null) + signal.alarm({FAILSAFE_LIFETIME_S}) + ignore_term = sys.argv[1] == {IGNORE_TERM!r} + + def on_term(_signal, _frame): + publish({TERM_OBSERVED!r}, "ignored" if ignore_term else "accepted") + if not ignore_term: + os._exit(0) + + signal.signal(signal.SIGTERM, on_term) + publish( + {DESCENDANT_READY!r}, + f"{{os.getpid()}} {{os.getpgrp()}} {{os.getsid(0)}}", + ) + while True: + with open({HEARTBEAT!r}, "a", encoding="utf-8") as heartbeat: + heartbeat.write(".") + time.sleep({POLL_S}) + + publish({DESCENDANT_PID!r}, str(descendant)) + publish( + {LEADER_READY!r}, + f"{{os.getpid()}} {{os.getpgrp()}} {{os.getsid(0)}}", + ) + for _line in sys.stdin: + pass + publish({LEADER_EOF!r}, "normal") + """ +) + +ProcessGroup = tuple[ + CodexStdioClient, + asyncio.subprocess.Process, + asyncio.Task[int | None], +] +ScenarioRunner = Callable[[str], Awaitable[None]] +GroupSignal = Callable[[int, int], None] + + +def leader_command(*, ignore_term: bool = False) -> tuple[str, ...]: + mode = IGNORE_TERM if ignore_term else NORMAL_CLOSE + return (sys.executable, "-u", "-c", LEADER, mode) + + +def group_is_empty(pgid: int, killpg: GroupSignal) -> bool: + try: + killpg(pgid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + return False + + +async def wait_until(observed: Callable[[], bool]) -> None: + async with asyncio.timeout(READINESS_BOUND_S): + while not observed(): + await asyncio.sleep(POLL_S) + + +def poll_descendant_exit(descendant_pid: int) -> int | None: + """Poll only the recorded adopted child without blocking the worker loop.""" + try: + reaped, status = os.waitpid(descendant_pid, os.WNOHANG) + except ChildProcessError: + return None + return status if reaped else None + + +async def reap_descendant( + descendant_pid: int, + pgid: int, + killpg: GroupSignal, +) -> int | None: + if sys.platform != "linux": + while not group_is_empty(pgid, killpg): + await asyncio.sleep(POLL_S) + return None + while True: + status = poll_descendant_exit(descendant_pid) + if status is not None: + return status + await asyncio.sleep(POLL_S) + + +async def clean_owned_group( + client: CodexStdioClient, + process: asyncio.subprocess.Process, + descendant: asyncio.Task[int | None] | None, + killpg: GroupSignal, +) -> None: + # A live recorded descendant pins this original group identity. Once both + # recorded processes are reaped, never signal a potentially recycled PGID. + descendant_settled = ( + descendant is not None + and descendant.done() + and not descendant.cancelled() + and descendant.exception() is None + ) + still_owned = process.returncode is None or not descendant_settled + if still_owned and not group_is_empty(process.pid, killpg): + with suppress(ProcessLookupError): + killpg(process.pid, signal.SIGKILL) + await process.wait() + if descendant is not None: + await descendant + assert group_is_empty(process.pid, killpg), "fixture's original group did not empty" + await client.close() + + +@asynccontextmanager +async def owned_group( + workspace: Path, + *, + ignore_term: bool = False, + client: CodexStdioClient | None = None, +) -> AsyncIterator[ProcessGroup]: + client = client or CodexStdioClient( + command=leader_command(ignore_term=ignore_term), cwd=str(workspace) + ) + native_killpg = os.killpg + descendant: asyncio.Task[int | None] | None = None + await client.connect() + process = client._proc + assert process is not None + try: + await wait_until( + lambda: ( + (workspace / LEADER_READY).exists() + and (workspace / DESCENDANT_READY).exists() + and (workspace / HEARTBEAT).exists() + ) + ) + leader_identity = tuple( + int(value) for value in (workspace / LEADER_READY).read_text().split() + ) + descendant_pid, descendant_group, descendant_session = ( + int(value) for value in (workspace / DESCENDANT_READY).read_text().split() + ) + recorded_pid = int((workspace / DESCENDANT_PID).read_text()) + descendant = asyncio.create_task( + reap_descendant(recorded_pid, process.pid, native_killpg) + ) + assert leader_identity == (process.pid, process.pid, process.pid) + assert descendant_pid == recorded_pid + assert descendant_pid != process.pid + assert (descendant_group, descendant_session) == (process.pid, process.pid) + initial_heartbeat = (workspace / HEARTBEAT).stat().st_size + await wait_until( + lambda: (workspace / HEARTBEAT).stat().st_size > initial_heartbeat + ) + assert process.returncode is None + assert not group_is_empty(process.pid, native_killpg) + yield client, process, descendant + finally: + primary_error = sys.exception() + try: + if descendant is None and (workspace / DESCENDANT_PID).exists(): + recorded_pid = int((workspace / DESCENDANT_PID).read_text()) + descendant = asyncio.create_task( + reap_descendant(recorded_pid, process.pid, native_killpg) + ) + async with asyncio.timeout(CLEANUP_BOUND_S): + await clean_owned_group(client, process, descendant, native_killpg) + except BaseException: + # Teardown must not turn the behavioral regression into a different + # cleanup exception. With no primary failure, cleanup still fails loudly. + if primary_error is None: + raise + logger.exception("Local process-group fixture cleanup failed") + + +async def assert_settled( + workspace: Path, + group: ProcessGroup, + killpg: GroupSignal, +) -> int | None: + _client, process, descendant = group + assert process.returncode == 0, "leader did not exit normally on stdin EOF" + assert (workspace / LEADER_EOF).read_text() == "normal" + assert group_is_empty(process.pid, killpg), ( + "normal leader exit left its originally owned group alive" + ) + status = await asyncio.wait_for(asyncio.shield(descendant), CLEANUP_BOUND_S) + assert (workspace / TERM_OBSERVED).exists(), "owned descendant never received TERM" + assert not (workspace / FAILSAFE_EXPIRED).exists(), ( + "failsafe, not close(), stopped work" + ) + stopped_at = (workspace / HEARTBEAT).stat().st_size + # Quiescence is itself the observation: span multiple scheduled heartbeats. + await asyncio.sleep(QUIET_S) + assert (workspace / HEARTBEAT).stat().st_size == stopped_at + return status + + +async def normal_close_scenario(workspace: Path) -> None: + async with owned_group(workspace) as group: + client, _process, _descendant = group + await asyncio.wait_for(client.close(), CLOSE_BOUND_S) + status = await assert_settled(workspace, group, os.killpg) + assert (workspace / TERM_OBSERVED).read_text() == "accepted" + if status is not None: + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + + +async def ignoring_term_scenario(workspace: Path) -> None: + async with owned_group(workspace, ignore_term=True) as group: + client, _process, _descendant = group + await asyncio.wait_for(client.close(), CLOSE_BOUND_S) + status = await assert_settled(workspace, group, os.killpg) + assert (workspace / TERM_OBSERVED).read_text() == "ignored" + if status is not None: + assert os.WIFSIGNALED(status) and os.WTERMSIG(status) == signal.SIGKILL + + +async def transient_empty_probe_scenario(workspace: Path) -> None: + native_killpg = os.killpg + async with owned_group(workspace) as group: + client, process, _descendant = group + uncertain_probes = [] + + def transient_probe(pgid: int, signum: int) -> None: + try: + native_killpg(pgid, signum) + except ProcessLookupError: + if pgid == process.pid and signum == 0 and not uncertain_probes: + uncertain_probes.append(pgid) + raise PermissionError( + errno.EPERM, "transient empty-group observation" + ) + raise + + with patch.object(os, "killpg", transient_probe): + await asyncio.wait_for(client.close(), CLOSE_BOUND_S) + assert uncertain_probes, "the transient unknown observation was not exercised" + await assert_settled(workspace, group, native_killpg) + + +async def unknown_empty_probe_scenario(workspace: Path) -> None: + adapter = CodexAdapter( + config=CodexAdapterConfig( + codex_command=leader_command(), + workspace_for_room=lambda _room_id: str(workspace), + client_close_timeout_s=CLOSE_BOUND_S, + ) + ) + room = adapter._room_client(ROOM_ID) + adapter._active_room.set(ROOM_ID) + client = adapter._build_client(room, None) + room.client = client + + async def acquire(_generation: int, _resume: bool) -> None: + await client.connect() + + room.retained._acquire = acquire + async with room.retained.use(): + pass + native_killpg = os.killpg + uncertain_probes: list[int] = [] + async with owned_group(workspace, client=client) as group: + _client, process, _descendant = group + original_pgid = process.pid + + def uncertain_empty_probe(pgid: int, signum: int) -> None: + try: + native_killpg(pgid, signum) + except ProcessLookupError: + if pgid != original_pgid or signum != 0: + raise + # Only the observation of a genuinely empty owned group fails. + # TERM/KILL delivery and observations of live groups stay real. + uncertain_probes.append(pgid) + raise PermissionError( + errno.EPERM, "group-empty observation unavailable" + ) + + try: + with patch.object(os, "killpg", uncertain_empty_probe): + with pytest.raises(CleanupIncompleteError): + await asyncio.wait_for(adapter.on_cleanup(ROOM_ID), CLOSE_BOUND_S) + assert uncertain_probes, ( + "cleanup never tried to confirm its empty group" + ) + assert adapter._room_clients[ROOM_ID] is room + assert room.client is client + assert adapter._workspaces.workspace(ROOM_ID) == str(workspace) + assert room.retained.closing + with pytest.raises(ValueError, match="assigned .* to both"): + adapter._workspaces.claim(OTHER_ROOM_ID, room.workspace) + await assert_settled(workspace, group, native_killpg) + + await asyncio.wait_for(adapter.on_cleanup(ROOM_ID), CLOSE_BOUND_S) + assert room.client is None + assert ROOM_ID not in adapter._room_clients + assert ROOM_ID not in adapter._workspaces.rooms + assert ( + adapter._workspaces.claim(OTHER_ROOM_ID, room.workspace) + == room.workspace + ) + await assert_settled(workspace, group, native_killpg) + finally: + primary_error = sys.exception() + try: + await asyncio.wait_for(adapter.on_cleanup(ROOM_ID), CLOSE_BOUND_S) + except BaseException: + if primary_error is None: + raise + logger.exception("Local adapter cleanup failed") + finally: + adapter._workspaces.release(OTHER_ROOM_ID) + + +def enable_worker_subreaper() -> None: + if sys.platform != "linux": + return + libc = ctypes.CDLL(None, use_errno=True) + prctl = getattr(libc, "prctl", None) + if prctl is None: + logger.error("Linux test worker has no prctl child-subreaper support") + raise SystemExit(SUBREAPER_UNAVAILABLE_EXIT) + prctl.argtypes = [ + ctypes.c_int, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_ulong, + ] + prctl.restype = ctypes.c_int + if prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0: + error = ctypes.get_errno() + logger.error( + "Linux test worker cannot enable child subreaping: %s", os.strerror(error) + ) + raise SystemExit(SUBREAPER_UNAVAILABLE_EXIT) + + +async def run_worker(scenario: str, workspace: Path) -> None: + task = asyncio.current_task() + assert task is not None + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGTERM, task.cancel) + try: + scenarios = { + NORMAL_CLOSE: normal_close_scenario, + IGNORE_TERM: ignoring_term_scenario, + UNKNOWN_EMPTY_PROBE: unknown_empty_probe_scenario, + TRANSIENT_EMPTY_PROBE: transient_empty_probe_scenario, + } + try: + exercise = scenarios[scenario] + except KeyError as error: + raise ValueError(f"unknown process-group scenario: {scenario}") from error + await exercise(workspace) + finally: + loop.remove_signal_handler(signal.SIGTERM) + + +@pytest_asyncio.fixture(loop_scope="function") +async def scenario_runner(tmp_path: Path) -> AsyncIterator[ScenarioRunner]: + workers: list[asyncio.subprocess.Process] = [] + + async def run(scenario: str) -> None: + process = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + WORKER_MODULE, + scenario, + str(tmp_path), + cwd=str(REPO_ROOT), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + workers.append(process) + stdout, stderr = await asyncio.wait_for(process.communicate(), WORKER_BOUND_S) + diagnostic = (stdout + stderr).decode("utf-8", errors="replace") + if process.returncode == SUBREAPER_UNAVAILABLE_EXIT: + pytest.skip(diagnostic) + assert process.returncode == 0, diagnostic + + try: + yield run + finally: + for process in workers: + if process.returncode is None: + with suppress(ProcessLookupError): + process.terminate() + try: + await asyncio.wait_for(process.wait(), CLEANUP_BOUND_S + 1) + except TimeoutError: + with suppress(ProcessLookupError): + process.kill() + await asyncio.wait_for(process.wait(), CLEANUP_BOUND_S) + + +class TestProcessGroupCleanup: + async def test_normal_close_settles_descendant_after_leader_stdin_eof( + self, scenario_runner: ScenarioRunner + ) -> None: + await scenario_runner(NORMAL_CLOSE) + + async def test_term_ignoring_descendant_is_killed_after_normal_leader_exit( + self, scenario_runner: ScenarioRunner + ) -> None: + await scenario_runner(IGNORE_TERM) + + async def test_unknown_group_empty_probe_retains_real_adapter_client_and_claim_until_retry( + self, scenario_runner: ScenarioRunner + ) -> None: + await scenario_runner(UNKNOWN_EMPTY_PROBE) + + async def test_transient_unknown_empty_probe_is_confirmed_within_close_bound( + self, scenario_runner: ScenarioRunner + ) -> None: + await scenario_runner(TRANSIENT_EMPTY_PROBE) + + +if __name__ == "__main__": + enable_worker_subreaper() + asyncio.run(run_worker(sys.argv[1], Path(sys.argv[2]))) From 1b61d7ca1a440e9be0e86d51fb5cb3c6b2e59ba8 Mon Sep 17 00:00:00 2001 From: Natan Zalkin Date: Thu, 15 Oct 2026 16:44:28 +0300 Subject: [PATCH 11/13] fix(codex): retain startup cancellation ownership through settlement Port the reviewed cancellation correction from dd8f3af8..942759f4. Delegate acquisition cancellation explicitly to the matching startup owner, preserve first abandonment/cleanup diagnostics, and cover public caller/cleanup orderings after uncancel. Keep Claude-owned retention changes in the stacked Claude PR. --- CHANGELOG.md | 9 + docs/adapters/codex.md | 13 +- src/band/adapters/codex.py | 17 ++ src/band/core/retention/owner.py | 10 +- src/band/core/retention/room.py | 32 +++- src/band/core/turn_lifecycle.py | 16 +- tests/adapters/codex/test_mcp_cancellation.py | 158 ++++++++++++++++++ 7 files changed, 239 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6027494..0ef9da7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Conventional Commits](https://www.conventionalcommits.or and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This changelog is automatically generated by [Release Please](https://github.com/googleapis/release-please). +## [Unreleased] + +### Bug Fixes + +* **codex:** keep readiness cancellation from interrupting startup settlement or + closing its client early when the public message caller is cancelled or room + cleanup joins startup settlement, even when settlement consumes the task's + cancellation count. + ## [5.1.0](https://github.com/band-ai/band-sdk-python/compare/band-sdk-v5.0.0...band-sdk-v5.1.0) (2026-10-13) diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md index a6ae71a34..f8d47f354 100644 --- a/docs/adapters/codex.md +++ b/docs/adapters/codex.md @@ -153,10 +153,15 @@ Pass the keyword-only `resolve_mcp_servers` callback to `CodexAdapter`, not names, repeated cursors, negative statuses and consumed timeout cancellation fail startup. - Cancellation and retirement fence each stage and every discovery request. - A resolver may request its own room cleanup without self-waiting. Cleanup - disarms readiness deadlines and joins an already-cancelling acquisition - without a second cancellation. Settlement beyond the reporting bound retains - acquisition, child, workspace and diagnostic scope until cleanup succeeds. + A resolver may request its own room cleanup without self-waiting. Cleanup and + acquisition abandonment disarm readiness deadlines through the startup + cancellation owner, which records and delivers the first cancellation with + its abandonment or cleanup reason. Later cleanup joins that same cancellation + authority even when settlement consumes the task's cancellation count; it + never sends a fallback cancellation into owned settlement. Explicit cleanup + retries retain their separate cancellation semantics. Settlement beyond the + reporting bound retains acquisition, child, workspace and diagnostic scope + until cleanup succeeds. Failed registration unwinds the gated worker before resolution can start. Invocation-only cancellation timers end with their owner, never later work. - Thread start/resume errors with adopted servers trigger bounded readiness diff --git a/src/band/adapters/codex.py b/src/band/adapters/codex.py index 2444bb6c5..307f70598 100644 --- a/src/band/adapters/codex.py +++ b/src/band/adapters/codex.py @@ -1027,10 +1027,25 @@ def _room_client(self, room_id: str) -> RoomCodexClient: acquire=partial(self._acquire, room), release=partial(self._release, room), dispose=partial(self._dispose, room), + before_cancel_acquire=partial(self._prepare_acquire_cancel, room), ) self._room_clients[room_id] = room return room + def _prepare_acquire_cancel( + self, room: RoomCodexClient, task: asyncio.Task[None], reason: str + ) -> bool: + """Keep a live startup's cancellation with its owner, even after delivery.""" + owner = room.startup_owner + if owner is None or owner.task is not task or task.done(): + return False + owner.terminate( + deadline=asyncio.get_running_loop().time() + + self.config.turn_settle_timeout_s, + reason=reason, + ) + return True + def _active_client_state(self) -> RoomCodexClient | None: room_id = self._active_room.get() return self._room_clients.get(room_id) if room_id is not None else None @@ -1944,6 +1959,7 @@ async def on_cleanup(self, room_id: str) -> None: deadline=asyncio.get_running_loop().time() + self.config.turn_settle_timeout_s, cooperative=True, + reason=f"Room {room_id} acquisition was cancelled for cleanup", ) try: await room.retained.dispose() @@ -1970,6 +1986,7 @@ async def cleanup_all(self) -> None: deadline=asyncio.get_running_loop().time() + self.config.turn_settle_timeout_s, cooperative=True, + reason=f"Room {room_id} acquisition was cancelled for cleanup", ) self._clear_pending_approvals_for_room(room_id) await self._retention.stop() diff --git a/src/band/core/retention/owner.py b/src/band/core/retention/owner.py index e5947ca53..920c8322a 100644 --- a/src/band/core/retention/owner.py +++ b/src/band/core/retention/owner.py @@ -50,11 +50,19 @@ def register( acquire: Callable[[int, bool], Awaitable[None]], release: Callable[[ReleaseCause], Awaitable[ReleaseOutcome]], dispose: Callable[[], Awaitable[None]], + before_cancel_acquire: Callable[[asyncio.Task[None], str], bool] | None = None, ) -> RetainedRoom: self.require_admission() if room_id in self._rooms: raise ValueError(f"Room {room_id} already has a residency owner") - room = RetainedRoom(self, room_id, acquire, release, dispose) + room = RetainedRoom( + self, + room_id, + acquire, + release, + dispose, + before_cancel_acquire=before_cancel_acquire, + ) self._rooms[room_id] = room self.reschedule() return room diff --git a/src/band/core/retention/room.py b/src/band/core/retention/room.py index e8b65e7e4..27c40b49f 100644 --- a/src/band/core/retention/room.py +++ b/src/band/core/retention/room.py @@ -63,6 +63,10 @@ class RetainedRoom: release must be a qualified, non-cancelling boundary, and every retry must finish partially acquired or closed handles. ``dispose`` runs only after a confirmed ``FINAL`` release and erases continuation and workspace. + + ``before_cancel_acquire`` receives the task and cancellation reason and returns + whether the provider owns its cancellation, even after delivery. Only ``False`` + or no callback permits a fallback; generation and release ordering stay here. """ def __init__( @@ -72,12 +76,15 @@ def __init__( acquire: Callable[[int, bool], Awaitable[None]], release: Callable[[ReleaseCause], Awaitable[ReleaseOutcome]], dispose: Callable[[], Awaitable[None]], + *, + before_cancel_acquire: Callable[[asyncio.Task[None], str], bool] | None = None, ) -> None: self._owner = owner self.room_id = room_id self._acquire = acquire self._release = release self._dispose = dispose + self._before_cancel_acquire = before_cancel_acquire self._state = Residency.SUSPENDED self._generation = 0 self._leases = 0 @@ -358,8 +365,18 @@ def _abandon_acquisition(self, transition: asyncio.Task[None]) -> None: self.room_id, self._generation, ) - if not transition.cancelling(): - transition.cancel(f"Room {self.room_id} acquisition was abandoned") + self._cancel_acquire( + transition, reason=f"Room {self.room_id} acquisition was abandoned" + ) + + def _cancel_acquire(self, transition: asyncio.Task[None], *, reason: str) -> None: + """Delegate provider cancellation, or deliver an unowned fallback once.""" + if self._before_cancel_acquire is not None and self._before_cancel_acquire( + transition, reason + ): + return + if not transition.cancelling(): + transition.cancel(reason) def _request(self, cause: ReleaseCause) -> None: self._cause = stronger_cause(self._cause, cause) @@ -611,12 +628,11 @@ async def _settle_accepted(self) -> None: self._work_cancelled = True for task in self._uncancelled_work(): task.cancel() - if ( - self._state == Residency.ACQUIRING - and self._transition is not None - and not self._transition.cancelling() - ): - self._transition.cancel() + if self._state == Residency.ACQUIRING and self._transition is not None: + self._cancel_acquire( + self._transition, + reason=f"Room {self.room_id} acquisition was cancelled for cleanup", + ) await self._drained.wait() def _uncancelled_work(self) -> set[asyncio.Task[Any]]: diff --git a/src/band/core/turn_lifecycle.py b/src/band/core/turn_lifecycle.py index 7c8afab97..ee105eb83 100644 --- a/src/band/core/turn_lifecycle.py +++ b/src/band/core/turn_lifecycle.py @@ -57,6 +57,7 @@ def __init__(self, task: asyncio.Task[None]) -> None: self._cancel_baseline = task.cancelling() self.deadline: float | None = None self._cancel_delivered = False + self._cancel_reason: str | None = None self._grace_expired = False self._timer: asyncio.Timeout | None = None self._cleanup_timer = False @@ -94,11 +95,20 @@ def _schedule_expiry(self, deadline: float) -> None: self._expiry_timer = asyncio.get_running_loop().call_at(deadline, self._expire) def terminate( - self, *, deadline: float | None = None, cooperative: bool = False + self, + *, + deadline: float | None = None, + cooperative: bool = False, + reason: str | None = None, ) -> None: - """Start the cleanup grace once; later requests never renew it.""" + """Start the cleanup grace once; later requests never renew it. + + Keep the first supplied reason for delivery, including cooperative expiry. + """ if self._closed: return + if self._cancel_reason is None: + self._cancel_reason = reason self._adopt_cancellation() if self.deadline is not None: return @@ -156,7 +166,7 @@ def _adopt_cancellation(self) -> None: def _cancel(self) -> None: if not self._closed and not self._cancel_delivered and not self.task.done(): self._cancel_delivered = True - self.task.cancel() + self.task.cancel(self._cancel_reason) async def drain(self) -> bool: """Wait within the original terminal grace without cancelling a waiter.""" diff --git a/tests/adapters/codex/test_mcp_cancellation.py b/tests/adapters/codex/test_mcp_cancellation.py index a89b3f915..004e11f12 100644 --- a/tests/adapters/codex/test_mcp_cancellation.py +++ b/tests/adapters/codex/test_mcp_cancellation.py @@ -202,3 +202,161 @@ async def request( if cleanup is not None: await asyncio.gather(cleanup, return_exceptions=True) await adapter.cleanup_all() + + @pytest.mark.looptime + async def test_public_cancel_disarms_readiness_before_startup_settlement( + self, tmp_path, monkeypatch + ) -> None: + entered, settling, release = (asyncio.Event() for _ in range(3)) + cancellations: list[int] = [] + order: list[str] = [] + monkeypatch.setattr("band.adapters.codex._MCP_READINESS_TIMEOUT_S", 0.1) + + class SettlingClient(StatusClient): + async def request( + self, method: str, params: dict[str, Any] | None = None, **kwargs: Any + ) -> dict[str, Any]: + if method != "mcpServerStatus/list": + return await super().request(method, params, **kwargs) + self.requests.append((method, params or {})) + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + task = asyncio.current_task() + assert task is not None + cancellations.append(task.cancelling()) + order.append("settling") + settling.set() + try: + await release.wait() + except asyncio.CancelledError: + cancellations.append(task.cancelling()) + order.append("cancelled-again") + raise + order.append("settled") + raise + + async def close(self) -> None: + order.append("close") + await super().close() + + client = SettlingClient() + adapter, _ = mcp_adapter( + tmp_path, [client], private_servers, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Public cancellation custody") + running = message_task(adapter) + try: + await entered.wait() + room = adapter._room_clients["a"] + snapshot = room.mcp_snapshot + running.cancel() + await settling.wait() + await asyncio.sleep(0.2) # Cross the original readiness deadline. + assert cancellations == [1] + assert order == ["settling"] + assert not client.closed + assert room.client is client and room.mcp_snapshot is snapshot + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.on_cleanup("a") + assert order == ["settling", "settled", "close"] + assert client.closed + assert "a" not in adapter._workspaces.rooms + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + await adapter.cleanup_all() + + @pytest.mark.looptime + @pytest.mark.parametrize("blocked_stage", ["resolution", "readiness"]) + @pytest.mark.parametrize( + "cancel_order", + ["caller", "cleanup", "caller-then-cleanup", "cleanup-then-caller"], + ) + async def test_public_cancel_consumed_count_keeps_settlement_owned( + self, tmp_path, monkeypatch, blocked_stage: str, cancel_order: str + ) -> None: + entered, settling, release = (asyncio.Event() for _ in range(3)) + cancellations: list[int] = [] + order: list[str] = [] + monkeypatch.setattr("band.adapters.codex._MCP_READINESS_TIMEOUT_S", 0.1) + + async def checkpoint(stage: str) -> None: + if stage != blocked_stage: + return + entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + task = asyncio.current_task() + assert task is not None + cancellations.append(task.cancelling()) + task.uncancel() + order.append("settling") + settling.set() + try: + await release.wait() + except asyncio.CancelledError: + cancellations.append(task.cancelling()) + order.append("cancelled-again") + raise + order.append("settled") + raise + + async def resolve(workspace: str) -> Mapping[str, CodexMcpServer]: + await checkpoint("resolution") + return await private_servers(workspace) + + class ClosingClient(StatusClient): + async def close(self) -> None: + order.append("close") + await super().close() + + client = ClosingClient(checkpoint=checkpoint) + adapter, launches = mcp_adapter( + tmp_path, [client], resolve, turn_settle_timeout_s=0.02 + ) + await adapter.on_started("Fixture", "Consumed cancellation custody") + running = message_task(adapter) + cleanup = None + try: + await entered.wait() + room = adapter._room_clients["a"] + snapshot, startup_client = room.mcp_snapshot, room.client + if cancel_order.startswith("caller"): + running.cancel() + else: + cleanup = asyncio.create_task(adapter.on_cleanup("a")) + await settling.wait() + if cancel_order == "caller-then-cleanup": + cleanup = asyncio.create_task(adapter.on_cleanup("a")) + elif cancel_order == "cleanup-then-caller": + running.cancel() + await asyncio.sleep(0.2) # Cross both grace and readiness expiry. + assert cancellations == [1] + assert order == ["settling"] + assert room.client is startup_client and room.mcp_snapshot is snapshot + assert not client.closed + with pytest.raises(ValueError): + adapter._workspaces.claim("b", room.workspace) + release.set() + await asyncio.gather(running, return_exceptions=True) + if cleanup is not None: + await asyncio.gather(cleanup, return_exceptions=True) + await adapter.on_cleanup("a") + if blocked_stage == "readiness": + assert order == ["settling", "settled", "close"] + assert client.closed + else: + assert order == ["settling", "settled"] + assert launches == [] + assert "a" not in adapter._workspaces.rooms + finally: + release.set() + await asyncio.gather(running, return_exceptions=True) + if cleanup is not None: + await asyncio.gather(cleanup, return_exceptions=True) + await adapter.cleanup_all() + adapter._workspaces.release("b") From 8ab244c4f8312d660d4f8c15fd7afe6e25fd0a5a Mon Sep 17 00:00:00 2001 From: Natan Zalkin Date: Thu, 15 Oct 2026 05:34:49 +0300 Subject: [PATCH 12/13] feat(claude): resolve ambient servers under retained runtime custody --- docs/adapters/claude_sdk.md | 64 +- src/band/adapters/claude_sdk.py | 94 +- src/band/core/retention/owner.py | 15 +- src/band/core/retention/room.py | 8 +- .../integrations/claude_sdk/mcp_startup.py | 338 +++++++ .../claude_sdk/session_manager.py | 327 +++++-- src/band/integrations/claude_sdk/transport.py | 88 +- tests/adapters/claude_sdk/external_mcp.py | 81 ++ tests/adapters/claude_sdk/fakecli.py | 77 +- tests/adapters/claude_sdk/helpers.py | 36 +- .../lifecycle/test_external_cancellation.py | 141 +++ .../lifecycle/test_startup_owner.py | 488 ++++++++++ .../lifecycle/test_teardown_custody.py | 104 +++ tests/adapters/claude_sdk/mcpkit.py | 365 ++++++++ tests/adapters/claude_sdk/process.py | 7 + tests/adapters/claude_sdk/test_cli_options.py | 191 ++-- .../adapters/claude_sdk/test_mcp_resolver.py | 873 ++++++++++++++++++ .../claude_sdk/test_mcp_startup_ownership.py | 668 ++++++++++++++ tests/core/retention/test_shutdown.py | 20 + .../claude_sdk/test_confirmed_close.py | 108 +++ tests/mcpclient.py | 33 +- 21 files changed, 3946 insertions(+), 180 deletions(-) create mode 100644 src/band/integrations/claude_sdk/mcp_startup.py create mode 100644 tests/adapters/claude_sdk/external_mcp.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_external_cancellation.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_startup_owner.py create mode 100644 tests/adapters/claude_sdk/lifecycle/test_teardown_custody.py create mode 100644 tests/adapters/claude_sdk/mcpkit.py create mode 100644 tests/adapters/claude_sdk/test_mcp_resolver.py create mode 100644 tests/adapters/claude_sdk/test_mcp_startup_ownership.py create mode 100644 tests/integrations/claude_sdk/test_confirmed_close.py diff --git a/docs/adapters/claude_sdk.md b/docs/adapters/claude_sdk.md index 42a742a92..c144686b8 100644 --- a/docs/adapters/claude_sdk.md +++ b/docs/adapters/claude_sdk.md @@ -78,6 +78,65 @@ reuse the failed client or take another room's claimed directory. such as `settings`, `agents`, `bare` and `dangerously-skip-permissions` that would bypass Band's wiring, permission gating or host-config isolation. `env` reaches only the Claude CLI process; the host's `os.environ` is untouched. +- **`resolve_mcp_servers=` adds per-connection MCP servers.** This keyword-only + constructor argument is an async callback `(workspace) -> {name: McpServerConfig}` + that returns the complete set of external servers (native stdio, `http` or `sse` + configs) for a new Claude connection in that room's workspace. + - It runs once per new connection, under the room's retained acquisition; reusing a + room's connection never calls it, so a rotated grant reaches only a later + connection. An empty result means no external servers. A failure to + resolve, read or validate the servers, or to see them connect, fails the + turn with `ClaudeMcpStartupError` and never resumes without the room's + history; only the CLI itself failing to start (a session that can't be + resumed, for one) is retried as a fresh session, which resolves again. + - Band's server is added separately; the name `band`, in-process `sdk` + servers, names that aren't plain strings and a `type` that isn't a string + are refused by name. The servers reach the CLI only as the path of a + private file, never on its command line. On POSIX, its directory is `0700` + and its file is `0600`. On Windows, privacy relies on the user-profile and + temp-directory ACLs, not POSIX mode bits. The file stays until the CLI's exit + is confirmed and the file is removed. + - The session is used only after every server, Band's included, reports + `connected` through the CLI's `mcp_status`; anything else within 30 seconds + (each status request included) fails the turn. Errors and logs name the + stage and server, and the exception type only when it is exactly one of a + few builtin classes (such as `LookupError` or `OSError`); they never read + an exception's own name or module, and never carry callback values, + credentials or native error text, even for a `ClaudeMcpStartupError` the + callback raised, or when the check of a held client's servers is what + fails, with or without a resolver. Such a failure never starts the room's + session again without its history. + - External tools stay under `permission_mode` and `approvals`; they are never + added to the tool allowlist. +- **A starting connection is owned until it settles.** Accepted operations share + the room's retained acquisition, after any outdated CLI is closed. If the + initiating operation stops waiting, the acquisition is cancelled (even if the + callback swallows it), or the room is cleaned up, the startup is retired: it + publishes nothing, is cancelled at most once, and keeps its process, file and + workspace claim until it settles. A resolver may itself request cleanup: + adapter cleanup returns without waiting on that acquisition, while direct + manager cleanup reports `CleanupIncompleteError` to avoid waiting on itself. + The request fences admission immediately, and the retained room completes + disposal once the resolver and its accepted turn finish unwinding. + Admission is rechecked, without calling out, after every callback the startup + runs: a callback that closes admission or retires the room stops any later + reuse, client construction or publication. The reuse decision is settled to a + plain yes/no before that last check, so reading it again calls nothing. + The shared turn owner is bound before tool binding or asynchronous preparation, + so STOP and room retirement also end a turn held in resolution or readiness. + A still-settling turn keeps the room fenced; its provider timeout starts only + at the query, not during preparation. +- **Teardown keeps what it can't release.** `cleanup_all()` refuses new turns + and session startups as soon as it is called, and keeps refusing them until a + successful `on_started`. A CLI whose exit can't be confirmed within 5 seconds of + being killed, or a private file that can't be removed (including one only + partly written), is kept with its workspace claim and the Band server it may + dial, and the cleanup raises a safe error; calling it again (or `on_started`) + retries. A failed room cleanup keeps new input unaccepted until `on_cleanup()` + succeeds on an explicit retry. A file or client left by a failed startup is + retried before a later turn starts a replacement connection. + Concurrent `cleanup_all()` calls share one teardown that a cancelled caller + can't abandon. ```python import pytest @@ -156,8 +215,9 @@ so a slow CLI in one room never holds up another. discards that start. - **Shutdown shares one budget.** `cleanup_all` closes every room, then the shared Band MCP server, within one `retention.transition_timeout_s`. An - unfinished or failed step raises, stays owned, and a retry joins it; the - server stays up while any room's CLI may still dial it. + unfinished or failed step raises and stays owned; a retry joins it. The whole + shutdown continues after a cancelled caller or an expired reporting budget, + and the server stays up while any room's CLI may still dial it. ```python import pytest diff --git a/src/band/adapters/claude_sdk.py b/src/band/adapters/claude_sdk.py index 432898145..87dfb8e14 100644 --- a/src/band/adapters/claude_sdk.py +++ b/src/band/adapters/claude_sdk.py @@ -84,6 +84,7 @@ ) from band.core.adapterconfig import BaseAdapterConfig from band.core.defaultmodels import ANTHROPIC_MODEL +from band.core.exceptions import CleanupIncompleteError from band.core.protocols import ( FAILURE_CODE_TIMEOUT, GENERIC_PROVIDER_FAILURE_MESSAGE, @@ -91,6 +92,7 @@ TurnResultAlreadyReported, ) from band.core.retention import RoomRetentionConfig, require_disabled_retention +from band.core.retention.cleanup import finish_by, rejoin_owned, report_owned from band.core.simple_adapter import SimpleAdapter from band.core.turn import judge_detached_turn from band.core.turn_lifecycle import ( @@ -114,6 +116,11 @@ DEFAULT_DEDUP_TTL_SECONDS, DedupingAgentTools, ) +from band.integrations.claude_sdk.mcp_startup import ( + ClaudeMcpStartupError, + McpServerResolver, + is_safe_startup_error, +) from band.integrations.claude_sdk.prompts import generate_claude_sdk_agent_prompt from band.integrations.claude_sdk.session_manager import ( CLI_STARTUP_TIMEOUT_S, # noqa: F401 -- re-exported for adapter configs @@ -608,6 +615,7 @@ def __init__( history_converter: ClaudeSDKHistoryConverter | None = None, additional_tools: list[CustomToolDef] | None = None, workspace_for_room: WorkspaceResolver | None = None, + resolve_mcp_servers: McpServerResolver | None = None, **features: Unpack[FeatureKwargs], ) -> None: """ @@ -621,6 +629,12 @@ def __init__( history_converter: Optional custom history converter additional_tools: Optional list of custom tools as (PydanticModel, callable) tuples. These are converted to MCP tools internally. + resolve_mcp_servers: Async callback returning the complete external + MCP servers for each new connection, given its workspace. Never + called on reuse; Band's server is separate and reserved. Servers + reach the CLI through a private file, and every server must + report connected within 30 seconds before the first prompt. + External tools keep their normal permission mode and approvals. """ if not _CLAUDE_SDK_AVAILABLE: raise ImportError( @@ -641,6 +655,9 @@ def __init__( # Created in on_started. self._session_manager: ClaudeSessionManager | None = None + self._resolve_mcp_servers = resolve_mcp_servers + # One owned whole-adapter close continues after its caller leaves. + self._teardown: asyncio.Task[None] | None = None self._mcp = SharedBandMCPBackend(self._mcp_settings) @@ -698,10 +715,6 @@ def _approvals(self) -> ClaudeApprovalOptions: raise RuntimeError("chat-based approvals are disabled") return approvals - async def _stop_sessions(self) -> None: - if self._session_manager is not None: - await self._session_manager.stop() - @property def _approval_label(self) -> str: approvals = self.config.approvals @@ -715,6 +728,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: await self.cleanup_all() await super().on_started(agent_name, agent_description) + self._teardown = None await self._mcp.reopen() mcp_backend = await self._mcp.ensure() @@ -784,6 +798,7 @@ async def on_started(self, agent_name: str, agent_description: str) -> None: can_use_tool_factory=can_use_tool_factory, mcp_servers_factory=self._room_mcp_servers, workspaces=self._workspaces, + resolve_mcp_servers=self._resolve_mcp_servers, retention=self.config.retention, settle_grace_s=self.config.turn_settle_timeout_s, on_disposed=self._forget_room, @@ -911,8 +926,15 @@ async def on_message( tools=tools, room_id=room_id, ) + try: + owner = bind_turn_task(tools, turn_task) + except BaseException: + # The eager worker is parked on this gate until registration succeeds. + owner_ready.cancel() + await asyncio.wait({turn_task}) + raise self._turn_release[room_id] = release_future - self._turn_tasks[room_id] = owner = bind_turn_task(tools, turn_task) + self._turn_tasks[room_id] = owner owner_ready.set_result(owner) try: await asyncio.wait( @@ -993,6 +1015,8 @@ async def _run_turn( """Run one turn under the room's lease; always releases ``release_future``.""" owner = await owner_ready try: + if owner.deadline is not None: + raise asyncio.CancelledError async with self._turn_lease(session, tools, resume_from) as client: await self._converse(owner, session, client, tools, full_message) logger.debug("Message %s processed successfully", msg_id) @@ -1015,15 +1039,26 @@ async def _turn_lease( try: # Before the lease: a replaced server retires the room's CLI, and # that recovery runs only while no operation leases the room. + if self._session_manager is not None: + self._session_manager.require_admission() await self._mcp.ensure() session.retire_outdated() prepare = partial(self._bind_turn, session, tools, resume_from) async with session.using(prepare) as client: started = True yield client - except Exception: + except Exception as error: if not started and not session.retained.closing: - logger.exception("Room %s: Session creation failed", session.room_id) + if isinstance(error, ClaudeMcpStartupError): + logger.error( + "Room %s: Session creation failed: %s", + session.room_id, + error if is_safe_startup_error(error) else "an untrusted error", + ) + else: + logger.exception( + "Room %s: Session creation failed", session.room_id + ) await tools.send_failure(_TURN_FAILURE) raise @@ -1617,7 +1652,11 @@ async def on_cleanup(self, room_id: str) -> None: if manager is None or manager.session(room_id) is None: self._forget_room(room_id) return - await manager.cleanup_session(room_id) + try: + await manager.cleanup_session(room_id) + except CleanupIncompleteError: + if not manager.owns_task(asyncio.current_task()): + raise def _forget_room(self, room_id: str) -> None: self._room_tools.pop(room_id, None) @@ -1630,18 +1669,43 @@ def _forget_room(self, room_id: str) -> None: logger.debug("Room %s: Cleaned up Claude SDK session", room_id) async def cleanup_all(self) -> None: - """Stop the session manager, then the shared Band MCP server, within - one ``retention.transition_timeout_s`` budget. + """Own the complete session-then-MCP shutdown under one reporting budget. - Stopping closes admission for every room at once; rooms close first - because their CLIs dial the server until they do. An unfinished or - failed step stays owned for a retry. + A cancelled caller leaves the whole close running. The retained room + contract reports self-requested cleanup instead of waiting on its own + acquisition; that request still fences admission immediately. """ + manager = self._session_manager + if manager is not None: + manager.close_admission() + if manager.owns_task(asyncio.current_task()): + try: + await manager.stop() + except CleanupIncompleteError: + pass # the retained owner joins this work once it unwinds + self._teardown = rejoin_owned( + self._teardown, self._tear_down, label="Claude adapter shutdown" + ) + return for room_id in list(self._pending_approvals): self._clear_pending_approvals_for_room(room_id) - await self._mcp.close_after( - self._stop_sessions(), retention=self.config.retention + self._teardown = rejoin_owned( + self._teardown, self._tear_down, label="Claude adapter shutdown" ) + deadline = ( + asyncio.get_running_loop().time() + + self.config.retention.transition_timeout_s + ) + await finish_by( + deadline, + report_owned(self._teardown, None), + what="Claude sessions and MCP shutdown", + ) + + async def _tear_down(self) -> None: + if self._session_manager is not None: + await self._session_manager.stop(report_timeout_s=None) + await self._mcp.close(final=True) # ------------------------------------------------------------------ # Chat-based approval flow diff --git a/src/band/core/retention/owner.py b/src/band/core/retention/owner.py index 920c8322a..b73b75dbd 100644 --- a/src/band/core/retention/owner.py +++ b/src/band/core/retention/owner.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable from functools import partial +from typing import Literal from band.core.exceptions import CleanupIncompleteError, RoomClosingError from band.core.retention.cleanup import ( @@ -101,11 +102,16 @@ def _release_due_rooms(self, due: float) -> None: room._start_idle_release() self.reschedule() - async def stop(self) -> None: + async def stop( + self, *, report_timeout_s: float | None | Literal["configured"] = "configured" + ) -> None: """Dispose every room under one reporting deadline; retain failures. Work enrolled in a room cannot await this shutdown; it is told cleanup is pending, is not cancelled by it, and is joined once it unwinds. + By default the caller uses ``transition_timeout_s``. An enclosing + owner may pass ``None`` to await settlement without a second reporting + deadline, while its own callers still receive bounded reports. """ requested_from_inside = [ room._spare_requester() for room in tuple(self._rooms.values()) @@ -119,7 +125,12 @@ async def stop(self) -> None: raise CleanupIncompleteError( "Room retention shutdown waits for the calling work to finish" ) - await report_owned(shutdown, self.config.transition_timeout_s) + timeout = ( + self.config.transition_timeout_s + if isinstance(report_timeout_s, str) + else report_timeout_s + ) + await report_owned(shutdown, timeout) def renewed(self) -> RoomRetention: """The owner to serve with after a restart. diff --git a/src/band/core/retention/room.py b/src/band/core/retention/room.py index 27c40b49f..6d5ad389d 100644 --- a/src/band/core/retention/room.py +++ b/src/band/core/retention/room.py @@ -565,7 +565,7 @@ def _spare_requester(self) -> bool: current = asyncio.current_task() if not self.enrolls(current): return False - if current is not None and current in self._owned: + if current is not None: self._unwinding.add(current) return True @@ -628,7 +628,11 @@ async def _settle_accepted(self) -> None: self._work_cancelled = True for task in self._uncancelled_work(): task.cancel() - if self._state == Residency.ACQUIRING and self._transition is not None: + if ( + self._state == Residency.ACQUIRING + and self._transition is not None + and self._transition not in self._unwinding + ): self._cancel_acquire( self._transition, reason=f"Room {self.room_id} acquisition was cancelled for cleanup", diff --git a/src/band/integrations/claude_sdk/mcp_startup.py b/src/band/integrations/claude_sdk/mcp_startup.py new file mode 100644 index 000000000..cc642af07 --- /dev/null +++ b/src/band/integrations/claude_sdk/mcp_startup.py @@ -0,0 +1,338 @@ +"""Connection-time MCP servers for Claude sessions: one frozen snapshot per new +connection, written to a private file, and positive per-server readiness. + +Nothing here renders callback values, native status payloads or native error +text: they can carry credentials. Errors name the stage, a known server name, +a fixed reason and the exception type only, and only errors created here are +trusted to say so -- one raised by a callback, even a ``ClaudeMcpStartupError``, +is replaced. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import tempfile +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, ForwardRef, TypeAlias + +if TYPE_CHECKING: + from claude_agent_sdk.types import McpServerConfig + + McpServerResolver: TypeAlias = Callable[ + [str], Awaitable[Mapping[str, McpServerConfig]] + ] +else: + # A real alias, so annotations such as ``McpServerResolver | None`` + # evaluate at runtime; the native config type is named by reference and + # resolved from the SDK only when introspected, keeping the SDK optional. + McpServerResolver = Callable[ + [str], + Awaitable[ + Mapping[str, ForwardRef("McpServerConfig", module="claude_agent_sdk.types")] + ], + ] +"""``resolve_mcp_servers(workspace)``: the complete external MCP servers for a +new Claude connection in ``workspace``, as native ``McpServerConfig`` values.""" + +MCP_READINESS_TIMEOUT_SECONDS = 30.0 +_STATUS_POLL_SECONDS = 0.25 +_REMOTE_TYPES = frozenset({"http", "sse"}) +_FAILED_STATUSES = frozenset({"failed", "needs-auth", "disabled"}) +_KNOWN_STATUSES = _FAILED_STATUSES | {"connected", "pending"} + + +class ClaudeMcpStartupError(RuntimeError): + """A Claude session's MCP servers could not be resolved, launched, confirmed + connected or cleaned up. Carries no callback, native status or error text. + + ``resume_eligible`` is set only when the native CLI failed to start, the one + case a resumed session may be retried as a fresh one. + """ + + def __init__( + self, + stage: str, + reason: str, + *, + server: str | None = None, + error_type: str | None = None, + resume_eligible: bool = False, + ) -> None: + self.stage = stage + self.reason = reason + self.server = server + self.error_type = error_type + self.resume_eligible = resume_eligible + parts = [f"Claude MCP startup failed at {stage}: {reason}"] + if server is not None: + parts.append(f"server {server!r}") + if error_type is not None: + parts.append(f"({error_type})") + super().__init__(" ".join(parts)) + + +class _OwnedStartupError(ClaudeMcpStartupError): + """Built here from owner-known metadata only.""" + + +def startup_error( + stage: str, + reason: str, + error: BaseException | None = None, + *, + server: str | None = None, + resume_eligible: bool = False, +) -> ClaudeMcpStartupError: + """A fresh safe error for ``error``, whatever its type; raise it ``from + None`` so the original text, which may hold credentials, never joins the + chain. The error's type is named only when it is a builtin or comes from a + library this adapter runs: a callback can name its own classes anything.""" + return _OwnedStartupError( + stage, + reason, + server=server, + error_type=_known_type_name(error), + resume_eligible=resume_eligible, + ) + + +# Literal labels for exactly these classes. An error is matched by the +# identity of its class alone -- never its name, module or other metadata, +# which a callback's own class can set (or compute) to anything. +_LABELLED_ERRORS: tuple[tuple[type[BaseException], str], ...] = ( + (LookupError, "LookupError"), + (KeyError, "KeyError"), + (IndexError, "IndexError"), + (TypeError, "TypeError"), + (ValueError, "ValueError"), + (RuntimeError, "RuntimeError"), + (OSError, "OSError"), + (PermissionError, "PermissionError"), + (FileNotFoundError, "FileNotFoundError"), + (ConnectionError, "ConnectionError"), + (TimeoutError, "TimeoutError"), +) + + +def _known_type_name(error: BaseException | None) -> str | None: + if error is None: + return None + cls = type(error) + for known, label in _LABELLED_ERRORS: + if cls is known: + return label + return None + + +def is_safe_startup_error(error: BaseException) -> bool: + """Whether ``error`` was built by the owner, not handed in by a callback.""" + return isinstance(error, _OwnedStartupError) + + +_NOT_A_MAPPING = object() + + +def freeze_external_servers( + resolved: object, protected: Mapping[str, Any] +) -> dict[str, dict[str, Any]]: + """An owned deep copy of the callback's servers, validated for the native CLI. + + Later changes to the mappings, lists or headers the callback returned can't + reach the launch. Reading the callback's objects runs their code, so every + exception raised while copying them -- whatever its type -- is replaced by + a safe error; only the plain copy is then validated, and in-process ``sdk`` + servers and names Band reserves are refused by name. + """ + return _validate(_copy_servers(resolved), protected) + + +def _copy_servers(resolved: object) -> dict[str, object]: + """Plain JSON values copied out of the callback's result. + + Every operation on the callback's objects -- the type check, iterating its + items, unpacking each item, reading and serializing each config -- runs + callback code, so any exception from one of them, whatever its type, is + replaced by a safe error. Only exact ``str`` names and plain copies leave; + no callback code runs after this returns. + """ + pairs: list[tuple[object, object]] | None = None + try: + if isinstance(resolved, Mapping): + pairs = [] + for item in resolved.items(): + name, config = item + pairs.append((name, config)) + except Exception as error: # noqa: BLE001 -- untrusted, whatever its type + raise startup_error( + "resolution", "the resolved servers could not be read", error + ) from None + if pairs is None: + raise startup_error( + "resolution", "resolve_mcp_servers must return a mapping of servers" + ) + copied: dict[str, object] = {} + for name, config in pairs: + # Exactly ``str`` (checked without calling into it): a subclass could + # render or compare itself however it likes. + if type(name) is not str or not name: + raise startup_error("resolution", "server names must be strings") + if name in copied: + raise startup_error("resolution", "duplicate server name", server=name) + try: + if isinstance(config, Mapping): + plain: object = json.loads(json.dumps(dict(config))) + else: + plain = _NOT_A_MAPPING + except (TypeError, ValueError) as error: + raise startup_error( + "resolution", "server config is not serializable", error, server=name + ) from None + except Exception as error: # noqa: BLE001 -- untrusted, whatever its type + raise startup_error( + "resolution", + "the resolved servers could not be read", + error, + server=name, + ) from None + copied[name] = plain + return copied + + +def _validate( + copied: dict[str, object], protected: Mapping[str, Any] +) -> dict[str, dict[str, Any]]: + frozen: dict[str, dict[str, Any]] = {} + for name, config in copied.items(): + if name in protected: + raise startup_error( + "resolution", "the name is reserved for Band", server=name + ) + if not isinstance(config, dict): + raise startup_error( + "resolution", "server config must be a mapping", server=name + ) + kind = config.get("type", "stdio") + if not isinstance(kind, str): + raise startup_error( + "resolution", "server type must be a string", server=name + ) + if kind == "sdk": + raise startup_error( + "resolution", "in-process sdk servers are not supported", server=name + ) + if kind not in _REMOTE_TYPES and kind != "stdio": + raise startup_error("resolution", "unsupported server type", server=name) + required = "command" if kind == "stdio" else "url" + if not isinstance(config.get(required), str): + raise startup_error( + "resolution", f"server config needs a string {required}", server=name + ) + frozen[name] = config + return frozen + + +@dataclass +class McpConfigFile: + """The private native ``--mcp-config`` file one connection launches with. + + ``allocate`` creates only its private directory, so the caller owns it + before anything can fail; ``write`` then fills it. Owned until its child's + exit is confirmed; then ``release`` removes it. A release that fails raises + and leaves it owned, for a later retry. + """ + + directory: Path + path: Path + + @classmethod + def allocate(cls) -> McpConfigFile: + directory = Path(tempfile.mkdtemp(prefix="band-claude-mcp-")) + return cls(directory=directory, path=directory / "mcp.json") + + def write(self, servers: Mapping[str, Any]) -> None: + os.chmod(self.directory, 0o700) + fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump({"mcpServers": dict(servers)}, handle) + + def release(self) -> None: + try: + shutil.rmtree(self.directory) + except FileNotFoundError: + return + except OSError as error: + raise startup_error( + "cleanup", "the private MCP file could not be removed", error + ) from None + + +def _check_status( + response: object, expected: frozenset[str] +) -> tuple[bool, str | None]: + """``(ready, waiting_on)``; raises when the evidence is unusable or a + server failed. Only a unique ``connected`` entry per expected name counts.""" + servers = response.get("mcpServers") if isinstance(response, Mapping) else None + if not isinstance(servers, list): + raise startup_error("status", "malformed status response") + seen: dict[str, str] = {} + for entry in servers: + if not isinstance(entry, Mapping): + raise startup_error("status", "malformed status entry") + name, status = entry.get("name"), entry.get("status") + if not isinstance(name, str) or not isinstance(status, str): + raise startup_error("status", "malformed status entry") + if name not in expected: + continue + if name in seen: + raise startup_error("status", "duplicate status", server=name) + if status not in _KNOWN_STATUSES: + raise startup_error("status", "unknown status", server=name) + if status in _FAILED_STATUSES: + raise startup_error("status", f"server is {status}", server=name) + seen[name] = status + for name in sorted(expected): + if seen.get(name) != "connected": + return False, name + return True, None + + +async def await_mcp_ready( + get_status: Callable[[], Awaitable[object]], + expected: frozenset[str], + *, + timeout: float = MCP_READINESS_TIMEOUT_SECONDS, +) -> None: + """Wait until every expected server reports ``connected``, within one total + deadline that also bounds each status request.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + waiting_on: str | None = None + while True: + remaining = deadline - loop.time() + if remaining <= 0: + raise startup_error( + "status", "not connected before the deadline", server=waiting_on + ) + try: + async with asyncio.timeout(remaining) as budget: + response = await get_status() + except TimeoutError: + raise startup_error( + "status", "not connected before the deadline", server=waiting_on + ) from None + except Exception as error: # noqa: BLE001 -- any native status failure is a typed startup failure + raise startup_error("status", "status request failed", error) from None + if budget.expired(): + # A callback may catch the timer's cancellation and return anyway. + raise startup_error( + "status", "not connected before the deadline", server=waiting_on + ) + ready, waiting_on = _check_status(response, expected) + if ready: + return + await asyncio.sleep(min(_STATUS_POLL_SECONDS, max(0.0, deadline - loop.time()))) diff --git a/src/band/integrations/claude_sdk/session_manager.py b/src/band/integrations/claude_sdk/session_manager.py index 5f663a87c..d6eed97c3 100644 --- a/src/band/integrations/claude_sdk/session_manager.py +++ b/src/band/integrations/claude_sdk/session_manager.py @@ -18,7 +18,7 @@ from contextlib import asynccontextmanager, contextmanager from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Literal from band.core.exceptions import RoomClosingError from band.core.retention import ( @@ -29,6 +29,15 @@ RoomRetentionConfig, ) from band.core.turn_lifecycle import TURN_SETTLE_GRACE_S +from band.integrations.claude_sdk.mcp_startup import ( + ClaudeMcpStartupError, + McpConfigFile, + McpServerResolver, + await_mcp_ready, + freeze_external_servers, + is_safe_startup_error, + startup_error, +) from band.workspaces import RoomWorkspaces try: @@ -123,6 +132,10 @@ def _start_failure(error: Exception) -> BaseException: return error +class _StartupReplaced(Exception): + """The Band endpoint changed before this connection was ready.""" + + class ClaudeRoomSession: """A room's Claude CLI session and everything it keeps until closure. @@ -144,6 +157,9 @@ def __init__( self._reader: asyncio.Task[None] | None = None self._turn: TurnStream | None = None self._forget_session = False + self._config_file: McpConfigFile | None = None + self._protected_servers: dict[str, McpServerConfig] | None = None + self._acquisition: asyncio.Task[None] | None = None self.retained: RetainedRoom = retention.register( room_id, acquire=self.acquire, release=self.release, dispose=self.dispose ) @@ -172,10 +188,27 @@ async def using( ``prepare`` binds the operation before a CLI is started for it. """ - async with self.retained.use(prepare): - if self.client is None: - raise RuntimeError(f"Room {self.room_id} is resident without a client") - yield self.client + try: + async with self.retained.use(prepare): + if self.client is None: + raise RuntimeError( + f"Room {self.room_id} is resident without a client" + ) + yield self.client + except asyncio.CancelledError: + acquisition = self._acquisition + if ( + self._manager._resolve_mcp_servers is not None + and acquisition is not None + and ( + acquisition.cancelling() + or not self.retained.has_authority(self.generation) + ) + ): + # The turn's runtime owner still answers for preparation until + # its cancelled callback and failed launch finish settling. + await asyncio.wait({acquisition}) + raise @contextmanager def listening(self) -> Iterator[TurnStream]: @@ -210,38 +243,67 @@ def _retire(self, generation: int, reason: str, *, forget_session: bool) -> bool return True def retire_outdated(self) -> None: - """A CLI dialing MCP servers the room no longer has resumes on the - current ones.""" - if self.client is None: - return - if self.client.options.mcp_servers != self._manager.mcp_servers(self.room_id): + """Compare only the protected servers: reuse never resolves external ones.""" + changed = False + try: + if self.client is not None or self._config_file is not None: + held: object = self._protected_servers + if ( + self._manager._resolve_mcp_servers is None + and self.client is not None + ): + held = self.client.options.mcp_servers + current = ( + self._manager.protected_servers(self.room_id) + if self._manager._resolve_mcp_servers is not None + else self._manager.mcp_servers(self.room_id) + ) + changed = bool(held != current) + except Exception as error: # noqa: BLE001 -- comparisons can run callback code + raise startup_error( + "reservation", "the room's protected servers could not be read", error + ) from None + self._manager.require_admission() + if changed: logger.info("Room %s: MCP servers changed; replacing its CLI", self.room_id) self.recover("Band MCP servers changed") async def acquire(self, generation: int, resume: bool) -> None: - """Start the room's CLI. ``resume`` restores the retained session - strictly; otherwise an unresumable earlier session gives way to a new - one, the bootstrap and crash policy, unless the room began closing.""" + """Resolve and ready each new connection under the retained acquisition.""" + self._acquisition = asyncio.current_task() try: - await self._connect(generation, resume=self.session_id) - except Exception as error: - if not self._may_start_fresh(resume): - raise - logger.warning( - "Room %s: Session resume failed (session_id=%s): %s. " - "Creating new session", - self.room_id, - self.session_id, - error, - ) - # The conversation is dropped only once a new one is running. - await self._connect(generation, resume=None) - self.session_id = None + while True: + try: + await self._connect(generation, resume=self.session_id) + except _StartupReplaced: + # Close the obsolete launch before resolving a fresh snapshot. + await self._close() + self._check_admitted(generation) + continue + except Exception as error: + if ( + isinstance(error, ClaudeMcpStartupError) + and ( + not is_safe_startup_error(error) + or not error.resume_eligible + ) + ) or not self._may_start_fresh(resume): + raise + logger.warning( + "Room %s: Session resume failed (session_id=%s): %s. " + "Creating new session", + self.room_id, + self.session_id, + error, + ) + await self._connect(generation, resume=None) + self.session_id = None + return + finally: + self._acquisition = None def _may_start_fresh(self, resume: bool) -> bool: - """Whether a failed resume may give way to a new session: never on a - strict resume or once closing, and not while the failed CLI, which - could not be closed, is still held for recovery.""" + """Only a failed native start may discard history, after its child closes.""" return ( not resume and self.session_id is not None @@ -249,6 +311,44 @@ def _may_start_fresh(self, resume: bool) -> bool: and self.client is None ) + def _check_admitted(self, generation: int) -> None: + """Callback-free fencing, including cancellation a callback swallowed.""" + try: + self._manager.require_admission() + except ClaudeSessionManagerStoppedError: + raise asyncio.CancelledError from None + current = asyncio.current_task() + if current is not None and current.cancelling(): + raise asyncio.CancelledError from None + if self.retained.closing: + if self._manager._resolve_mcp_servers is None: + self.retained.require_admission() + raise startup_error("retirement", "the room's startup was retired") + if ( + self._manager._resolve_mcp_servers is not None + and not self.retained.has_authority(generation) + ): + raise asyncio.CancelledError from None + + def _check_current(self, generation: int) -> None: + """Materialize comparisons before the final callback-free guard.""" + changed = False + if self._manager._resolve_mcp_servers is not None: + try: + changed = bool( + self._protected_servers + != self._manager.protected_servers(self.room_id) + ) + except Exception as error: # noqa: BLE001 -- a protected-server factory + raise startup_error( + "reservation", + "the room's protected servers could not be read", + error, + ) from None + self._check_admitted(generation) + if changed: + raise _StartupReplaced + async def release(self, cause: ReleaseCause) -> ReleaseOutcome: """Close the room's CLI for recovery or final cleanup.""" await self._close() @@ -264,19 +364,87 @@ async def dispose(self) -> None: self._manager.discard(self) async def _connect(self, generation: int, *, resume: str | None) -> None: - options = self._manager.options_for(self, generation, resume=resume) - transport = create_transport(options) - client = ClaudeSDKClient(options=options, transport=transport) - # Retain both before connect: SDK disconnect cannot close a subprocess - # when startup fails before its Query is constructed. - self.client, self.transport = client, transport + # A failed removal can leave only a file. Retry that obligation before + # resolving or allocating anything for the next attempt. + if self._config_file is not None: + await self._close() + self._check_admitted(generation) + stage = "reservation" + native_start = False + resolving = self._manager._resolve_mcp_servers is not None + ready_servers: frozenset[str] = frozenset() try: + if resolving: + workspace = self._manager.workspace(self.room_id) + protected = self._manager.protected_servers(self.room_id) + self._protected_servers = protected + self._check_current(generation) + stage = "resolution" + resolver = self._manager._resolve_mcp_servers + assert resolver is not None + try: + resolved = await resolver(workspace) + except Exception as error: # noqa: BLE001 -- callback errors are never trusted + raise startup_error( + "resolution", "resolve_mcp_servers failed", error + ) from None + external = freeze_external_servers(resolved, protected) + ready_servers = frozenset({*external, *protected}) + self._check_current(generation) + stage = "launch" + self._config_file = McpConfigFile.allocate() + self._config_file.write({**external, **protected}) + options = self._manager.options_for( + self, + generation, + resume=resume, + mcp_servers=str(self._config_file.path), + ) + else: + options = self._manager.options_for(self, generation, resume=resume) + self._check_current(generation) + self.transport = transport = create_transport(options) + self.client = client = ClaudeSDKClient(options=options, transport=transport) + # Retain both before connect: SDK disconnect cannot close a child + # when startup fails before its Query is constructed. + native_start = True await client.connect() + native_start = False + self._check_current(generation) + if resolving: + stage = "status" + try: + await await_mcp_ready(client.get_mcp_status, ready_servers) + except ClaudeMcpStartupError: + self._check_current(generation) + raise + self._check_current(generation) + except asyncio.CancelledError: + if resolving: + await self._close_failed_start() + raise asyncio.CancelledError from None + except _StartupReplaced: + raise except Exception as error: await self._close_failed_start() - if (failure := _start_failure(error)) is error: + self._check_admitted(generation) + failure = _start_failure(error) + if not resolving: + if failure is error: + raise + raise failure + if isinstance(error, ClaudeMcpStartupError) and is_safe_startup_error( + error + ): raise - raise failure + raise startup_error( + stage, + "the Claude CLI did not start" + if native_start + else "the Claude session could not be constructed", + failure, + resume_eligible=native_start, + ) from None self._reader = asyncio.create_task(self._read(client, generation)) logger.info( "Room %s: Claude session started (resume=%s)", self.room_id, options.resume @@ -291,20 +459,32 @@ async def _close_failed_start(self) -> None: logger.warning( "Room %s: closing a failed Claude start failed; recovery retries it", self.room_id, - exc_info=True, + exc_info=self._manager._resolve_mcp_servers is None, ) async def _close(self) -> None: - """Close the retained CLI; a failure keeps every handle for a retry.""" - if self.client is None or self.transport is None: - return + """Keep every unresolved child or private-file obligation for a retry.""" try: - await self.client.disconnect() - finally: - await self.transport.close() - if self._reader is not None: - await self._reader - self.client = self.transport = self._reader = None + if self.client is not None or self.transport is not None: + try: + if self.client is not None: + await self.client.disconnect() + finally: + if self.transport is not None: + await self.transport.close() + if self._reader is not None: + await self._reader + self.client = self.transport = self._reader = None + except Exception as error: + if self._manager._resolve_mcp_servers is None: + raise + raise startup_error( + "cleanup", "the Claude CLI could not be confirmed closed", error + ) from None + if self._config_file is not None: + self._config_file.release() + self._config_file = None + self._protected_servers = None async def _read(self, client: ClaudeSDKClient, generation: int) -> None: """The CLI's only reader, across turns, from connect to disconnect.""" @@ -373,6 +553,7 @@ def __init__( retention: RoomRetentionConfig | None = None, settle_grace_s: float = TURN_SETTLE_GRACE_S, on_disposed: Callable[[str], None] | None = None, + resolve_mcp_servers: McpServerResolver | None = None, ) -> None: """ Initialize session manager. @@ -392,22 +573,36 @@ def __init__( down on its own before cancelling it. on_disposed: Called with a room's id once its final release is confirmed, even if the leave that started it stopped waiting. + resolve_mcp_servers: Complete external servers for each new connection; + never called on reuse, and never allowed to replace Band's servers. """ self.base_options = base_options self._can_use_tool_factory = can_use_tool_factory self._mcp_servers_factory = mcp_servers_factory self._workspaces = workspaces self._on_disposed = on_disposed + self._resolve_mcp_servers = resolve_mcp_servers self._retention = RoomRetention( retention or default_claude_retention(), settle_grace_s=settle_grace_s, ) self._rooms: dict[str, ClaudeRoomSession] = {} - def room(self, room_id: str) -> ClaudeRoomSession: - """The room's session record, registered on first use.""" + def require_admission(self) -> None: if self._retention.stopping: raise ClaudeSessionManagerStoppedError() + + def close_admission(self) -> None: + """Fence new work now; the retained owner settles it through ``stop``.""" + self._retention.stopping = True + self._retention.reschedule() + + def owns_task(self, task: asyncio.Task[Any] | None) -> bool: + return any(session.retained.enrolls(task) for session in self._rooms.values()) + + def room(self, room_id: str) -> ClaudeRoomSession: + """The room's session record, registered on first use.""" + self.require_admission() if (session := self._rooms.get(room_id)) is None: session = self._rooms[room_id] = ClaudeRoomSession( self, room_id, self._retention @@ -423,14 +618,36 @@ def mcp_servers(self, room_id: str) -> dict[str, McpServerConfig] | str | Path: return self.base_options.mcp_servers return self._mcp_servers_factory(room_id) + def protected_servers(self, room_id: str) -> dict[str, McpServerConfig]: + """Band's protected servers, separate from the resolver's complete result.""" + return ( + {} + if self._mcp_servers_factory is None + else self._mcp_servers_factory(room_id) + ) + + def workspace(self, room_id: str) -> str: + if self._workspaces is not None: + return self._workspaces.claim(room_id) + return str(self.base_options.cwd or Path.cwd()) + def options_for( - self, session: ClaudeRoomSession, generation: int, *, resume: str | None + self, + session: ClaudeRoomSession, + generation: int, + *, + resume: str | None, + mcp_servers: str | None = None, ) -> ClaudeAgentOptions: """``base_options`` with one room session's overrides applied. ``dataclasses.replace()`` shallow-copies every base field. """ - overrides: dict[str, Any] = {"mcp_servers": self.mcp_servers(session.room_id)} + overrides: dict[str, Any] = { + "mcp_servers": self.mcp_servers(session.room_id) + if mcp_servers is None + else mcp_servers + } if self._workspaces is not None: overrides["cwd"] = self._workspaces.claim(session.room_id) if resume: @@ -457,10 +674,12 @@ async def cleanup_session(self, room_id: str) -> None: if (session := self._rooms.get(room_id)) is not None: await session.retained.dispose() - async def stop(self) -> None: + async def stop( + self, *, report_timeout_s: float | None | Literal["configured"] = "configured" + ) -> None: """Dispose every room; a failed or unfinished room stays owned for a retry, and the manager never serves again.""" - await self._retention.stop() + await self._retention.stop(report_timeout_s=report_timeout_s) def has_session(self, room_id: str) -> bool: """Whether the room has a CLI process held.""" diff --git a/src/band/integrations/claude_sdk/transport.py b/src/band/integrations/claude_sdk/transport.py index a157c0478..fe810306c 100644 --- a/src/band/integrations/claude_sdk/transport.py +++ b/src/band/integrations/claude_sdk/transport.py @@ -1,13 +1,97 @@ -"""Claude subprocess construction, including SDK permission normalization.""" +"""Claude subprocess construction, including SDK permission normalization, and +custody of the CLI child until its exit is confirmed.""" from __future__ import annotations +import asyncio +import contextlib +from collections.abc import AsyncIterator +from typing import Any + from claude_agent_sdk import ClaudeAgentOptions from claude_agent_sdk._internal.transport import Transport from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport from claude_agent_sdk.types import _configure_can_use_tool +CHILD_EXIT_TIMEOUT_SECONDS = 5.0 +"""How long a child the native close left running may take to die once killed.""" + + +class ClaudeProcessCloseError(RuntimeError): + """The Claude CLI child could not be confirmed exited; its transport keeps + the child for a later ``close()`` to retry.""" + + +class ConfirmedCloseTransport(Transport): + """The SDK's subprocess transport, holding the CLI child until it is reaped. + + The native ``close()`` can return after its waits time out with the child + still running, and it forgets the child either way, so neither its return + nor a cleared handle proves exit. This wrapper keeps the child it saw + spawned, kills and reaps one that a normal close left running, and keeps + the child (raising ``ClaudeProcessCloseError``) when that cannot be + confirmed. A close that raised or was cancelled also keeps the child, so a + later ``close()`` retries. + """ + + def __init__(self, inner: Transport) -> None: + self._inner = inner + self._child: Any = None + + @property + def close_pending(self) -> bool: + """Whether a child is held whose exit is not yet confirmed.""" + return self._child is not None + + def _observe_child(self) -> None: + if (process := getattr(self._inner, "_process", None)) is not None: + self._child = process + + async def connect(self) -> None: + try: + await self._inner.connect() + finally: + self._observe_child() + + async def write(self, data: str) -> None: + await self._inner.write(data) + + def read_messages(self) -> AsyncIterator[dict[str, Any]]: + return self._inner.read_messages() + + def is_ready(self) -> bool: + return self._inner.is_ready() + + async def end_input(self) -> None: + await self._inner.end_input() + + async def close(self) -> None: + self._observe_child() + await self._inner.close() + await self._confirm_exit() + + async def _confirm_exit(self) -> None: + """Kill and reap a child the native close left running, within + ``CHILD_EXIT_TIMEOUT_SECONDS``; otherwise keep it and raise.""" + child = self._child + if child is None: + return + if child.returncode is None: + try: + with contextlib.suppress(ProcessLookupError): + child.kill() + async with asyncio.timeout(CHILD_EXIT_TIMEOUT_SECONDS): + await child.wait() + except Exception as error: # noqa: BLE001 -- unconfirmed exit, whatever the cause + raise ClaudeProcessCloseError( + "the Claude CLI process could not be confirmed exited" + f" ({type(error).__name__})" + ) from None + self._child = None + def create_transport(options: ClaudeAgentOptions) -> Transport: """Keep permission routing identical to the SDK's default transport.""" - return SubprocessCLITransport(prompt="", options=_configure_can_use_tool(options)) + return ConfirmedCloseTransport( + SubprocessCLITransport(prompt="", options=_configure_can_use_tool(options)) + ) diff --git a/tests/adapters/claude_sdk/external_mcp.py b/tests/adapters/claude_sdk/external_mcp.py new file mode 100644 index 000000000..c9a3f318a --- /dev/null +++ b/tests/adapters/claude_sdk/external_mcp.py @@ -0,0 +1,81 @@ +"""A real local MCP server outside Band that only accepts its current bearer +grant, recording every request it sees (for connection-time resolver tests).""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +import uvicorn +from mcp.server.fastmcp import FastMCP + + +@dataclass +class ExternalMcp: + url: str + grants: set[str] + # (grant presented, HTTP status) per request. + receipts: list[tuple[str, int]] = field(default_factory=list) + + def server(self, grant: str, **extra: Any) -> dict[str, Any]: + """The native config a resolver returns for this server and grant.""" + return { + "type": "http", + "url": self.url, + "headers": {"Authorization": f"Bearer {grant}"}, + **extra, + } + + +@asynccontextmanager +async def external_mcp(*grants: str) -> AsyncIterator[ExternalMcp]: + mcp = FastMCP("external", stateless_http=True) + + @mcp.tool() + def echo(text: str) -> str: + return text + + inner = mcp.streamable_http_app() + state = ExternalMcp(url="", grants=set(grants)) + + async def app(scope: Any, receive: Any, send: Any) -> None: + if scope["type"] != "http": + await inner(scope, receive, send) + return + auth = dict(scope["headers"]).get(b"authorization", b"").decode() + grant = auth.removeprefix("Bearer ") + if grant not in state.grants: + state.receipts.append((grant, 401)) + await send({"type": "http.response.start", "status": 401, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + return + status: dict[str, int] = {} + + async def recording(message: Any) -> None: + if message["type"] == "http.response.start": + status["code"] = message["status"] + await send(message) + + await inner(scope, receive, recording) + state.receipts.append((grant, status.get("code", 0))) + + server = uvicorn.Server( + uvicorn.Config( + app, host="127.0.0.1", port=0, log_level="warning", lifespan="on" + ) + ) + serving = asyncio.create_task(server.serve()) + try: + while not server.started: + if serving.done(): + await serving + await asyncio.sleep(0.01) + port = server.servers[0].sockets[0].getsockname()[1] + state.url = f"http://127.0.0.1:{port}/mcp" + yield state + finally: + server.should_exit = True + await serving diff --git a/tests/adapters/claude_sdk/fakecli.py b/tests/adapters/claude_sdk/fakecli.py index 7e851c9d6..3c2cd63cc 100644 --- a/tests/adapters/claude_sdk/fakecli.py +++ b/tests/adapters/claude_sdk/fakecli.py @@ -17,6 +17,7 @@ from collections.abc import AsyncIterator, Callable, Iterator, Sequence from contextlib import AbstractAsyncContextManager, contextmanager from dataclasses import dataclass +from pathlib import Path from typing import Any from unittest.mock import patch @@ -158,6 +159,11 @@ def __init__(self) -> None: # Parks the next close; the session that reaches it takes it. self.closing: Hold | None = None self.refuse_close = False + # Scripted status replies repeat the last answer, as the native CLI does. + self.mcp_statuses: deque[list[dict[str, Any]]] = deque() + self.status_requests = 0 + self.status_hold: Hold | None = None + self.status_unsupported = False def script(self, *turns: Turn) -> None: self._turns.extend(turns) @@ -251,6 +257,10 @@ async def close(self) -> None: self.alive = False if self._turn is not None: self._turn.cancel() + for task in tuple(self._background): + task.cancel() + if self._background: + await asyncio.wait(self._background) self._outbox.put_nowait(None) def die(self) -> None: @@ -283,6 +293,11 @@ def _answer_sdk(self, message: dict[str, Any]) -> None: request = message["request"] if request["subtype"] == "initialize": self._hooks = request.get("hooks") or {} + if request["subtype"] == "mcp_status": + task = asyncio.create_task(self._answer_status(message["request_id"])) + self._background.add(task) + task.add_done_callback(self._background.discard) + return self._emit( type="control_response", response={ @@ -532,12 +547,66 @@ async def _execute(self, call: ToolCall) -> tuple[Any, bool]: content = [block.model_dump(exclude_none=True) for block in result.content] return content, result.isError + @property + def configured_servers(self) -> dict[str, Any]: + """The configured servers, read from the private file as the CLI reads it.""" + servers = self.options.mcp_servers + if isinstance(servers, dict): + return servers + return json.loads(Path(str(servers)).read_text())["mcpServers"] + def _mcp(self, server: str) -> AbstractAsyncContextManager[ClientSession]: """A session to the MCP server the options name, dialed by its URL.""" - servers = self.options.mcp_servers - assert isinstance(servers, dict), servers - config = servers[server] - return mcp_session(config["url"], BandMCPTransport(config["type"])) + config = self.configured_servers[server] + return mcp_session( + config["url"], BandMCPTransport(config["type"]), config.get("headers") + ) + + async def _mcp_status(self) -> dict[str, Any]: + """Report real handshakes, or scripted status with native credential fields.""" + self.claude.status_requests += 1 + if self.claude.mcp_statuses: + statuses = self.claude.mcp_statuses + entries = statuses.popleft() if len(statuses) > 1 else statuses[0] + return {"mcpServers": entries} + entries = [] + for name, config in self.configured_servers.items(): + entry: dict[str, Any] = {"name": name, "config": config} + if config.get("type") in ("http", "sse"): + try: + async with asyncio.timeout(5), self._mcp(name): + pass + entry["status"] = "connected" + except Exception as error: # noqa: BLE001 -- the CLI reports any handshake failure + entry["status"] = "failed" + entry["error"] = f"{type(error).__name__}: {config}" + else: + entry["status"] = "pending" + entries.append(entry) + return {"mcpServers": entries} + + async def _answer_status(self, request_id: str) -> None: + if (hold := self.claude.status_hold) is not None: + hold.reached.set() + await hold.released.wait() + if self.claude.status_unsupported: + self._emit( + type="control_response", + response={ + "subtype": "error", + "request_id": request_id, + "error": "Unsupported control request subtype: mcp_status", + }, + ) + return + self._emit( + type="control_response", + response={ + "subtype": "success", + "request_id": request_id, + "response": await self._mcp_status(), + }, + ) def _tool_result(self, tool_use_id: str, content: Any, *, is_error: bool) -> None: self._emit( diff --git a/tests/adapters/claude_sdk/helpers.py b/tests/adapters/claude_sdk/helpers.py index 27bd0e697..9ad47fdd9 100644 --- a/tests/adapters/claude_sdk/helpers.py +++ b/tests/adapters/claude_sdk/helpers.py @@ -6,11 +6,16 @@ import copy import itertools import json -from collections.abc import Awaitable, Callable +import shutil +from collections.abc import Awaitable, Callable, Iterator +from contextlib import contextmanager from datetime import UTC, datetime +from pathlib import Path from typing import Any from urllib.parse import urlsplit +import pytest +from claude_agent_sdk import ClaudeSDKClient from claude_agent_sdk.types import Message from band.adapters.claude_sdk import ( @@ -61,6 +66,35 @@ NO_SETTLE_GRACE = ClaudeSDKAdapterConfig(turn_settle_timeout_s=0) +@contextmanager +def refuse_mcp_removal(path: Path) -> Iterator[None]: + """Refuse deletion before any private file is removed.""" + remove = shutil.rmtree + + def refuse(directory: str | Path, *args: Any, **kwargs: Any) -> None: + if Path(directory) == path.parent: + raise PermissionError("private MCP removal refused") + remove(directory, *args, **kwargs) + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(shutil, "rmtree", refuse) + yield + + +async def lease_client( + manager: ClaudeSessionManager, + room_id: str, + *, + resume_session_id: str | None = None, +) -> ClaudeSDKClient: + """Acquire a resident client through the current retained-room contract.""" + session = manager.room(room_id) + session.retire_outdated() + session.adopt(resume_session_id) + async with session.using() as client: + return client + + async def exchange(session: ClaudeRoomSession, prompt: str) -> list[Message]: """One leased query through the room's single reader, up to its result.""" async with session.using() as client: diff --git a/tests/adapters/claude_sdk/lifecycle/test_external_cancellation.py b/tests/adapters/claude_sdk/lifecycle/test_external_cancellation.py new file mode 100644 index 000000000..df5c8b3bb --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_external_cancellation.py @@ -0,0 +1,141 @@ +"""External worker cancellation keeps Claude and runtime custody through settlement.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping +from datetime import UTC, datetime +from typing import Any + +import pytest + +from band.adapters.claude_sdk import ClaudeSDKAdapterConfig +from band.core.exceptions import CleanupIncompleteError +from band.core.turn_lifecycle import CYCLE_CANCEL_GRACE_SECONDS +from band.core.types import AgentInput, HistoryProvider, PlatformMessage +from band.platform.link import BandLink +from band.runtime.execution import ExecutionContext +from band.runtime.tools import AgentTools +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, ClaudeRoom +from tests.conftest import make_message_event +from tests.runtime.helpers import LifecyclePlatform, rest_client_over + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + + +class TestExternalCancellation: + @pytest.mark.parametrize("cleanup", ["room", "adapter"]) + async def test_external_worker_cancel_keeps_handler_and_owner_until_resolver_settles( + self, claude_room: OpenRoom, monkeypatch: pytest.MonkeyPatch, cleanup: str + ) -> None: + resolving, settling = Hold(), Hold() + native_constructions: list[tuple[Any, ...]] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + resolving.reached.set() + try: + await resolving.released.wait() + except asyncio.CancelledError: + settling.reached.set() + await settling.released.wait() + raise + return {} + + def refuse_native_client(*args: Any, **kwargs: Any) -> None: + native_constructions.append(args) + raise AssertionError( + "retired resolution must not construct a native client" + ) + + monkeypatch.setattr( + "band.integrations.claude_sdk.session_manager.ClaudeSDKClient", + refuse_native_client, + ) + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + manager = room.adapter._session_manager + assert manager is not None + peer = LifecyclePlatform() + async with rest_client_over(peer.answer) as rest: + link = BandLink(agent_id="agent-123", api_key="test") + link.rest = rest + + async def handle(ctx: ExecutionContext, event: Any) -> None: + await room.adapter.on_event( + AgentInput( + msg=PlatformMessage( + id=event.payload.id, + room_id=room.room_id, + content="start", + sender_id="user-456", + sender_type="User", + sender_name="User", + message_type="text", + metadata={}, + created_at=datetime.now(UTC), + ), + tools=AgentTools.from_context(ctx), + history=HistoryProvider(raw=[]), + participants_msg=None, + contacts_msg=None, + is_session_bootstrap=True, + room_id=room.room_id, + ) + ) + + ctx = ExecutionContext(room.room_id, link, handle) + scope = await ctx._begin_scope() + assert scope is not None + running = asyncio.create_task( + ctx._run_cycle(scope, make_message_event(room_id=room.room_id), None) + ) + cleaning: asyncio.Task[None] | None = None + try: + await asyncio.wait_for(resolving.reached.wait(), timeout=5.0) + owner = room.adapter._turn_tasks[room.room_id] + handler = scope.handler + assert handler is not None and owner in scope.workers + owner.task.cancel() + await asyncio.wait_for(settling.reached.wait(), timeout=5.0) + cleaning = asyncio.create_task( + room.leave() if cleanup == "room" else room.adapter.cleanup_all() + ) + await asyncio.wait({cleaning}, timeout=2 * CYCLE_CANCEL_GRACE_SECONDS) + assert owner.task.cancelling() == 1 + assert room.adapter._turn_tasks.get(room.room_id) is owner + assert owner in scope.workers and not owner.task.done() + assert ( + ctx._active_cycle_task is handler.task and not handler.task.done() + ) + assert not scope.settled + assert manager._rooms + assert not settling.released.is_set() + assert native_constructions == [] + assert room.claude.sessions == [] + assert cleaning.done(), "cleanup must report bounded incompleteness" + assert isinstance(cleaning.exception(), CleanupIncompleteError) + finally: + resolving.released.set() + settling.released.set() + await asyncio.gather(running, return_exceptions=True) + if cleaning is not None: + await asyncio.gather(cleaning, return_exceptions=True) + await room.leave() + await room.adapter.cleanup_all() + + assert owner.task.cancelling() == 1 + assert owner.task.done() and handler.task.done() + assert scope.settled and ctx._active_cycle_task is None + assert ctx._detached_cycle_tasks == {} + assert not manager._rooms + assert room.adapter._turn_tasks == {} + assert room.adapter._turn_release == {} + assert room.adapter._room_tools == {} + assert room.adapter._mcp_room_tools == {} + assert room.adapter._mcp._closing is None + assert room.adapter._mcp.current is None + assert native_constructions == [] + assert room.claude.sessions == [] diff --git a/tests/adapters/claude_sdk/lifecycle/test_startup_owner.py b/tests/adapters/claude_sdk/lifecycle/test_startup_owner.py new file mode 100644 index 000000000..0e5d7cd62 --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_startup_owner.py @@ -0,0 +1,488 @@ +"""The room's shared turn owner covers asynchronous Claude preparation too.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping +from contextlib import nullcontext +from pathlib import Path +from typing import Any + +import pytest + +from band.adapters.claude_sdk import ClaudeSDKAdapterConfig +from band.client.streaming import ControlMode +from band.core.exceptions import CleanupIncompleteError, InputNotAccepted +from band.core.turn_lifecycle import TurnTask +from band.integrations.claude_sdk.mcp_startup import ClaudeMcpStartupError +from tests.adapters.claude_sdk.fakecli import Hold +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, ClaudeRoom +from tests.conftest import eager_tasks, requires_eager_tasks + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + + +class TestStartupOwner: + @pytest.mark.looptime + async def test_the_provider_deadline_starts_after_preparation( + self, claude_room: OpenRoom + ) -> None: + hold = Hold() + + async def resolver(workspace: str) -> Mapping[str, Any]: + hold.reached.set() + await hold.released.wait() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(turn_timeout_s=2.0), + resolve_mcp_servers=resolver, + ) + room.claude.script([room.model_reply("Ready.")]) + sending = room.send_in_background("hello") + try: + await hold.reached.wait() + await asyncio.wait({sending}, timeout=4.0) + assert not sending.done() + assert room.claude.prompts == [] + finally: + hold.released.set() + await asyncio.gather(sending, return_exceptions=True) + await sending + assert room.chat == ["Ready."] + assert room.failures == [] + + async def test_a_resolving_turn_refuses_overlap_without_rebinding_tools( + self, claude_room: OpenRoom + ) -> None: + hold = Hold() + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + hold.reached.set() + await hold.released.wait() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + accepted, busy = room.fresh_tools(), room.fresh_tools() + room.claude.script([room.model_reply("First.")]) + sending = asyncio.create_task(room.send("first", tools=accepted)) + try: + await hold.reached.wait() + with pytest.raises(InputNotAccepted): + await room.send("overlap", tools=busy) + assert not busy.turn.complete + assert room.claude.prompts == [] + finally: + hold.released.set() + await sending + + assert accepted.turn.replied + assert not busy.turn.replied + assert len(calls) == 1 + assert room.chat == ["First."] + room.claude.script([room.model_reply("Second.")]) + await room.send("again", tools=busy) + assert busy.turn.replied + assert len(calls) == 1 + assert len(room.claude.sessions) == 1 + + @pytest.mark.parametrize("phase", ["resolution", "readiness"]) + async def test_stop_retains_startup_custody_until_settlement( + self, claude_room: OpenRoom, phase: str + ) -> None: + hold, closing = Hold(), Hold() + cancelled = asyncio.Event() + cancellations: list[str] = [] + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + if phase == "resolution" and len(calls) == 1: + hold.reached.set() + try: + await hold.released.wait() + except asyncio.CancelledError: + cancellations.append(workspace) + cancelled.set() + await hold.released.wait() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + if phase == "readiness": + room.claude.status_hold = hold + room.claude.closing = closing + sending = room.send_in_background("start") + stopping: asyncio.Task[None] | None = None + config_file: Path | None = None + try: + await hold.reached.wait() + if phase == "readiness": + config_file = Path(str(room.claude.sessions[0].options.mcp_servers)) + assert config_file.exists() + stopping = asyncio.create_task( + room.adapter.on_interrupt(room.room_id, ControlMode.STOP) + ) + if phase == "resolution": + await cancelled.wait() + else: + await closing.reached.wait() + await asyncio.gather( + room.adapter.on_interrupt(room.room_id, ControlMode.STOP), + room.adapter.on_interrupt(room.room_id, ControlMode.STOP), + ) + with pytest.raises(CleanupIncompleteError): + await room.leave() + with pytest.raises(InputNotAccepted): + await room.send("overlap") + manager = room.adapter._session_manager + assert manager is not None and bool(manager._rooms) + assert room.claude.prompts == [] + if phase == "resolution": + assert len(cancellations) == 1 + assert room.claude.sessions == [] + else: + assert room.claude.sessions[0].alive + assert config_file is not None and config_file.exists() + finally: + hold.released.set() + closing.released.set() + room.claude.status_hold = None + room.claude.closing = None + if stopping is not None: + await stopping + await asyncio.gather(sending, return_exceptions=True) + + with pytest.raises(asyncio.CancelledError): + await sending + await room.settled() + await room.leave() + assert manager is not None and not bool(manager._rooms) + if config_file is not None: + assert not config_file.exists() + assert not room.claude.sessions[0].alive + assert room.failures == [] + room.claude.script([room.model_reply("Fresh.")]) + await room.send("again") + assert len(calls) == 2 + assert room.chat == ["Fresh."] + + async def test_a_held_room_retirement_does_not_block_another_room( + self, claude_room: OpenRoom + ) -> None: + hold = Hold() + cancelled = asyncio.Event() + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + if workspace.endswith("room-1") and not hold.released.is_set(): + hold.reached.set() + try: + await hold.released.wait() + except asyncio.CancelledError: + cancelled.set() + await hold.released.wait() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + sending = room.send_in_background("start") + leaving: asyncio.Task[None] | None = None + try: + await hold.reached.wait() + leaving = asyncio.create_task(room.leave()) + await cancelled.wait() + other = room.beside("room-2") + room.claude.script([other.model_reply("Independent.")]) + await other.send("hello") + with pytest.raises(InputNotAccepted): + await room.send("overlap") + assert room.chat == [] + assert other.chat == ["Independent."] + finally: + hold.released.set() + await asyncio.gather(sending, return_exceptions=True) + if leaving is not None: + try: + await leaving + except CleanupIncompleteError: + pass + + with pytest.raises(asyncio.CancelledError): + await sending + await room.settled() + await room.leave() + room.claude.script([room.model_reply("Rejoined.")]) + await room.send("again") + assert len(calls) == 3 + assert room.chat == ["Rejoined."] + assert room.failures == [] + + async def test_registration_failure_launches_nothing_and_leaves_no_worker( + self, claude_room: OpenRoom, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls: list[str] = [] + workers: list[asyncio.Task[None]] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return {} + + def refuse_registration(owner: TurnTask) -> None: + workers.append(owner.task) + raise RuntimeError("turn registration refused") + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + refused = room.fresh_tools() + monkeypatch.setattr( + refused, "register_turn_task", refuse_registration, raising=False + ) + with pytest.raises(RuntimeError, match="turn registration refused"): + await room.send("refused", tools=refused) + + assert workers and all(worker.done() for worker in workers) + assert not room.adapter._turn_running(room.room_id) + assert room.claude.sessions == [] + assert calls == [] + assert room.chat == [] + assert room.failures == [] + room.claude.script([room.model_reply("Accepted.")]) + await room.send("again") + assert len(calls) == 1 + assert room.chat == ["Accepted."] + + @requires_eager_tasks + @pytest.mark.parametrize("cooperative", [False, True]) + async def test_a_scope_stopped_at_registration_never_starts_preparation( + self, + claude_room: OpenRoom, + monkeypatch: pytest.MonkeyPatch, + cooperative: bool, + ) -> None: + calls: list[str] = [] + workers: list[asyncio.Task[None]] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return {} + + def stopped_registration(owner: TurnTask) -> None: + workers.append(owner.task) + owner.terminate(cooperative=cooperative) + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + stopped = room.fresh_tools() + monkeypatch.setattr( + stopped, "register_turn_task", stopped_registration, raising=False + ) + with eager_tasks(), pytest.raises(asyncio.CancelledError): + await room.send("stopped", tools=stopped) + + assert workers and all(worker.done() for worker in workers) + assert not room.adapter._turn_running(room.room_id) + assert room.claude.sessions == [] + assert calls == [] + assert room.failures == [] + room.claude.script([room.model_reply("Accepted.")]) + await room.send("again") + assert room.chat == ["Accepted."] + + @requires_eager_tasks + async def test_resolver_preparation_runs_under_the_eager_bound_owner( + self, claude_room: OpenRoom + ) -> None: + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + room.claude.script([room.model_reply("Ready.")]) + with eager_tasks(): + await room.send("hello") + + assert len(calls) == 1 + assert room.chat == ["Ready."] + assert room.failures == [] + + @pytest.mark.parametrize( + "eager", + [ + pytest.param(False, id="default"), + pytest.param(True, marks=requires_eager_tasks, id="eager"), + ], + ) + async def test_a_resolver_can_clean_up_its_own_adapter_room( + self, claude_room: OpenRoom, tmp_path: Path, eager: bool + ) -> None: + room_ref: list[ClaudeRoom] = [] + returned = asyncio.Event() + calls: list[str] = [] + workspace = tmp_path / "shared" + marker = workspace / "keep.txt" + + async def resolver(path: str) -> Mapping[str, Any]: + calls.append(path) + if len(calls) == 1: + room = room_ref[0] + manager = room.adapter._session_manager + assert manager is not None and bool(manager._rooms) + marker.write_text("membership files stay", encoding="utf-8") + await room.adapter.on_cleanup(room.room_id) + returned.set() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + workspace_for_room=lambda room_id: str(workspace), + resolve_mcp_servers=resolver, + ) + room_ref.append(room) + with eager_tasks() if eager else nullcontext(): + async with asyncio.timeout(5): + [outcome] = await asyncio.gather( + room.send("retire this startup"), return_exceptions=True + ) + + assert returned.is_set(), "self-cleanup must return to its resolver" + assert isinstance(outcome, (ClaudeMcpStartupError, asyncio.CancelledError)) + if isinstance(outcome, ClaudeMcpStartupError): + assert outcome.stage == "retirement" + assert room.claude.sessions == [] + assert room.claude.prompts == [] + assert room.chat == [] + assert room.failures == [] + await room.leave() + manager = room.adapter._session_manager + assert manager is not None and not bool(manager._rooms) + assert marker.read_text(encoding="utf-8") == "membership files stay" + + other = room.beside("room-2") + room.claude.script([other.model_reply("Reclaimed.")]) + await other.send("use the released workspace") + assert calls == [str(workspace), str(workspace)] + assert room.chat == [] + assert other.chat == ["Reclaimed."] + + async def test_a_resolver_closes_a_foreign_room_without_deferring_its_turn( + self, claude_room: OpenRoom + ) -> None: + room_ref: list[ClaudeRoom] = [] + returned = asyncio.Event() + + async def resolver(workspace: str) -> Mapping[str, Any]: + if workspace.endswith("room-1"): + await room_ref[0].adapter.on_cleanup("room-2") + returned.set() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + resolve_mcp_servers=resolver, + ) + room_ref.append(room) + other = room.beside("room-2") + work = Hold() + room.claude.script( + [work], + [room.model_reply("Current.")], + [other.model_reply("Fresh.")], + ) + sending = other.send_in_background("hold this turn") + try: + await work.reached.wait() + await room.send("clean up the other room") + finally: + work.released.set() + await asyncio.gather(sending, return_exceptions=True) + + with pytest.raises(asyncio.CancelledError): + await sending + assert returned.is_set() + assert room.chat == ["Current."] + assert other.chat == [] + assert room.failures == [] + assert other.failures == [] + assert not room.claude.sessions[0].alive + await other.send("rejoin") + assert other.chat == ["Fresh."] + assert room.claude.resumed == [None, None, None] + + @pytest.mark.parametrize("stop_after_cleanup", [False, True]) + async def test_self_cleanup_keeps_ownership_while_its_resolver_settles( + self, claude_room: OpenRoom, tmp_path: Path, stop_after_cleanup: bool + ) -> None: + room_ref: list[ClaudeRoom] = [] + hold = Hold() + calls: list[str] = [] + workspace = tmp_path / "shared" + + async def resolver(path: str) -> Mapping[str, Any]: + calls.append(path) + if len(calls) == 1: + room = room_ref[0] + await room.adapter.on_cleanup(room.room_id) + hold.reached.set() + await hold.released.wait() + return {} + + room = await claude_room( + ClaudeSDKAdapterConfig(retention=REPORTING_BUDGET, turn_settle_timeout_s=0), + workspace_for_room=lambda room_id: str(workspace), + resolve_mcp_servers=resolver, + ) + room_ref.append(room) + manager = room.adapter._session_manager + assert manager is not None + sending = room.send_in_background("retire this startup") + try: + await hold.reached.wait() + assert bool(manager._rooms) + assert room.adapter._turn_running(room.room_id) + with pytest.raises(InputNotAccepted): + await room.send("overlap") + if stop_after_cleanup: + await room.adapter.on_interrupt(room.room_id, ControlMode.STOP) + assert room.adapter._turn_running(room.room_id) + assert not sending.done() + with pytest.raises(CleanupIncompleteError): + await room.leave() + assert room.claude.sessions == [] + assert room.claude.prompts == [] + finally: + hold.released.set() + [outcome] = await asyncio.gather(sending, return_exceptions=True) + + assert isinstance(outcome, (ClaudeMcpStartupError, asyncio.CancelledError)) + if isinstance(outcome, ClaudeMcpStartupError): + assert outcome.stage == "retirement" + assert room.failures == [] + await room.leave() + assert not bool(manager._rooms) + assert not room.adapter._turn_running(room.room_id) + other = room.beside("room-2") + room.claude.script([other.model_reply("Reclaimed.")]) + await other.send("use the released workspace") + assert calls == [str(workspace), str(workspace)] + assert room.chat == [] + assert other.chat == ["Reclaimed."] diff --git a/tests/adapters/claude_sdk/lifecycle/test_teardown_custody.py b/tests/adapters/claude_sdk/lifecycle/test_teardown_custody.py new file mode 100644 index 000000000..7c5498d0a --- /dev/null +++ b/tests/adapters/claude_sdk/lifecycle/test_teardown_custody.py @@ -0,0 +1,104 @@ +"""The adapter's teardown is one durable sequence that keeps the Band MCP +server until every Claude process it may serve is confirmed closed.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import ClaudeRoom + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + + +def hold_stop(room: ClaudeRoom) -> tuple[Hold, list[str]]: + backend = room.adapter._mcp.current + assert backend is not None + stop = backend.stop + hold, stops = Hold(), [] + + async def held() -> None: + hold.reached.set() + await hold.released.wait() + await stop() + stops.append("stopped") + + object.__setattr__(backend, "stop", held) + return hold, stops + + +class TestTeardownCustody: + async def test_a_cancelled_teardown_caller_does_not_abandon_the_band_server( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room() + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + hold, stops = hold_stop(room) + + caller = asyncio.create_task(room.adapter.cleanup_all()) + try: + await hold.reached.wait() + caller.cancel() + await asyncio.gather(caller, return_exceptions=True) + finally: + hold.released.set() + await room.adapter.cleanup_all() + + assert stops == ["stopped"] + assert room.adapter._mcp._closing is None + assert not claude.sessions[0].alive + + async def test_a_session_that_will_not_close_keeps_the_band_server_until_retry( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room() + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + backend = room.adapter._mcp.current + claude.refuse_close = True + + with pytest.raises(ExceptionGroup) as caught: + await room.adapter.cleanup_all() + assert caught.value.subgroup(RuntimeError) is not None + # The CLI may still dial it: the Band server keeps running. + assert room.adapter._mcp.current is backend + assert backend is not None and backend.is_running + + claude.refuse_close = False + await room.adapter.cleanup_all() + assert room.adapter._mcp.current is None + assert not backend.is_running + + async def test_a_failed_band_stop_is_retried_before_a_restart( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room() + backend = room.adapter._mcp.current + assert backend is not None + stop = backend.stop + attempts: list[Any] = [] + + async def failing_once() -> None: + attempts.append(None) + if len(attempts) == 1: + raise RuntimeError("stop failed") + await stop() + + object.__setattr__(backend, "stop", failing_once) + with pytest.raises(RuntimeError, match="stop failed"): + await room.adapter.cleanup_all() + assert room.adapter._mcp._closing is backend + + await room.adapter.on_started("Test Agent", "restarted") + + assert len(attempts) == 2 + assert room.adapter._mcp._closing is None + assert not backend.is_running + claude.script([room.model_reply("Back.")]) + await room.send("hi") + assert room.chat == ["Back."] diff --git a/tests/adapters/claude_sdk/mcpkit.py b/tests/adapters/claude_sdk/mcpkit.py new file mode 100644 index 000000000..e04cf59ad --- /dev/null +++ b/tests/adapters/claude_sdk/mcpkit.py @@ -0,0 +1,365 @@ +"""Connection snapshots, hostile callback values and retained startup doubles.""" + +from __future__ import annotations + +import asyncio +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from band.core.exceptions import CleanupIncompleteError +from band.integrations.claude_sdk.mcp_startup import startup_error +from band.integrations.claude_sdk.session_manager import ClaudeSessionManager +from band.runtime.tools import BAND_MCP_SERVER_NAME +from band.workspaces import RoomWorkspaces +from tests.adapters.claude_sdk.fakecli import FakeClaude, FakeCLISession +from tests.adapters.claude_sdk.helpers import REPORTING_BUDGET, lease_client + +GRANT_A = "grant-a-5ecret" +GRANT_B = "grant-b-5ecret" + + +class Resolver: + """Returns ``servers`` and records each workspace it was asked for.""" + + def __init__(self, servers: Mapping[str, Any]) -> None: + self.servers = servers + self.calls: list[str] = [] + + async def __call__(self, workspace: str) -> Mapping[str, Any]: + self.calls.append(workspace) + return self.servers + + +def launch_file(session: FakeCLISession) -> Path: + servers = session.options.mcp_servers + assert isinstance(servers, str), "a resolver's servers go through a file" + return Path(servers) + + +def connected(*names: str) -> list[dict[str, Any]]: + return [{"name": name, "status": "connected"} for name in names] + + +def assert_secret_free(secret: str, error: BaseException, logs: str) -> None: + rendered = [str(error), repr(error), logs] + chained: BaseException | None = error + while chained is not None: + rendered.append(str(chained)) + chained = chained.__cause__ or ( + None if chained.__suppress_context__ else chained.__context__ + ) + assert not any(secret in text for text in rendered) + + +class ExplodingServers(Mapping[str, Any]): + """A mapping that fails while being read, with a credential in its error.""" + + def __getitem__(self, key: str) -> Any: + raise LookupError(f"vault said {GRANT_A}") + + def __iter__(self) -> Any: + raise LookupError(f"vault said {GRANT_A}") + + def __len__(self) -> int: + return 1 + + +def private_files() -> set[Path]: + return set(Path(tempfile.gettempdir()).glob("band-claude-mcp-*/mcp.json")) + + +class MarkedServers(Mapping[str, Any]): + """A lazy mapping raising the module's own safe-looking error type, with a + credential in its reason and resume eligibility set.""" + + def __getitem__(self, key: str) -> Any: + raise startup_error("launch", f"token {GRANT_A}", resume_eligible=True) + + def __iter__(self) -> Any: + raise startup_error("launch", f"token {GRANT_A}", resume_eligible=True) + + def __len__(self) -> int: + return 1 + + +def item_failure(kind: str) -> BaseException: + if kind == "ordinary": + return LookupError(f"vault said {GRANT_A}") + if kind == "marked": + return startup_error("launch", f"token {GRANT_A}", resume_eligible=True) + # A callback-defined exception whose very class name carries the secret. + return type(f"Leak_{GRANT_A.replace('-', '_')}", (Exception,), {})() + + +class BadItem(tuple): + """A ``(name, config)`` item that fails when unpacked.""" + + failure: BaseException + + def __iter__(self) -> Any: + raise self.failure + + +class HostileItems(Mapping[str, Any]): + """A mapping whose items fail only once a caller unpacks them.""" + + def __init__(self, failure: BaseException) -> None: + self.failure = failure + + def __getitem__(self, key: str) -> Any: + return {"type": "stdio", "command": "tool"} + + def __iter__(self) -> Any: + return iter(["tool"]) + + def __len__(self) -> int: + return 1 + + def items(self) -> Any: + item = BadItem(("tool", {"type": "stdio", "command": "tool"})) + item.failure = self.failure + return [item] + + +class _ExplodingMeta(type): + """A metaclass whose class metadata raises a secret-bearing error.""" + + def __getattribute__(cls, name: str) -> Any: + if name in {"__module__", "__name__", "__qualname__"}: + raise LookupError(f"meta said {GRANT_A}") + return super().__getattribute__(name) + + +def metadata_attack(kind: str) -> BaseException: + secret_name = f"Leak_{GRANT_A.replace('-', '_')}" + if kind == "spoofed-module": + cls = type(secret_name, (Exception,), {"__module__": "builtins"}) + elif kind == "non-string-module": + cls = type(secret_name, (Exception,), {"__module__": 3}) + else: + cls = _ExplodingMeta(secret_name, (Exception,), {}) + return cls() + + +BAND = {BAND_MCP_SERVER_NAME: {"type": "http", "url": "http://127.0.0.1:9/band"}} +SECRET = "close-5ecret" + + +class HeldResolver: + """Resolves only when released; records calls and cancellations. With + ``swallow`` it finishes its own work despite a cancellation and returns.""" + + def __init__(self, *, swallow: bool = False) -> None: + self.entered = asyncio.Event() + self.release = asyncio.Event() + self.swallow = swallow + self.calls: list[str] = [] + self.cancellations = 0 + self.settled = asyncio.Event() + + async def __call__(self, workspace: str) -> Mapping[str, Any]: + self.calls.append(workspace) + self.entered.set() + try: + while True: + try: + await self.release.wait() + break + except asyncio.CancelledError: + self.cancellations += 1 + if not self.swallow: + raise + return {} + finally: + self.settled.set() + + +def manager_for( + tmp_path: Path, + resolver: Any, + claude: FakeClaude, + band: dict[str, Any] | None = None, +) -> ClaudeSessionManager: + """A manager whose protected Band server is ``band`` (changed in place to + model a replaced Band server).""" + protected = BAND if band is None else band + claude.mcp_statuses.append([{"name": BAND_MCP_SERVER_NAME, "status": "connected"}]) + return ClaudeSessionManager( + ClaudeAgentOptions(), + mcp_servers_factory=lambda room_id: dict(protected), + workspaces=RoomWorkspaces(lambda room_id: str(tmp_path / room_id)), + resolve_mcp_servers=resolver, + retention=REPORTING_BUDGET, + settle_grace_s=0, + ) + + +class SettlingResolver: + """Already being cancelled (by itself or its own room cleanup) when it + enters a settlement it must finish; logs any further cancellation.""" + + def __init__(self, *, retire_own_room: bool = False) -> None: + self.retire_own_room = retire_own_room + self.manager: ClaudeSessionManager | None = None + self.settling = asyncio.Event() + self.release = asyncio.Event() + self.log: list[str] = [] + + async def __call__(self, workspace: str) -> Mapping[str, Any]: + if self.retire_own_room: + assert self.manager is not None + with pytest.raises(CleanupIncompleteError): + await self.manager.cleanup_session("room-a") + else: + task = asyncio.current_task() + assert task is not None + task.cancel() + try: + await asyncio.sleep(0) + except asyncio.CancelledError: + pass # caught, never uncancelled + self.settling.set() + try: + await self.release.wait() + except asyncio.CancelledError: + self.log.append("settlement interrupted") + raise + self.log.append("settled") + return {} + + +class CountingResolver: + def __init__(self) -> None: + self.calls: list[str] = [] + + async def __call__(self, workspace: str) -> Mapping[str, Any]: + self.calls.append(workspace) + return {} + + +async def outdated_session( + tmp_path: Path, claude: FakeClaude +) -> tuple[ClaudeSessionManager, CountingResolver, dict[str, Any]]: + """A manager whose room-a session (A) dials a Band server since replaced.""" + resolver = CountingResolver() + band = dict(BAND) + manager = manager_for(tmp_path, resolver, claude, band) + await lease_client(manager, "room-a") + band[BAND_MCP_SERVER_NAME] = {"type": "http", "url": "http://127.0.0.1:9/band-2"} + return manager, resolver, band + + +class FailingBand: + """A protected-server factory that can be made to fail, with a secret.""" + + def __init__(self) -> None: + self.failing = False + + def __call__(self, room_id: str) -> dict[str, Any]: + if self.failing: + raise LookupError(f"band backend said {SECRET}") + return dict(BAND) + + +class ReuseProbe: + """The servers a held client dials, as a callback would hand them over: the + comparison answers with this callback's own object, whose truth is therefore + consulted wherever the decision is read (an ``and`` chain coerces each + earlier operand and leaves the final one, and the settled result is read + again at the branch). Closing admission from the second read models a + callback acting when the decision is read after the final admission guard; + closing on the first read is the early control. The client itself stays the + manager's real one, so its teardown stays owned.""" + + def __init__(self, manager: ClaudeSessionManager, mode: str) -> None: + self.manager = manager + self.mode = mode + self.reads = 0 + + def _decide(self) -> bool: + self.reads += 1 + closing_now = ( + self.reads == 1 if self.mode == "materialized" else self.reads >= 2 + ) + if closing_now: + self.manager.close_admission() + return True + + def __eq__(self, other: object) -> Any: + return self + + def __bool__(self) -> bool: + return self._decide() + + __hash__ = None # type: ignore[assignment] + + +class HeldServers: + """A stand-in for the held client's options: only its servers are read.""" + + def __init__(self, servers: Any) -> None: + self.mcp_servers = servers + + +class ClientCustody: + """What teardown must leave behind on an owned client: one disconnect, its + Query closed on both sides, and every reference dropped. A manager that + reports nothing held while the client it owned stays connected is the + failure this pins, so the checks read the client, not the manager.""" + + def __init__(self, client: ClaudeSDKClient) -> None: + self.client = client + self.query = client._query + self.disconnects = 0 + self.calls: list[str] = [] + assert self.query is not None, "the client was never connected" + query, real_disconnect = self.query, client.disconnect + real_close, real_close_receive = query.close, query.close_receive_stream + + async def disconnect() -> None: + self.disconnects += 1 + await real_disconnect() + + async def close() -> None: + self.calls.append("close") + await real_close() + + def close_receive() -> None: + self.calls.append("receive") + real_close_receive() + + client.disconnect = disconnect # type: ignore[method-assign] + query.close = close # type: ignore[method-assign] + query.close_receive_stream = close_receive # type: ignore[method-assign] + + def assert_disconnected(self) -> None: + assert self.disconnects == 1, f"disconnect ran {self.disconnects} times" + assert self.calls == ["close", "receive"], self.calls + assert self.query._closed, "the client's Query stayed open" + assert self.client._query is None + assert self.client._transport is None + assert self.client._materialized is None + + +class RaisingTruth: + """A callback's servers whose comparison answers with the callback's own + object and whose truth test raises a secret-bearing error of a builtin + type, as a callback-computed step could.""" + + def __init__(self, text: str) -> None: + self.text = text + self.reads = 0 + + def __eq__(self, other: object) -> Any: + return self + + def __bool__(self) -> bool: + self.reads += 1 + raise LookupError(self.text) + + __hash__ = None # type: ignore[assignment] diff --git a/tests/adapters/claude_sdk/process.py b/tests/adapters/claude_sdk/process.py index 001a995b0..0d240250a 100644 --- a/tests/adapters/claude_sdk/process.py +++ b/tests/adapters/claude_sdk/process.py @@ -101,3 +101,10 @@ def launch(*, prompt: str, options: ClaudeAgentOptions) -> Child: monkeypatch.setattr(transport, "SubprocessCLITransport", launch) return children + + +class ForgetfulProcess(WorkspaceProcess): + """A native close that returns while its child still runs and forgets it.""" + + async def close(self) -> None: + self._process = None diff --git a/tests/adapters/claude_sdk/test_cli_options.py b/tests/adapters/claude_sdk/test_cli_options.py index 258f26cb0..9b3c7305f 100644 --- a/tests/adapters/claude_sdk/test_cli_options.py +++ b/tests/adapters/claude_sdk/test_cli_options.py @@ -22,6 +22,7 @@ ClaudeSDKAdapterConfig, ) from band.core.types import Emit +from band.integrations.claude_sdk import transport as transport_module from band.integrations.claude_sdk.transport import create_transport from tests.adapters.claude_sdk.helpers import ( SEND_MESSAGE_MCP_NAME, @@ -31,21 +32,6 @@ from tests.paths import host_absolute_path -def test_owned_transport_preserves_sdk_permission_routing() -> None: - async def allow( - tool_name: str, tool_input: dict[str, Any], context: ToolPermissionContext - ) -> PermissionResultAllow: - return PermissionResultAllow() - - options = ClaudeAgentOptions(cli_path=sys.executable, can_use_tool=allow) - transport = create_transport(options) - assert isinstance(transport, SubprocessCLITransport) - command = transport._build_command() - assert command[command.index("--permission-prompt-tool") + 1] == "stdio" - assert options.permission_prompt_tool_name is None - assert options.can_use_tool is allow - - async def _cli_options( room: ClaudeRoom, session_id: str | None = None ) -> ClaudeAgentOptions: @@ -70,87 +56,118 @@ def _emitted_flags(options: ClaudeAgentOptions) -> set[str]: } -async def test_cli_options_reach_the_cli(claude_room: OpenRoom, tmp_path: Path) -> None: - cli = ClaudeCLIOptions( - cli_path=host_absolute_path("opt", "claude", "bin", "claude"), - plugin_dirs=(str(tmp_path / "plugin"),), - add_dirs=(str(tmp_path / "shared"),), - env={"ANTHROPIC_API_KEY": "per-agent-key"}, - extra_args={"debug-to-stderr": None}, - ) - room = await claude_room(ClaudeSDKAdapterConfig(cli=cli)) - - options = await _cli_options(room) - - assert options.plugins == [{"type": "local", "path": str(tmp_path / "plugin")}] - assert options.cli_path == cli.cli_path - assert options.env == {"ANTHROPIC_API_KEY": "per-agent-key"} - assert options.add_dirs == [str(tmp_path / "shared")] - assert options.extra_args == {"debug-to-stderr": None} +class TestCliOptions: + def test_owned_transport_preserves_sdk_permission_routing( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The CLI the adapter spawns is told to route permission prompts to the + SDK, without the caller's options being rewritten.""" + + async def allow( + tool_name: str, tool_input: dict[str, Any], context: ToolPermissionContext + ) -> PermissionResultAllow: + return PermissionResultAllow() + + spawned: list[SubprocessCLITransport] = [] + + def spawn( + *, prompt: str, options: ClaudeAgentOptions + ) -> SubprocessCLITransport: + native = SubprocessCLITransport(prompt=prompt, options=options) + spawned.append(native) + return native + + monkeypatch.setattr(transport_module, "SubprocessCLITransport", spawn) + options = ClaudeAgentOptions(cli_path=sys.executable, can_use_tool=allow) + create_transport(options) + + [native] = spawned + command = native._build_command() + assert command[command.index("--permission-prompt-tool") + 1] == "stdio" + assert options.permission_prompt_tool_name is None + assert options.can_use_tool is allow + + async def test_cli_options_reach_the_cli( + self, claude_room: OpenRoom, tmp_path: Path + ) -> None: + cli = ClaudeCLIOptions( + cli_path=host_absolute_path("opt", "claude", "bin", "claude"), + plugin_dirs=(str(tmp_path / "plugin"),), + add_dirs=(str(tmp_path / "shared"),), + env={"ANTHROPIC_API_KEY": "per-agent-key"}, + extra_args={"debug-to-stderr": None}, + ) + room = await claude_room(ClaudeSDKAdapterConfig(cli=cli)) + options = await _cli_options(room) -async def test_plugins_leave_band_wiring_in_place( - claude_room: OpenRoom, tmp_path: Path -) -> None: - room = await claude_room( - ClaudeSDKAdapterConfig(cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),))) - ) + assert options.plugins == [{"type": "local", "path": str(tmp_path / "plugin")}] + assert options.cli_path == cli.cli_path + assert options.env == {"ANTHROPIC_API_KEY": "per-agent-key"} + assert options.add_dirs == [str(tmp_path / "shared")] + assert options.extra_args == {"debug-to-stderr": None} - options = await _cli_options(room) - - assert list(options.mcp_servers) == ["band"] - assert SEND_MESSAGE_MCP_NAME in options.allowed_tools - assert options.setting_sources == [] - - -async def test_sdk_owned_flags_match_what_the_adapter_makes_the_sdk_emit( - claude_room: OpenRoom, tmp_path: Path -) -> None: - room = await claude_room( - ClaudeSDKAdapterConfig( - model="sonnet", - fallback_model="haiku", - effort="high", - max_thinking_tokens=1024, - cwd=tmp_path, - approvals=ClaudeApprovalOptions(mode="manual"), - cli=ClaudeCLIOptions( - plugin_dirs=(str(tmp_path),), add_dirs=(str(tmp_path),) - ), + async def test_plugins_leave_band_wiring_in_place( + self, claude_room: OpenRoom, tmp_path: Path + ) -> None: + room = await claude_room( + ClaudeSDKAdapterConfig(cli=ClaudeCLIOptions(plugin_dirs=(str(tmp_path),))) ) - ) - options = await _cli_options(room, session_id="sess-1") + options = await _cli_options(room) + + assert list(options.mcp_servers) == ["band"] + assert SEND_MESSAGE_MCP_NAME in options.allowed_tools + assert options.setting_sources == [] + + async def test_sdk_owned_flags_match_what_the_adapter_makes_the_sdk_emit( + self, claude_room: OpenRoom, tmp_path: Path + ) -> None: + room = await claude_room( + ClaudeSDKAdapterConfig( + model="sonnet", + fallback_model="haiku", + effort="high", + max_thinking_tokens=1024, + cwd=tmp_path, + approvals=ClaudeApprovalOptions(mode="manual"), + cli=ClaudeCLIOptions( + plugin_dirs=(str(tmp_path),), add_dirs=(str(tmp_path),) + ), + ) + ) - assert _emitted_flags(options) == SDK_OWNED_CLI_FLAGS + options = await _cli_options(room, session_id="sess-1") + assert _emitted_flags(options) == SDK_OWNED_CLI_FLAGS -@pytest.mark.parametrize( - ("emit", "display"), - [(Emit.THOUGHTS, "summarized"), (Emit.TOOL_CALLS, None)], - ids=["thoughts-posted", "thoughts-not-posted"], -) -async def test_thinking_text_is_requested_only_when_thoughts_are_posted( - claude_room: OpenRoom, emit: Emit, display: str | None -) -> None: - room = await claude_room( - ClaudeSDKAdapterConfig(max_thinking_tokens=1024), emit=emit + @pytest.mark.parametrize( + ("emit", "display"), + [(Emit.THOUGHTS, "summarized"), (Emit.TOOL_CALLS, None)], + ids=["thoughts-posted", "thoughts-not-posted"], ) + async def test_thinking_text_is_requested_only_when_thoughts_are_posted( + self, claude_room: OpenRoom, emit: Emit, display: str | None + ) -> None: + room = await claude_room( + ClaudeSDKAdapterConfig(max_thinking_tokens=1024), emit=emit + ) - options = await _cli_options(room) - - assert options.thinking is not None - assert options.thinking.get("display") == display - - -@pytest.mark.parametrize( - "flag", ["mcp-config", "effort", "settings", "dangerously-skip-permissions"] -) -def test_extra_args_cannot_replace_adapter_owned_flags(flag: str) -> None: - with pytest.raises(ValueError, match="adapter-owned CLI flags"): - ClaudeCLIOptions(extra_args={flag: "x"}) + options = await _cli_options(room) + assert options.thinking is not None + assert options.thinking.get("display") == display -def test_extra_args_keys_are_bare_flag_names() -> None: - with pytest.raises(ValueError, match="bare flag names"): - ClaudeCLIOptions(extra_args={"--debug-to-stderr": None}) + @pytest.mark.parametrize( + "flag", ["mcp-config", "effort", "settings", "dangerously-skip-permissions"] + ) + def test_extra_args_cannot_replace_adapter_owned_flags(self, flag: str) -> None: + with pytest.raises(ValueError, match="adapter-owned CLI flags"): + ClaudeCLIOptions(extra_args={flag: "x"}) + + def test_extra_args_keys_are_bare_flag_names( + self, + ) -> None: + with pytest.raises(ValueError, match="bare flag names"): + ClaudeCLIOptions(extra_args={"--debug-to-stderr": None}) diff --git a/tests/adapters/claude_sdk/test_mcp_resolver.py b/tests/adapters/claude_sdk/test_mcp_resolver.py new file mode 100644 index 000000000..d1a71ff2b --- /dev/null +++ b/tests/adapters/claude_sdk/test_mcp_resolver.py @@ -0,0 +1,873 @@ +"""``resolve_mcp_servers``: one frozen snapshot per new Claude connection, sent +through a private file, used only once every server reports connected.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import os +import stat +import subprocess +import sys +import tempfile +import typing +from collections.abc import Awaitable, Callable, Mapping +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient +from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport +from claude_agent_sdk.types import McpServerConfig + +from band.adapters.claude_sdk import ClaudeSDKAdapter +from band.core.exceptions import InputNotAccepted +from band.core.retention import Residency +from band.integrations.claude_sdk.mcp_startup import ( + ClaudeMcpStartupError, + McpConfigFile, + await_mcp_ready, +) +from band.integrations.claude_sdk.session_manager import ( + ClaudeSessionManager, + ClaudeSessionManagerStoppedError, +) +from band.runtime.tools import BAND_MCP_SERVER_NAME +from tests.adapters.claude_sdk.external_mcp import external_mcp +from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import ( + ClaudeRoom, + refuse_mcp_removal, + with_approvals, +) +from tests.baseline.decisions import ModelDecision + +OpenRoom = Callable[..., Awaitable[ClaudeRoom]] + +from tests.adapters.claude_sdk.mcpkit import ( + GRANT_A, + GRANT_B, + ExplodingServers, + HostileItems, + MarkedServers, + Resolver, + assert_secret_free, + connected, + item_failure, + launch_file, + metadata_attack, + private_files, +) + + +@pytest.fixture(autouse=True) +def private_tempdir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Private MCP files go to test-owned storage, so their inventory never + sees another process's files in the host's temp directory.""" + owned = tmp_path / "tmp" + owned.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(owned)) + return owned + + +class TestMcpResolver: + async def test_a_new_connection_dials_its_snapshot_and_reuse_never_resolves( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + async with external_mcp(GRANT_A) as ext: + resolver = Resolver({"ext": ext.server(GRANT_A)}) + room = await claude_room( + workspace_for_room=lambda room_id: str(tmp_path / room_id), + resolve_mcp_servers=resolver, + ) + claude.script([room.model_reply("One.")], [room.model_reply("Two.")]) + await room.send("first") + await room.send("second") + + assert resolver.calls == [str(tmp_path / "room-1")] + [session] = claude.sessions + assert set(session.configured_servers) == {"ext", BAND_MCP_SERVER_NAME} + # A real MCP handshake with grant A happened before the first prompt. + assert (GRANT_A, 200) in ext.receipts + assert room.chat == ["One.", "Two."] + + async def test_an_empty_result_launches_only_band( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room(resolve_mcp_servers=Resolver({})) + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + + assert set(claude.sessions[0].configured_servers) == {BAND_MCP_SERVER_NAME} + + async def test_the_launch_ignores_changes_to_what_the_callback_returned( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + async with external_mcp(GRANT_A) as ext: + returned = {"ext": ext.server(GRANT_A)} + + async def resolver(workspace: str) -> Mapping[str, Any]: + # The callback keeps changing its objects after handing them over. + asyncio.get_running_loop().call_soon( + returned["ext"]["headers"].__setitem__, "Authorization", "Bearer x" + ) + return returned + + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + + sent = json.loads(launch_file(claude.sessions[0]).read_text()) + assert sent["mcpServers"]["ext"]["headers"] == { + "Authorization": f"Bearer {GRANT_A}" + } + + async def test_a_rotated_grant_reaches_only_a_new_connection( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + async with external_mcp(GRANT_A) as ext: + resolver = Resolver({"ext": ext.server(GRANT_A)}) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script( + [room.model_reply("A.")], + [room.model_reply("Still A.")], + [room.model_reply("B.")], + ) + await room.send("one") + ext.grants = {GRANT_B} + resolver.servers = {"ext": ext.server(GRANT_B)} + await room.send("two") # reused: no resolution, no restart + assert len(resolver.calls) == 1 + assert len(claude.sessions) == 1 + + await room.leave() + await room.send("three") + + assert len(resolver.calls) == 2 + assert len(claude.sessions) == 2 + assert not claude.sessions[0].alive + assert (GRANT_B, 200) in ext.receipts + assert room.chat == ["A.", "Still A.", "B."] + + async def test_a_replaced_band_server_gets_a_new_connection( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + resolver = Resolver({}) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Before.")], [room.model_reply("After.")]) + await room.send("one") + first = claude.sessions[0].configured_servers[BAND_MCP_SERVER_NAME]["url"] + await room.crash_band_server() + await room.send("two") + + assert len(claude.sessions) == 2 + assert not claude.sessions[0].alive + second = claude.sessions[1].configured_servers[BAND_MCP_SERVER_NAME]["url"] + assert first != second + assert len(resolver.calls) == 2 + assert room.chat == ["Before.", "After."] + + async def test_the_first_prompt_waits_for_every_server_to_connect( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + pending = [ + {"name": "ext", "status": "pending"}, + {"name": BAND_MCP_SERVER_NAME, "status": "connected"}, + ] + claude.mcp_statuses.extend( + [pending, pending, connected("ext", BAND_MCP_SERVER_NAME)] + ) + resolver = Resolver({"ext": {"type": "stdio", "command": "ext-server"}}) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Ready.")]) + await room.send("hi") + + assert claude.status_requests == 3 + assert room.chat == ["Ready."] + + @pytest.mark.parametrize( + "statuses", + [ + pytest.param( + [{"name": "ext", "status": "failed", "error": GRANT_A}], id="failed" + ), + pytest.param([{"name": "ext", "status": "needs-auth"}], id="needs-auth"), + pytest.param([{"name": "ext", "status": "disabled"}], id="disabled"), + pytest.param( + [*connected("ext", BAND_MCP_SERVER_NAME), *connected("ext")], + id="duplicate", + ), + pytest.param([{"name": "ext"}], id="malformed"), + pytest.param([{"name": "ext", "status": "weird"}], id="unknown"), + ], + ) + async def test_a_server_that_cannot_connect_fails_the_turn_safely( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + statuses: list[dict[str, Any]], + ) -> None: + claude.mcp_statuses.append( + [{"config": {"headers": {"Authorization": GRANT_A}}, **s} for s in statuses] + ) + resolver = Resolver( + {"ext": {"type": "stdio", "command": "ext", "env": {"T": GRANT_A}}} + ) + room = await claude_room(resolve_mcp_servers=resolver) + + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi") + + assert claude.prompts == [] + [session] = claude.sessions + assert not session.alive + assert not launch_file(session).exists() + assert len(room.failures) == 1 + assert_secret_free(GRANT_A, caught.value, caplog.text) + + @pytest.mark.looptime + async def test_servers_still_pending_at_the_deadline_fail_the_turn( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + claude.mcp_statuses.append( + [{"name": BAND_MCP_SERVER_NAME, "status": "connected"}] + ) + resolver = Resolver({"ext": {"type": "stdio", "command": "ext"}}) + room = await claude_room(resolve_mcp_servers=resolver) + loop = asyncio.get_running_loop() + started = loop.time() + + with pytest.raises(ClaudeMcpStartupError, match="deadline"): + await room.send("hi") + + assert 30 <= loop.time() - started < 31 + assert claude.prompts == [] + assert not claude.sessions[0].alive + + async def test_a_failing_resolver_fails_once_without_falling_back( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + ) -> None: + async def resolver(workspace: str) -> Mapping[str, Any]: + raise LookupError(f"grant store said {GRANT_A}") + + room = await claude_room(resolve_mcp_servers=resolver) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + # No retry without the history, and no CLI was ever started. + assert claude.sessions == [] + assert caught.value.stage == "resolution" + assert caught.value.error_type == "LookupError" + assert_secret_free(GRANT_A, caught.value, caplog.text) + + @pytest.mark.parametrize( + "servers", + [ + pytest.param( + {BAND_MCP_SERVER_NAME: {"type": "http", "url": "http://x"}}, + id="band-name", + ), + pytest.param({"inproc": {"type": "sdk", "name": "inproc"}}, id="sdk"), + pytest.param({"bad": {"type": "http"}}, id="no-url"), + ], + ) + async def test_unacceptable_servers_are_refused_by_name_before_launch( + self, claude_room: OpenRoom, claude: FakeClaude, servers: dict[str, Any] + ) -> None: + room = await claude_room(resolve_mcp_servers=Resolver(servers)) + with pytest.raises(ClaudeMcpStartupError) as caught: + await room.send("hi") + + assert claude.sessions == [] + assert caught.value.server == next(iter(servers)) + + async def test_an_external_tool_still_needs_host_permission( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + async with external_mcp(GRANT_A) as ext: + room = await claude_room( + resolve_mcp_servers=Resolver({"ext": ext.server(GRANT_A)}) + ) + claude.script( + [ + ModelDecision.call("mcp__ext__echo", text="hello"), + room.model_reply("Done."), + ] + ) + await room.send("hi") + + assert room.tool_errors == { + "mcp__ext__echo": True, + "band_send_message": False, + } + assert room.chat == ["Done."] + + async def test_the_native_launch_carries_a_private_file_path_not_credentials( + self, claude_room: OpenRoom, claude: FakeClaude, tmp_path: Path + ) -> None: + resolver = Resolver( + { + "ext": { + "type": "http", + "url": "http://127.0.0.1:9/mcp", + "headers": {"Authorization": f"Bearer {GRANT_A}"}, + } + } + ) + claude.mcp_statuses.append(connected("ext", BAND_MCP_SERVER_NAME)) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + + [session] = claude.sessions + path = launch_file(session) + native = SubprocessCLITransport( + prompt="", + options=ClaudeAgentOptions( + cli_path=sys.executable, mcp_servers=session.options.mcp_servers + ), + ) + argv = native._build_command() + assert argv[argv.index("--mcp-config") + 1] == str(path) + assert not any(GRANT_A in arg for arg in argv) + + await room.leave() + assert not path.exists() + assert not path.parent.exists() + + @pytest.mark.skipif( + os.name != "posix", + reason="POSIX modes do not prove Windows user-profile/temp-directory ACL privacy", + ) + async def test_the_private_launch_file_has_posix_permissions( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room(resolve_mcp_servers=Resolver({})) + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + path = launch_file(claude.sessions[0]) + try: + assert stat.S_IMODE(os.stat(path.parent).st_mode) == 0o700 + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + finally: + await room.leave() + + async def test_a_missing_history_still_falls_back_to_a_fresh_session( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + claude.unresumable = {"sess-gone"} + resolver = Resolver({}) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Fresh.")]) + await room.send("hi", session_id="sess-gone") + + assert claude.resumed == ["sess-gone", None] + # Each genuine connection resolved its own snapshot. + assert len(resolver.calls) == 2 + assert room.chat == ["Fresh."] + + @pytest.mark.parametrize("failure", ["unreadable-result", "typed-error"]) + async def test_callback_failures_are_sanitized_and_never_retried_fresh( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + failure: str, + ) -> None: + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + if failure == "typed-error": + raise ClaudeMcpStartupError( + "resolution", f"token {GRANT_A}", server=GRANT_A + ) + return ExplodingServers() + + room = await claude_room(resolve_mcp_servers=resolver) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + assert len(calls) == 1 + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + + async def test_no_prompt_before_a_held_status_reports_connected( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + hold = claude.status_hold = Hold() + room = await claude_room(resolve_mcp_servers=Resolver({})) + claude.script([room.model_reply("Ready.")]) + sending = room.send_in_background("hi") + try: + await hold.reached.wait() + assert claude.prompts == [] + session = room.session_manager.session(room.room_id) + assert ( + session is not None + and session.retained.snapshot.state == Residency.ACQUIRING + ) + finally: + hold.released.set() + await sending + assert room.chat == ["Ready."] + + @pytest.mark.looptime + async def test_a_status_request_that_never_answers_fails_at_the_readiness_deadline( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + hold = claude.status_hold = Hold() + room = await claude_room(resolve_mcp_servers=Resolver({})) + loop = asyncio.get_running_loop() + started = loop.time() + try: + with pytest.raises(ClaudeMcpStartupError, match="deadline"): + await room.send("hi") + # Bounded by the 30 s total, not the SDK's own 60 s per request. + assert 30 <= loop.time() - started < 31 + assert claude.prompts == [] + finally: + hold.released.set() + + async def test_a_cli_without_mcp_status_fails_safely( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + claude.status_unsupported = True + room = await claude_room(resolve_mcp_servers=Resolver({})) + with pytest.raises(ClaudeMcpStartupError, match="status request failed"): + await room.send("hi") + assert claude.prompts == [] + assert not claude.sessions[0].alive + + @pytest.mark.parametrize("held", ["resolution", "readiness"]) + async def test_a_band_server_replaced_during_startup_gets_a_new_connection( + self, claude_room: OpenRoom, claude: FakeClaude, held: str + ) -> None: + entered, release = asyncio.Event(), asyncio.Event() + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + if held == "resolution" and len(calls) == 1: + entered.set() + await release.wait() + return {} + + if held == "readiness": + hold = claude.status_hold = Hold() + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("Live.")]) + sending = room.send_in_background("hi") + try: + if held == "resolution": + await entered.wait() + else: + await hold.reached.wait() + old_url = room.adapter._room_mcp_servers(room.room_id)[ + BAND_MCP_SERVER_NAME + ]["url"] + await room.crash_band_server() + await room.adapter._mcp.ensure() + finally: + release.set() + if held == "readiness": + claude.status_hold = None + hold.released.set() + await sending + + live = claude.sessions[-1] + assert live.alive + assert live.configured_servers[BAND_MCP_SERVER_NAME]["url"] != old_url + assert all(not s.alive for s in claude.sessions[:-1]) + assert len(calls) == 2 + assert room.chat == ["Live."] + + async def test_a_resolver_requesting_teardown_behind_a_settling_turn_publishes_nothing( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + adapter_ref: list[Any] = [] + requested = asyncio.Event() + + async def resolver(workspace: str) -> Mapping[str, Any]: + if workspace.endswith("room-1"): + return {} + await adapter_ref[0].cleanup_all() + requested.set() + return {} + + room = await claude_room(resolve_mcp_servers=resolver) + adapter_ref.append(room.adapter) + other = room.beside("room-2") + turn_hold = Hold() + claude.script([turn_hold]) + first = room.send_in_background("hold on") + try: + await turn_hold.reached.wait() + sessions_before = len(claude.sessions) + + with pytest.raises( + ( + asyncio.CancelledError, + ClaudeMcpStartupError, + ClaudeSessionManagerStoppedError, + ) + ): + await other.send("hi") + assert requested.is_set() + finally: + # Released and drained whatever the assertions above found. + turn_hold.released.set() + await asyncio.gather(first, return_exceptions=True) + await room.adapter.cleanup_all() + + # The room-2 startup asked for the teardown and was refused on its own: + # nothing launched after the request. + assert len(claude.sessions) == sessions_before + assert not bool(room.adapter._session_manager._rooms) + + async def test_a_failed_teardown_keeps_turns_out_until_a_restart( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + room = await claude_room(resolve_mcp_servers=Resolver({})) + claude.script([room.model_reply("Hi.")]) + await room.send("hi") + backend = room.adapter._mcp.current + bound = room.adapter._mcp_room_tools.get(room.room_id) + claude.refuse_close = True + try: + with pytest.raises(BaseException, match="cleanup"): + await room.adapter.cleanup_all() + # The retained Band server dies; a late turn must not start another. + await room.crash_band_server() + with pytest.raises(ClaudeSessionManagerStoppedError): + await room.send("again") + assert room.adapter._mcp.current is backend + assert room.adapter._mcp_room_tools.get(room.room_id) is bound + finally: + claude.refuse_close = False + await room.adapter.on_started("Test Agent", "restarted") + claude.script([room.model_reply("Back.")]) + await room.send("later") + assert room.chat == ["Hi.", "Back."] + + async def test_a_file_left_behind_fences_new_connections_until_removed( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + before = private_files() + resolver = Resolver({}) + room = await claude_room(resolve_mcp_servers=resolver) + claude.script([room.model_reply("A.")], [room.model_reply("B.")]) + await room.send("first") + first = launch_file(claude.sessions[0]) + with refuse_mcp_removal(first): + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + await room.leave() + with pytest.raises(InputNotAccepted, match="closing"): + await room.send("second") + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + await room.leave() + assert len(claude.sessions) == 1 + assert len(resolver.calls) == 1 + assert first.exists() + assert bool(room.adapter._session_manager._rooms) + + await room.leave() + await room.send("third") + assert not first.exists() + assert room.chat == ["A.", "B."] + await room.leave() + assert private_files() == before + assert not bool(room.adapter._session_manager._rooms) + + async def test_a_resume_fallback_does_not_strand_the_failed_attempts_file( + self, claude_room: OpenRoom, claude: FakeClaude + ) -> None: + before = private_files() + claude.unresumable = {"sess-gone"} + resolver = Resolver({}) + room = await claude_room(resolve_mcp_servers=resolver) + hold = claude.connecting = Hold() + sending = asyncio.create_task(room.send("hi", session_id="sess-gone")) + try: + await hold.reached.wait() + attempt = launch_file(claude.sessions[0]) + with refuse_mcp_removal(attempt): + claude.connecting = None + hold.released.set() + # The resumed attempt fails; the fresh fallback finds its file + # still held and stops there. + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + await sending + assert claude.resumed == ["sess-gone"] + assert attempt.exists() + assert bool(room.adapter._session_manager._rooms) + finally: + claude.connecting = None + hold.released.set() + await asyncio.gather(sending, return_exceptions=True) + + claude.script([room.model_reply("Fresh.")]) + await room.send("again") + assert not attempt.exists() + assert room.chat == ["Fresh."] + await room.leave() + assert private_files() == before + + async def test_a_partly_written_file_stays_owned_until_removed( + self, + claude_room: OpenRoom, + claude: FakeClaude, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + before = private_files() + written: list[Path] = [] + real_write = McpConfigFile.write + removal_failures = contextlib.ExitStack() + + def interrupted(self: McpConfigFile, servers: Mapping[str, Any]) -> None: + self.path.write_text(f'{{"partial": "{GRANT_A}"') + written.append(self.path) + removal_failures.enter_context(refuse_mcp_removal(self.path)) + raise OSError(f"disk full writing {GRANT_A}") + + monkeypatch.setattr(McpConfigFile, "write", interrupted) + room = await claude_room(resolve_mcp_servers=Resolver({})) + try: + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi") + assert caught.value.stage == "launch" + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + [partial] = written + assert partial.exists() + assert bool(room.adapter._session_manager._rooms) + finally: + removal_failures.close() + monkeypatch.setattr(McpConfigFile, "write", real_write) + + await room.leave() + assert not written[0].exists() + assert private_files() == before + assert not bool(room.adapter._session_manager._rooms) + + async def test_a_failing_permission_factory_is_sanitized_and_never_retried_fresh( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + ) -> None: + before = private_files() + resolver = Resolver({}) + room = await claude_room(with_approvals(), resolve_mcp_servers=resolver) + + def leaking_factory(room_id: str) -> Any: + raise LookupError(f"policy store said {GRANT_A}") + + room.adapter._session_manager._can_use_tool_factory = leaking_factory + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + assert caught.value.stage == "launch" + assert not caught.value.resume_eligible + assert len(resolver.calls) == 1 + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + assert private_files() == before + + async def test_an_owned_looking_error_from_the_result_is_still_untrusted( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + ) -> None: + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return MarkedServers() + + room = await claude_room(resolve_mcp_servers=resolver) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + assert caught.value.stage == "resolution" + assert len(calls) == 1 + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + + def test_the_public_constructors_have_evaluable_native_resolver_types( + self, + ) -> None: + for constructor in (ClaudeSDKAdapter.__init__, ClaudeSessionManager.__init__): + hint = typing.get_type_hints(constructor)["resolve_mcp_servers"] + resolver, none = typing.get_args(hint) + assert none is type(None) + [returned] = typing.get_args(typing.get_args(resolver)[1]) + assert typing.get_args(returned) == (str, McpServerConfig) + + def test_the_resolver_type_needs_no_claude_sdk_at_import( + self, + ) -> None: + probe = ( + "import sys\n" + "class Block:\n" + " def find_spec(self, name, path=None, target=None):\n" + " if name.split('.')[0] == 'claude_agent_sdk':\n" + " raise ImportError(name)\n" + "sys.meta_path.insert(0, Block())\n" + "from band.integrations.claude_sdk.mcp_startup import McpServerResolver\n" + "McpServerResolver | None\n" + ) + subprocess.run([sys.executable, "-c", probe], check=True) + + @pytest.mark.parametrize("kind", ["ordinary", "marked", "named-class"]) + async def test_an_item_that_fails_when_unpacked_is_untrusted( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + kind: str, + ) -> None: + before = private_files() + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return HostileItems(item_failure(kind)) + + room = await claude_room(resolve_mcp_servers=resolver) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + assert caught.value.stage == "resolution" + assert not caught.value.resume_eligible + assert len(calls) == 1 + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + assert_secret_free(GRANT_A.replace("-", "_"), caught.value, caplog.text) + await room.leave() + assert private_files() == before + assert not bool(room.adapter._session_manager._rooms) + + @pytest.mark.parametrize( + "kind", [[], {}, 3, None], ids=["list", "dict", "int", "null"] + ) + async def test_a_non_string_server_type_is_refused_by_name( + self, claude_room: OpenRoom, claude: FakeClaude, kind: Any + ) -> None: + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + return {"tool": {"type": kind, "command": "tool", "url": "http://x"}} + + room = await claude_room(resolve_mcp_servers=resolver) + with pytest.raises( + ClaudeMcpStartupError, match="type must be a string" + ) as caught: + await room.send("hi", session_id="sess-earlier") + + assert caught.value.server == "tool" + assert not caught.value.resume_eligible + assert len(calls) == 1 + assert claude.sessions == [] + + @pytest.mark.parametrize( + "kind", ["spoofed-module", "non-string-module", "raising-metaclass"] + ) + async def test_an_error_classs_own_metadata_is_never_read( + self, + claude_room: OpenRoom, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + kind: str, + ) -> None: + calls: list[str] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + calls.append(workspace) + raise metadata_attack(kind) + + room = await claude_room(resolve_mcp_servers=resolver) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi", session_id="sess-earlier") + + assert caught.value.stage == "resolution" + assert caught.value.error_type is None + assert not caught.value.resume_eligible + assert len(calls) == 1 + assert claude.sessions == [] + assert_secret_free(GRANT_A, caught.value, caplog.text) + assert_secret_free(GRANT_A.replace("-", "_"), caught.value, caplog.text) + await room.leave() + assert not bool(room.adapter._session_manager._rooms) + + @pytest.mark.looptime + async def test_a_status_callback_swallowing_the_deadline_cannot_publish_readiness( + self, + ) -> None: + async def late_status() -> object: + try: + await asyncio.sleep(31) + except asyncio.CancelledError: + return {"mcpServers": []} + return {"mcpServers": []} + + with pytest.raises(ClaudeMcpStartupError, match="deadline"): + await await_mcp_ready(late_status, frozenset()) + + async def test_a_failed_client_constructor_closes_the_owned_transport( + self, + claude_room: OpenRoom, + claude: FakeClaude, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + def refuse(self: ClaudeSDKClient, *args: Any, **kwargs: Any) -> None: + raise LookupError(f"client construction failed with {GRANT_A}") + + monkeypatch.setattr(ClaudeSDKClient, "__init__", refuse) + room = await claude_room(resolve_mcp_servers=Resolver({})) + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await room.send("hi") + assert caught.value.stage == "launch" + assert not caught.value.resume_eligible + assert_secret_free(GRANT_A, caught.value, caplog.text) + assert len(claude.sessions) == 1 + assert not claude.sessions[0].alive + assert not launch_file(claude.sessions[0]).exists() + await room.leave() diff --git a/tests/adapters/claude_sdk/test_mcp_startup_ownership.py b/tests/adapters/claude_sdk/test_mcp_startup_ownership.py new file mode 100644 index 000000000..f9247d965 --- /dev/null +++ b/tests/adapters/claude_sdk/test_mcp_startup_ownership.py @@ -0,0 +1,668 @@ +"""Who owns a Claude connection while its MCP servers resolve: the room's +startup, until it publishes or settles. The CLI is the scripted fake behind +the real SDK client.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pytest +from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient + +from band.core.exceptions import CleanupIncompleteError, RoomClosingError +from band.integrations.claude_sdk.mcp_startup import ClaudeMcpStartupError +from band.integrations.claude_sdk.session_manager import ( + ClaudeSessionManager, + ClaudeSessionManagerStoppedError, +) +from band.runtime.tools import BAND_MCP_SERVER_NAME +from band.workspaces import RoomWorkspaces +from tests.adapters.claude_sdk.fakecli import FakeClaude, Hold +from tests.adapters.claude_sdk.helpers import ( + lease_client, + refuse_mcp_removal, +) +from tests.adapters.claude_sdk.mcpkit import ( + BAND, + SECRET, + ClientCustody, + CountingResolver, + FailingBand, + HeldResolver, + HeldServers, + RaisingTruth, + ReuseProbe, + SettlingResolver, + manager_for, + outdated_session, +) + + +class TestMcpStartupOwnership: + async def test_concurrent_callers_share_one_resolution( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + resolver = HeldResolver() + manager = manager_for(tmp_path, resolver, claude) + try: + first = asyncio.create_task(lease_client(manager, "room-a")) + second = asyncio.create_task(lease_client(manager, "room-a")) + await resolver.entered.wait() + resolver.release.set() + a, b = await asyncio.gather(first, second) + assert a is b + assert len(resolver.calls) == 1 + assert len(claude.sessions) == 1 + finally: + resolver.release.set() + await manager.stop() + + async def test_a_held_resolution_never_blocks_another_room( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + held = HeldResolver() + + async def resolver(workspace: str) -> Mapping[str, Any]: + if workspace.endswith("room-a"): + return await held(workspace) + return {} + + manager = manager_for(tmp_path, resolver, claude) + try: + blocked = asyncio.create_task(lease_client(manager, "room-a")) + await held.entered.wait() + other = await lease_client(manager, "room-b") + await manager.cleanup_session("room-b") + assert not blocked.done() + assert other is not None + finally: + held.release.set() + await asyncio.gather(blocked, return_exceptions=True) + await manager.stop() + + async def test_the_last_waiter_leaving_retires_the_startup_without_a_late_client( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + resolver = HeldResolver(swallow=True) + manager = manager_for(tmp_path, resolver, claude) + try: + staying = asyncio.create_task(lease_client(manager, "room-a")) + leaving = asyncio.create_task(lease_client(manager, "room-a")) + await resolver.entered.wait() + leaving.cancel() + await asyncio.gather(leaving, return_exceptions=True) + # One waiter still wants it: nothing was cancelled. + assert resolver.cancellations == 0 + staying.cancel() + while resolver.cancellations == 0: + await asyncio.sleep(0) + assert not staying.done() + # The callback swallows that one cancellation and keeps going; the room + # keeps its claim until it settles, and nothing launches afterwards. + assert bool(manager._rooms) + resolver.release.set() + await resolver.settled.wait() + await asyncio.gather(staying, return_exceptions=True) + await manager.cleanup_session("room-a") + assert resolver.cancellations == 1 + assert claude.sessions == [] + assert not bool(manager._rooms) + finally: + resolver.release.set() + await manager.stop() + + async def test_a_request_cancelled_before_starting_claims_nothing( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + resolver = HeldResolver() + manager = manager_for(tmp_path, resolver, claude) + try: + request = asyncio.create_task(lease_client(manager, "room-a")) + request.cancel() + await asyncio.gather(request, return_exceptions=True) + # No room or resolver is entered by the cancelled operation. + await manager.cleanup_session("room-other") + assert resolver.calls == [] + assert not bool(manager._rooms) + finally: + await manager.stop() + + @pytest.mark.parametrize("swallow", [False, True]) + async def test_cleanup_retires_a_resolving_startup_and_holds_the_room_until_it_settles( + self, tmp_path: Path, claude: FakeClaude, swallow: bool + ) -> None: + resolver = HeldResolver(swallow=swallow) + manager = manager_for(tmp_path, resolver, claude) + try: + waiting = asyncio.create_task(lease_client(manager, "room-a")) + await resolver.entered.wait() + cleanup = asyncio.create_task(manager.cleanup_session("room-a")) + arrival = asyncio.create_task(lease_client(manager, "room-a")) + await asyncio.wait((cleanup,), timeout=0) + if swallow: + assert not cleanup.done() + resolver.release.set() + await cleanup + with pytest.raises((ClaudeMcpStartupError, asyncio.CancelledError)): + await waiting + # Closing admission refuses arrivals; the host can redeliver after + # the retained disposal has released the previous room. + with pytest.raises(RoomClosingError): + await arrival + assert resolver.cancellations == 1 + resolver.release.set() + client = await lease_client(manager, "room-a") + assert len(resolver.calls) == 2 + assert len(claude.sessions) == 1 + assert claude.sessions[0].alive and client is not None + finally: + resolver.release.set() + await manager.stop() + + async def test_a_resolver_cleaning_up_its_own_room_does_not_wait_on_itself( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager_ref: list[ClaudeSessionManager] = [] + returned = asyncio.Event() + + async def resolver(workspace: str) -> Mapping[str, Any]: + with pytest.raises(CleanupIncompleteError): + await manager_ref[0].cleanup_session("room-a") + returned.set() + return {} + + manager = manager_for(tmp_path, resolver, claude) + manager_ref.append(manager) + try: + with pytest.raises((ClaudeMcpStartupError, asyncio.CancelledError)): + async with asyncio.timeout(5): + await lease_client(manager, "room-a") + assert returned.is_set() + assert claude.sessions == [] + async with asyncio.timeout(5): + while bool(manager._rooms): + await asyncio.sleep(0) + finally: + await manager.stop() + + async def test_a_resolver_stopping_the_manager_does_not_wait_on_itself( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager_ref: list[ClaudeSessionManager] = [] + + async def resolver(workspace: str) -> Mapping[str, Any]: + with pytest.raises(CleanupIncompleteError): + await manager_ref[0].stop() + return {} + + manager = manager_for(tmp_path, resolver, claude) + manager_ref.append(manager) + with pytest.raises((ClaudeSessionManagerStoppedError, asyncio.CancelledError)): + async with asyncio.timeout(5): + await lease_client(manager, "room-a") + async with asyncio.timeout(5): + await manager.stop() + assert claude.sessions == [] + assert not bool(manager._rooms) + + async def test_stop_wins_over_a_startup_finishing_late( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + resolver = HeldResolver(swallow=True) + manager = manager_for(tmp_path, resolver, claude) + waiting = asyncio.create_task(lease_client(manager, "room-a")) + await resolver.entered.wait() + stopping = asyncio.create_task(manager.stop()) + await asyncio.wait((stopping,), timeout=0) + resolver.release.set() + await stopping + with pytest.raises((ClaudeSessionManagerStoppedError, asyncio.CancelledError)): + await waiting + assert claude.sessions == [] + assert not bool(manager._rooms) + + async def test_a_failed_close_keeps_the_file_and_claim_until_a_retry( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + async def resolver(workspace: str) -> Mapping[str, Any]: + return {} + + manager = manager_for(tmp_path, resolver, claude) + try: + await lease_client(manager, "room-a") + [session] = claude.sessions + path = Path(str(session.options.mcp_servers)) + claude.refuse_close = True + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + await manager.cleanup_session("room-a") + assert path.exists() + assert bool(manager._rooms) + + claude.refuse_close = False + await manager.cleanup_session("room-a") + assert not path.exists() + assert not bool(manager._rooms) + finally: + claude.refuse_close = False + await manager.stop() + + @pytest.mark.parametrize( + "retire_own_room", [False, True], ids=["self-cancel", "self-cleanup"] + ) + async def test_a_settling_startup_is_never_cancelled_again( + self, tmp_path: Path, claude: FakeClaude, retire_own_room: bool + ) -> None: + resolver = SettlingResolver(retire_own_room=retire_own_room) + manager = manager_for(tmp_path, resolver, claude) + resolver.manager = manager + try: + waiting = asyncio.create_task(lease_client(manager, "room-a")) + await resolver.settling.wait() + # An outside cleanup takes the startup over while it settles. + cleanup = asyncio.create_task(manager.cleanup_session("room-a")) + await asyncio.sleep(0) + resolver.release.set() + await cleanup + with pytest.raises((ClaudeMcpStartupError, asyncio.CancelledError)): + await waiting + assert resolver.log == ["settled"] + assert claude.sessions == [] + assert not bool(manager._rooms) + finally: + resolver.release.set() + await manager.stop() + + async def test_a_replacement_whose_caller_left_during_the_old_close_never_publishes( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager, resolver, _ = await outdated_session(tmp_path, claude) + hold = claude.closing = Hold() + try: + replacing = asyncio.create_task(lease_client(manager, "room-a")) + await hold.reached.wait() # the old client's close is in progress + replacing.cancel() + await asyncio.gather(replacing, return_exceptions=True) + hold.released.set() + await manager.cleanup_session("room-a") + + # The replacement was retired: never resolved, launched or published. + assert len(resolver.calls) == 1 + assert len(claude.sessions) == 1 + assert not claude.sessions[0].alive + assert not bool(manager._rooms) + finally: + hold.released.set() + claude.closing = None + await manager.stop() + + async def test_stop_during_a_replacements_old_close_leaves_nothing_behind( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager, resolver, _ = await outdated_session(tmp_path, claude) + hold = claude.closing = Hold() + try: + replacing = asyncio.create_task(lease_client(manager, "room-a")) + await hold.reached.wait() + stopping = asyncio.create_task(manager.stop()) + hold.released.set() + await stopping + with pytest.raises(RoomClosingError): + await replacing + await manager.stop() + + assert len(resolver.calls) == 1 + assert len(claude.sessions) == 1 + assert not bool(manager._rooms) + finally: + hold.released.set() + claude.closing = None + + async def test_a_resolver_that_swallows_its_own_cancellation_publishes_nothing( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + async def resolver(workspace: str) -> Mapping[str, Any]: + task = asyncio.current_task() + assert task is not None + task.cancel() + try: + await asyncio.sleep(0) + except asyncio.CancelledError: + pass # caught, never uncancelled, and a result returned anyway + return {} + + manager = manager_for(tmp_path, resolver, claude) + try: + with pytest.raises(asyncio.CancelledError): + await lease_client(manager, "room-a") + assert claude.sessions == [] + assert not manager.has_session("room-a") + finally: + await manager.stop() + + async def test_a_finished_keep_claim_retirement_does_not_stall_a_releasing_cleanup( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager = manager_for(tmp_path, CountingResolver(), claude) + await lease_client(manager, "room-a") + try: + session = manager.room("room-a") + assert session.recover("retire before final disposal") + + await manager.cleanup_session("room-a") + + assert not bool(manager._rooms) + finally: + await manager.stop() + + async def test_a_file_that_cannot_be_removed_stays_owned_until_a_retry( + self, tmp_path: Path, claude: FakeClaude + ) -> None: + manager = manager_for(tmp_path, CountingResolver(), claude) + await lease_client(manager, "room-a") + path = Path(str(claude.sessions[0].options.mcp_servers)) + try: + with refuse_mcp_removal(path): + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + await manager.cleanup_session("room-a") + assert path.exists() + assert bool(manager._rooms) + + await manager.cleanup_session("room-a") + assert not path.exists() + assert not bool(manager._rooms) + finally: + await manager.stop() + + async def test_a_close_failure_reports_no_native_text( + self, tmp_path: Path, claude: FakeClaude, caplog: pytest.LogCaptureFixture + ) -> None: + manager = manager_for(tmp_path, CountingResolver(), claude) + await lease_client(manager, "room-a") + [session] = claude.sessions + real_close = session.close + + async def leaking_close() -> None: + raise OSError(f"close failed with {SECRET}") + + session.close = leaking_close # type: ignore[method-assign] + try: + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await manager.cleanup_session("room-a") + rendered = [str(caught.value), repr(caught.value), caplog.text] + assert caught.value.__cause__ is None and caught.value.__suppress_context__ + assert not any(SECRET in text for text in rendered) + assert bool(manager._rooms) + finally: + session.close = real_close # type: ignore[method-assign] + await manager.stop() + + @pytest.mark.parametrize("retained", ["file", "child"]) + async def test_a_failed_reservation_keeps_what_the_room_still_holds( + self, + tmp_path: Path, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + retained: str, + ) -> None: + resolver = CountingResolver() + band = FailingBand() + claude.mcp_statuses.append( + [{"name": BAND_MCP_SERVER_NAME, "status": "connected"}] + ) + manager = ClaudeSessionManager( + ClaudeAgentOptions(), + mcp_servers_factory=band, + workspaces=RoomWorkspaces(lambda room_id: str(tmp_path / room_id)), + resolve_mcp_servers=resolver, + ) + await lease_client(manager, "room-a") + path = Path(str(claude.sessions[0].options.mcp_servers)) + refused_removal = contextlib.ExitStack() + try: + # The room's cleanup fails, leaving its file (or its whole child) held. + if retained == "file": + refused_removal.enter_context(refuse_mcp_removal(path)) + else: + claude.refuse_close = True + with pytest.raises(ClaudeMcpStartupError, match="cleanup"): + session = manager.room("room-a") + session.recover("retire before replacement") + await lease_client(manager, "room-a") + if retained == "file": + refused_removal.close() + else: + claude.refuse_close = False + + band.failing = True + with ( + caplog.at_level(logging.DEBUG), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await lease_client(manager, "room-a") + assert caught.value.stage == "reservation" + assert SECRET not in str(caught.value) and SECRET not in caplog.text + # Nothing new was resolved, allocated or spawned, and the old custody + # is still owned. + assert len(resolver.calls) == 1 + assert len(claude.sessions) == 1 + assert path.exists() + assert bool(manager._rooms) + + band.failing = False + await manager.cleanup_session("room-a") + assert not path.exists() + assert not bool(manager._rooms) + await lease_client(manager, "room-a") + assert len(claude.sessions) == 2 + finally: + refused_removal.close() + claude.refuse_close = False + band.failing = False + await manager.stop() + assert not bool(manager._rooms) + + @pytest.mark.parametrize( + "phase", ["reservation", "reuse", "pre-launch", "construction", "post-status"] + ) + async def test_a_callback_closing_admission_stops_everything_after_it( + self, + tmp_path: Path, + claude: FakeClaude, + monkeypatch: pytest.MonkeyPatch, + phase: str, + ) -> None: + """A callback the manager runs closes its admission mid-startup: nothing + is constructed, reused or published after that.""" + owned_tmp = tmp_path / "tmp" + owned_tmp.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(owned_tmp)) + armed = {"on": phase == "reservation"} + manager_ref: list[ClaudeSessionManager] = [] + resolutions: list[str] = [] + + def band(room_id: str) -> dict[str, Any]: + if armed["on"] or (phase == "post-status" and claude.status_requests > 0): + manager_ref[0].close_admission() + return dict(BAND) + + async def resolver(workspace: str) -> Mapping[str, Any]: + resolutions.append(workspace) + if phase == "pre-launch": + armed["on"] = True + return {} + + async def allow(*args: Any) -> Any: + return None + + def permissions(session: Any, generation: int) -> Any: + if phase == "construction" and resolutions: + manager_ref[0].close_admission() + return allow + + claude.mcp_statuses.append( + [{"name": BAND_MCP_SERVER_NAME, "status": "connected"}] + ) + manager = ClaudeSessionManager( + ClaudeAgentOptions(), + can_use_tool_factory=permissions, + mcp_servers_factory=band, + workspaces=RoomWorkspaces(lambda room_id: str(tmp_path / room_id)), + resolve_mcp_servers=resolver, + ) + manager_ref.append(manager) + try: + if phase == "reuse": + await lease_client(manager, "room-a") + armed["on"] = True + sessions_before = len(claude.sessions) + with pytest.raises( + (ClaudeSessionManagerStoppedError, asyncio.CancelledError) + ): + async with asyncio.timeout(10): + await lease_client(manager, "room-a") + + if phase == "post-status": + # Built and confirmed, then refused instead of published. + assert len(claude.sessions) == 1 + assert not claude.sessions[0].alive + else: + assert len(claude.sessions) == sessions_before + if phase == "reservation": + assert resolutions == [] + assert not manager.has_session("room-a") or phase == "reuse" + finally: + await manager.stop() + assert not bool(manager._rooms) + assert all(not s.alive for s in claude.sessions) + assert list(owned_tmp.glob("band-claude-mcp-*")) == [] + + @pytest.mark.parametrize("mode", ["materialized", "deferred"]) + async def test_the_reuse_decision_is_a_plain_bool_by_the_final_guard( + self, + tmp_path: Path, + claude: FakeClaude, + mode: str, + ) -> None: + """The reuse decision is reduced to a plain bool once, inside the fallible + prerequisite boundary, before the callback-free admission guard. An object + answering the currentness comparison is therefore never asked its truth + again: a callback that would close admission at the branch test cannot run + there at all (the deferred case), and closing on the first read is refused + outright (the control).""" + + def band(room_id: str) -> Any: + return dict(BAND) + + manager = ClaudeSessionManager( + ClaudeAgentOptions(), + mcp_servers_factory=band, + workspaces=RoomWorkspaces(lambda room_id: str(tmp_path / room_id)), + ) + claude.mcp_statuses.append( + [{"name": BAND_MCP_SERVER_NAME, "status": "connected"}] + ) + client = await lease_client(manager, "room-a") + custody = ClientCustody(client) + real_options = client.options + probe = ReuseProbe(manager, mode) + sessions_before = len(claude.sessions) + try: + client.options = HeldServers(probe) + if mode == "materialized": # closes while the decision is materialized + with pytest.raises(ClaudeSessionManagerStoppedError): + await lease_client(manager, "room-a") + else: # a second read, which the settled decision must never take + returned = await lease_client(manager, "room-a") + assert probe.reads == 1, f"the decision was read {probe.reads} times" + assert not manager._retention.stopping + assert returned is client + finally: + client.options = real_options + await manager.stop() + assert all(not s.alive for s in claude.sessions) + assert len(claude.sessions) == sessions_before + assert not bool(manager._rooms) + custody.assert_disconnected() + + @pytest.mark.parametrize("with_resolver", [True, False]) + async def test_a_failing_reuse_decision_stays_a_safe_non_resume_failure( + self, + tmp_path: Path, + claude: FakeClaude, + caplog: pytest.LogCaptureFixture, + with_resolver: bool, + ) -> None: + """A truth test that raises while the reuse decision is worked out is a + reservation failure the owner built, with or without a resolver: its text + carries no callback value, and the adapter never retries the room without + its history.""" + secret = "res-ul3t-9a7c" + + def band(room_id: str) -> Any: + return dict(BAND) + + async def resolver(workspace: str) -> Mapping[str, Any]: + return {} + + manager = ClaudeSessionManager( + ClaudeAgentOptions(), + mcp_servers_factory=band, + workspaces=RoomWorkspaces(lambda room_id: str(tmp_path / room_id)), + resolve_mcp_servers=resolver if with_resolver else None, + ) + calls: list[str | None] = [] + + async def counted( + room_id: str, *, resume_session_id: str | None = None + ) -> ClaudeSDKClient: + calls.append(resume_session_id) + return await lease_client( + manager, room_id, resume_session_id=resume_session_id + ) + + claude.mcp_statuses.append( + [{"name": BAND_MCP_SERVER_NAME, "status": "connected"}] + ) + client = await lease_client(manager, "room-a") + custody = ClientCustody(client) + probe = RaisingTruth(secret) + real_options = client.options + session = manager.room("room-a") + real_identity = session._protected_servers + try: + if with_resolver: # the retained protected servers are the comparison + session._protected_servers = probe + else: # what the held client dials is the comparison + client.options = HeldServers(probe) + # The decision's truth test runs inside the fallible boundary; the + # branch test after the callback-free guard reads the settled bool. + with ( + caplog.at_level(logging.WARNING), + pytest.raises(ClaudeMcpStartupError) as caught, + ): + await counted("room-a", resume_session_id="hist-1") + finally: + client.options = real_options + if real_identity is not None: + session._protected_servers = real_identity + await manager.stop() + assert probe.reads == 1, f"the decision was read {probe.reads} times" + error = caught.value + assert error.stage == "reservation" and not error.resume_eligible + assert secret not in str(error) + assert secret not in caplog.text + assert calls == ["hist-1"], ( + "the room's session was started again without history" + ) + assert all(not s.alive for s in claude.sessions) + assert not bool(manager._rooms) + custody.assert_disconnected() diff --git a/tests/core/retention/test_shutdown.py b/tests/core/retention/test_shutdown.py index 2defd8d11..c084b0141 100644 --- a/tests/core/retention/test_shutdown.py +++ b/tests/core/retention/test_shutdown.py @@ -122,3 +122,23 @@ async def test_stops_that_stopped_waiting_leave_one_shutdown_failure( ), ) ] + + async def test_owned_shutdown_can_wait_beyond_the_callers_reporting_budget( + self, impatient_host: ResidenceHost + ) -> None: + resident = impatient_host.room() + await resident.turn() + resident.leaving = Hold() + stopping = asyncio.create_task(impatient_host.owner.stop(report_timeout_s=None)) + try: + await asyncio.wait( + {stopping}, timeout=2 * impatient_host.owner.config.transition_timeout_s + ) + if stopping.done(): + await stopping + pytest.fail("owned shutdown returned before disposal settled") + assert resident.identity is not None + finally: + resident.leaving.released.set() + await stopping + assert resident.identity is None diff --git a/tests/integrations/claude_sdk/test_confirmed_close.py b/tests/integrations/claude_sdk/test_confirmed_close.py new file mode 100644 index 000000000..c5d0d128c --- /dev/null +++ b/tests/integrations/claude_sdk/test_confirmed_close.py @@ -0,0 +1,108 @@ +"""The native transport cannot forget a child before its exit is confirmed.""" + +from __future__ import annotations + +import asyncio +import contextlib +import sys +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import pytest +from claude_agent_sdk import ClaudeAgentOptions + +from band.integrations.claude_sdk import transport +from tests.adapters.claude_sdk.process import ForgetfulProcess, WorkspaceProcess + + +@asynccontextmanager +async def started( + child_type: type[WorkspaceProcess], tmp_path: Path +) -> AsyncIterator[tuple[transport.ConfirmedCloseTransport, WorkspaceProcess]]: + native = child_type( + prompt="", + options=ClaudeAgentOptions( + cli_path=sys.executable, cwd=str(tmp_path), env={"PROBE_MARKER": "close"} + ), + ) + owned = transport.ConfirmedCloseTransport(native) + await owned.connect() + child = native._child + kill, wait = type(child).kill, type(child).wait + try: + yield owned, native + finally: + # Reap with unpatched operations even when the assertion under test fails. + if child.returncode is None: + with contextlib.suppress(ProcessLookupError): + kill(child) + async with asyncio.timeout(10): + await wait(child) + + +class TestConfirmedClose: + async def test_a_close_that_falsely_succeeds_still_reaps_the_child( + self, tmp_path: Path + ) -> None: + async with started(ForgetfulProcess, tmp_path) as (owned, native): + await owned.close() + assert native.exited + assert not owned.close_pending + + async def test_an_unconfirmed_exit_keeps_the_child_for_a_retry( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + async with started(ForgetfulProcess, tmp_path) as (owned, native): + child = native._child + real_kill = child.kill + monkeypatch.setattr( + child, "kill", lambda: (_ for _ in ()).throw(PermissionError()) + ) + with pytest.raises(transport.ClaudeProcessCloseError): + await owned.close() + assert owned.close_pending + assert not native.exited + + monkeypatch.setattr(child, "kill", real_kill) + await owned.close() + assert native.exited + assert not owned.close_pending + + async def test_an_exit_that_never_confirms_fails_within_the_bound( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(transport, "CHILD_EXIT_TIMEOUT_SECONDS", 0.2) + async with started(ForgetfulProcess, tmp_path) as (owned, native): + child = native._child + real_wait = child.wait + + async def never() -> int: + await asyncio.Event().wait() + return 0 + + monkeypatch.setattr(child, "wait", never) + async with asyncio.timeout(5): + with pytest.raises(transport.ClaudeProcessCloseError): + await owned.close() + assert owned.close_pending + + monkeypatch.setattr(child, "wait", real_wait) + await owned.close() + assert native.exited + assert not owned.close_pending + + async def test_a_close_that_raised_keeps_the_child_for_a_retry( + self, tmp_path: Path + ) -> None: + async with started(WorkspaceProcess, tmp_path) as (owned, native): + native.refuse_close = True + with pytest.raises(RuntimeError, match="cleanup failed"): + await owned.close() + assert owned.close_pending + assert not native.exited + + native.refuse_close = False + await owned.close() + assert native.exited + assert not owned.close_pending diff --git a/tests/mcpclient.py b/tests/mcpclient.py index b79fffb1d..e81fb8c47 100644 --- a/tests/mcpclient.py +++ b/tests/mcpclient.py @@ -4,12 +4,13 @@ import asyncio from collections.abc import AsyncIterator, Sequence -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from typing import Any from mcp import ClientSession from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client +from mcp.shared._httpx_utils import create_mcp_http_client from mcp.types import Tool from band.integrations.mcp import ( @@ -94,19 +95,29 @@ async def crash_backend(owner: SharedBandMCPBackend) -> BandMCPBackend: @asynccontextmanager async def mcp_session( - url: str, transport: BandMCPTransport = BandMCPTransport.HTTP + url: str, + transport: BandMCPTransport = BandMCPTransport.HTTP, + headers: dict[str, str] | None = None, ) -> AsyncIterator[ClientSession]: """An initialized MCP client session to ``url``, closed on exit.""" streams: AbstractAsyncContextManager[tuple[Any, ...]] - match transport: - case BandMCPTransport.HTTP: - streams = streamable_http_client(url) - case BandMCPTransport.SSE: - streams = sse_client(url) - async with ( - streams as (read_stream, write_stream, *_), - ClientSession(read_stream, write_stream) as session, - ): + async with AsyncExitStack() as stack: + match transport: + case BandMCPTransport.HTTP: + http_client = ( + None + if headers is None + else await stack.enter_async_context( + create_mcp_http_client(headers=headers) + ) + ) + streams = streamable_http_client(url, http_client=http_client) + case BandMCPTransport.SSE: + streams = sse_client(url, headers=headers) + read_stream, write_stream, *_ = await stack.enter_async_context(streams) + session = await stack.enter_async_context( + ClientSession(read_stream, write_stream) + ) await session.initialize() yield session From fb7a44c7900af42f25b12b48cae3f2fbf3a92fe8 Mon Sep 17 00:00:00 2001 From: Muninn Date: Fri, 16 Oct 2026 01:56:21 +0300 Subject: [PATCH 13/13] ci(diagnostic): passive cyclic-GC and failure-only observer for u2d-r1 targets (fork-only, do not merge) --- .github/scripts/u2d_gc_observer.py | 845 +++++++++++++++++++++++++++++ .github/workflows/ci.yml | 23 + 2 files changed, 868 insertions(+) create mode 100644 .github/scripts/u2d_gc_observer.py diff --git a/.github/scripts/u2d_gc_observer.py b/.github/scripts/u2d_gc_observer.py new file mode 100644 index 000000000..07ec48059 --- /dev/null +++ b/.github/scripts/u2d_gc_observer.py @@ -0,0 +1,845 @@ +"""Diagnostic-only pytest plugin (fork CI only): passive cyclic-GC timing plus failure-only state. + +Recorder: one ``gc.callbacks`` listener, appended once at configure time. Each +collection's start/stop pair (matched by generation and collecting thread) is +written by index into a fixed, preallocated ring of the most recent RING +collections, short ones included: sequence, generation, collecting thread, start +and end ``time.monotonic``, start and end ``time.thread_time`` of the collecting +thread, collected and uncollectable. A pending start opens only after all its +fields are set; any recorder error closes it, and a row's sequence is written +last, so only fully initialized matched intervals are published. Counters record +overwritten slots, unmatched starts/stops and recorder errors. The callback takes no locks and does no stack +inspection, serialization, file IO or container growth. Collector enablement, +thresholds and debug flags are never touched; nothing forces, disables, freezes +or retunes collection. + +Failure-only capture: ``pytest.fail`` is replaced by an instance of a ``_Fail`` +subclass with the same signature and the same class-level ``Exception``. It +forwards every call exactly once to the original, catches only the original's +failure class and bare-re-raises the same instance. Only when that failure comes +from the pinned helper line (``tests/runtime/conftest.py:48`` in +``wait_for_condition``) called from the pinned deferral target line +(``tests/runtime/test_deferral.py:145``) with ``recovery == "live"`` does it +first freeze a sequence/time cutoff and a ring copy, then copy scalar state: the +helper's timeout/deadline, the peer's marks, adapter and execution-context state +and every task's await chain. File identity is checkout-relative (relative to +pytest's rootpath), never a suffix match. Shutdown state is copied in +``pytest_runtest_makereport`` only for the exact checkout-relative target +(``tests/test_session_shutdown.py`` :: ``TestInstantShutdown`` :: +``test_stop_returns_quickly_when_idle``), gated before any freeze or traceback +walk, from the failed assertion's own traceback (ctx, +start, elapsed): after-stop state, not an in-flight snapshot. No frame object is +retained. Capture errors, including a BaseException such as KeyboardInterrupt +raised inside the diagnostics or an exception whose ``__str__`` raises, are +recorded by nonthrowing accounting and never replace the original failure. + +Windows: initial-A nominal loop window [deadline - timeout, deadline]; the +interval (deadline, interception] is only an upper bound on when the failing +check ran and is reported separately. Shutdown window [start, start + elapsed], +excluding assertion formatting and reporting. Loop time is mapped to monotonic +time only when (a) report-time evidence recognizes the clock implementation +itself (stdlib ``BaseEventLoop.time`` or looptime 0.7 ``LoopTimeEventLoop.time`` +delegating to it, no instance override, original ``time.monotonic``, looptime off, +one agreeing sample) and (b) the historical window is established separately: +trivially for a stdlib loop, or for a looptime loop when looptime's own per-item +decision says it was not enabled for the test and the test module does not +reference looptime. A sampled value alone never validates a clock; otherwise +attribution is Unknown. + +Inference limits: brackets measure observed cyclic-collection intervals and +include other callbacks', finalizers' and descheduling cost; collecting-thread +CPU is not loop-thread CPU unless the threads match; a window with complete +coverage and no overlap excludes only an observed cyclic collection, not +refcount-triggered destructors, weakref callbacks or other stalls. Any unmatched +event, recorder error, pending collection or copy race makes the verdict Unknown +before overlap is interpreted; incomplete coverage prevents a negative +conclusion. Preallocation does not remove timestamp/callback/update overhead. + +Reporting and teardown never raise: serialization, terminal and file failures are +recorded and mark the evidence invalid (``U2D_EVIDENCE_INVALID`` on any working +sink); teardown removes only the owned callback and the owned interception, by +identity, first, and never overwrites an intervening change to ``pytest.fail``, +the callback list or collector policy. +""" + +from __future__ import annotations + +import asyncio +import asyncio.base_events +import collections +import gc +import json +import os +import sys +import threading +import time +from array import array +from typing import NoReturn + +import pytest +from _pytest import outcomes + +HELPER_FILE = os.path.join("tests", "runtime", "conftest.py") +HELPER_FUNCTION = "wait_for_condition" +HELPER_FAIL_LINE = 48 +DEFERRAL_FILE = os.path.join("tests", "runtime", "test_deferral.py") +DEFERRAL = "test_approval_beyond_first_page_releases_ordered_work" +DEFERRAL_CALL_LINE = 145 +SHUTDOWN_FILE = os.path.join("tests", "test_session_shutdown.py") +SHUTDOWN = "test_stop_returns_quickly_when_idle" +RING = int(os.environ.get("U2D_GC_RING", "16384")) +CLOCK_TOLERANCE_S = 0.002 + +# Ring rows. Times: start wall, end wall, start thread CPU, end thread CPU. +# Meta: sequence, generation, thread ident, collected, uncollectable. +_TIMES, _META = 4, 5 +_ring_t = array("d", bytes(8 * _TIMES * RING)) +_ring_m = array("q", bytes(8 * _META * RING)) +_pending_t = array("d", bytes(8 * 2)) # start wall, start thread CPU +_pending_m = array("q", bytes(8 * 3)) # open flag, generation, thread ident +_gen = array("d", bytes(8 * 3 * 3)) # per generation: count, total s, max s +# Counters: next sequence, overwritten slots, unmatched starts, unmatched stops, recorder errors. +SEQ, OVERWRITTEN, UNMATCHED_START, UNMATCHED_STOP, RECORDER_ERRORS = range(5) +_counters = array("q", bytes(8 * 5)) + +STATE: dict = {"installed": False} +CAPTURES: list[dict] = [] +ERRORS: list[str] = [] + + +def _gc_callback(phase, info) -> None: + """Publishes a row only for a fully initialized start matched by its stop.""" + try: + if phase == "start": + if _pending_m[0]: + _counters[UNMATCHED_START] += 1 + _pending_m[0] = 0 + generation = info["generation"] + ident = threading.get_ident() + start = time.monotonic() + start_cpu = time.thread_time() + _pending_m[1] = generation + _pending_m[2] = ident + _pending_t[0] = start + _pending_t[1] = start_cpu + _pending_m[0] = 1 # opened only after every start field is set + return + end = time.monotonic() + end_cpu = time.thread_time() + generation = info["generation"] + collected = info["collected"] + uncollectable = info["uncollectable"] + if ( + not _pending_m[0] + or _pending_m[1] != generation + or _pending_m[2] != threading.get_ident() + ): + _pending_m[0] = 0 + _counters[UNMATCHED_STOP] += 1 + return + _pending_m[0] = 0 + n = _counters[SEQ] + t = _TIMES * (n % RING) + m = _META * (n % RING) + _ring_m[m] = -1 # the slot is invalid until its sequence is written last + _ring_t[t] = _pending_t[0] + _ring_t[t + 1] = end + _ring_t[t + 2] = _pending_t[1] + _ring_t[t + 3] = end_cpu + _ring_m[m + 1] = generation + _ring_m[m + 2] = _pending_m[2] + _ring_m[m + 3] = collected + _ring_m[m + 4] = uncollectable + _ring_m[m] = n + if n >= RING: + _counters[OVERWRITTEN] += 1 + _counters[SEQ] = n + 1 + g = 3 * generation + duration = end - _pending_t[0] + _gen[g] += 1 + _gen[g + 1] += duration + _gen[g + 2] = max(_gen[g + 2], duration) + except Exception: # noqa: BLE001 - a diagnostic callback must never disturb the collector + _pending_m[0] = 0 + _counters[RECORDER_ERRORS] += 1 + + +def _freeze() -> dict: + """Cutoff before any expensive capture: sequence, time, counters and a ring copy.""" + STATE["freezes"] = STATE.get("freezes", 0) + 1 + cut_seq = _counters[SEQ] + cut_wall = time.monotonic() + ring_t, ring_m = _ring_t[:], _ring_m[:] + return { + "cut_seq": cut_seq, + "cut_wall": cut_wall, + "seq_after_copy": _counters[SEQ], + "counters": list(_counters), + "pending_open": bool(_pending_m[0]), + "ring_t": ring_t, + "ring_m": ring_m, + } + + +def _rows(frozen: dict) -> list[dict]: + cut = frozen["cut_seq"] + rows = [] + for seq in range(max(0, cut - RING), cut): + t, m = _TIMES * (seq % RING), _META * (seq % RING) + if frozen["ring_m"][m] != seq: + continue # overwritten while copying; counted by the validation below + rt, rm = frozen["ring_t"], frozen["ring_m"] + rows.append( + { + "seq": seq, + "generation": rm[m + 1], + "thread": rm[m + 2], + "start": rt[t], + "end": rt[t + 1], + "wall_s": rt[t + 1] - rt[t], + "thread_cpu_s": rt[t + 3] - rt[t + 2], + "collected": rm[m + 3], + "uncollectable": rm[m + 4], + } + ) + return rows + + +def _overlaps(rows: list[dict], start: float, end: float, loop_thread: int) -> dict: + hits = [r for r in rows if r["end"] >= start and r["start"] <= end] + return { + "window": [start, end], + "count": len(hits), + "clipped_overlap_s": sum( + min(r["end"], end) - max(r["start"], start) for r in hits + ), + "wall_s": sum(r["wall_s"] for r in hits), + "collecting_thread_cpu_s": sum(r["thread_cpu_s"] for r in hits), + "on_loop_thread": sum(1 for r in hits if r["thread"] == loop_thread), + "on_other_threads": sum(1 for r in hits if r["thread"] != loop_thread), + "intervals": hits[-200:], + } + + +def _verdict( + frozen: dict, + rows: list[dict], + window_start: float | None, + overlap: dict | None, + clock_ok: bool, +) -> dict: + counters = frozen["counters"] + complete = frozen["cut_seq"] <= RING or ( + rows and rows[0]["start"] <= (window_start or float("-inf")) + ) + expected_rows = min(frozen["cut_seq"], RING) + clean = ( + counters[UNMATCHED_START] == 0 + and counters[UNMATCHED_STOP] == 0 + and counters[RECORDER_ERRORS] == 0 + and frozen["seq_after_copy"] == frozen["cut_seq"] + and not frozen["pending_open"] + and len(rows) == expected_rows + ) + if not clock_ok or overlap is None: + verdict = "Unknown: loop-to-monotonic mapping not validated" + elif not clean: + verdict = "Unknown: recorder or snapshot integrity not trusted" + elif overlap["count"]: + verdict = "observed cyclic-collection overlap" + elif complete: + verdict = ( + "no observed cyclic-collection overlap (excludes only cyclic collections)" + ) + else: + verdict = "Unknown: incomplete coverage" + return { + "verdict": verdict, + "coverage_complete": bool(complete), + "recorder_clean": clean, + "counters_at_cutoff": dict( + zip( + ( + "next_seq", + "overwritten", + "unmatched_start", + "unmatched_stop", + "recorder_errors", + ), + counters, + ) + ), + "cut_seq": frozen["cut_seq"], + "cut_wall": frozen["cut_wall"], + "pending_open_at_cutoff": frozen["pending_open"], + "snapshot_rows_missing": expected_rows - len(rows), + "oldest_retained_start": rows[0]["start"] if rows else None, + } + + +def _after_capture(frozen: dict) -> dict: + now = _counters[SEQ] + return { + "collections_after_cutoff": now - frozen["cut_seq"], + "ring_slots_overwritten_after_cutoff": max(0, now - RING) + - max(0, frozen["cut_seq"] - RING), + } + + +_MONOTONIC = time.monotonic +_BASE_TIME = asyncio.base_events.BaseEventLoop.time + + +def _looptime_time(): + try: + from looptime._internal.loops import LoopTimeEventLoop # noqa: PLC0415 - optional, pinned 0.7 + except Exception: # noqa: BLE001 - absent or moved: the implementation is not recognized + return None, None + return LoopTimeEventLoop, LoopTimeEventLoop.__dict__.get("time") + + +def _next_time_after(cls_type, owner) -> object: + mro = cls_type.__mro__ + for cls in mro[mro.index(owner) + 1 :]: + if "time" in cls.__dict__: + return cls.__dict__["time"] + return None + + +def _looptime_enabled_for(item) -> bool | None: + """The looptime plugin's own per-test decision (markers and option); None if unknown.""" + if item is None: + return None + try: + from looptime._internal.plugin import _get_options # noqa: PLC0415 - pinned 0.7 decision function + + return _get_options(item) is not None + except Exception: # noqa: BLE001 + return None + + +def _clock(loop, item, module_globals: dict | None) -> dict: + """Report-time evidence about the loop clock, kept apart from the historical-window inference. + + Report time: the loop's ``time`` must be a recognized implementation with no instance + override (the stdlib ``BaseEventLoop.time`` or looptime 0.7's ``LoopTimeEventLoop.time`` + delegating to it), ``asyncio.base_events`` must still use the original ``time.monotonic``, + looptime must be off now and one sample must agree with ``time.monotonic``. + Historical window: established only if the clock cannot have been virtual during the test, + i.e. a plain stdlib loop, or a looptime loop whose plugin decision for this test item is + 'not enabled' and whose test module does not reference looptime. Otherwise Unknown. + """ + if loop is None: + return {"validated": False, "reason": "no loop recovered"} + looptime_cls, looptime_time = _looptime_time() + impl = getattr(type(loop), "time", None) + instance_override = "time" in getattr(loop, "__dict__", {}) + is_stdlib = impl is _BASE_TIME + is_looptime = ( + looptime_time is not None + and impl is looptime_time + and _next_time_after(type(loop), looptime_cls) is _BASE_TIME + ) + monotonic_original = ( + getattr(asyncio.base_events, "time", None) is time + and time.monotonic is _MONOTONIC + ) + looptime_on = getattr(loop, "looptime_on", None) if is_looptime else None + before = time.monotonic() + loop_now = loop.time() + after = time.monotonic() + offset = loop_now - (before + after) / 2 + report_ok = ( + (is_stdlib or is_looptime) + and not instance_override + and monotonic_original + and not looptime_on + and abs(offset) <= CLOCK_TOLERANCE_S + ) + if is_stdlib: + historical, basis = True, "stdlib BaseEventLoop.time has no virtual mode" + elif is_looptime: + enabled = _looptime_enabled_for(item) + module_refs = module_globals is not None and any( + "looptime" in getattr(value, "__module__", "") + or getattr(value, "__name__", "") == "looptime" + for value in module_globals.values() + ) + historical = enabled is False and module_globals is not None and not module_refs + basis = f"looptime plugin decision for the item: enabled={enabled}; test module references looptime: {module_refs if module_globals is not None else 'unknown'}" + else: + historical, basis = False, "clock implementation not recognized" + return { + "validated": bool(report_ok and historical), + "report_time": { + "recognized": bool(is_stdlib or is_looptime), + "implementation": f"{getattr(impl, '__module__', None)}.{getattr(impl, '__qualname__', None)}", + "instance_override": instance_override, + "monotonic_original": monotonic_original, + "looptime_on": looptime_on, + "loop_minus_monotonic_s": offset, + "ok": bool(report_ok), + }, + "historical_window": {"established": bool(historical), "basis": basis}, + "loop_minus_monotonic_s": offset, + "loop_type": f"{type(loop).__module__}.{type(loop).__qualname__}", + "loop_closed": loop.is_closed(), + } + + +def _await_chain(awaitable) -> list: + chain, seen = [], set() + while awaitable is not None and id(awaitable) not in seen and len(chain) < 64: + seen.add(id(awaitable)) + frame = ( + getattr(awaitable, "cr_frame", None) + or getattr(awaitable, "gi_frame", None) + or getattr(awaitable, "ag_frame", None) + ) + if frame is None: + chain.append({"awaiting": type(awaitable).__qualname__}) + break + chain.append([frame.f_code.co_filename, frame.f_code.co_name, frame.f_lineno]) + awaitable = ( + getattr(awaitable, "cr_await", None) + or getattr(awaitable, "gi_yieldfrom", None) + or getattr(awaitable, "ag_await", None) + ) + return chain + + +def _task(task) -> dict: + waiter = getattr(task, "_fut_waiter", None) + return { + "name": task.get_name(), + "done": task.done(), + "cancelled": task.cancelled() if task.done() else None, + "cancelling": task.cancelling() if hasattr(task, "cancelling") else None, + "chain": [] if task.done() else _await_chain(task.get_coro()), + "fut_waiter": None if waiter is None else repr(waiter)[:300], + } + + +def _value(value): + if isinstance(value, (bool, int, float, str)) or value is None: + return value + if isinstance(value, asyncio.Task): + return {"task": _task(value)} + if isinstance(value, asyncio.Event): + return {"event_set": value.is_set()} + if isinstance(value, (list, tuple, set, frozenset, collections.deque)): + items = list(value) + if all(isinstance(i, (str, int, float, bool)) or i is None for i in items): + return items[-50:] + if all( + isinstance(i, tuple) + and all(isinstance(x, (str, int, float, bool)) or x is None for x in i) + for i in items + ): + return [list(i) for i in items[-50:]] + return {"type": type(value).__name__, "len": len(items)} + if isinstance(value, dict): + if any(isinstance(k, asyncio.Task) for k in value): + return {"tasks": [_task(k) for k in value][:20]} + return {"type": "dict", "len": len(value)} + return {"type": type(value).__qualname__} + + +def _object_state(obj) -> dict: + return {name: _value(value) for name, value in sorted(vars(obj).items())} + + +def _ctx_state(ctx) -> dict: + state = _object_state(ctx) + try: + cycle = ctx._active_cycle_task + state["_active_cycle_task"] = None if cycle is None else {"task": _task(cycle)} + except Exception as error: # noqa: BLE001 - diagnostic read only + state["_active_cycle_task"] = f"unreadable: {type(error).__name__}" + return state + + +def _relative(path) -> str | None: + try: + return os.path.relpath(os.path.realpath(os.fspath(path)), STATE["rootpath"]) + except Exception: # noqa: BLE001 - unknown location is never a pinned target + return None + + +def _is_pinned_initial_a(helper, target) -> bool: + return ( + helper.f_code.co_name == HELPER_FUNCTION + and helper.f_lineno == HELPER_FAIL_LINE + and _relative(helper.f_code.co_filename) == HELPER_FILE + and target is not None + and target.f_code.co_name == DEFERRAL + and target.f_lineno == DEFERRAL_CALL_LINE + and _relative(target.f_code.co_filename) == DEFERRAL_FILE + and target.f_locals.get("recovery") == "live" + ) + + +def _deferral_item(): + """The live deferral item, looked up only after a pinned failure (never on passing paths).""" + session = STATE.get("session") + for item in getattr(session, "items", ()) or (): + callspec = getattr(item, "callspec", None) + if ( + getattr(item, "originalname", None) == DEFERRAL + and callspec is not None + and callspec.params.get("recovery") == "live" + and _relative(item.path) == DEFERRAL_FILE + ): + return item + return None + + +def _is_pinned_shutdown(item) -> bool: + cls = getattr(item, "cls", None) + return ( + getattr(item, "name", None) == SHUTDOWN + and cls is not None + and cls.__name__ == "TestInstantShutdown" + and _relative(item.path) == SHUTDOWN_FILE + ) + + +def _capture_initial_a(helper, target, failure: BaseException) -> None: + loop = asyncio.get_running_loop() + intercepted_loop = loop.time() + frozen = _freeze() + clock = _clock(loop, _deferral_item(), target.f_globals) + helper_locals, test_locals = helper.f_locals, target.f_locals + deadline, timeout = helper_locals.get("deadline"), helper_locals.get("timeout") + record = { + "kind": "initial-A helper failure, captured before test cleanup", + "failure_id": id(failure), + "failure_type": type(failure).__qualname__, + "pinned": [ + HELPER_FILE, + HELPER_FAIL_LINE, + DEFERRAL_FILE, + DEFERRAL_CALL_LINE, + "live", + ], + "helper": { + "timeout": timeout, + "interval": helper_locals.get("interval"), + "deadline_loop": deadline, + "intercepted_loop": intercepted_loop, + }, + "clock": clock, + "peer": _object_state(test_locals["peer"]) if "peer" in test_locals else None, + "adapter": _object_state(test_locals["adapter"]) + if "adapter" in test_locals + else None, + } + rows = _rows(frozen) + loop_thread = threading.get_ident() + nominal = upper = None + if clock["validated"] and deadline is not None and timeout is not None: + shift = clock["loop_minus_monotonic_s"] + nominal = _overlaps( + rows, deadline - timeout - shift, deadline - shift, loop_thread + ) + upper = _overlaps(rows, deadline - shift, intercepted_loop - shift, loop_thread) + record["gc"] = { + "nominal_window": nominal, + "after_deadline_upper_bound_window": upper, + "loop_thread": loop_thread, + **_verdict( + frozen, + rows, + None if nominal is None else nominal["window"][0], + nominal, + clock["validated"], + ), + } + record["ctx"] = _ctx_state(test_locals["ctx"]) if "ctx" in test_locals else None + record["tasks"] = [_task(t) for t in asyncio.all_tasks(loop)] + record["gc"].update(_after_capture(frozen)) + CAPTURES.append(record) + + +class _ObservedFail(outcomes._Fail): + """``pytest.fail`` that forwards once to the original and observes only the pinned failure.""" + + def __call__(self, reason: str = "", pytrace: bool = True) -> NoReturn: + __tracebackhide__ = True + try: + result = STATE["original_fail"](reason, pytrace) + except STATE["failure_class"] as failure: + helper = target = None + try: + helper = sys._getframe(1) + target = helper.f_back + if _is_pinned_initial_a(helper, target): + _capture_initial_a(helper, target, failure) + except BaseException as error: # noqa: BLE001 - even an interrupt inside diagnostics must not replace the original failure + _note("initial-A capture", error) + finally: + del helper, target + raise + return result # unreachable for pytest's own fail, which always raises + + +def _shutdown_capture(item, call) -> None: + frozen = _freeze() + test_locals = loop = None + tb = call.excinfo.tb + while tb is not None: + frame = tb.tb_frame + candidate = frame.f_locals.get("self") + if loop is None and isinstance(candidate, asyncio.AbstractEventLoop): + loop = candidate + if ( + frame.f_code.co_name == SHUTDOWN + and _relative(frame.f_code.co_filename) == SHUTDOWN_FILE + ): + test_locals = dict(frame.f_locals) + tb = tb.tb_next + del tb + if test_locals is None or "ctx" not in test_locals: + return + start, elapsed = test_locals.get("start"), test_locals.get("elapsed") + clock = _clock(loop, item, getattr(getattr(item, "module", None), "__dict__", None)) + rows = _rows(frozen) + loop_thread = threading.get_ident() + window = None + if clock["validated"] and isinstance(start, float) and isinstance(elapsed, float): + shift = clock["loop_minus_monotonic_s"] + window = _overlaps(rows, start - shift, start + elapsed - shift, loop_thread) + record = { + "kind": "after-stop state from the failed assertion traceback (not an in-flight snapshot)", + "node": item.nodeid, + "error": f"{call.excinfo.typename}: {str(call.excinfo.value)[:300]}", + "start_loop": start, + "elapsed_loop_s": elapsed, + "clock": clock, + "gc": { + "stop_window": window, + "loop_thread": loop_thread, + **_verdict( + frozen, + rows, + None if window is None else window["window"][0], + window, + clock["validated"], + ), + }, + "ctx": _ctx_state(test_locals["ctx"]), + } + del test_locals + record["gc"].update(_after_capture(frozen)) + CAPTURES.append(record) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + if call.when == "call" and call.excinfo is not None and _is_pinned_shutdown(item): + try: + _shutdown_capture(item, call) + except BaseException as error: # noqa: BLE001 - even an interrupt inside diagnostics must not break reporting + _note("after-stop capture", error) + yield + + +def pytest_configure(config) -> None: + if STATE["installed"]: + return + original = pytest.fail + observed = _ObservedFail() + STATE.update( + installed=True, + original_fail=original, + failure_class=original.Exception, + observed_fail=observed, + settings_at_install=(gc.isenabled(), gc.get_threshold(), gc.get_debug()), + rootpath=os.path.realpath(os.fspath(config.rootpath)), + freezes=0, + ) + gc.callbacks.append(_gc_callback) + pytest.fail = observed + + +def pytest_sessionstart(session) -> None: + STATE["session"] = session + + +def _summary() -> dict: + return { + "counters": dict( + zip( + ( + "next_seq", + "overwritten", + "unmatched_start", + "unmatched_stop", + "recorder_errors", + ), + _counters, + ) + ), + "ring": RING, + "per_generation": [ + { + "generation": g, + "count": int(_gen[3 * g]), + "total_s": _gen[3 * g + 1], + "max_s": _gen[3 * g + 2], + } + for g in range(3) + ], + "settings_at_install": [ + STATE["settings_at_install"][0], + list(STATE["settings_at_install"][1]), + STATE["settings_at_install"][2], + ], + "settings_now": [gc.isenabled(), list(gc.get_threshold()), gc.get_debug()], + "observer_errors": ERRORS, + "freezes": STATE.get("freezes", 0), + "captures": len(CAPTURES), + } + + +def _note(prefix: str, error: BaseException) -> None: + """Records a diagnostic failure; nothing here may raise, whatever the error does.""" + try: + name = type(error).__name__ + except BaseException: # noqa: BLE001 + name = "" + try: + text = f"{prefix}: {name}: {error}" + except BaseException: # noqa: BLE001 - an exception whose __str__ raises + text = f"{prefix}: {name}: " + try: + ERRORS.append(text) + except BaseException: # noqa: BLE001 + STATE["unrecorded_errors"] = STATE.get("unrecorded_errors", 0) + 1 + + +def _invalidate(reason: str) -> None: + try: + STATE.setdefault("evidence_invalid", []).append(reason) + except BaseException as error: # noqa: BLE001 - invalidation must not raise + _note("evidence invalidation", error) + + +def _serialized_lines() -> list[str]: + lines = [] + items = [("U2D_GC_SUMMARY", _summary)] + [ + ("U2D_FAILURE_CAPTURE", lambda c=c: c) for c in list(CAPTURES) + ] + for label, produce in items: + try: + lines.append( + label + " " + json.dumps(produce(), sort_keys=True, default=repr) + ) + except BaseException as error: # noqa: BLE001 - reporting must not change the run + _note(f"serialize {label}", error) + _invalidate(f"serialize {label}") + lines.append(label + ' {"serialization_failed": true}') + return lines + + +def _write_file(lines: list[str], sink: str) -> bool: + out = os.environ.get("U2D_GC_OUT") + if not out: + return True + try: + with open(out, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + return True + except BaseException as error: # noqa: BLE001 - reporting must not change the run + _note(f"{sink} file output", error) + _invalidate(f"{sink} file output") + return False + + +def _write_terminal(terminalreporter, lines: list[str]) -> bool: + try: + terminalreporter.write_sep("=", "U2D GC OBSERVER") + for line in lines: + terminalreporter.write_line(line) + return True + except BaseException as error: # noqa: BLE001 - reporting must not change the run + _note("terminal output", error) + _invalidate("terminal output") + return False + + +def _invalid_marker() -> str: + try: + return "U2D_EVIDENCE_INVALID " + json.dumps( + { + "reasons": list(STATE.get("evidence_invalid", [])), + "observer_errors": list(ERRORS), + }, + default=repr, + ) + except BaseException: # noqa: BLE001 + return "U2D_EVIDENCE_INVALID {}" + + +def pytest_terminal_summary(terminalreporter) -> None: + if not STATE.get("installed"): + return + lines = _serialized_lines() + terminal_ok = _write_terminal(terminalreporter, lines) + file_ok = _write_file(lines, "summary") + if not (terminal_ok and file_ok) or STATE.get("evidence_invalid"): + marker = [_invalid_marker()] + if terminal_ok: + _write_terminal_line(terminalreporter, marker[0]) + if file_ok: + _write_file(marker, "invalid-marker") + + +def _write_terminal_line(terminalreporter, line: str) -> None: + try: + terminalreporter.write_line(line) + except BaseException as error: # noqa: BLE001 + _note("terminal output", error) + + +def pytest_unconfigure(config) -> None: + if not STATE.get("installed"): + return + removed = owned_fail = False + try: + for index, callback in enumerate(gc.callbacks): + if callback is _gc_callback: + del gc.callbacks[index] + removed = True + break + except BaseException as error: # noqa: BLE001 - cleanup must not raise + _note("listener removal", error) + try: + owned_fail = pytest.fail is STATE["observed_fail"] + if owned_fail: + pytest.fail = STATE["original_fail"] + except BaseException as error: # noqa: BLE001 - cleanup must not raise + _note("interception removal", error) + STATE["installed"] = False + try: + report = { + "listener_removed": removed, + "interception_restored": owned_fail, + "intervening_pytest_fail_left_in_place": not owned_fail, + "collector_settings_changed_since_install": ( + gc.isenabled(), + gc.get_threshold(), + gc.get_debug(), + ) + != STATE["settings_at_install"], + "evidence_invalid": list(STATE.get("evidence_invalid", [])), + } + line = "U2D_GC_REMOVAL " + json.dumps(report, sort_keys=True, default=repr) + except BaseException as error: # noqa: BLE001 + _note("removal report", error) + line = "U2D_GC_REMOVAL {}" + if not _write_file([line], "removal"): + try: + sys.__stderr__.write(_invalid_marker() + "\n") + except BaseException as error: # noqa: BLE001 - last-resort sink must not raise + _note("stderr marker", error) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0830d6ec..b6e1de89a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,20 @@ jobs: - name: Collect baseline E2E in the core environment run: uv run --all-packages pytest tests/e2e/baseline/ --collect-only -q --no-cov + - name: Install temporary U2D GC observer (diagnostic only) + if: matrix.os == 'ubuntu-latest' && (matrix.python-version == '3.11' || matrix.python-version == '3.12') + env: + U2D_PYTHON: ${{ matrix.python-version }} + run: | + mkdir -p "$RUNNER_TEMP/u2d-gc" + cp .github/scripts/u2d_gc_observer.py "$RUNNER_TEMP/u2d_gc_observer.py" + sha256sum .github/scripts/u2d_gc_observer.py "$RUNNER_TEMP/u2d_gc_observer.py" + { + echo "PYTHONPATH=$RUNNER_TEMP" + echo "PYTEST_PLUGINS=u2d_gc_observer" + echo "U2D_GC_OUT=$RUNNER_TEMP/u2d-gc/records-$U2D_PYTHON.txt" + } >> "$GITHUB_ENV" + - name: Run tests run: uv run --all-packages pytest @@ -107,6 +121,15 @@ jobs: # shellcheck disable=SC2046 # word-splitting the ls-files output is the point uv run --all-packages pytest --markdown-docs $(git ls-files '*.md' ':!:examples/*' ':!:docs/adapters/nooa.md') --no-cov + - name: Retain U2D GC observer records (diagnostic only) + if: always() && matrix.os == 'ubuntu-latest' && (matrix.python-version == '3.11' || matrix.python-version == '3.12') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: u2d-gc-${{ matrix.python-version }} + path: ${{ runner.temp }}/u2d-gc + if-no-files-found: warn + retention-days: 7 + test-crewai: runs-on: ${{ matrix.os }}