From 72fc5e2d882aa55a25854b701703065003dcc272 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 08:59:09 -0700 Subject: [PATCH 01/10] docs(runtimes): strands delegation wire capture and emitter-seam decision Co-Authored-By: Claude Fable 5 --- .../python/docs/wire-capture-subagents.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md diff --git a/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md b/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md new file mode 100644 index 000000000..80b4f49a0 --- /dev/null +++ b/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md @@ -0,0 +1,197 @@ +# Strands agents-as-tools: wire capture + emitter-seam decision + +Task 0 spike evidence for translating Strands agents-as-tools delegation into +AG-UI `SUBAGENT_*` events. Captured 2026-09-02 against the live meeting-scheduler +backend (`src/agent.py` + `src/server.py`, bridge pinned to git rev +`363d3878e30887e88c1fd5ca1916ec3a5962b6be`), scratch delegation tool +`research_availability` wrapping a tool-less specialist `Agent(name="availability_researcher")`. +The scratch edit was reverted after capture; only this doc lands. + +All bridge citations are into the installed venv source: +`.venv/lib/python3.14/site-packages/ag_ui_strands/` (referred to as `agent.py` +etc. below). + +## 1. Seam decision: `ToolBehavior.tool_stream_event_handler` (config-level), not a subclass + +**Decision: emit `SUBAGENT_*` from a per-tool `tool_stream_event_handler` +registered in `StrandsAgentConfig.tool_behaviors["research_availability"]`, +with the delegation tool written as an async-generator `@tool` that re-yields +the specialist's `stream_async` events.** + +The exact hook point: the bridge's run loop dispatches every +`tool_stream_event` to the tool's registered handler in +`StrandsAgent.run`, `agent.py:4644-4663` — the handler is an async generator +called with a `ToolStreamEventContext` (`config.py:56-91`) and *"may yield zero +or more AG-UI Event objects which are forwarded directly into the top-level +event stream"* (`config.py:76-91`). Registering a handler suppresses the +default routing for that tool (state snapshots at `agent.py:4664-4669`, +agent-as-tool lifecycle forwarding at `agent.py:4670-4682`), so the handler +owns the whole child stream. + +Why not the LangGraph lane's dispatch-hook subclass +(`cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py` +overriding `_dispatch_event`): + +- (1b) **A `@tool` body cannot reach an emitter directly, but it does not need + one.** Strands wraps every value an async-generator tool yields as a + `tool_stream_event` in the parent stream (`agent.py:4596-4598`), and the + bridge hands that payload to the per-tool handler with `tool_use_id` and + `tool_name` attached (`agent.py:4648-4652`). That IS the sanctioned + tool-body-to-wire channel — no writer/contextvar/queue plumbing, no fork of + the 5,771-line bridge module. +- The Strands bridge has no `_dispatch_event` seam at all: `StrandsAgent.run` + (`agent.py:2956` onward) is one ~2,800-line async generator with dozens of + inline `yield` sites. A subclass would have to wrap the entire generator and + pattern-match already-serialized events to find the delegation window — + strictly worse information than the handler gets (raw inner Strands events, + pre-translation). +- (1a) Strands→AG-UI translation happens inline in that same `run` generator + (text deltas, `current_tool_use`, `contentBlockStop`, tool results — e.g. + the `tool_stream_event` branch at `agent.py:4596-4682`, tool results at + `agent.py:4684+`), then each pydantic event is SSE-serialized by + `EventEncoder.encode` → `event.model_dump_json(by_alias=True)` + (`ag_ui/encoder/encoder.py:22-36`), called from the endpoint's + `event_generator` (`endpoint.py:290-343`). +- (1c) **Unknown raw dicts do NOT pass through.** The encoder requires pydantic + `BaseEvent` instances (`model_dump_json` call, `encoder.py:36`); a plain dict + would crash the stream. Unmapped *Strands* events are forwarded only as + sanitized `RawEvent` payloads (`_sanitize_raw_event`, `agent.py:1089-1125`) + — and inner-agent payload keys (`data`, `current_tool_use`, ...) are in + `_RAW_SUPPRESSED_KEYS` (`agent.py:1078-1086`), so nothing from the child + leaks via RAW either. The handler must therefore yield real + `ag_ui.core` event objects — which exist, see §2. +- (1d) **`tool_stream_event` from a nested agent-as-tool is forwarded today + only for the inner TOOL-CALL lifecycle, never for inner text.** + `_forward_inner_agent_events` (`agent.py:1195-1313`, invoked at + `agent.py:4677-4682`) translates inner `current_tool_use` / + `contentBlockStop` / `toolResult` into namespaced `TOOL_CALL_*` events and + explicitly nothing else ("Only the tool-call lifecycle is forwarded", + `agent.py:1209`). Inner `{"data": ...}` text deltas fall through every + branch and are dropped. The live capture in §3 confirms this on the wire. + +### Emitter shape (next task) + +- Delegation tool: async-generator `@tool` that does + `async for event in specialist.stream_async(prompt): yield event`, then + yields the accumulated text as its final value (Strands takes the last + yielded value as the tool result — confirmed in §3, the `TOOL_CALL_RESULT` + content is exactly the joined child text). +- Handler on that tool: lazily emits `SubagentStartedEvent` + (`subagent_run_id=f"{ctx.tool_use_id}-sub"`, `name="availability_researcher"`, + `parent_tool_call_id=ctx.tool_use_id`) on the first inner event, translates + inner `{"data": }` into `TEXT_MESSAGE_START/CONTENT/END` carrying + `subagent_run_id`, and emits `SubagentFinishedEvent(outcome=success)` when it + sees the inner terminal `{"result": AgentResult}` event; + `SubagentErrorEvent` when the inner stream surfaces an error / + `forceStop`. `ctx.tool_use_id` equals the wire `toolCallId` (the model's + `call_...` id — §3 line 4 vs. the handler context), so `parentToolCallId` + lines up with the bridge-native `TOOL_CALL_START` with zero bookkeeping. +- Wire-order nuance: the bridge emits `TOOL_CALL_END` when the *args* finish + streaming, before the tool executes (§3 line 19). So the shipped order will + be `TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → SUBAGENT_STARTED → + TEXT_MESSAGE_* → SUBAGENT_FINISHED → TOOL_CALL_RESULT` — the `SUBAGENT_*` + block nests inside the tool call's start/result span, not inside + start/end. + +## 2. SDK check + +``` +$ uv run python -c "import ag_ui.core as c; print([n for n in dir(c) if 'Subagent' in n])" +['SubagentErrorEvent', 'SubagentFinishedEvent', 'SubagentFinishedOutcome', + 'SubagentFinishedSuccessOutcome', 'SubagentFinishedSuspendedOutcome', + 'SubagentStartedEvent'] +``` + +The pinned SDK has first-class subagent events (`ag_ui/core/events.py:455-512`) +with exactly the target fields (`subagent_run_id`, `name`, +`parent_tool_call_id`, `outcome` discriminated union), and every +`TextMessage*` / `ToolCall*` event carries an optional `subagent_run_id` +(`events.py:127-235`). **No raw-dict fallback is needed** — the handler +constructs typed events and the stock encoder serializes them. + +## 3. Live captures (scrubbed) + +Prompt: *"Find a slot for Ada and Grace next week — research their availability +first"*. The model called `research_availability` on the first attempt in both +runs. No API keys or org ids appeared in either stream; nothing was scrubbed — +only long `MESSAGES_SNAPSHOT` lines and repetitive delta runs are elided, each +marked with a `# [elided: ...]` comment. + +### 3a. Async-generator delegation tool (the seam-relevant variant) + +The tool re-yielded the specialist's entire `stream_async` output, so every +child event crossed the bridge as `tool_stream_event` — and the wire between +`TOOL_CALL_END` (line 19) and `TOOL_CALL_RESULT` (line 21) still carries +**zero child events**: `_forward_inner_agent_events` dropped every inner text +delta (the child called no tools, so nothing was forwardable). Line numbers +refer to non-blank SSE lines. + +``` +data: {"type":"RUN_STARTED","threadId":"spike-thread-2","runId":"spike-run-2"} +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"MESSAGES_SNAPSHOT","messages":[{"id":"u1","role":"user","content":"Find a slot for Ada and Grace next week — research their availability first"}]} +data: {"type":"TOOL_CALL_START","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","toolCallName":"research_availability","parentMessageId":"0130e374-95eb-4a08-aed3-2b6f877331c6"} +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","delta":"{\""} +# [elided: 12 more TOOL_CALL_ARGS deltas spelling {"attendees": "Ada, Grace", "date_range": "next week"}] +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","delta":"\"}"} +data: {"type":"TOOL_CALL_END","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7"} +# [elided: MESSAGES_SNAPSHOT mirroring the assistant tool-call message] +# <-- the specialist ran HERE; its interim + final text produced tool_stream_events, none reached the wire +data: {"type":"TOOL_CALL_RESULT","messageId":"2d0594ca-dc44-4c24-841a-1461d55759fc","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","content":"\"To provide a summary of likely availability windows for Ada and Grace for the next week, I will check their schedules. Please hold on for a moment.\\nI actually do not have access to the scheduling information for the attendees. Please provide their typical availability or any specific constraints you might know about them, and I can help you summarize likely availability windows accordingly.\""} +# [elided: MESSAGES_SNAPSHOT adding the tool-result message] +data: {"type":"TEXT_MESSAGE_START","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","role":"assistant"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","delta":"I"} +# [elided: 27 more TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, after the tool result] +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","delta":"."} +data: {"type":"TEXT_MESSAGE_END","messageId":"51b44583-454d-4252-803a-1f91fa681f5e"} +# [elided: final MESSAGES_SNAPSHOT] +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"RUN_FINISHED","threadId":"spike-thread-2","runId":"spike-run-2","outcome":{"type":"success"}} +``` + +Event tally: 1 RUN_STARTED, 2 STATE_SNAPSHOT, 4 MESSAGES_SNAPSHOT, +1 TOOL_CALL_START, 14 TOOL_CALL_ARGS, 1 TOOL_CALL_END, 1 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 29 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +1 RUN_FINISHED. No SUBAGENT_*, no RAW, no CUSTOM, no STEP_*. + +### 3b. Plain sync delegation tool (the naive idiom) + +Same prompt, tool body `result = availability_researcher(...); return str(result)`. +Identical wire shape: `TOOL_CALL_START/ARGS×14/END → MESSAGES_SNAPSHOT → +TOOL_CALL_RESULT` (child's full multi-paragraph answer as one string) `→ +MESSAGES_SNAPSHOT → TEXT_MESSAGE_*` (orchestrator summary) `→ RUN_FINISHED +success`. A sync tool yields nothing mid-flight, so no `tool_stream_event` +fires at all — the child is a black box by construction, and this variant can +never feed a subagent emitter. (Capture withheld here as it adds nothing over +3a; tally: 24 TEXT_MESSAGE_CONTENT, otherwise identical event mix.) + +## 4. Did child tokens appear on the wire? + +**No — in neither variant.** During the delegation call the stream goes +straight from `TOOL_CALL_END` (§3a line 19) to `TOOL_CALL_RESULT` (§3a line +21) with only a `MESSAGES_SNAPSHOT` between. The specialist's interim sentence +("To provide a summary ... Please hold on for a moment.") exists in the run — +it surfaces verbatim *inside* the final `TOOL_CALL_RESULT` content — proving +the child streamed internally and the bridge dropped the deltas +(`_forward_inner_agent_events` forwards tool-call lifecycle only, +`agent.py:1195-1313`). Natively, delegation is: parent tool-call args stream → +silence → one opaque result string. This is the matrix cell the emitter fixes: +child tokens must be re-emitted by our `tool_stream_event_handler` as +`TEXT_MESSAGE_*` events carrying `subagentRunId`. + +## 5. Other observations + +- Restreaming the child through the generator tool logs repeated + `ValueError: was created + in a different Context` server-side (OTel context tokens crossing task + boundaries; cosmetic with OTel disabled, but worth watching once the real + emitter lands). +- `RunAgentInput` requires `threadId`, `runId`, `messages`, `tools`, + `context`, `forwardedProps` (camelCase; `ag_ui/core/types.py:396-412`); + the endpoint validates with `model_validate` and 422s otherwise + (`endpoint.py:55-86`). +- `emit_messages_snapshot` (on by default) interleaves full + `MESSAGES_SNAPSHOT`s after every tool END/RESULT and TEXT_MESSAGE_END — + the future emitter must not splice subagent messages into those snapshots + (mirroring `_forward_inner_agent_events`' deliberate choice, + `agent.py:1209-1211`). From bed7e9a7c9725f75561c5c8ee00ed632640908f3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:02:30 -0700 Subject: [PATCH 02/10] feat(runtimes): strands meeting scheduler delegates availability research to a specialist Co-Authored-By: Claude Fable 5 --- .../aws-strands/python/pyproject.toml | 9 +++ .../runtimes/aws-strands/python/src/agent.py | 66 ++++++++++++++++-- .../python/tests/test_delegation.py | 43 ++++++++++++ cockpit/runtimes/aws-strands/python/uv.lock | 68 +++++++++++++++++++ 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 cockpit/runtimes/aws-strands/python/tests/test_delegation.py diff --git a/cockpit/runtimes/aws-strands/python/pyproject.toml b/cockpit/runtimes/aws-strands/python/pyproject.toml index a95c6f092..d93b5f47a 100644 --- a/cockpit/runtimes/aws-strands/python/pyproject.toml +++ b/cockpit/runtimes/aws-strands/python/pyproject.toml @@ -24,9 +24,18 @@ dependencies = [ [tool.uv.sources] ag-ui-strands = { git = "https://github.com/ag-ui-protocol/ag-ui.git", rev = "363d3878e30887e88c1fd5ca1916ec3a5962b6be", subdirectory = "integrations/aws-strands/python" } +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/cockpit/runtimes/aws-strands/python/src/agent.py b/cockpit/runtimes/aws-strands/python/src/agent.py index a74c3e5ea..89d25a33f 100644 --- a/cockpit/runtimes/aws-strands/python/src/agent.py +++ b/cockpit/runtimes/aws-strands/python/src/agent.py @@ -19,11 +19,16 @@ resumes from the client's top-level ``resume`` entries keyed by ``interruptId`` (never the LangGraph bridge's CUSTOM ``on_interrupt``). -No subagents surface and no multi-agent route: the Strands bridge routes -delegation through CUSTOM MultiAgentHandoff + STEP_* with zero ACTIVITY -events (measured red upstream), and multi-agent routes crash the stale -PyPI ``ag-ui-strands`` 0.3.0 wheel — which is why this example pins the -bridge to a git ref (see pyproject.toml). +- subagents: ``research_availability`` delegates to a tool-less specialist + ``availability_researcher`` Agent via an async-generator ``@tool`` that + re-yields the specialist's ``stream_async`` events; a per-tool + ``ToolBehavior.tool_stream_event_handler`` (src/subagent_emitter.py) + translates them into standard ``SUBAGENT_*`` + child ``TEXT_MESSAGE_*`` + wire events. (The bridge natively drops inner text deltas and would + otherwise route delegation through CUSTOM MultiAgentHandoff + STEP_*; + multi-agent routes also crash the stale PyPI ``ag-ui-strands`` 0.3.0 + wheel — which is why this example pins the bridge to a git ref, see + pyproject.toml and docs/wire-capture-subagents.md.) Model: Strands' native OpenAI provider on plain ``OPENAI_API_KEY`` — no AWS credentials involved. ``OPENAI_BASE_URL`` is honored, which is how the @@ -139,6 +144,41 @@ async def booking_state(context) -> dict | None: return _complete_state() +_RESEARCHER_INSTRUCTIONS = ( + "You are an availability researcher. Given attendee names and a date " + "range, produce a short bullet summary of likely availability windows. " + "Be concise: 3 bullets max." +) + + +@tool +async def research_availability(attendees: str, date_range: str): + """Delegate availability research for the given attendees to a specialist. + + Args: + attendees: Comma-separated attendee names, e.g. 'Ada, Grace'. + date_range: The window to research, e.g. 'next week'. + + Returns: + The specialist's bullet summary of likely availability windows. + """ + chunks: list[str] = [] + try: + async for event in availability_researcher.stream_async( + f"Attendees: {attendees}\nDate range: {date_range}" + ): + if isinstance(event, dict) and isinstance(event.get("data"), str): + chunks.append(event["data"]) + yield event + except Exception as exc: # pragma: no cover - exercised via the emitter tests + # Surface the failure to the emitter (which owns the SUBAGENT_ERROR + # wire event), then let the tool error propagate to Strands normally. + yield {"delegation_error": str(exc)} + raise + # Strands takes the LAST yielded value as the tool result. + yield "".join(chunks) + + _INSTRUCTIONS = """You are a meeting scheduling copilot. When the user asks to book a meeting: @@ -155,6 +195,9 @@ async def booking_state(context) -> dict | None: Keep every response brief and factual. Never invent availability — use the tool. + +When the user asks about attendees' availability, delegate that research to +the `research_availability` tool before proposing meeting slots. """ @@ -172,11 +215,22 @@ def build_model() -> OpenAIModel: return OpenAIModel(client_args=client_args, model_id=os.environ.get("OPENAI_CHAT_MODEL", "gpt-4o-mini")) +# Tool-less specialist the orchestrator delegates availability research to +# via the `research_availability` async-generator tool above. Its streamed +# events cross the bridge as tool_stream_events and are translated into +# SUBAGENT_* wire events by the emitter registered in ToolBehavior below. +availability_researcher = Agent( + model=build_model(), + system_prompt=_RESEARCHER_INSTRUCTIONS, + name="availability_researcher", + tools=[], +) + agent = StrandsAgent( agent=Agent( model=build_model(), system_prompt=_INSTRUCTIONS, - tools=[check_availability, book_meeting], + tools=[check_availability, book_meeting, research_availability], ), name="aws-strands", description="Books meetings with availability lookup, shared state, and human approval.", diff --git a/cockpit/runtimes/aws-strands/python/tests/test_delegation.py b/cockpit/runtimes/aws-strands/python/tests/test_delegation.py new file mode 100644 index 000000000..24c9b6f8f --- /dev/null +++ b/cockpit/runtimes/aws-strands/python/tests/test_delegation.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MIT +"""Tests for the `research_availability` delegation scenario — the tool is a +registered async-generator `@tool` that hands availability research to the +tool-less `availability_researcher` specialist. No live model calls: these +tests only inspect registration metadata (the module builds its OpenAI +clients with a placeholder key that would 401 at request time).""" + +import inspect + +from src.agent import agent, availability_researcher, research_availability + + +def _tool_names() -> list[str]: + return [t.tool_name for t in agent._tools] + + +def test_research_availability_is_registered_on_the_agent(): + assert "research_availability" in _tool_names() + # Existing tools stay registered untouched. + assert "check_availability" in _tool_names() + assert "book_meeting" in _tool_names() + + +def test_tool_name_and_docstring(): + assert research_availability.tool_name == "research_availability" + description = research_availability.tool_spec["description"] + assert description.startswith( + "Delegate availability research for the given attendees to a specialist." + ) + schema = research_availability.tool_spec["inputSchema"]["json"] + assert set(schema["required"]) == {"attendees", "date_range"} + + +def test_tool_is_an_async_generator(): + # The seam depends on it: only an async-generator tool produces + # tool_stream_events for the bridge to hand to the subagent emitter. + assert inspect.isasyncgenfunction(research_availability._tool_func) + + +def test_specialist_is_toolless_researcher(): + assert availability_researcher.name == "availability_researcher" + assert availability_researcher.tool_registry.registry == {} + assert "availability researcher" in availability_researcher.system_prompt diff --git a/cockpit/runtimes/aws-strands/python/uv.lock b/cockpit/runtimes/aws-strands/python/uv.lock index f38a5b2da..26bd24a2b 100644 --- a/cockpit/runtimes/aws-strands/python/uv.lock +++ b/cockpit/runtimes/aws-strands/python/uv.lock @@ -236,6 +236,12 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "ag-ui-strands", git = "https://github.com/ag-ui-protocol/ag-ui.git?subdirectory=integrations%2Faws-strands%2Fpython&rev=363d3878e30887e88c1fd5ca1916ec3a5962b6be" }, @@ -245,6 +251,12 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -429,6 +441,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jiter" version = "0.16.0" @@ -654,6 +675,15 @@ wheels = [ { 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]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -767,6 +797,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -781,6 +820,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +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" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +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'" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 0f08a55d5b1e6a71a3e614ce3587020179903592 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:04:05 -0700 Subject: [PATCH 03/10] =?UTF-8?q?feat(runtimes):=20strands=20subagent=20em?= =?UTF-8?q?itter=20=E2=80=94=20standard=20SUBAGENT=5F*=20events=20at=20the?= =?UTF-8?q?=20bridge=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../runtimes/aws-strands/python/src/agent.py | 5 + .../python/src/subagent_emitter.py | 150 +++++++++++++++++ .../python/tests/test_subagent_emitter.py | 154 ++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 cockpit/runtimes/aws-strands/python/src/subagent_emitter.py create mode 100644 cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py diff --git a/cockpit/runtimes/aws-strands/python/src/agent.py b/cockpit/runtimes/aws-strands/python/src/agent.py index 89d25a33f..1eeee2b58 100644 --- a/cockpit/runtimes/aws-strands/python/src/agent.py +++ b/cockpit/runtimes/aws-strands/python/src/agent.py @@ -50,6 +50,8 @@ from ag_ui_strands import StrandsAgent, StrandsAgentConfig, ToolBehavior +from .subagent_emitter import emit_subagent_events + _SLOTS = { "monday": ["09:00", "13:30"], "tuesday": ["10:00", "15:00"], @@ -238,6 +240,9 @@ def build_model() -> OpenAIModel: tool_behaviors={ "check_availability": ToolBehavior(state_from_result=availability_state), "book_meeting": ToolBehavior(state_from_args=booking_state), + "research_availability": ToolBehavior( + tool_stream_event_handler=emit_subagent_events, + ), }, ), ) diff --git a/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py b/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py new file mode 100644 index 000000000..da8fec848 --- /dev/null +++ b/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `research_availability` delegation tool. + +The Strands bridge natively drops a child agent's text deltas: an +async-generator `@tool` re-yielding a specialist's ``stream_async`` events +produces ``tool_stream_event``s, but `_forward_inner_agent_events` forwards +only the inner TOOL-CALL lifecycle and never inner text (measured in +docs/wire-capture-subagents.md). Registering this +``ToolBehavior.tool_stream_event_handler`` claims the whole child stream and +re-emits it as standard AG-UI subagent wire events: + + SUBAGENT_STARTED (first inner event) + TEXT_MESSAGE_START/CONTENT.../END (inner ``data`` deltas, streamed) + SUBAGENT_FINISHED outcome=success (inner terminal ``result`` event) + +or ``SUBAGENT_ERROR`` when the delegation fails (the tool yields a +``delegation_error`` sentinel before re-raising, or the inner stream +force-stops). Ids derive from the wire ``toolCallId`` (``ctx.tool_use_id``) +so the client can key the subagent card on ``parentToolCallId`` with zero +bookkeeping. + +The bridge instantiates this handler ONCE PER EVENT (a fresh async generator +per ``tool_stream_event``), so per-invocation lifecycle state lives in a +module-level dict keyed by ``tool_use_id``. The encoder requires pydantic +``BaseEvent`` instances — raw dicts crash the stream — so only typed +``ag_ui.core`` events are yielded. +""" + +from dataclasses import dataclass + +from ag_ui.core import ( + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) + +from ag_ui_strands import ToolStreamEventContext + +SPECIALIST_NAME = "availability_researcher" + + +@dataclass +class _DelegationState: + """Lifecycle of one delegation call, keyed by tool_use_id.""" + + started: bool = False + message_open: bool = False + finished: bool = False + + +# Finished entries are kept (not popped) so the tool's trailing result-string +# yield and any stragglers stay suppressed; the dict grows one small entry per +# delegation call for the life of the process, matching the module-level demo +# state in agent.py. +_sessions: dict[str, _DelegationState] = {} + + +def _subagent_run_id(tool_use_id: str) -> str: + return f"{tool_use_id}-sub" + + +def _message_id(tool_use_id: str) -> str: + return f"{tool_use_id}-sub-m1" + + +async def emit_subagent_events(ctx: ToolStreamEventContext): + """tool_stream_event_handler translating child events to SUBAGENT_* wire + events. Async generator, called once per inner event.""" + state = _sessions.setdefault(ctx.tool_use_id, _DelegationState()) + run_id = _subagent_run_id(ctx.tool_use_id) + message_id = _message_id(ctx.tool_use_id) + try: + if state.finished: + # The tool's final yield (the result string) and any stragglers + # arrive after the inner terminal event — nothing left to emit. + return + + if not state.started: + state.started = True + yield SubagentStartedEvent( + type=EventType.SUBAGENT_STARTED, + subagent_run_id=run_id, + name=SPECIALIST_NAME, + parent_tool_call_id=ctx.tool_use_id, + ) + + data = ctx.stream_data + if not isinstance(data, dict): + return + + if "delegation_error" in data or data.get("force_stop"): + message = str( + data.get("delegation_error") + or data.get("force_stop_reason") + or "subagent stream force-stopped" + ) + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=run_id, + message=message, + ) + elif isinstance(data.get("data"), str) and data["data"]: + if not state.message_open: + state.message_open = True + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=message_id, + role="assistant", + subagent_run_id=run_id, + ) + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=message_id, + delta=data["data"], + subagent_run_id=run_id, + ) + elif "result" in data: + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + except Exception as exc: # pragma: no cover - defensive: never crash the run + state.finished = True + yield SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=run_id, + message=str(exc), + ) diff --git a/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py b/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py new file mode 100644 index 000000000..c4bff60c0 --- /dev/null +++ b/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: MIT +"""Tests for the SUBAGENT_* emitter — drives the tool_stream_event_handler +with synthetic Strands events (shapes copied from the Task-0 wire capture in +docs/wire-capture-subagents.md) and asserts the exact emitted AG-UI sequence +field-for-field. The bridge calls the handler once per event with a fresh +generator, which is exactly how these tests drive it.""" + +import pytest + +from ag_ui.core import EventType +from ag_ui_strands import ToolStreamEventContext + +from src import subagent_emitter +from src.subagent_emitter import emit_subagent_events + +TOOL_USE_ID = "call_vF6Vc6Wzl40vz9pBZOOrDxS7" +RUN_ID = f"{TOOL_USE_ID}-sub" +MESSAGE_ID = f"{TOOL_USE_ID}-sub-m1" + + +@pytest.fixture(autouse=True) +def _clean_sessions(): + subagent_emitter._sessions.clear() + yield + subagent_emitter._sessions.clear() + + +async def _drive(events: list, tool_use_id: str = TOOL_USE_ID) -> list: + """Feed synthetic stream payloads one at a time, the way the bridge + dispatches tool_stream_events, collecting everything yielded.""" + out = [] + for data in events: + ctx = ToolStreamEventContext( + tool_use_id=tool_use_id, + tool_name="research_availability", + stream_data=data, + ) + async for ev in emit_subagent_events(ctx): + out.append(ev) + return out + + +async def test_success_sequence_field_for_field(): + out = await _drive([ + {"init_event_loop": True}, # specialist loop init + {"start": True}, + {"data": "- Ada"}, # streamed text deltas + {"data": " is free"}, + {"data": " Tuesday"}, + {"result": object()}, # terminal AgentResult event + "- Ada is free Tuesday", # the tool's final yield (result string) + ]) + + types = [ev.type for ev in out] + assert types == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + + started = out[0] + assert started.subagent_run_id == RUN_ID + assert started.name == "availability_researcher" + assert started.parent_tool_call_id == TOOL_USE_ID + + start = out[1] + assert start.message_id == MESSAGE_ID + assert start.role == "assistant" + assert start.subagent_run_id == RUN_ID + + deltas = out[2:5] + assert [ev.delta for ev in deltas] == ["- Ada", " is free", " Tuesday"] + for ev in deltas: + assert ev.message_id == MESSAGE_ID + assert ev.subagent_run_id == RUN_ID + + end = out[5] + assert end.message_id == MESSAGE_ID + assert end.subagent_run_id == RUN_ID + + finished = out[6] + assert finished.subagent_run_id == RUN_ID + assert finished.outcome.type == "success" + + # The session stays, marked finished, so stragglers stay suppressed. + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_no_deltas_still_brackets_with_started_and_finished(): + out = await _drive([{"init_event_loop": True}, {"result": object()}]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +async def test_delegation_error_yields_subagent_error(): + out = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada"}, + {"delegation_error": "specialist exploded"}, + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, # open message is closed before the error + EventType.SUBAGENT_ERROR, + ] + err = out[-1] + assert err.subagent_run_id == RUN_ID + assert err.message == "specialist exploded" + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_force_stop_yields_subagent_error(): + out = await _drive([ + {"init_event_loop": True}, + {"force_stop": True, "force_stop_reason": "max tokens"}, + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_ERROR, + ] + assert out[-1].message == "max tokens" + + +async def test_events_after_terminal_are_ignored(): + out = await _drive([ + {"result": object()}, + {"data": "late straggler"}, + "final string", + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +async def test_ids_derive_from_tool_use_id(): + out = await _drive([{"data": "x"}, {"result": object()}], tool_use_id="call_other") + assert out[0].subagent_run_id == "call_other-sub" + assert out[1].message_id == "call_other-sub-m1" + + +def test_handler_is_registered_on_the_agent_config(): + from src.agent import agent + + behavior = agent.config.tool_behaviors["research_availability"] + assert behavior.tool_stream_event_handler is emit_subagent_events From 85dd50b7645972d713abdb81c74da2ba278d3bc2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:05:12 -0700 Subject: [PATCH 04/10] docs(runtimes): strands post-emitter wire capture Co-Authored-By: Claude Fable 5 --- .../python/docs/wire-capture-subagents.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md b/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md index 80b4f49a0..d4c9dd3b0 100644 --- a/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md +++ b/cockpit/runtimes/aws-strands/python/docs/wire-capture-subagents.md @@ -195,3 +195,64 @@ child tokens must be re-emitted by our `tool_stream_event_handler` as the future emitter must not splice subagent messages into those snapshots (mirroring `_forward_inner_agent_events`' deliberate choice, `agent.py:1209-1211`). + +## After the emitter + +Captured 2026-09-02 against the shipped scenario (`research_availability` +registered with `ToolBehavior(tool_stream_event_handler=emit_subagent_events)`, +`src/subagent_emitter.py`), same `RunAgentInput` as §3 (prompt *"Find a slot +for Ada and Grace next week — research their availability first"*, empty +`tools`/`context`/`state`/`forwardedProps`). No keys or org ids appeared in +the stream; only repetitive delta runs and `MESSAGES_SNAPSHOT`s are elided, +marked with `# [elided: ...]`. + +The model delegated TWICE this run (first to ask for concrete dates, then to +research them) — both rounds carried the full `SUBAGENT_*` block, each nested +between its own `TOOL_CALL_END` and `TOOL_CALL_RESULT` exactly as §1 +predicted. First round shown; the second is shape-identical with +`toolCallId=call_ur6NyfucZhR4FlDTxrDt0hy9` and 61 content deltas. + +``` +data: {"type":"RUN_STARTED","threadId":"smoke-thread-1","runId":"smoke-run-1"} +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +# [elided: MESSAGES_SNAPSHOT] +data: {"type":"TOOL_CALL_START","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg","toolCallName":"research_availability","parentMessageId":"6228ef46-8396-4044-903c-ef9e3f3abc3d"} +# [elided: 14 TOOL_CALL_ARGS deltas spelling {"attendees": "Ada, Grace", "date_range": "next week"}] +data: {"type":"TOOL_CALL_END","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg"} +# [elided: MESSAGES_SNAPSHOT] +data: {"type":"SUBAGENT_STARTED","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub","name":"availability_researcher","parentToolCallId":"call_YW6LslLJbLNZul9qYlYfeySg"} +data: {"type":"TEXT_MESSAGE_START","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","role":"assistant","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":"Please","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":" provide","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +# [elided: 25 more TEXT_MESSAGE_CONTENT deltas — the CHILD's text, token by token] +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":".","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_END","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"SUBAGENT_FINISHED","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub","outcome":{"type":"success"}} +data: {"type":"TOOL_CALL_RESULT","messageId":"b4c51965-4781-4683-8808-05fddd85135f","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg","content":"\"Please provide the specific dates for next week (e.g., from October 16 to October 20) so I can summarize the availability accordingly.\""} +# [elided: MESSAGES_SNAPSHOT, then the second delegation round — TOOL_CALL_START/ARGS×19/END, +# SUBAGENT_STARTED, TEXT_MESSAGE_START, 61 TEXT_MESSAGE_CONTENT deltas, TEXT_MESSAGE_END, +# SUBAGENT_FINISHED success, TOOL_CALL_RESULT — ids derived from call_ur6NyfucZhR4FlDTxrDt0hy9] +data: {"type":"TEXT_MESSAGE_START","messageId":"77635fb9-a418-443a-af7e-c70a29d079f6","role":"assistant"} +# [elided: 26 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, no subagentRunId] +data: {"type":"TEXT_MESSAGE_END","messageId":"77635fb9-a418-443a-af7e-c70a29d079f6"} +# [elided: final MESSAGES_SNAPSHOT] +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"RUN_FINISHED","threadId":"smoke-thread-1","runId":"smoke-run-1","outcome":{"type":"success"}} +``` + +Event tally (174 SSE lines): 1 RUN_STARTED, 2 STATE_SNAPSHOT, +6 MESSAGES_SNAPSHOT, 2 TOOL_CALL_START, 33 TOOL_CALL_ARGS, 2 TOOL_CALL_END, +2 SUBAGENT_STARTED, 2 TEXT_MESSAGE_START(sub), 89 TEXT_MESSAGE_CONTENT(sub), +2 TEXT_MESSAGE_END(sub), 2 SUBAGENT_FINISHED, 2 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 26 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +1 RUN_FINISHED. No RAW, no CUSTOM, no STEP_*, no SUBAGENT_ERROR. + +**Child deltas: streaming (89 content events across the two delegation +rounds: 28 + 61)** — §4's black box is gone. Every child event carries +`subagentRunId` derived from the wire `toolCallId` +(`-sub` / `-sub-m1`), `SUBAGENT_STARTED.parentToolCallId` +matches the bridge-native `TOOL_CALL_START.toolCallId` verbatim, and the +`TOOL_CALL_RESULT` content equals the joined child deltas (the tool's final +yield), confirming the last-yield-as-result contract survived the handler +registration. The §5 OTel `ContextVar` warnings still log server-side and +remain cosmetic. From 6e891e505aa4b9e015b9ab4f36f23f877a1760c9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:13:13 -0700 Subject: [PATCH 05/10] =?UTF-8?q?fix(runtimes):=20strands=20emitter=20?= =?UTF-8?q?=E2=80=94=20reset=20on=20reused=20tool=5Fuse=5Fid;=20terminal?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../runtimes/aws-strands/python/src/agent.py | 2 +- .../python/src/subagent_emitter.py | 55 ++++++++++++--- .../python/tests/test_delegation.py | 2 + .../python/tests/test_subagent_emitter.py | 68 +++++++++++++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/cockpit/runtimes/aws-strands/python/src/agent.py b/cockpit/runtimes/aws-strands/python/src/agent.py index 1eeee2b58..0a709ee30 100644 --- a/cockpit/runtimes/aws-strands/python/src/agent.py +++ b/cockpit/runtimes/aws-strands/python/src/agent.py @@ -172,7 +172,7 @@ async def research_availability(attendees: str, date_range: str): if isinstance(event, dict) and isinstance(event.get("data"), str): chunks.append(event["data"]) yield event - except Exception as exc: # pragma: no cover - exercised via the emitter tests + except Exception as exc: # pragma: no cover - not reachable without a live model failure # Surface the failure to the emitter (which owns the SUBAGENT_ERROR # wire event), then let the tool error propagate to Strands normally. yield {"delegation_error": str(exc)} diff --git a/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py b/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py index da8fec848..e858babb1 100644 --- a/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py +++ b/cockpit/runtimes/aws-strands/python/src/subagent_emitter.py @@ -51,29 +51,50 @@ class _DelegationState: started: bool = False message_open: bool = False finished: bool = False + generation: int = 1 + """Bumped when a reused tool_use_id starts a fresh inner stream, so the + re-run's message id (-m2, -m3, ...) never collides with an already-emitted + one.""" # Finished entries are kept (not popped) so the tool's trailing result-string -# yield and any stragglers stay suppressed; the dict grows one small entry per -# delegation call for the life of the process, matching the module-level demo -# state in agent.py. +# yield and any stragglers stay suppressed. Growth is capped at _MAX_SESSIONS +# (dict insertion order = age; each entry is a short key string plus a +# 4-field dataclass, ~414 bytes measured with sys.getsizeof — the cap bounds +# the dict at ~200 KiB). _sessions: dict[str, _DelegationState] = {} +_MAX_SESSIONS = 512 def _subagent_run_id(tool_use_id: str) -> str: return f"{tool_use_id}-sub" -def _message_id(tool_use_id: str) -> str: - return f"{tool_use_id}-sub-m1" +def _message_id(tool_use_id: str, generation: int) -> str: + return f"{tool_use_id}-sub-m{generation}" async def emit_subagent_events(ctx: ToolStreamEventContext): """tool_stream_event_handler translating child events to SUBAGENT_* wire events. Async generator, called once per inner event.""" - state = _sessions.setdefault(ctx.tool_use_id, _DelegationState()) + state = _sessions.get(ctx.tool_use_id) + if state is None: + state = _DelegationState() + _sessions[ctx.tool_use_id] = state + while len(_sessions) > _MAX_SESSIONS: + del _sessions[next(k for k in _sessions if k != ctx.tool_use_id)] run_id = _subagent_run_id(ctx.tool_use_id) - message_id = _message_id(ctx.tool_use_id) + data = ctx.stream_data + if state.finished and isinstance(data, dict) and "init_event_loop" in data: + # Reused tool_use_id (real for some Strands providers — see the + # bridge's _reused_frontend_tool_identity_error): a fresh inner + # stream always opens with init_event_loop, so reset for a second + # full SUBAGENT_* sequence under the same subagent_run_id (the + # adapter treats an identity-unchanged re-announce as content-only). + # Non-init stragglers after the terminal stay swallowed below. + state = _DelegationState(generation=state.generation + 1) + _sessions[ctx.tool_use_id] = state + message_id = _message_id(ctx.tool_use_id, state.generation) try: if state.finished: # The tool's final yield (the result string) and any stragglers @@ -89,7 +110,25 @@ async def emit_subagent_events(ctx: ToolStreamEventContext): parent_tool_call_id=ctx.tool_use_id, ) - data = ctx.stream_data + if isinstance(data, str): + # Terminal-success fallback: the tool's final result-string yield + # arriving on an UNFINISHED session means the inner stream ended + # without a {"result": ...} event — close out rather than leaving + # the subagent card open forever. + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + return if not isinstance(data, dict): return diff --git a/cockpit/runtimes/aws-strands/python/tests/test_delegation.py b/cockpit/runtimes/aws-strands/python/tests/test_delegation.py index 24c9b6f8f..293b3cc95 100644 --- a/cockpit/runtimes/aws-strands/python/tests/test_delegation.py +++ b/cockpit/runtimes/aws-strands/python/tests/test_delegation.py @@ -11,6 +11,8 @@ def _tool_names() -> list[str]: + # Private-attr coupling (StrandsAgent._tools) is frozen by the git-ref + # pin on ag-ui-strands in pyproject.toml. return [t.tool_name for t in agent._tools] diff --git a/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py b/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py index c4bff60c0..0f2c7a073 100644 --- a/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py +++ b/cockpit/runtimes/aws-strands/python/tests/test_subagent_emitter.py @@ -141,6 +141,74 @@ async def test_events_after_terminal_are_ignored(): ] +async def test_reused_tool_use_id_resets_on_init_event_loop(): + # Some Strands providers reuse tool_use_ids across calls (see the + # bridge's _reused_frontend_tool_identity_error). A fresh inner stream + # always opens with init_event_loop, so a full second sequence must be + # emitted — with a bumped message-id generation so -m1 is not reused. + first = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada"}, + {"result": object()}, + "- Ada", + ]) + assert [ev.type for ev in first] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert first[1].message_id == f"{TOOL_USE_ID}-sub-m1" + + second = await _drive([ + {"init_event_loop": True}, + {"data": "- Grace"}, + {"result": object()}, + "- Grace", + ]) + assert [ev.type for ev in second] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + # Same subagent_run_id (identity-unchanged re-announce), fresh message id. + assert second[0].subagent_run_id == RUN_ID + assert second[1].message_id == f"{TOOL_USE_ID}-sub-m2" + assert second[2].delta == "- Grace" + + +async def test_str_payload_on_unfinished_session_is_terminal_success(): + # A stream that ends without a {"result": ...} event still terminates: + # the tool's final result-string yield closes the message and finishes + # the subagent instead of leaving the card open forever. + out = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada is free Tuesday"}, + "- Ada is free Tuesday", + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert out[-1].outcome.type == "success" + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_sessions_growth_is_capped(): + for i in range(subagent_emitter._MAX_SESSIONS + 5): + await _drive([{"result": object()}], tool_use_id=f"call_{i}") + assert len(subagent_emitter._sessions) == subagent_emitter._MAX_SESSIONS + # Oldest entries were evicted, newest kept. + assert "call_0" not in subagent_emitter._sessions + assert f"call_{subagent_emitter._MAX_SESSIONS + 4}" in subagent_emitter._sessions + + async def test_ids_derive_from_tool_use_id(): out = await _drive([{"data": "x"}, {"result": object()}], tool_use_id="call_other") assert out[0].subagent_run_id == "call_other-sub" From 5fc3b7e5342b0cc3821226584ea939a226c91b07 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:23:06 -0700 Subject: [PATCH 06/10] fix(chat): anchor subagent cards by Subagent.toolCallId, not the adapter map key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat-tool-calls looked up subagents via subs.has(tc.id) — the adapter's map KEY. Adapters key subagents() differently (LangGraph by toolCallId, AG-UI activities by messageId, AG-UI native SUBAGENT_* by subagentRunId, e.g. `-sub`), so a Strands delegation streamed correct SUBAGENT_* wire events yet rendered as a plain tool-call chip. Re-index by the contract field Subagent.toolCallId ("Tool call ID that spawned this subagent"), which is the only guaranteed anchor. Co-Authored-By: Claude Fable 5 --- .../chat-tool-calls/chat-tool-calls.component.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts index bd3e14c90..02cb2ea77 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts @@ -124,7 +124,15 @@ export class ChatToolCallsComponent { readonly groups = computed((): Group[] => { const excludeSet = new Set(this.excludeToolNames()); const calls = this.toolCalls().filter(tc => !excludeSet.has(tc.name)); - const subs = this.agent().subagents?.() ?? new Map(); + // Anchor subagents by the contract field `Subagent.toolCallId` (the tool + // call that spawned them), NOT by the adapter's map key. Adapters key the + // map differently — LangGraph by toolCallId, AG-UI activities by + // messageId, AG-UI native SUBAGENT_* by subagentRunId (e.g. + // `-sub`) — and only the wrapper's toolCallId is guaranteed + // to match the spawning call. + const rawSubs = this.agent().subagents?.() ?? new Map(); + const subs = new Map(); + rawSubs.forEach((sa) => subs.set(sa.toolCallId, sa)); const groupingMode = this.grouping(); const registry = this.templateRegistry(); const wildcard = registry.get('*'); From 2769ce9b7f1752ea19d6e69cd276740439288985 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:23:06 -0700 Subject: [PATCH 07/10] =?UTF-8?q?test(runtimes):=20strands=20delegation=20?= =?UTF-8?q?e2e=20=E2=80=94=20subagent=20card=20via=20fixture=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hand-written aimock fixtures cover the delegation turn's LLM calls: orchestrator tool-call (research_availability), the specialist (matched on its distinctive "availability researcher" system prompt), and the orchestrator continuation (hasToolResult discriminator ordered before the looser same-message entry). The spec asserts the inline renders with the specialist's name and the final bubble proposes a slot. Co-Authored-By: Claude Fable 5 --- .../angular/e2e/aws-strands.spec.ts | 20 ++++++++++++++ .../angular/e2e/fixtures/aws-strands.json | 26 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cockpit/runtimes/aws-strands/angular/e2e/aws-strands.spec.ts b/cockpit/runtimes/aws-strands/angular/e2e/aws-strands.spec.ts index 42266309c..38ebee249 100644 --- a/cockpit/runtimes/aws-strands/angular/e2e/aws-strands.spec.ts +++ b/cockpit/runtimes/aws-strands/angular/e2e/aws-strands.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; +import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness'; // Second proof outside unit tests that the neutral Agent contract's // interrupt path works against a genuinely non-LangGraph AG-UI backend: the @@ -37,3 +38,22 @@ test.describe('cockpit runtimes/aws-strands: meeting booking approval', () => { await expect(page.getByText(/is booked for Tuesday 10:00/i)).toBeVisible({ timeout: 30_000 }); }); }); + +// Delegation over the same non-LangGraph bridge: the orchestrator's +// `research_availability` async-generator tool re-yields the specialist's +// stream, and the per-tool ToolBehavior handler (src/subagent_emitter.py) +// translates it into SUBAGENT_STARTED / attributed TEXT_MESSAGE_* / +// SUBAGENT_FINISHED wire events. The @threadplane/ag-ui reducer keys the +// subagent to its spawning toolCallId, so renders the +// delegation inline as a instead of a tool-call chip. +test.describe('cockpit runtimes/aws-strands: subagent delegation', () => { + test('rt-strands: delegated availability research renders a streaming subagent card', async ({ page }) => { + const bubble = await submitAndWaitForResponse( + page, + 'Find a slot for Ada and Grace next week — research their availability first', + ); + await expect(page.locator('chat-subagent-card')).toHaveCount(1); + await expect(page.locator('chat-subagent-card')).toContainText('availability_researcher'); + await expect(bubble).toContainText(/slot|available/i); + }); +}); diff --git a/cockpit/runtimes/aws-strands/angular/e2e/fixtures/aws-strands.json b/cockpit/runtimes/aws-strands/angular/e2e/fixtures/aws-strands.json index 5f5ca4389..5c93c8bc5 100644 --- a/cockpit/runtimes/aws-strands/angular/e2e/fixtures/aws-strands.json +++ b/cockpit/runtimes/aws-strands/angular/e2e/fixtures/aws-strands.json @@ -30,6 +30,32 @@ } ] } + }, + { + "match": { "userMessage": "Ada and Grace", "hasToolResult": true }, + "response": { + "content": "Both are available Tuesday 10:00 next week — that slot works for Ada and Grace." + } + }, + { + "match": { "systemMessage": "availability researcher" }, + "response": { + "content": "- Ada: free Tuesday 10:00–12:00 and Thursday afternoon next week.\n- Grace: free Tuesday 10:00–11:30 and Friday morning next week.\n- Overlap: Tuesday 10:00 works for both." + } + }, + { + "match": { "userMessage": "Ada and Grace" }, + "response": { + "toolCalls": [ + { + "name": "research_availability", + "arguments": { + "attendees": "Ada, Grace", + "date_range": "next week" + } + } + ] + } } ] } From cc0e47da8f238fbc71cb9ebf561b21de46063fd6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:25:07 -0700 Subject: [PATCH 08/10] =?UTF-8?q?docs(runtimes):=20strands=20live=20browse?= =?UTF-8?q?r=20verification=20=E2=80=94=20streaming=20subagent=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-model run (real key) driven headlessly: the delegation prompt renders an inline chat-subagent-card (availability_researcher, wire toolCallId, status badge) whose text streams mid-run — innerText polled at 150ms grew 144→643 chars while status stayed `running`, then settled to `complete` — before the orchestrator's own summary bubble. Screenshot committed under e2e/manual/. Matrix cell evidence: subagent streaming = yes. Co-Authored-By: Claude Fable 5 --- .../angular/e2e/manual/subagent-card-live.png | Bin 0 -> 106049 bytes .../python/docs/wire-capture-subagents.md | 31 ++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 cockpit/runtimes/aws-strands/angular/e2e/manual/subagent-card-live.png diff --git a/cockpit/runtimes/aws-strands/angular/e2e/manual/subagent-card-live.png b/cockpit/runtimes/aws-strands/angular/e2e/manual/subagent-card-live.png new file mode 100644 index 0000000000000000000000000000000000000000..72c0c6cfa31102189d9f5d0120e863a4a40ab87e GIT binary patch literal 106049 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 anchored to the `research_availability` tool call — +header `availability_researcher` + wire `toolCallId` + status badge — with +the specialist's transcript inside it, followed by the orchestrator's own +summary bubble. The child text never leaked into the parent bubble, and the +card persists (collapsed to `complete`) after the run. + +Did the card text stream mid-run: **yes** (matrix cell: expected +streaming = yes). Polling the card's `innerText` every 150ms during the +run showed the badge at `running` while the specialist's message grew +monotonically across successive samples (one run: lengths 144 → 213 → 288 +→ 403 → 420 → 438 → 501 → 643 chars between t≈2.0s and t≈3.5s), then +flipped to `complete` and collapsed. This confirms the attributed +`TEXT_MESSAGE_CONTENT` deltas render progressively in the card, not as one +post-hoc paste. + +One rendering defect surfaced and was fixed on this branch: the wire and +the `@threadplane/ag-ui` reducer were both correct, but `chat-tool-calls` +anchored subagent cards by the adapter's `subagents()` **map key** (here +the `subagentRunId`, `-sub`) instead of the contract field +`Subagent.toolCallId`, so the card never mounted for Strands delegations. +Fixed in `libs/chat` by re-indexing on `Subagent.toolCallId`. From 05670d0e78aaa052dfad67616a1b0d73b68b3c15 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:29:37 -0700 Subject: [PATCH 09/10] test(chat): pin subagent card anchoring to the Subagent.toolCallId field The two existing anchoring tests key the subagents map identically to the toolCallId, so they pass with or without the field-based lookup. This one keys the map by an AG-UI-style subagentRunId ('call_t-sub') while the wrapper's toolCallId is 'call_t', and asserts the card still anchors to the spawning call (mutation-checked: red on reverting the component fix). Co-Authored-By: Claude Fable 5 --- .../chat-tool-calls.component.spec.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts index 48a519f39..85cf95848 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.spec.ts @@ -398,6 +398,48 @@ describe('ChatToolCallsComponent — subagent cards anchored to spawning task ca expect(fullText).toContain('search'); }); + it('anchors the card by the Subagent.toolCallId FIELD even when the map key differs', () => { + // The AG-UI adapter keys subagents() by subagentRunId (e.g. `-sub` + // for Strands' native SUBAGENT_* events) — only the wrapper's toolCallId + // field is guaranteed to match the spawning call. + const agent = mockAgent({ + withSubagents: true, + toolCalls: [ + { id: 'call_t', name: 'research_availability', args: {}, status: 'success' as never }, + ], + }); + const sub: Subagent = { + toolCallId: 'call_t', + name: 'availability_researcher', + status: signal('running'), + messages: signal([{ + id: 'm1', + role: 'assistant', + content: 'checking calendars', + delivery: staticDelivery('m1'), + }]), + state: signal({}), + }; + agent.subagents!.set(new Map([['call_t-sub', sub]])); + + const fixture = TestBed.createComponent(SubagentHost); + fixture.componentInstance.agent = agent; + fixture.componentInstance.message = { + id: 'a1', + role: 'assistant', + content: '', + toolCallIds: ['call_t'], + delivery: staticDelivery('a1'), + }; + fixture.detectChanges(); + + const cards = fixture.nativeElement.querySelectorAll('chat-subagent-card'); + expect(cards.length).toBe(1); + expect(cards[0].textContent).toContain('availability_researcher'); + // The spawning call renders AS the card, not as a generic tool-call chip. + expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card').length).toBe(0); + }); + it('renders two separate subagent cards for two task calls (not collapsed into one strip)', () => { const agent = mockAgent({ withSubagents: true, From 5416bbb5c16032d4ac6b53f8b0dbbce22968cfd8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:30:00 -0700 Subject: [PATCH 10/10] chore(deployments): regenerate ag-ui-dev with the strands delegation demo Co-Authored-By: Claude Fable 5 --- .../docs/wire-capture-subagents.md | 289 ++++++++++++++++++ .../ag-ui-dev/deps/aws_strands/pyproject.toml | 9 + .../ag-ui-dev/deps/aws_strands/src/agent.py | 71 ++++- .../deps/aws_strands/src/subagent_emitter.py | 189 ++++++++++++ .../deps/aws_strands/tests/test_delegation.py | 45 +++ .../tests/test_subagent_emitter.py | 222 ++++++++++++++ .../ag-ui-dev/deps/aws_strands/uv.lock | 68 +++++ 7 files changed, 887 insertions(+), 6 deletions(-) create mode 100644 deployments/ag-ui-dev/deps/aws_strands/docs/wire-capture-subagents.md create mode 100644 deployments/ag-ui-dev/deps/aws_strands/src/subagent_emitter.py create mode 100644 deployments/ag-ui-dev/deps/aws_strands/tests/test_delegation.py create mode 100644 deployments/ag-ui-dev/deps/aws_strands/tests/test_subagent_emitter.py diff --git a/deployments/ag-ui-dev/deps/aws_strands/docs/wire-capture-subagents.md b/deployments/ag-ui-dev/deps/aws_strands/docs/wire-capture-subagents.md new file mode 100644 index 000000000..520222b19 --- /dev/null +++ b/deployments/ag-ui-dev/deps/aws_strands/docs/wire-capture-subagents.md @@ -0,0 +1,289 @@ +# Strands agents-as-tools: wire capture + emitter-seam decision + +Task 0 spike evidence for translating Strands agents-as-tools delegation into +AG-UI `SUBAGENT_*` events. Captured 2026-09-02 against the live meeting-scheduler +backend (`src/agent.py` + `src/server.py`, bridge pinned to git rev +`363d3878e30887e88c1fd5ca1916ec3a5962b6be`), scratch delegation tool +`research_availability` wrapping a tool-less specialist `Agent(name="availability_researcher")`. +The scratch edit was reverted after capture; only this doc lands. + +All bridge citations are into the installed venv source: +`.venv/lib/python3.14/site-packages/ag_ui_strands/` (referred to as `agent.py` +etc. below). + +## 1. Seam decision: `ToolBehavior.tool_stream_event_handler` (config-level), not a subclass + +**Decision: emit `SUBAGENT_*` from a per-tool `tool_stream_event_handler` +registered in `StrandsAgentConfig.tool_behaviors["research_availability"]`, +with the delegation tool written as an async-generator `@tool` that re-yields +the specialist's `stream_async` events.** + +The exact hook point: the bridge's run loop dispatches every +`tool_stream_event` to the tool's registered handler in +`StrandsAgent.run`, `agent.py:4644-4663` — the handler is an async generator +called with a `ToolStreamEventContext` (`config.py:56-91`) and *"may yield zero +or more AG-UI Event objects which are forwarded directly into the top-level +event stream"* (`config.py:76-91`). Registering a handler suppresses the +default routing for that tool (state snapshots at `agent.py:4664-4669`, +agent-as-tool lifecycle forwarding at `agent.py:4670-4682`), so the handler +owns the whole child stream. + +Why not the LangGraph lane's dispatch-hook subclass +(`cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py` +overriding `_dispatch_event`): + +- (1b) **A `@tool` body cannot reach an emitter directly, but it does not need + one.** Strands wraps every value an async-generator tool yields as a + `tool_stream_event` in the parent stream (`agent.py:4596-4598`), and the + bridge hands that payload to the per-tool handler with `tool_use_id` and + `tool_name` attached (`agent.py:4648-4652`). That IS the sanctioned + tool-body-to-wire channel — no writer/contextvar/queue plumbing, no fork of + the 5,771-line bridge module. +- The Strands bridge has no `_dispatch_event` seam at all: `StrandsAgent.run` + (`agent.py:2956` onward) is one ~2,800-line async generator with dozens of + inline `yield` sites. A subclass would have to wrap the entire generator and + pattern-match already-serialized events to find the delegation window — + strictly worse information than the handler gets (raw inner Strands events, + pre-translation). +- (1a) Strands→AG-UI translation happens inline in that same `run` generator + (text deltas, `current_tool_use`, `contentBlockStop`, tool results — e.g. + the `tool_stream_event` branch at `agent.py:4596-4682`, tool results at + `agent.py:4684+`), then each pydantic event is SSE-serialized by + `EventEncoder.encode` → `event.model_dump_json(by_alias=True)` + (`ag_ui/encoder/encoder.py:22-36`), called from the endpoint's + `event_generator` (`endpoint.py:290-343`). +- (1c) **Unknown raw dicts do NOT pass through.** The encoder requires pydantic + `BaseEvent` instances (`model_dump_json` call, `encoder.py:36`); a plain dict + would crash the stream. Unmapped *Strands* events are forwarded only as + sanitized `RawEvent` payloads (`_sanitize_raw_event`, `agent.py:1089-1125`) + — and inner-agent payload keys (`data`, `current_tool_use`, ...) are in + `_RAW_SUPPRESSED_KEYS` (`agent.py:1078-1086`), so nothing from the child + leaks via RAW either. The handler must therefore yield real + `ag_ui.core` event objects — which exist, see §2. +- (1d) **`tool_stream_event` from a nested agent-as-tool is forwarded today + only for the inner TOOL-CALL lifecycle, never for inner text.** + `_forward_inner_agent_events` (`agent.py:1195-1313`, invoked at + `agent.py:4677-4682`) translates inner `current_tool_use` / + `contentBlockStop` / `toolResult` into namespaced `TOOL_CALL_*` events and + explicitly nothing else ("Only the tool-call lifecycle is forwarded", + `agent.py:1209`). Inner `{"data": ...}` text deltas fall through every + branch and are dropped. The live capture in §3 confirms this on the wire. + +### Emitter shape (next task) + +- Delegation tool: async-generator `@tool` that does + `async for event in specialist.stream_async(prompt): yield event`, then + yields the accumulated text as its final value (Strands takes the last + yielded value as the tool result — confirmed in §3, the `TOOL_CALL_RESULT` + content is exactly the joined child text). +- Handler on that tool: lazily emits `SubagentStartedEvent` + (`subagent_run_id=f"{ctx.tool_use_id}-sub"`, `name="availability_researcher"`, + `parent_tool_call_id=ctx.tool_use_id`) on the first inner event, translates + inner `{"data": }` into `TEXT_MESSAGE_START/CONTENT/END` carrying + `subagent_run_id`, and emits `SubagentFinishedEvent(outcome=success)` when it + sees the inner terminal `{"result": AgentResult}` event; + `SubagentErrorEvent` when the inner stream surfaces an error / + `forceStop`. `ctx.tool_use_id` equals the wire `toolCallId` (the model's + `call_...` id — §3 line 4 vs. the handler context), so `parentToolCallId` + lines up with the bridge-native `TOOL_CALL_START` with zero bookkeeping. +- Wire-order nuance: the bridge emits `TOOL_CALL_END` when the *args* finish + streaming, before the tool executes (§3 line 19). So the shipped order will + be `TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → SUBAGENT_STARTED → + TEXT_MESSAGE_* → SUBAGENT_FINISHED → TOOL_CALL_RESULT` — the `SUBAGENT_*` + block nests inside the tool call's start/result span, not inside + start/end. + +## 2. SDK check + +``` +$ uv run python -c "import ag_ui.core as c; print([n for n in dir(c) if 'Subagent' in n])" +['SubagentErrorEvent', 'SubagentFinishedEvent', 'SubagentFinishedOutcome', + 'SubagentFinishedSuccessOutcome', 'SubagentFinishedSuspendedOutcome', + 'SubagentStartedEvent'] +``` + +The pinned SDK has first-class subagent events (`ag_ui/core/events.py:455-512`) +with exactly the target fields (`subagent_run_id`, `name`, +`parent_tool_call_id`, `outcome` discriminated union), and every +`TextMessage*` / `ToolCall*` event carries an optional `subagent_run_id` +(`events.py:127-235`). **No raw-dict fallback is needed** — the handler +constructs typed events and the stock encoder serializes them. + +## 3. Live captures (scrubbed) + +Prompt: *"Find a slot for Ada and Grace next week — research their availability +first"*. The model called `research_availability` on the first attempt in both +runs. No API keys or org ids appeared in either stream; nothing was scrubbed — +only long `MESSAGES_SNAPSHOT` lines and repetitive delta runs are elided, each +marked with a `# [elided: ...]` comment. + +### 3a. Async-generator delegation tool (the seam-relevant variant) + +The tool re-yielded the specialist's entire `stream_async` output, so every +child event crossed the bridge as `tool_stream_event` — and the wire between +`TOOL_CALL_END` (line 19) and `TOOL_CALL_RESULT` (line 21) still carries +**zero child events**: `_forward_inner_agent_events` dropped every inner text +delta (the child called no tools, so nothing was forwardable). Line numbers +refer to non-blank SSE lines. + +``` +data: {"type":"RUN_STARTED","threadId":"spike-thread-2","runId":"spike-run-2"} +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"MESSAGES_SNAPSHOT","messages":[{"id":"u1","role":"user","content":"Find a slot for Ada and Grace next week — research their availability first"}]} +data: {"type":"TOOL_CALL_START","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","toolCallName":"research_availability","parentMessageId":"0130e374-95eb-4a08-aed3-2b6f877331c6"} +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","delta":"{\""} +# [elided: 12 more TOOL_CALL_ARGS deltas spelling {"attendees": "Ada, Grace", "date_range": "next week"}] +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","delta":"\"}"} +data: {"type":"TOOL_CALL_END","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7"} +# [elided: MESSAGES_SNAPSHOT mirroring the assistant tool-call message] +# <-- the specialist ran HERE; its interim + final text produced tool_stream_events, none reached the wire +data: {"type":"TOOL_CALL_RESULT","messageId":"2d0594ca-dc44-4c24-841a-1461d55759fc","toolCallId":"call_vF6Vc6Wzl40vz9pBZOOrDxS7","content":"\"To provide a summary of likely availability windows for Ada and Grace for the next week, I will check their schedules. Please hold on for a moment.\\nI actually do not have access to the scheduling information for the attendees. Please provide their typical availability or any specific constraints you might know about them, and I can help you summarize likely availability windows accordingly.\""} +# [elided: MESSAGES_SNAPSHOT adding the tool-result message] +data: {"type":"TEXT_MESSAGE_START","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","role":"assistant"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","delta":"I"} +# [elided: 27 more TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, after the tool result] +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"51b44583-454d-4252-803a-1f91fa681f5e","delta":"."} +data: {"type":"TEXT_MESSAGE_END","messageId":"51b44583-454d-4252-803a-1f91fa681f5e"} +# [elided: final MESSAGES_SNAPSHOT] +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"RUN_FINISHED","threadId":"spike-thread-2","runId":"spike-run-2","outcome":{"type":"success"}} +``` + +Event tally: 1 RUN_STARTED, 2 STATE_SNAPSHOT, 4 MESSAGES_SNAPSHOT, +1 TOOL_CALL_START, 14 TOOL_CALL_ARGS, 1 TOOL_CALL_END, 1 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 29 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +1 RUN_FINISHED. No SUBAGENT_*, no RAW, no CUSTOM, no STEP_*. + +### 3b. Plain sync delegation tool (the naive idiom) + +Same prompt, tool body `result = availability_researcher(...); return str(result)`. +Identical wire shape: `TOOL_CALL_START/ARGS×14/END → MESSAGES_SNAPSHOT → +TOOL_CALL_RESULT` (child's full multi-paragraph answer as one string) `→ +MESSAGES_SNAPSHOT → TEXT_MESSAGE_*` (orchestrator summary) `→ RUN_FINISHED +success`. A sync tool yields nothing mid-flight, so no `tool_stream_event` +fires at all — the child is a black box by construction, and this variant can +never feed a subagent emitter. (Capture withheld here as it adds nothing over +3a; tally: 24 TEXT_MESSAGE_CONTENT, otherwise identical event mix.) + +## 4. Did child tokens appear on the wire? + +**No — in neither variant.** During the delegation call the stream goes +straight from `TOOL_CALL_END` (§3a line 19) to `TOOL_CALL_RESULT` (§3a line +21) with only a `MESSAGES_SNAPSHOT` between. The specialist's interim sentence +("To provide a summary ... Please hold on for a moment.") exists in the run — +it surfaces verbatim *inside* the final `TOOL_CALL_RESULT` content — proving +the child streamed internally and the bridge dropped the deltas +(`_forward_inner_agent_events` forwards tool-call lifecycle only, +`agent.py:1195-1313`). Natively, delegation is: parent tool-call args stream → +silence → one opaque result string. This is the matrix cell the emitter fixes: +child tokens must be re-emitted by our `tool_stream_event_handler` as +`TEXT_MESSAGE_*` events carrying `subagentRunId`. + +## 5. Other observations + +- Restreaming the child through the generator tool logs repeated + `ValueError: was created + in a different Context` server-side (OTel context tokens crossing task + boundaries; cosmetic with OTel disabled, but worth watching once the real + emitter lands). +- `RunAgentInput` requires `threadId`, `runId`, `messages`, `tools`, + `context`, `forwardedProps` (camelCase; `ag_ui/core/types.py:396-412`); + the endpoint validates with `model_validate` and 422s otherwise + (`endpoint.py:55-86`). +- `emit_messages_snapshot` (on by default) interleaves full + `MESSAGES_SNAPSHOT`s after every tool END/RESULT and TEXT_MESSAGE_END — + the future emitter must not splice subagent messages into those snapshots + (mirroring `_forward_inner_agent_events`' deliberate choice, + `agent.py:1209-1211`). + +## After the emitter + +Captured 2026-09-02 against the shipped scenario (`research_availability` +registered with `ToolBehavior(tool_stream_event_handler=emit_subagent_events)`, +`src/subagent_emitter.py`), same `RunAgentInput` as §3 (prompt *"Find a slot +for Ada and Grace next week — research their availability first"*, empty +`tools`/`context`/`state`/`forwardedProps`). No keys or org ids appeared in +the stream; only repetitive delta runs and `MESSAGES_SNAPSHOT`s are elided, +marked with `# [elided: ...]`. + +The model delegated TWICE this run (first to ask for concrete dates, then to +research them) — both rounds carried the full `SUBAGENT_*` block, each nested +between its own `TOOL_CALL_END` and `TOOL_CALL_RESULT` exactly as §1 +predicted. First round shown; the second is shape-identical with +`toolCallId=call_ur6NyfucZhR4FlDTxrDt0hy9` and 61 content deltas. + +``` +data: {"type":"RUN_STARTED","threadId":"smoke-thread-1","runId":"smoke-run-1"} +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +# [elided: MESSAGES_SNAPSHOT] +data: {"type":"TOOL_CALL_START","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg","toolCallName":"research_availability","parentMessageId":"6228ef46-8396-4044-903c-ef9e3f3abc3d"} +# [elided: 14 TOOL_CALL_ARGS deltas spelling {"attendees": "Ada, Grace", "date_range": "next week"}] +data: {"type":"TOOL_CALL_END","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg"} +# [elided: MESSAGES_SNAPSHOT] +data: {"type":"SUBAGENT_STARTED","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub","name":"availability_researcher","parentToolCallId":"call_YW6LslLJbLNZul9qYlYfeySg"} +data: {"type":"TEXT_MESSAGE_START","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","role":"assistant","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":"Please","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":" provide","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +# [elided: 25 more TEXT_MESSAGE_CONTENT deltas — the CHILD's text, token by token] +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","delta":".","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"TEXT_MESSAGE_END","messageId":"call_YW6LslLJbLNZul9qYlYfeySg-sub-m1","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub"} +data: {"type":"SUBAGENT_FINISHED","subagentRunId":"call_YW6LslLJbLNZul9qYlYfeySg-sub","outcome":{"type":"success"}} +data: {"type":"TOOL_CALL_RESULT","messageId":"b4c51965-4781-4683-8808-05fddd85135f","toolCallId":"call_YW6LslLJbLNZul9qYlYfeySg","content":"\"Please provide the specific dates for next week (e.g., from October 16 to October 20) so I can summarize the availability accordingly.\""} +# [elided: MESSAGES_SNAPSHOT, then the second delegation round — TOOL_CALL_START/ARGS×19/END, +# SUBAGENT_STARTED, TEXT_MESSAGE_START, 61 TEXT_MESSAGE_CONTENT deltas, TEXT_MESSAGE_END, +# SUBAGENT_FINISHED success, TOOL_CALL_RESULT — ids derived from call_ur6NyfucZhR4FlDTxrDt0hy9] +data: {"type":"TEXT_MESSAGE_START","messageId":"77635fb9-a418-443a-af7e-c70a29d079f6","role":"assistant"} +# [elided: 26 TEXT_MESSAGE_CONTENT deltas — the ORCHESTRATOR's own summary, no subagentRunId] +data: {"type":"TEXT_MESSAGE_END","messageId":"77635fb9-a418-443a-af7e-c70a29d079f6"} +# [elided: final MESSAGES_SNAPSHOT] +data: {"type":"STATE_SNAPSHOT","snapshot":{}} +data: {"type":"RUN_FINISHED","threadId":"smoke-thread-1","runId":"smoke-run-1","outcome":{"type":"success"}} +``` + +Event tally (174 SSE lines): 1 RUN_STARTED, 2 STATE_SNAPSHOT, +6 MESSAGES_SNAPSHOT, 2 TOOL_CALL_START, 33 TOOL_CALL_ARGS, 2 TOOL_CALL_END, +2 SUBAGENT_STARTED, 2 TEXT_MESSAGE_START(sub), 89 TEXT_MESSAGE_CONTENT(sub), +2 TEXT_MESSAGE_END(sub), 2 SUBAGENT_FINISHED, 2 TOOL_CALL_RESULT, +1 TEXT_MESSAGE_START, 26 TEXT_MESSAGE_CONTENT, 1 TEXT_MESSAGE_END, +1 RUN_FINISHED. No RAW, no CUSTOM, no STEP_*, no SUBAGENT_ERROR. + +**Child deltas: streaming (89 content events across the two delegation +rounds: 28 + 61)** — §4's black box is gone. Every child event carries +`subagentRunId` derived from the wire `toolCallId` +(`-sub` / `-sub-m1`), `SUBAGENT_STARTED.parentToolCallId` +matches the bridge-native `TOOL_CALL_START.toolCallId` verbatim, and the +`TOOL_CALL_RESULT` content equals the joined child deltas (the tool's final +yield), confirming the last-yield-as-result contract survived the handler +registration. The §5 OTel `ContextVar` warnings still log server-side and +remain cosmetic. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :5331) + `nx +serve cockpit-runtimes-aws-strands-angular` on :4331, driven headlessly +with Playwright. Screenshot: +`cockpit/runtimes/aws-strands/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the delegation prompt (*"Find a slot for Ada and Grace next +week — research their availability first"*) produced an inline +`` anchored to the `research_availability` tool call — +header `availability_researcher` + wire `toolCallId` + status badge — with +the specialist's transcript inside it, followed by the orchestrator's own +summary bubble. The child text never leaked into the parent bubble, and the +card persists (collapsed to `complete`) after the run. + +Did the card text stream mid-run: **yes** (matrix cell: expected +streaming = yes). Polling the card's `innerText` every 150ms during the +run showed the badge at `running` while the specialist's message grew +monotonically across successive samples (one run: lengths 144 → 213 → 288 +→ 403 → 420 → 438 → 501 → 643 chars between t≈2.0s and t≈3.5s), then +flipped to `complete` and collapsed. This confirms the attributed +`TEXT_MESSAGE_CONTENT` deltas render progressively in the card, not as one +post-hoc paste. + +One rendering defect surfaced and was fixed on this branch: the wire and +the `@threadplane/ag-ui` reducer were both correct, but `chat-tool-calls` +anchored subagent cards by the adapter's `subagents()` **map key** (here +the `subagentRunId`, `-sub`) instead of the contract field +`Subagent.toolCallId`, so the card never mounted for Strands delegations. +Fixed in `libs/chat` by re-indexing on `Subagent.toolCallId`. diff --git a/deployments/ag-ui-dev/deps/aws_strands/pyproject.toml b/deployments/ag-ui-dev/deps/aws_strands/pyproject.toml index a95c6f092..d93b5f47a 100644 --- a/deployments/ag-ui-dev/deps/aws_strands/pyproject.toml +++ b/deployments/ag-ui-dev/deps/aws_strands/pyproject.toml @@ -24,9 +24,18 @@ dependencies = [ [tool.uv.sources] ag-ui-strands = { git = "https://github.com/ag-ui-protocol/ag-ui.git", rev = "363d3878e30887e88c1fd5ca1916ec3a5962b6be", subdirectory = "integrations/aws-strands/python" } +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/deployments/ag-ui-dev/deps/aws_strands/src/agent.py b/deployments/ag-ui-dev/deps/aws_strands/src/agent.py index a74c3e5ea..0a709ee30 100644 --- a/deployments/ag-ui-dev/deps/aws_strands/src/agent.py +++ b/deployments/ag-ui-dev/deps/aws_strands/src/agent.py @@ -19,11 +19,16 @@ resumes from the client's top-level ``resume`` entries keyed by ``interruptId`` (never the LangGraph bridge's CUSTOM ``on_interrupt``). -No subagents surface and no multi-agent route: the Strands bridge routes -delegation through CUSTOM MultiAgentHandoff + STEP_* with zero ACTIVITY -events (measured red upstream), and multi-agent routes crash the stale -PyPI ``ag-ui-strands`` 0.3.0 wheel — which is why this example pins the -bridge to a git ref (see pyproject.toml). +- subagents: ``research_availability`` delegates to a tool-less specialist + ``availability_researcher`` Agent via an async-generator ``@tool`` that + re-yields the specialist's ``stream_async`` events; a per-tool + ``ToolBehavior.tool_stream_event_handler`` (src/subagent_emitter.py) + translates them into standard ``SUBAGENT_*`` + child ``TEXT_MESSAGE_*`` + wire events. (The bridge natively drops inner text deltas and would + otherwise route delegation through CUSTOM MultiAgentHandoff + STEP_*; + multi-agent routes also crash the stale PyPI ``ag-ui-strands`` 0.3.0 + wheel — which is why this example pins the bridge to a git ref, see + pyproject.toml and docs/wire-capture-subagents.md.) Model: Strands' native OpenAI provider on plain ``OPENAI_API_KEY`` — no AWS credentials involved. ``OPENAI_BASE_URL`` is honored, which is how the @@ -45,6 +50,8 @@ from ag_ui_strands import StrandsAgent, StrandsAgentConfig, ToolBehavior +from .subagent_emitter import emit_subagent_events + _SLOTS = { "monday": ["09:00", "13:30"], "tuesday": ["10:00", "15:00"], @@ -139,6 +146,41 @@ async def booking_state(context) -> dict | None: return _complete_state() +_RESEARCHER_INSTRUCTIONS = ( + "You are an availability researcher. Given attendee names and a date " + "range, produce a short bullet summary of likely availability windows. " + "Be concise: 3 bullets max." +) + + +@tool +async def research_availability(attendees: str, date_range: str): + """Delegate availability research for the given attendees to a specialist. + + Args: + attendees: Comma-separated attendee names, e.g. 'Ada, Grace'. + date_range: The window to research, e.g. 'next week'. + + Returns: + The specialist's bullet summary of likely availability windows. + """ + chunks: list[str] = [] + try: + async for event in availability_researcher.stream_async( + f"Attendees: {attendees}\nDate range: {date_range}" + ): + if isinstance(event, dict) and isinstance(event.get("data"), str): + chunks.append(event["data"]) + yield event + except Exception as exc: # pragma: no cover - not reachable without a live model failure + # Surface the failure to the emitter (which owns the SUBAGENT_ERROR + # wire event), then let the tool error propagate to Strands normally. + yield {"delegation_error": str(exc)} + raise + # Strands takes the LAST yielded value as the tool result. + yield "".join(chunks) + + _INSTRUCTIONS = """You are a meeting scheduling copilot. When the user asks to book a meeting: @@ -155,6 +197,9 @@ async def booking_state(context) -> dict | None: Keep every response brief and factual. Never invent availability — use the tool. + +When the user asks about attendees' availability, delegate that research to +the `research_availability` tool before proposing meeting slots. """ @@ -172,11 +217,22 @@ def build_model() -> OpenAIModel: return OpenAIModel(client_args=client_args, model_id=os.environ.get("OPENAI_CHAT_MODEL", "gpt-4o-mini")) +# Tool-less specialist the orchestrator delegates availability research to +# via the `research_availability` async-generator tool above. Its streamed +# events cross the bridge as tool_stream_events and are translated into +# SUBAGENT_* wire events by the emitter registered in ToolBehavior below. +availability_researcher = Agent( + model=build_model(), + system_prompt=_RESEARCHER_INSTRUCTIONS, + name="availability_researcher", + tools=[], +) + agent = StrandsAgent( agent=Agent( model=build_model(), system_prompt=_INSTRUCTIONS, - tools=[check_availability, book_meeting], + tools=[check_availability, book_meeting, research_availability], ), name="aws-strands", description="Books meetings with availability lookup, shared state, and human approval.", @@ -184,6 +240,9 @@ def build_model() -> OpenAIModel: tool_behaviors={ "check_availability": ToolBehavior(state_from_result=availability_state), "book_meeting": ToolBehavior(state_from_args=booking_state), + "research_availability": ToolBehavior( + tool_stream_event_handler=emit_subagent_events, + ), }, ), ) diff --git a/deployments/ag-ui-dev/deps/aws_strands/src/subagent_emitter.py b/deployments/ag-ui-dev/deps/aws_strands/src/subagent_emitter.py new file mode 100644 index 000000000..e858babb1 --- /dev/null +++ b/deployments/ag-ui-dev/deps/aws_strands/src/subagent_emitter.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `research_availability` delegation tool. + +The Strands bridge natively drops a child agent's text deltas: an +async-generator `@tool` re-yielding a specialist's ``stream_async`` events +produces ``tool_stream_event``s, but `_forward_inner_agent_events` forwards +only the inner TOOL-CALL lifecycle and never inner text (measured in +docs/wire-capture-subagents.md). Registering this +``ToolBehavior.tool_stream_event_handler`` claims the whole child stream and +re-emits it as standard AG-UI subagent wire events: + + SUBAGENT_STARTED (first inner event) + TEXT_MESSAGE_START/CONTENT.../END (inner ``data`` deltas, streamed) + SUBAGENT_FINISHED outcome=success (inner terminal ``result`` event) + +or ``SUBAGENT_ERROR`` when the delegation fails (the tool yields a +``delegation_error`` sentinel before re-raising, or the inner stream +force-stops). Ids derive from the wire ``toolCallId`` (``ctx.tool_use_id``) +so the client can key the subagent card on ``parentToolCallId`` with zero +bookkeeping. + +The bridge instantiates this handler ONCE PER EVENT (a fresh async generator +per ``tool_stream_event``), so per-invocation lifecycle state lives in a +module-level dict keyed by ``tool_use_id``. The encoder requires pydantic +``BaseEvent`` instances — raw dicts crash the stream — so only typed +``ag_ui.core`` events are yielded. +""" + +from dataclasses import dataclass + +from ag_ui.core import ( + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) + +from ag_ui_strands import ToolStreamEventContext + +SPECIALIST_NAME = "availability_researcher" + + +@dataclass +class _DelegationState: + """Lifecycle of one delegation call, keyed by tool_use_id.""" + + started: bool = False + message_open: bool = False + finished: bool = False + generation: int = 1 + """Bumped when a reused tool_use_id starts a fresh inner stream, so the + re-run's message id (-m2, -m3, ...) never collides with an already-emitted + one.""" + + +# Finished entries are kept (not popped) so the tool's trailing result-string +# yield and any stragglers stay suppressed. Growth is capped at _MAX_SESSIONS +# (dict insertion order = age; each entry is a short key string plus a +# 4-field dataclass, ~414 bytes measured with sys.getsizeof — the cap bounds +# the dict at ~200 KiB). +_sessions: dict[str, _DelegationState] = {} +_MAX_SESSIONS = 512 + + +def _subagent_run_id(tool_use_id: str) -> str: + return f"{tool_use_id}-sub" + + +def _message_id(tool_use_id: str, generation: int) -> str: + return f"{tool_use_id}-sub-m{generation}" + + +async def emit_subagent_events(ctx: ToolStreamEventContext): + """tool_stream_event_handler translating child events to SUBAGENT_* wire + events. Async generator, called once per inner event.""" + state = _sessions.get(ctx.tool_use_id) + if state is None: + state = _DelegationState() + _sessions[ctx.tool_use_id] = state + while len(_sessions) > _MAX_SESSIONS: + del _sessions[next(k for k in _sessions if k != ctx.tool_use_id)] + run_id = _subagent_run_id(ctx.tool_use_id) + data = ctx.stream_data + if state.finished and isinstance(data, dict) and "init_event_loop" in data: + # Reused tool_use_id (real for some Strands providers — see the + # bridge's _reused_frontend_tool_identity_error): a fresh inner + # stream always opens with init_event_loop, so reset for a second + # full SUBAGENT_* sequence under the same subagent_run_id (the + # adapter treats an identity-unchanged re-announce as content-only). + # Non-init stragglers after the terminal stay swallowed below. + state = _DelegationState(generation=state.generation + 1) + _sessions[ctx.tool_use_id] = state + message_id = _message_id(ctx.tool_use_id, state.generation) + try: + if state.finished: + # The tool's final yield (the result string) and any stragglers + # arrive after the inner terminal event — nothing left to emit. + return + + if not state.started: + state.started = True + yield SubagentStartedEvent( + type=EventType.SUBAGENT_STARTED, + subagent_run_id=run_id, + name=SPECIALIST_NAME, + parent_tool_call_id=ctx.tool_use_id, + ) + + if isinstance(data, str): + # Terminal-success fallback: the tool's final result-string yield + # arriving on an UNFINISHED session means the inner stream ended + # without a {"result": ...} event — close out rather than leaving + # the subagent card open forever. + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + return + if not isinstance(data, dict): + return + + if "delegation_error" in data or data.get("force_stop"): + message = str( + data.get("delegation_error") + or data.get("force_stop_reason") + or "subagent stream force-stopped" + ) + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=run_id, + message=message, + ) + elif isinstance(data.get("data"), str) and data["data"]: + if not state.message_open: + state.message_open = True + yield TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=message_id, + role="assistant", + subagent_run_id=run_id, + ) + yield TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=message_id, + delta=data["data"], + subagent_run_id=run_id, + ) + elif "result" in data: + state.finished = True + if state.message_open: + state.message_open = False + yield TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=message_id, + subagent_run_id=run_id, + ) + yield SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + except Exception as exc: # pragma: no cover - defensive: never crash the run + state.finished = True + yield SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=run_id, + message=str(exc), + ) diff --git a/deployments/ag-ui-dev/deps/aws_strands/tests/test_delegation.py b/deployments/ag-ui-dev/deps/aws_strands/tests/test_delegation.py new file mode 100644 index 000000000..293b3cc95 --- /dev/null +++ b/deployments/ag-ui-dev/deps/aws_strands/tests/test_delegation.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT +"""Tests for the `research_availability` delegation scenario — the tool is a +registered async-generator `@tool` that hands availability research to the +tool-less `availability_researcher` specialist. No live model calls: these +tests only inspect registration metadata (the module builds its OpenAI +clients with a placeholder key that would 401 at request time).""" + +import inspect + +from src.agent import agent, availability_researcher, research_availability + + +def _tool_names() -> list[str]: + # Private-attr coupling (StrandsAgent._tools) is frozen by the git-ref + # pin on ag-ui-strands in pyproject.toml. + return [t.tool_name for t in agent._tools] + + +def test_research_availability_is_registered_on_the_agent(): + assert "research_availability" in _tool_names() + # Existing tools stay registered untouched. + assert "check_availability" in _tool_names() + assert "book_meeting" in _tool_names() + + +def test_tool_name_and_docstring(): + assert research_availability.tool_name == "research_availability" + description = research_availability.tool_spec["description"] + assert description.startswith( + "Delegate availability research for the given attendees to a specialist." + ) + schema = research_availability.tool_spec["inputSchema"]["json"] + assert set(schema["required"]) == {"attendees", "date_range"} + + +def test_tool_is_an_async_generator(): + # The seam depends on it: only an async-generator tool produces + # tool_stream_events for the bridge to hand to the subagent emitter. + assert inspect.isasyncgenfunction(research_availability._tool_func) + + +def test_specialist_is_toolless_researcher(): + assert availability_researcher.name == "availability_researcher" + assert availability_researcher.tool_registry.registry == {} + assert "availability researcher" in availability_researcher.system_prompt diff --git a/deployments/ag-ui-dev/deps/aws_strands/tests/test_subagent_emitter.py b/deployments/ag-ui-dev/deps/aws_strands/tests/test_subagent_emitter.py new file mode 100644 index 000000000..0f2c7a073 --- /dev/null +++ b/deployments/ag-ui-dev/deps/aws_strands/tests/test_subagent_emitter.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: MIT +"""Tests for the SUBAGENT_* emitter — drives the tool_stream_event_handler +with synthetic Strands events (shapes copied from the Task-0 wire capture in +docs/wire-capture-subagents.md) and asserts the exact emitted AG-UI sequence +field-for-field. The bridge calls the handler once per event with a fresh +generator, which is exactly how these tests drive it.""" + +import pytest + +from ag_ui.core import EventType +from ag_ui_strands import ToolStreamEventContext + +from src import subagent_emitter +from src.subagent_emitter import emit_subagent_events + +TOOL_USE_ID = "call_vF6Vc6Wzl40vz9pBZOOrDxS7" +RUN_ID = f"{TOOL_USE_ID}-sub" +MESSAGE_ID = f"{TOOL_USE_ID}-sub-m1" + + +@pytest.fixture(autouse=True) +def _clean_sessions(): + subagent_emitter._sessions.clear() + yield + subagent_emitter._sessions.clear() + + +async def _drive(events: list, tool_use_id: str = TOOL_USE_ID) -> list: + """Feed synthetic stream payloads one at a time, the way the bridge + dispatches tool_stream_events, collecting everything yielded.""" + out = [] + for data in events: + ctx = ToolStreamEventContext( + tool_use_id=tool_use_id, + tool_name="research_availability", + stream_data=data, + ) + async for ev in emit_subagent_events(ctx): + out.append(ev) + return out + + +async def test_success_sequence_field_for_field(): + out = await _drive([ + {"init_event_loop": True}, # specialist loop init + {"start": True}, + {"data": "- Ada"}, # streamed text deltas + {"data": " is free"}, + {"data": " Tuesday"}, + {"result": object()}, # terminal AgentResult event + "- Ada is free Tuesday", # the tool's final yield (result string) + ]) + + types = [ev.type for ev in out] + assert types == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + + started = out[0] + assert started.subagent_run_id == RUN_ID + assert started.name == "availability_researcher" + assert started.parent_tool_call_id == TOOL_USE_ID + + start = out[1] + assert start.message_id == MESSAGE_ID + assert start.role == "assistant" + assert start.subagent_run_id == RUN_ID + + deltas = out[2:5] + assert [ev.delta for ev in deltas] == ["- Ada", " is free", " Tuesday"] + for ev in deltas: + assert ev.message_id == MESSAGE_ID + assert ev.subagent_run_id == RUN_ID + + end = out[5] + assert end.message_id == MESSAGE_ID + assert end.subagent_run_id == RUN_ID + + finished = out[6] + assert finished.subagent_run_id == RUN_ID + assert finished.outcome.type == "success" + + # The session stays, marked finished, so stragglers stay suppressed. + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_no_deltas_still_brackets_with_started_and_finished(): + out = await _drive([{"init_event_loop": True}, {"result": object()}]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +async def test_delegation_error_yields_subagent_error(): + out = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada"}, + {"delegation_error": "specialist exploded"}, + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, # open message is closed before the error + EventType.SUBAGENT_ERROR, + ] + err = out[-1] + assert err.subagent_run_id == RUN_ID + assert err.message == "specialist exploded" + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_force_stop_yields_subagent_error(): + out = await _drive([ + {"init_event_loop": True}, + {"force_stop": True, "force_stop_reason": "max tokens"}, + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_ERROR, + ] + assert out[-1].message == "max tokens" + + +async def test_events_after_terminal_are_ignored(): + out = await _drive([ + {"result": object()}, + {"data": "late straggler"}, + "final string", + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +async def test_reused_tool_use_id_resets_on_init_event_loop(): + # Some Strands providers reuse tool_use_ids across calls (see the + # bridge's _reused_frontend_tool_identity_error). A fresh inner stream + # always opens with init_event_loop, so a full second sequence must be + # emitted — with a bumped message-id generation so -m1 is not reused. + first = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada"}, + {"result": object()}, + "- Ada", + ]) + assert [ev.type for ev in first] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert first[1].message_id == f"{TOOL_USE_ID}-sub-m1" + + second = await _drive([ + {"init_event_loop": True}, + {"data": "- Grace"}, + {"result": object()}, + "- Grace", + ]) + assert [ev.type for ev in second] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + # Same subagent_run_id (identity-unchanged re-announce), fresh message id. + assert second[0].subagent_run_id == RUN_ID + assert second[1].message_id == f"{TOOL_USE_ID}-sub-m2" + assert second[2].delta == "- Grace" + + +async def test_str_payload_on_unfinished_session_is_terminal_success(): + # A stream that ends without a {"result": ...} event still terminates: + # the tool's final result-string yield closes the message and finishes + # the subagent instead of leaving the card open forever. + out = await _drive([ + {"init_event_loop": True}, + {"data": "- Ada is free Tuesday"}, + "- Ada is free Tuesday", + ]) + assert [ev.type for ev in out] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert out[-1].outcome.type == "success" + assert subagent_emitter._sessions[TOOL_USE_ID].finished is True + + +async def test_sessions_growth_is_capped(): + for i in range(subagent_emitter._MAX_SESSIONS + 5): + await _drive([{"result": object()}], tool_use_id=f"call_{i}") + assert len(subagent_emitter._sessions) == subagent_emitter._MAX_SESSIONS + # Oldest entries were evicted, newest kept. + assert "call_0" not in subagent_emitter._sessions + assert f"call_{subagent_emitter._MAX_SESSIONS + 4}" in subagent_emitter._sessions + + +async def test_ids_derive_from_tool_use_id(): + out = await _drive([{"data": "x"}, {"result": object()}], tool_use_id="call_other") + assert out[0].subagent_run_id == "call_other-sub" + assert out[1].message_id == "call_other-sub-m1" + + +def test_handler_is_registered_on_the_agent_config(): + from src.agent import agent + + behavior = agent.config.tool_behaviors["research_availability"] + assert behavior.tool_stream_event_handler is emit_subagent_events diff --git a/deployments/ag-ui-dev/deps/aws_strands/uv.lock b/deployments/ag-ui-dev/deps/aws_strands/uv.lock index f38a5b2da..26bd24a2b 100644 --- a/deployments/ag-ui-dev/deps/aws_strands/uv.lock +++ b/deployments/ag-ui-dev/deps/aws_strands/uv.lock @@ -236,6 +236,12 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "ag-ui-strands", git = "https://github.com/ag-ui-protocol/ag-ui.git?subdirectory=integrations%2Faws-strands%2Fpython&rev=363d3878e30887e88c1fd5ca1916ec3a5962b6be" }, @@ -245,6 +251,12 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -429,6 +441,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jiter" version = "0.16.0" @@ -654,6 +675,15 @@ wheels = [ { 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]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -767,6 +797,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -781,6 +820,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +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" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +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'" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"