From aea6b669211edf14967d8b32969d80f1dabd7d4e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:35:36 -0700 Subject: [PATCH 1/8] docs(runtimes): maf delegation wire capture and pattern decision Co-Authored-By: Claude Fable 5 --- .../python/docs/wire-capture-subagents.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md diff --git a/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md new file mode 100644 index 000000000..47e851d89 --- /dev/null +++ b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md @@ -0,0 +1,183 @@ +# MAF delegation wire capture — subagent pattern decision (Task 0 spike) + +Date: 2026-09-02. Live capture against `src/server.py` (uvicorn, port 5330) with the +plain-OpenAI client path (`build_chat_client`, `gpt-4o-mini`). The scratch delegation +code described below was NOT committed; only this document is. + +Installed bridge inspected end to end: `agent-framework-ag-ui` 1.2.x in +`.venv/lib/python3.14/site-packages/agent_framework_ag_ui/` (all venv line numbers below +refer to that tree). + +## Verdict: Candidate A (agents-as-tools), emitter injects via a run-wrapper merge queue + +- **Candidate A works and is observable.** A specialist `Agent` invoked from an async + function tool streams its updates INTO the tool body in real time (101 streamed + updates observed in-tool for a ~550-char answer). That is everything the emitter + needs to synthesize the SUBAGENT_* sequence with streaming child deltas. +- **Candidate B (two-executor workflow) is not needed.** The endpoint does mount + workflows (`_endpoint.py:137-138` wraps a `Workflow` in `AgentFrameworkWorkflow`), + so B remains a fallback, but A is simpler and keeps the demo's existing + approval/predictive-state surfaces untouched. + +## Seam analysis (venv file:line) + +### (a) Where MAF run events become AG-UI events + +- Single entry point: `run_agent_stream` (`agent_framework_ag_ui/_agent_run.py:2259`), + reached from `AgentFrameworkAgent.run` (`_agent.py:147-166`). +- The wrapped agent is invoked at `_agent_run.py:2723` + (`response_stream = (a2ui_runner or agent).run(messages, stream=True, **run_kwargs)`); + updates are pulled at `_agent_run.py:2726` and each content item is converted to + AG-UI events by `_emit_content` (`_run_common.py:1166`, dispatched from + `_agent_run.py:2828`). `_emit_content` handles `text`, `function_call`, + `function_result`, `function_approval_request`, `usage`, reasoning, and MCP content + types (`_run_common.py:1174-1200`); anything else is dropped with a debug log + (`_run_common.py:1200`). +- The FastAPI endpoint consumes `protocol_runner.run(input_data)` and encodes each + yielded event generically (`_endpoint.py:212-242`). + +### (b) Can a function tool reach an event emitter/queue/context? + +**No.** There is no ContextVar, queue, writer, or middleware hook anywhere in +`agent_framework_ag_ui/*.py` or in `agent_framework/_tools.py` / `_middleware.py` / +`_agents.py` that a tool body could use to inject AG-UI events +(`grep -rn ContextVar` over those modules returns nothing). The event pipeline is a +pure pull-driven async generator; tools execute deep inside the framework's function +invocation loop within `agent.run(stream=True)` and only their return value surfaces +(as `function_result` content → `TOOL_CALL_RESULT`). + +**Injection seam (named):** wrap `AgentFrameworkAgent.run` — the exact method the +endpoint calls at `_endpoint.py:212`. Our emitter will be a small subclass (or +compositional wrapper) in the demo: + +1. `run()` creates an `asyncio.Queue` and sets a module-level `ContextVar` to it + before delegating to the inner `run_agent_stream` generator. Because the tool body + executes on the same async call chain (endpoint → wrapper → `run_agent_stream` → + `agent.run` → function invocation), the ContextVar value propagates into the tool. +2. The wrapper pumps the inner generator into the same queue from an + `asyncio.create_task` and yields from the merged queue. This is required for LIVE + interleaving: while the tool runs, the bridge generator is suspended awaiting the + next provider update, so a naive "drain queue between inner yields" design would + batch all child deltas until the tool returns. With the pump-task merge, a + `queue.put_nowait` from the tool body wakes the outer consumer immediately. +3. The tool body reads the ContextVar and enqueues + `SubagentStartedEvent {subagentRunId: -sub, name: "policy_researcher", + parentToolCallId: }` → attributed `TextMessageStart/Content×N/End` + (one delta per specialist update) → `SubagentFinishedEvent success` + (`SubagentErrorEvent` on exception). The tool's own `toolCallId` is available to + the body via the framework's function-call content on the update stream; the + emitter wrapper can also correlate it by observing the preceding + `TOOL_CALL_START` for the delegation tool on the bridge stream. + +This is the same "emit from inside the tool body" shape the Strands PR proved, with +the writer supplied by our own wrapper instead of the runtime (MAF's bridge provides +none). Reference translator: `cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py`. + +### (c) Does the encoder accept ag_ui.core pydantic events generally? + +**Yes.** `EventEncoder.encode` takes any `BaseEvent` and does a generic +model-dump → SSE `data:` frame (`ag_ui/encoder/encoder.py`, `encode`/`_encode_sse`); +the endpoint applies it uniformly with no per-type allowlist (`_endpoint.py:224`). +`Subagent*` events are `BaseEvent` subclasses, so they pass through untouched. + +SDK check (in this venv): + +``` +$ 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'] +``` + +### (d) What does the bridge do with nested-agent activity inside a tool? + +**Nothing is observable.** The specialist's `run(stream=True)` updates are consumed +entirely inside the tool body; the bridge sees only the tool's `function_call` +(streamed as `TOOL_CALL_START/ARGS/END`) and its string return value +(`TOOL_CALL_RESULT`). No ACTIVITY_*, no per-child events, no specialist name on the +wire beyond the delegation tool's own name. This matches the "measured red upstream" +note in `src/agent.py` and is confirmed by the capture below. + +## Scratch setup (uncommitted, reverted after capture) + +Added to `src/agent.py`: a `policy_researcher` `Agent` (same `build_chat_client()`, +instructions: expense-policy researcher, 3 short bullets) plus an async +`@tool research_policy(category: str, amount: float) -> str` that ran +`specialist.run(prompt, stream=True)`, accumulated `update.text`, logged each update +to stderr, and returned the joined text; registered on the primary agent with one +instruction sentence about delegating policy research. + +## In-tool streaming datum + +The specialist's deltas DID stream into the tool body, token by token: + +``` +[spike] specialist update #2: '-' +[spike] specialist update #3: ' **' +[spike] specialist update #4: 'Pre' +... +[spike] specialist DONE: 101 streamed updates, 548 chars (attempt 2; attempt 1: 106 updates, 582 chars) +``` + +So the emitter can produce **streaming child deltas** (preferred contract), not just a +single final chunk. + +## Live wire capture (attempt 2 of 2; attempt 1 also delegated but was truncated client-side) + +Request: POST `/agent` with `threadId: spike-thread-2`, `runId: spike-run-2`, single +user message "Should I submit a $900 conference travel expense? Research the policy +first". The model delegated on the first turn in both attempts. Full event-type +census of the complete stream: + +``` +1 RUN_STARTED 1 STATE_SNAPSHOT 3 CUSTOM (PredictState, usage×2) +2 TEXT_MESSAGE_START 35 TEXT_MESSAGE_CONTENT 2 TEXT_MESSAGE_END +1 TOOL_CALL_START 9 TOOL_CALL_ARGS 1 TOOL_CALL_END 1 TOOL_CALL_RESULT +1 MESSAGES_SNAPSHOT 1 RUN_FINISHED +0 SUBAGENT_* / ACTIVITY_* / anything child-related +``` + +Abridged stream (ids as captured; no secrets present): + +``` +data: {"type":"RUN_STARTED","threadId":"spike-thread-2","runId":"spike-run-2"} +data: {"type":"CUSTOM","name":"PredictState","value":[{"state_key":"expense","tool":"submit_expense","tool_argument":"expense"}]} +data: {"type":"STATE_SNAPSHOT","snapshot":{"expense":{}}} +data: {"type":"TEXT_MESSAGE_START","messageId":"5cff954e-...","role":"assistant"} +data: {"type":"TOOL_CALL_START","toolCallId":"call_7sxPY1sC236nPyHRTWAZMJB9","toolCallName":"research_policy","parentMessageId":"5cff954e-..."} +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_7sxP...","delta":"{\""} +... (9 ARGS deltas spelling {"category":"travel","amount":900}) +data: {"type":"TOOL_CALL_END","toolCallId":"call_7sxP..."} + <-- specialist runs HERE; 101 updates streamed in-tool; NOTHING on the wire --> +data: {"type":"TOOL_CALL_RESULT","messageId":"c2c72fd2-...","toolCallId":"call_7sxP...","content":"1. **Pre-Approval Required**: Travel expenses exceeding $500 ...","role":"tool"} +data: {"type":"TEXT_MESSAGE_END","messageId":"5cff954e-..."} +data: {"type":"TEXT_MESSAGE_START","messageId":"534fe3b3-...","role":"assistant"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"534fe3b3-...","delta":"The"} +... (35 deltas: policy summary + ask to confirm approval) +data: {"type":"TEXT_MESSAGE_END","messageId":"534fe3b3-..."} +data: {"type":"MESSAGES_SNAPSHOT","messages":[...user, assistant toolCalls(research_policy), tool result, assistant text...]} +data: {"type":"RUN_FINISHED","threadId":"spike-thread-2","runId":"spike-run-2"} +``` + +### Explicit statements + +- **Native delegation on the wire:** an ordinary function tool call — + `TOOL_CALL_START(research_policy)` → streamed `TOOL_CALL_ARGS` → `TOOL_CALL_END` → + a single `TOOL_CALL_RESULT` carrying the specialist's complete final text. The + wall-clock gap between `TOOL_CALL_END` and `TOOL_CALL_RESULT` is where the + specialist runs, silently. +- **Child updates streamed in-tool:** YES — 101 streamed updates (attempt 2; 106 in + attempt 1), token-granular. +- **Anything child-related on the wire:** NO — zero events; the specialist is + invisible except as the tool's result string. + +## Emitter plan (for the implementation PR) + +Target sequence, injected by the wrapper-queue seam around the existing bridge stream +for tool call id ``: + +`SUBAGENT_STARTED {subagentRunId: "-sub", name: "policy_researcher", parentToolCallId: ""}` +→ `TEXT_MESSAGE_START/CONTENT×N/END` attributed to the subagent run (one CONTENT per +specialist update; live-interleaved via the pump-task merge) → `SUBAGENT_FINISHED +{outcome: success}` (or `SUBAGENT_ERROR` on tool-body exception), all before the +bridge's own `TOOL_CALL_RESULT` for `` reaches the client. From 06c05b4bc4ee6c230c14aedc958df9da281d09b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:43:30 -0700 Subject: [PATCH 2/8] feat(runtimes): maf expense demo delegates policy research to a specialist The expense copilot gains a tool-less policy_researcher specialist Agent and an async research_policy tool that streams the specialist's deltas through the delegation_* emitter helpers (SUBAGENT_STARTED / attributed TEXT_MESSAGE_* / SUBAGENT_FINISHED|ERROR, ids derived from the delegation toolCallId with a generated sub- fallback). The helpers are pure no-ops outside a wrapped run; the queue-merge run wrapper that puts these events on the wire lands in the next commit. Adds the pytest dev-group and asyncio_mode infra plus registration tests (no live model calls). Co-Authored-By: Claude Fable 5 --- .../python/pyproject.toml | 9 + .../python/src/agent.py | 68 +++++- .../python/src/subagent_emitter.py | 205 ++++++++++++++++++ .../python/tests/test_delegation.py | 58 +++++ .../microsoft-agent-framework/python/uv.lock | 77 +++++++ 5 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py create mode 100644 cockpit/runtimes/microsoft-agent-framework/python/tests/test_delegation.py diff --git a/cockpit/runtimes/microsoft-agent-framework/python/pyproject.toml b/cockpit/runtimes/microsoft-agent-framework/python/pyproject.toml index 8f718a771..dbf6404e4 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/pyproject.toml +++ b/cockpit/runtimes/microsoft-agent-framework/python/pyproject.toml @@ -10,9 +10,18 @@ dependencies = [ "uvicorn[standard]>=0.29", ] +[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/microsoft-agent-framework/python/src/agent.py b/cockpit/runtimes/microsoft-agent-framework/python/src/agent.py index 9414d37f9..4d28e8c45 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/src/agent.py +++ b/cockpit/runtimes/microsoft-agent-framework/python/src/agent.py @@ -14,8 +14,13 @@ protocol-standard ``RUN_FINISHED.outcome = {type: 'interrupt', ...}`` and resumes from the client's top-level ``resume`` entries. -No subagents surface: the MAF bridge emits no per-subagent -ACTIVITY_SNAPSHOT/ACTIVITY_DELTA stream (measured red upstream). +- subagents: ``research_policy`` delegates to the tool-less + ``policy_researcher`` specialist Agent and streams its deltas through the + ``delegation_*`` helpers in src/subagent_emitter.py, which merge standard + ``SUBAGENT_*`` + attributed child ``TEXT_MESSAGE_*`` events into the run + stream at the run-wrapper seam (the MAF bridge natively emits NOTHING for + nested-agent activity inside a tool — measured red upstream and in + docs/wire-capture-subagents.md). Model client: Azure OpenAI is the DEFAULT path — when ``AZURE_OPENAI_ENDPOINT`` is set the client routes to Azure (key auth via @@ -32,6 +37,8 @@ from agent_framework.openai import OpenAIChatCompletionClient from pydantic import BaseModel, Field +from . import subagent_emitter + _POLICIES = { "meals": {"limit_usd": 300, "receipt_required_over_usd": 25, "notes": "Team meals require attendee count in the memo."}, "travel": {"limit_usd": 1500, "receipt_required_over_usd": 0, "notes": "Book through the travel portal when possible."}, @@ -97,6 +104,10 @@ def submit_expense(expense: Expense) -> str: _INSTRUCTIONS = """You are an expense approval copilot. +Before recommending whether to submit an expense, delegate the policy +research to the specialist by calling `research_policy` with the category +and amount. + When the user asks to file an expense: 1. FIRST call `lookup_expense_policy` with the expense category. 2. THEN call `submit_expense` with the complete structured expense @@ -138,12 +149,63 @@ def build_chat_client() -> OpenAIChatCompletionClient: ) +policy_researcher = Agent( + name="policy_researcher", + instructions=( + "You are an expense-policy researcher. Given an expense category and " + "amount, summarize the applicable policy rules in 3 short bullets." + ), + client=build_chat_client(), +) + + +@tool( + name="research_policy", + description="Delegate policy research for this expense to a specialist.", +) +async def research_policy(category: str, amount: float) -> str: + """Delegate policy research for this expense to a specialist. + + Streams the ``policy_researcher`` specialist and mirrors each text delta + onto the AG-UI wire as attributed SUBAGENT_* / TEXT_MESSAGE_* events via + src/subagent_emitter.py (no-ops outside the wrapped run). + + Args: + category: Expense category, e.g. 'meals' or 'travel'. + amount: Expense amount in USD. + + Returns: + The specialist's complete policy summary. + """ + # Deterministically recorded by the run wrapper's pump before this body + # runs (the bridge streams TOOL_CALL_START/ARGS/END first); None when + # invoked outside a wrapped run. + tid = subagent_emitter.current_tool_call_id("research_policy") + subagent_emitter.delegation_started(tid, policy_researcher.name) + parts: list[str] = [] + try: + prompt = ( + f"Expense category: {category}. Amount: ${amount:.2f}. " + "Summarize the applicable policy rules." + ) + async for update in policy_researcher.run(prompt, stream=True): + text = update.text + if text: + parts.append(text) + subagent_emitter.delegation_delta(tid, text) + except Exception as exc: + subagent_emitter.delegation_error(tid, str(exc)) + raise + subagent_emitter.delegation_finished(tid) + return "".join(parts) + + agent = AgentFrameworkAgent( agent=Agent( name="expense_approval_copilot", instructions=_INSTRUCTIONS, client=build_chat_client(), - tools=[lookup_expense_policy, submit_expense], + tools=[lookup_expense_policy, research_policy, submit_expense], ), name="ExpenseApprovalCopilot", description="Files expense reports with policy lookup, shared state, and human approval.", diff --git a/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py new file mode 100644 index 000000000..ace896c0f --- /dev/null +++ b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `research_policy` delegation tool. + +The MAF AG-UI bridge is a pure pull-driven async generator with no writer a +tool body could reach: the specialist's streamed updates are consumed +entirely inside the tool and only the return string surfaces (as +TOOL_CALL_RESULT). Measured in docs/wire-capture-subagents.md, which also +names the injection seam implemented here: wrap ``AgentFrameworkAgent.run`` +— the exact method the FastAPI endpoint consumes (`_endpoint.py:212`) — +with a queue-merge generator. + +How the seam works (``SubagentEmittingAgent`` / ``wrap_agent_run``): + +1. ``run()`` creates an ``asyncio.Queue`` and publishes it (plus a small + correlation map) through a module-level ``ContextVar``. The delegation + tool executes on the same async call chain, so the value propagates + into the tool body. +2. A pump task drains the inner bridge generator into that queue. This is + required for LIVE interleaving: while the tool runs, the bridge + generator is suspended awaiting the next provider update, so a naive + "drain between inner yields" design would batch every child delta until + the tool returned. With the pump-task merge, a ``put_nowait`` from the + tool body wakes the outer consumer immediately. +3. The tool body calls the ``delegation_*`` helpers below, which build the + typed ``ag_ui.core`` events and enqueue them: + + SUBAGENT_STARTED {subagentRunId: -sub, parentToolCallId: } + TEXT_MESSAGE_START/CONTENT.../END (streamed specialist deltas) + SUBAGENT_FINISHED outcome=success (or SUBAGENT_ERROR on failure) + +Correlation: the pump records every TOOL_CALL_START's ``toolCallId`` by +tool name as it passes through the queue. The bridge yields the complete +TOOL_CALL_START/ARGS/END lifecycle for the delegation call BEFORE the +framework invokes the tool (both run on the pump task's driving chain), so +``current_tool_call_id("research_policy")`` is deterministically populated +by the time the tool body runs. If a caller ever invokes the tool outside +the wrapped run, the helpers fall back to a generated ``sub-`` run id +with ``parentToolCallId`` omitted — and with no queue at all they are pure +no-ops, which is what keeps unit tests and direct agent runs side-effect +free. +""" + +from __future__ import annotations + +import asyncio +import uuid +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any + +from ag_ui.core import ( + BaseEvent, + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) +DELEGATION_TOOL_NAME = "research_policy" + + +@dataclass +class _Delegation: + """Lifecycle of one delegation call, keyed by parent tool-call id.""" + + run_id: str + message_id: str + message_open: bool = False + finished: bool = False + + +@dataclass +class _EmitterSession: + """Per-run channel shared between the run wrapper and the tool body.""" + + queue: asyncio.Queue[Any] + tool_call_ids: dict[str, str] = field(default_factory=dict) + runs: dict[str | None, _Delegation] = field(default_factory=dict) + + +_event_queue: ContextVar[_EmitterSession | None] = ContextVar( + "maf_subagent_emitter_session", default=None +) + + +def current_tool_call_id(tool_name: str) -> str | None: + """The wire toolCallId of the most recent TOOL_CALL_START for a tool. + + Deterministically populated before the tool body runs (see module + docstring); ``None`` outside a wrapped run. + """ + session = _event_queue.get() + if session is None: + return None + return session.tool_call_ids.get(tool_name) + + +def emit(event: BaseEvent) -> None: + """Enqueue one AG-UI event onto the live run stream; no-op unwrapped.""" + session = _event_queue.get() + if session is not None: + session.queue.put_nowait(event) + + +def delegation_started(tid: str | None, name: str) -> None: + """Announce the specialist run. Ids derive from the delegation tool-call + id; without one (unwrapped fallback) a ``sub-`` run id is generated + and ``parentToolCallId`` omitted.""" + session = _event_queue.get() + run_id = f"{tid}-sub" if tid else f"sub-{uuid.uuid4().hex[:8]}" + if session is not None: + session.runs[tid] = _Delegation(run_id=run_id, message_id=f"{run_id}-m1") + emit( + SubagentStartedEvent( + type=EventType.SUBAGENT_STARTED, + subagent_run_id=run_id, + name=name, + parent_tool_call_id=tid, + ) + ) + + +def _active(tid: str | None) -> _Delegation | None: + session = _event_queue.get() + if session is None: + return None + delegation = session.runs.get(tid) + if delegation is None or delegation.finished: + return None + return delegation + + +def delegation_delta(tid: str | None, text: str) -> None: + """Stream one specialist text delta, lazily opening the attributed + message (so a zero-delta run emits no empty message).""" + delegation = _active(tid) + if delegation is None or not text: + return + if not delegation.message_open: + delegation.message_open = True + emit( + TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=delegation.message_id, + role="assistant", + subagent_run_id=delegation.run_id, + ) + ) + emit( + TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=delegation.message_id, + delta=text, + subagent_run_id=delegation.run_id, + ) + ) + + +def _close_message(delegation: _Delegation) -> None: + if delegation.message_open: + delegation.message_open = False + emit( + TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=delegation.message_id, + subagent_run_id=delegation.run_id, + ) + ) + + +def delegation_finished(tid: str | None) -> None: + """Close the open child message and finish the subagent with success.""" + delegation = _active(tid) + if delegation is None: + return + delegation.finished = True + _close_message(delegation) + emit( + SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=delegation.run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + ) + + +def delegation_error(tid: str | None, message: str) -> None: + """Close the open child message and report the specialist failure. The + tool re-raises afterwards, so the bridge's own tool-error path still + runs normally.""" + delegation = _active(tid) + if delegation is None: + return + delegation.finished = True + _close_message(delegation) + emit( + SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=delegation.run_id, + message=message, + ) + ) diff --git a/cockpit/runtimes/microsoft-agent-framework/python/tests/test_delegation.py b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_delegation.py new file mode 100644 index 000000000..16e465e26 --- /dev/null +++ b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_delegation.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: MIT +"""Tests for the `research_policy` delegation scenario — the tool is a +registered async `@tool` that hands expense-policy research to the tool-less +`policy_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, policy_researcher, research_policy + + +def _tool_names() -> list[str]: + return [t.name for t in agent.agent.default_options["tools"]] + + +def test_research_policy_is_registered_on_the_agent(): + assert "research_policy" in _tool_names() + # Existing tools stay registered untouched. + assert "lookup_expense_policy" in _tool_names() + assert "submit_expense" in _tool_names() + + +def test_tool_name_and_docstring(): + assert research_policy.name == "research_policy" + assert research_policy.description.startswith( + "Delegate policy research for this expense to a specialist." + ) + schema = research_policy.parameters() + assert set(schema["required"]) == {"category", "amount"} + + +def test_tool_is_async(): + # The seam depends on it: the tool must be able to async-iterate the + # specialist's streamed updates and enqueue deltas as they arrive. + assert inspect.iscoroutinefunction(research_policy.func) + + +def test_specialist_is_toolless_researcher(): + assert policy_researcher.name == "policy_researcher" + assert policy_researcher.default_options.get("tools") == [] + assert "expense-policy researcher" in policy_researcher.default_options["instructions"] + + +def test_instructions_mention_delegation(): + instructions = agent.agent.default_options["instructions"] + assert "research_policy" in instructions + + +def test_untouched_surfaces_still_configured(): + # The subagent scenario must not disturb the existing shared-state and + # approval surfaces. + assert agent.config.state_schema == { + "expense": {"type": "object", "description": "The expense entry being drafted."}, + } + assert agent.config.predict_state_config == { + "expense": {"tool": "submit_expense", "tool_argument": "expense"}, + } diff --git a/cockpit/runtimes/microsoft-agent-framework/python/uv.lock b/cockpit/runtimes/microsoft-agent-framework/python/uv.lock index d164f91fb..aafd57d70 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/uv.lock +++ b/cockpit/runtimes/microsoft-agent-framework/python/uv.lock @@ -121,6 +121,12 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "agent-framework-ag-ui", specifier = ">=1.2.1" }, @@ -130,6 +136,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" @@ -246,6 +258,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" @@ -385,6 +406,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/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 = "pydantic" version = "2.13.5" @@ -475,6 +514,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] +[[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 = "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-dotenv" version = "1.2.3" From 32aee4d4584027925ba969997d47c5dafd0a9a16 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:43:41 -0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(runtimes):=20maf=20subagent=20emitter?= =?UTF-8?q?=20=E2=80=94=20queue-merged=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 SubagentEmittingAgent wraps AgentFrameworkAgent.run (the exact method the FastAPI endpoint consumes) with the pump-task merge queue the Task-0 spike named: a task drains the inner bridge generator into an asyncio.Queue the ContextVar shares with the tool body, so a delta enqueued mid-tool wakes the outer consumer immediately (live interleaving; a drain-between-yields design would batch every child delta until the tool returned). The pump records each TOOL_CALL_START toolCallId by name, which is what makes current_tool_call_id deterministic before the tool body runs. Pump exceptions propagate to the consumer; consumer break / client disconnect cancels and awaits the pump and closes the inner generator — no orphaned tasks. server.py mounts the wrapped agent. Co-Authored-By: Claude Fable 5 --- .../python/src/server.py | 7 +- .../python/src/subagent_emitter.py | 90 ++++- .../python/tests/test_subagent_emitter.py | 340 ++++++++++++++++++ 3 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py diff --git a/cockpit/runtimes/microsoft-agent-framework/python/src/server.py b/cockpit/runtimes/microsoft-agent-framework/python/src/server.py index 7b192ca0b..e26a54f02 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/src/server.py +++ b/cockpit/runtimes/microsoft-agent-framework/python/src/server.py @@ -3,9 +3,14 @@ from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint from .agent import agent +from .subagent_emitter import wrap_agent_run app = FastAPI(title="cockpit-runtimes-microsoft-agent-framework") -add_agent_framework_fastapi_endpoint(app, agent, path="/agent") +# The wrapper is the SUBAGENT_* injection seam: the endpoint consumes +# protocol_runner.run, and wrap_agent_run merges the delegation tool's +# enqueued child events into that stream (src/subagent_emitter.py). +wrapped_agent = wrap_agent_run(agent) +add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/agent") @app.get("/ok") diff --git a/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py index ace896c0f..3f7dfc3b7 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py +++ b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py @@ -43,10 +43,11 @@ from __future__ import annotations import asyncio +import contextlib import uuid from contextvars import ContextVar from dataclasses import dataclass, field -from typing import Any +from typing import Any, AsyncGenerator from ag_ui.core import ( BaseEvent, @@ -59,6 +60,8 @@ TextMessageEndEvent, TextMessageStartEvent, ) +from agent_framework.ag_ui import AgentFrameworkAgent + DELEGATION_TOOL_NAME = "research_policy" @@ -203,3 +206,88 @@ def delegation_error(tid: str | None, message: str) -> None: message=message, ) ) + + +class _PumpFailure: + """Sentinel carrying an inner-generator exception across the queue.""" + + def __init__(self, exc: BaseException) -> None: + self.exc = exc + + +_DONE = object() + + +def _record_tool_call(session: _EmitterSession, event: Any) -> None: + if getattr(event, "type", None) == EventType.TOOL_CALL_START: + session.tool_call_ids[event.tool_call_name] = event.tool_call_id + + +class SubagentEmittingAgent(AgentFrameworkAgent): + """AgentFrameworkAgent whose ``run`` merges tool-enqueued SUBAGENT_* + events into the bridge stream via the pump-task queue. + + Constructed from an already-configured ``AgentFrameworkAgent`` (shares + its config and approval-state store rather than re-running ``__init__``), + so the endpoint's ``isinstance(agent, AgentFrameworkAgent)`` dispatch + and approval resume flow are untouched. + """ + + def __init__(self, inner: AgentFrameworkAgent) -> None: + self._inner = inner + self.agent = inner.agent + self.name = inner.name + self.description = inner.description + self.config = inner.config + self._approval_state_store = inner._approval_state_store + + async def run( + self, input_data: dict[str, Any] + ) -> AsyncGenerator[BaseEvent, None]: + queue: asyncio.Queue[Any] = asyncio.Queue() + session = _EmitterSession(queue=queue) + token = _event_queue.set(session) + inner_gen = self._inner.run(input_data) + + async def _pump() -> None: + try: + async for event in inner_gen: + _record_tool_call(session, event) + queue.put_nowait(event) + except asyncio.CancelledError: + raise + except BaseException as exc: # propagate to the consumer, never swallow + queue.put_nowait(_PumpFailure(exc)) + else: + queue.put_nowait(_DONE) + + # create_task copies the current context AFTER the ContextVar set, + # so the tool body (which executes on the pump's driving chain) + # sees this session. + pump = asyncio.create_task(_pump()) + try: + while True: + item = await queue.get() + if item is _DONE: + break + if isinstance(item, _PumpFailure): + raise item.exc + yield item + finally: + # Consumer break / client disconnect (GeneratorExit) or pump + # failure: cancel and await the pump so no task is orphaned, + # then close the inner generator. + pump.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump + with contextlib.suppress(Exception): + await inner_gen.aclose() + # reset raises ValueError if a GC-driven aclose runs the finally + # in a different context than the one that set the var. + with contextlib.suppress(ValueError): + _event_queue.reset(token) + + +def wrap_agent_run(agent: AgentFrameworkAgent) -> SubagentEmittingAgent: + """Wrap an AgentFrameworkAgent so its run stream carries SUBAGENT_*.""" + return SubagentEmittingAgent(agent) diff --git a/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py new file mode 100644 index 000000000..569ab520a --- /dev/null +++ b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: MIT +"""Tests for the SUBAGENT_* emitter — drives the `delegation_*` helpers and +the queue-merge run wrapper with a fake inner bridge generator plus a +scripted tool enqueue (the fake generator calls the helpers between its own +yields, exactly where the framework invokes the real tool on the pump's +driving chain) and asserts the exact merged sequence field-for-field.""" + +import asyncio + +import pytest + +from ag_ui.core import ( + EventType, + RunFinishedEvent, + RunStartedEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) + +from src import subagent_emitter +from src.subagent_emitter import ( + SubagentEmittingAgent, + current_tool_call_id, + delegation_delta, + delegation_error, + delegation_finished, + delegation_started, + wrap_agent_run, +) + +TID = "call_7sxPY1sC236nPyHRTWAZMJB9" +RUN_ID = f"{TID}-sub" +MESSAGE_ID = f"{TID}-sub-m1" + + +# --------------------------------------------------------------------------- +# Helper-level tests: install a session directly and inspect the queue. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def session(): + s = subagent_emitter._EmitterSession(queue=asyncio.Queue()) + token = subagent_emitter._event_queue.set(s) + yield s + subagent_emitter._event_queue.reset(token) + + +def _drain(session) -> list: + out = [] + while not session.queue.empty(): + out.append(session.queue.get_nowait()) + return out + + +def test_success_sequence_field_for_field(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- Pre") + delegation_delta(TID, "-approval") + delegation_delta(TID, " required") + delegation_finished(TID) + + out = _drain(session) + assert [ev.type for ev in out] == [ + 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 == "policy_researcher" + assert started.parent_tool_call_id == TID + + 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] == ["- Pre", "-approval", " required"] + 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" + + +def test_no_deltas_still_brackets_with_started_and_finished(session): + delegation_started(TID, "policy_researcher") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_error_closes_open_message_then_reports(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- Pre") + delegation_error(TID, "specialist exploded") + + out = _drain(session) + 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" + + +def test_events_after_terminal_are_ignored(session): + delegation_started(TID, "policy_researcher") + delegation_finished(TID) + delegation_delta(TID, "late straggler") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_empty_delta_is_dropped(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_none_tid_falls_back_to_generated_run_id(session): + delegation_started(None, "policy_researcher") + delegation_delta(None, "- x") + delegation_finished(None) + + out = _drain(session) + started = out[0] + assert started.parent_tool_call_id is None + assert started.subagent_run_id.startswith("sub-") + assert len(started.subagent_run_id) == len("sub-") + 8 + # All subsequent events carry the same generated run id. + assert {ev.subagent_run_id for ev in out} == {started.subagent_run_id} + assert out[1].message_id == f"{started.subagent_run_id}-m1" + + +def test_helpers_are_noops_without_a_session(): + # Unit tests / direct agent runs: no wrapper, no queue — nothing raises. + assert subagent_emitter._event_queue.get() is None + assert current_tool_call_id("research_policy") is None + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- x") + delegation_finished(TID) + delegation_error(TID, "boom") + + +# --------------------------------------------------------------------------- +# Wrapper-level tests: queue-merge around a fake inner bridge generator. +# --------------------------------------------------------------------------- + + +class _FakeInner: + """Duck-typed AgentFrameworkAgent carrying the attributes the wrapper + copies plus a scripted `run` generator.""" + + def __init__(self, gen_fn): + self.agent = object() + self.name = "fake" + self.description = "" + self.config = object() + self._approval_state_store = object() + self._gen_fn = gen_fn + + def run(self, input_data): + return self._gen_fn(input_data) + + +def _run_started(): + return RunStartedEvent(type=EventType.RUN_STARTED, thread_id="t", run_id="r") + + +def _run_finished(): + return RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id="t", run_id="r") + + +async def _collect(agent) -> list: + return [ev async for ev in agent.run({"messages": []})] + + +async def test_wrapper_merges_tool_enqueued_events_mid_stream(): + async def inner(_input): + yield _run_started() + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) + # The framework invokes the tool HERE, on the pump's driving chain, + # while the outer consumer is awaiting the queue. + tid = current_tool_call_id("research_policy") + assert tid == TID # recorded by the pump from TOOL_CALL_START + delegation_started(tid, "policy_researcher") + delegation_delta(tid, "- Pre-approval") + delegation_delta(tid, " required") + delegation_finished(tid) + yield ToolCallResultEvent( + type=EventType.TOOL_CALL_RESULT, message_id="m", tool_call_id=TID, content="- Pre-approval required" + ) + yield _run_finished() + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + assert [ev.type for ev in out] == [ + EventType.RUN_STARTED, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_END, + # Child events land BETWEEN inner generator items — before the + # bridge's own TOOL_CALL_RESULT reaches the client. + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_RESULT, + EventType.RUN_FINISHED, + ] + started = out[3] + assert started.subagent_run_id == RUN_ID + assert started.parent_tool_call_id == TID + assert [ev.delta for ev in out[5:7]] == ["- Pre-approval", " required"] + + +async def test_wrapper_error_path_emits_subagent_error_then_propagates(): + class _Boom(RuntimeError): + pass + + async def inner(_input): + yield _run_started() + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + tid = current_tool_call_id("research_policy") + delegation_started(tid, "policy_researcher") + delegation_delta(tid, "- Pre") + delegation_error(tid, "specialist exploded") + raise _Boom("specialist exploded") + + agent = wrap_agent_run(_FakeInner(inner)) + out = [] + with pytest.raises(_Boom): + async for ev in agent.run({"messages": []}): + out.append(ev) + # Everything enqueued before the failure was delivered, ending in the + # SUBAGENT_ERROR (with the open child message closed first). + assert [ev.type for ev in out] == [ + EventType.RUN_STARTED, + EventType.TOOL_CALL_START, + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_ERROR, + ] + assert out[-1].message == "specialist exploded" + + +async def test_wrapper_clean_shutdown_on_consumer_break(): + closed = asyncio.Event() + + async def inner(_input): + try: + yield _run_started() + while True: # endless stream: only a cancel/close ends it + await asyncio.sleep(0.01) + yield _run_started() + finally: + closed.set() + + agent = wrap_agent_run(_FakeInner(inner)) + gen = agent.run({"messages": []}) + first = await gen.__anext__() + assert first.type == EventType.RUN_STARTED + await gen.aclose() # consumer break / client disconnect + + await asyncio.wait_for(closed.wait(), timeout=1) + # No orphaned tasks: everything spawned by the wrapper is done. + pending = [ + t + for t in asyncio.all_tasks() + if t is not asyncio.current_task() and not t.done() + ] + assert pending == [] + # The ContextVar session was uninstalled. + assert subagent_emitter._event_queue.get() is None + + +async def test_wrapper_resets_contextvar_after_normal_completion(): + async def inner(_input): + yield _run_started() + yield _run_finished() + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + assert [ev.type for ev in out] == [EventType.RUN_STARTED, EventType.RUN_FINISHED] + assert subagent_emitter._event_queue.get() is None + + +def test_wrap_agent_run_returns_agentframeworkagent_for_endpoint_dispatch(): + fake = _FakeInner(None) + wrapped = wrap_agent_run(fake) + assert isinstance(wrapped, SubagentEmittingAgent) + # The endpoint dispatches on isinstance(agent, AgentFrameworkAgent) and + # shares the config / approval-state store. + from agent_framework.ag_ui import AgentFrameworkAgent + + assert isinstance(wrapped, AgentFrameworkAgent) + assert wrapped.config is fake.config + assert wrapped._approval_state_store is fake._approval_state_store + + +def test_server_mounts_the_wrapped_agent(): + from src import server + + # The FastAPI endpoint consumes the wrapped run (protocol_runner is the + # SubagentEmittingAgent), so SUBAGENT_* events reach the wire. + assert isinstance(server.wrapped_agent, SubagentEmittingAgent) From d77bb43cc4239f3c4f220a670491afbd9b850c6e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:44:45 -0700 Subject: [PATCH 4/8] docs(runtimes): maf post-emitter wire capture Co-Authored-By: Claude Fable 5 --- .../python/docs/wire-capture-subagents.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md index 47e851d89..550e37422 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md +++ b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md @@ -181,3 +181,53 @@ for tool call id ``: specialist update; live-interleaved via the pump-task merge) → `SUBAGENT_FINISHED {outcome: success}` (or `SUBAGENT_ERROR` on tool-body exception), all before the bridge's own `TOOL_CALL_RESULT` for `` reaches the client. + +## After the emitter + +Date: 2026-09-02, post-implementation. Live capture against the committed +`src/server.py` (uvicorn, port 5330; plain-OpenAI path, `gpt-4o-mini`), request +identical in shape to the spike: single user message "Should I submit a $900 +conference travel expense? Research the policy first" (`threadId: +smoke-thread-1`, `runId: smoke-run-1`). The model called +`lookup_expense_policy` first and then delegated via `research_policy` on the +same turn. Full event-type census of the complete stream: + +``` +1 RUN_STARTED 1 STATE_SNAPSHOT 4 CUSTOM (PredictState, usage×3) +4 TEXT_MESSAGE_START 128 TEXT_MESSAGE_CONTENT 4 TEXT_MESSAGE_END +2 TOOL_CALL_START 14 TOOL_CALL_ARGS 2 TOOL_CALL_END 2 TOOL_CALL_RESULT +1 SUBAGENT_STARTED 1 SUBAGENT_FINISHED 0 SUBAGENT_ERROR +1 MESSAGES_SNAPSHOT 1 RUN_FINISHED +``` + +**Child delta count: 100 streamed TEXT_MESSAGE_CONTENT events** attributed to +the subagent run (`messageId: -sub-m1`, token-granular — same order of +magnitude as the spike's 101/106 in-tool updates), live-interleaved between the +bridge's own events: the bridge yields `TOOL_CALL_END` for `research_policy` +only after the tool returns, and the entire SUBAGENT_* sequence lands between +`TOOL_CALL_ARGS` and that `TOOL_CALL_END` — proof the pump-task merge queue +delivered the deltas while the bridge generator was suspended inside the tool. + +Abridged stream around the delegation (ids as captured; no secrets present): + +``` +data: {"type":"TOOL_CALL_START","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx","toolCallName":"research_policy","parentMessageId":"c1a737b4-..."} +... (11 ARGS deltas spelling {"category":"travel","amount":900}) +data: {"type":"SUBAGENT_STARTED","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub","name":"policy_researcher","parentToolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx"} +data: {"type":"TEXT_MESSAGE_START","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","role":"assistant","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":"-","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":" **","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":"Pre","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +... (100 CONTENT deltas total, token-granular) +data: {"type":"TEXT_MESSAGE_END","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"SUBAGENT_FINISHED","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub","outcome":{"type":"success"}} +data: {"type":"TOOL_CALL_END","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx"} +data: {"type":"TOOL_CALL_RESULT","messageId":"7dec6623-...","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx","content":"- **Pre-Approval Required**: Travel expenses exceeding $500 ...","role":"tool"} +``` + +Tid derivation on the live run: the pump-recorded `TOOL_CALL_START` path +(`current_tool_call_id("research_policy")`) — the real wire toolCallId keys +the whole sequence (`subagentRunId = -sub`, `parentToolCallId = `); +the generated `sub-` fallback was not needed. Existing surfaces are +untouched: PredictState CUSTOM, STATE_SNAPSHOT, both TOOL_CALL_RESULTs, and +MESSAGES_SNAPSHOT all present as before. From 8c439be0d2dbdc2686154b2cccc5c640b9011f2d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:49:59 -0700 Subject: [PATCH 5/8] =?UTF-8?q?fix(runtimes):=20maf=20emitter=20=E2=80=94?= =?UTF-8?q?=20per-call=20tid=20queue;=20correct=20wire-order=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name→tid map overwrote on a same-tool double call (reachable: MAF runs multi-tool batches concurrently via asyncio.gather and the bridge streams all TOOL_CALL_STARTs first), making both bodies share the second tid and one delegation. The correlation map is now a per-name FIFO: the pump appends each TOOL_CALL_START id, current_tool_call_id pops the oldest, so every invocation gets its own tid, _Delegation, and message id (new identity-separation test). Also corrects the module docstring — the measured wire shows TOOL_CALL_END arrives AFTER the tool returns (SUBAGENT_* lands between ARGS and END), so correlation relies on START alone — and aligns the unit-test fake's event order with that wire. Co-Authored-By: Claude Fable 5 --- .../python/src/subagent_emitter.py | 40 +++++++--- .../python/tests/test_subagent_emitter.py | 76 ++++++++++++++++++- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py index 3f7dfc3b7..0b231ab4c 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py +++ b/cockpit/runtimes/microsoft-agent-framework/python/src/subagent_emitter.py @@ -28,12 +28,18 @@ TEXT_MESSAGE_START/CONTENT.../END (streamed specialist deltas) SUBAGENT_FINISHED outcome=success (or SUBAGENT_ERROR on failure) -Correlation: the pump records every TOOL_CALL_START's ``toolCallId`` by -tool name as it passes through the queue. The bridge yields the complete -TOOL_CALL_START/ARGS/END lifecycle for the delegation call BEFORE the -framework invokes the tool (both run on the pump task's driving chain), so -``current_tool_call_id("research_policy")`` is deterministically populated -by the time the tool body runs. If a caller ever invokes the tool outside +Correlation: the pump appends every TOOL_CALL_START's ``toolCallId`` to a +per-tool-name FIFO as it passes through the queue, and each tool body pops +the oldest via ``current_tool_call_id`` — so a multi-tool batch calling the +same tool twice (MAF runs batches concurrently via ``asyncio.gather``, and +the bridge streams all TOOL_CALL_STARTs first) gives each invocation its +own tid and its own delegation. The bridge yields TOOL_CALL_START (and the +ARGS deltas) for the delegation call BEFORE the framework invokes the tool +on the same driving chain, so the FIFO is deterministically populated by +the time the tool body runs; TOOL_CALL_END arrives only AFTER the tool +returns (measured wire order — the SUBAGENT_* sequence lands between ARGS +and END), which is why correlation relies on START alone. If a caller ever +invokes the tool outside the wrapped run, the helpers fall back to a generated ``sub-`` run id with ``parentToolCallId`` omitted — and with no queue at all they are pure no-ops, which is what keeps unit tests and direct agent runs side-effect @@ -80,7 +86,10 @@ class _EmitterSession: """Per-run channel shared between the run wrapper and the tool body.""" queue: asyncio.Queue[Any] - tool_call_ids: dict[str, str] = field(default_factory=dict) + # Per-tool-name FIFO of not-yet-claimed TOOL_CALL_START toolCallIds: + # the pump appends, each tool body pops the oldest — one tid per call + # even when a batch invokes the same tool twice. + tool_call_ids: dict[str, list[str]] = field(default_factory=dict) runs: dict[str | None, _Delegation] = field(default_factory=dict) @@ -90,15 +99,20 @@ class _EmitterSession: def current_tool_call_id(tool_name: str) -> str | None: - """The wire toolCallId of the most recent TOOL_CALL_START for a tool. + """Claim the oldest unclaimed TOOL_CALL_START toolCallId for a tool. - Deterministically populated before the tool body runs (see module - docstring); ``None`` outside a wrapped run. + Pops from the per-name FIFO the pump fills, so each concurrent + invocation of the same tool gets its own tid. Deterministically + populated before the tool body runs (see module docstring); ``None`` + outside a wrapped run. """ session = _event_queue.get() if session is None: return None - return session.tool_call_ids.get(tool_name) + pending = session.tool_call_ids.get(tool_name) + if not pending: + return None + return pending.pop(0) def emit(event: BaseEvent) -> None: @@ -220,7 +234,9 @@ def __init__(self, exc: BaseException) -> None: def _record_tool_call(session: _EmitterSession, event: Any) -> None: if getattr(event, "type", None) == EventType.TOOL_CALL_START: - session.tool_call_ids[event.tool_call_name] = event.tool_call_id + session.tool_call_ids.setdefault(event.tool_call_name, []).append( + event.tool_call_id + ) class SubagentEmittingAgent(AgentFrameworkAgent): diff --git a/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py index 569ab520a..a9a28d0fa 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py +++ b/cockpit/runtimes/microsoft-agent-framework/python/tests/test_subagent_emitter.py @@ -13,6 +13,7 @@ EventType, RunFinishedEvent, RunStartedEvent, + ToolCallArgsEvent, ToolCallEndEvent, ToolCallResultEvent, ToolCallStartEvent, @@ -205,11 +206,16 @@ async def _collect(agent) -> list: async def test_wrapper_merges_tool_enqueued_events_mid_stream(): async def inner(_input): + # Measured wire order: the bridge streams TOOL_CALL_START + ARGS + # before invoking the tool; TOOL_CALL_END arrives only AFTER the + # tool returns (docs/wire-capture-subagents.md, "After the emitter"). yield _run_started() yield ToolCallStartEvent( type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" ) - yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) + yield ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, tool_call_id=TID, delta='{"category":"travel","amount":900}' + ) # The framework invokes the tool HERE, on the pump's driving chain, # while the outer consumer is awaiting the queue. tid = current_tool_call_id("research_policy") @@ -218,6 +224,7 @@ async def inner(_input): delegation_delta(tid, "- Pre-approval") delegation_delta(tid, " required") delegation_finished(tid) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) yield ToolCallResultEvent( type=EventType.TOOL_CALL_RESULT, message_id="m", tool_call_id=TID, content="- Pre-approval required" ) @@ -227,15 +234,16 @@ async def inner(_input): assert [ev.type for ev in out] == [ EventType.RUN_STARTED, EventType.TOOL_CALL_START, - EventType.TOOL_CALL_END, + EventType.TOOL_CALL_ARGS, # Child events land BETWEEN inner generator items — before the - # bridge's own TOOL_CALL_RESULT reaches the client. + # bridge's own TOOL_CALL_END/RESULT reach the client. EventType.SUBAGENT_STARTED, EventType.TEXT_MESSAGE_START, EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_CONTENT, EventType.TEXT_MESSAGE_END, EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_END, EventType.TOOL_CALL_RESULT, EventType.RUN_FINISHED, ] @@ -245,6 +253,68 @@ async def inner(_input): assert [ev.delta for ev in out[5:7]] == ["- Pre-approval", " required"] +async def test_same_tool_double_call_gets_distinct_tids_and_delegations(): + # MAF runs multi-tool batches concurrently (asyncio.gather) and the + # bridge streams every TOOL_CALL_START before the tools execute — so + # two research_policy calls must each claim their OWN tid from the + # FIFO and drive their own delegation, never sharing one message. + tid2 = "call_secondResearchPolicyCall00" + + async def inner(_input): + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=tid2, tool_call_name="research_policy" + ) + # Both tool bodies run concurrently; their deltas interleave. + tid_a = current_tool_call_id("research_policy") + tid_b = current_tool_call_id("research_policy") + assert (tid_a, tid_b) == (TID, tid2) # FIFO: oldest first + delegation_started(tid_a, "policy_researcher") + delegation_started(tid_b, "policy_researcher") + delegation_delta(tid_a, "- travel rules") + delegation_delta(tid_b, "- meal rules") + delegation_delta(tid_a, " apply") + delegation_finished(tid_b) + delegation_finished(tid_a) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=tid2) + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + + started = [ev for ev in out if ev.type == EventType.SUBAGENT_STARTED] + assert [ev.subagent_run_id for ev in started] == [f"{TID}-sub", f"{tid2}-sub"] + assert [ev.parent_tool_call_id for ev in started] == [TID, tid2] + + # Identity separation: every child event carries its own delegation's + # ids — interleaved ORDER between the two runs is fine. + for ev in out: + if ev.type == EventType.TEXT_MESSAGE_CONTENT and ev.delta.startswith("- travel"): + assert ev.message_id == f"{TID}-sub-m1" + if ev.type == EventType.TEXT_MESSAGE_CONTENT and ev.delta.startswith("- meal"): + assert ev.message_id == f"{tid2}-sub-m1" + a_events = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{TID}-sub"] + b_events = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{tid2}-sub"] + assert [ev.type for ev in a_events] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert [ev.type for ev in b_events] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert {ev.message_id for ev in a_events if hasattr(ev, "message_id")} == {f"{TID}-sub-m1"} + assert {ev.message_id for ev in b_events if hasattr(ev, "message_id")} == {f"{tid2}-sub-m1"} + + async def test_wrapper_error_path_emits_subagent_error_then_propagates(): class _Boom(RuntimeError): pass From 8c6bd5b2ba74e74095eec00feeef40b49cabc4f7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:53:15 -0700 Subject: [PATCH 6/8] =?UTF-8?q?test(runtimes):=20maf=20delegation=20e2e=20?= =?UTF-8?q?=E2=80=94=20subagent=20card=20via=20fixture=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixture entries for the delegation turn (primary tool call, specialist matched on its 'expense-policy researcher' system prompt, hasToolResult continuation ordered first) plus a spec asserting the delegation renders as one naming policy_researcher. Requires the cherry-picked toolCallId anchoring fix — AG-UI native SUBAGENT_* keys the adapter map by subagentRunId (`-sub`). Co-Authored-By: Claude Fable 5 --- .../fixtures/microsoft-agent-framework.json | 26 +++++++++++++++++++ .../e2e/microsoft-agent-framework.spec.ts | 20 ++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cockpit/runtimes/microsoft-agent-framework/angular/e2e/fixtures/microsoft-agent-framework.json b/cockpit/runtimes/microsoft-agent-framework/angular/e2e/fixtures/microsoft-agent-framework.json index 1c1cf6aff..a441c5ab7 100644 --- a/cockpit/runtimes/microsoft-agent-framework/angular/e2e/fixtures/microsoft-agent-framework.json +++ b/cockpit/runtimes/microsoft-agent-framework/angular/e2e/fixtures/microsoft-agent-framework.json @@ -34,6 +34,32 @@ } ] } + }, + { + "match": { "userMessage": "conference travel", "hasToolResult": true }, + "response": { + "content": "Yes — submit the $900 conference travel expense. It is under the $1,500 travel cap; attach itemized receipts since it exceeds the $75 receipts threshold." + } + }, + { + "match": { "systemMessage": "expense-policy researcher" }, + "response": { + "content": "- Travel expenses up to $1,500 per trip are reimbursable with manager approval.\n- Any expense over the $75 receipts threshold requires itemized receipts.\n- Conference travel must be filed within 30 days of the trip end date." + } + }, + { + "match": { "userMessage": "conference travel" }, + "response": { + "toolCalls": [ + { + "name": "research_policy", + "arguments": { + "category": "travel", + "amount": 900 + } + } + ] + } } ] } diff --git a/cockpit/runtimes/microsoft-agent-framework/angular/e2e/microsoft-agent-framework.spec.ts b/cockpit/runtimes/microsoft-agent-framework/angular/e2e/microsoft-agent-framework.spec.ts index 02716871b..5ac29eeef 100644 --- a/cockpit/runtimes/microsoft-agent-framework/angular/e2e/microsoft-agent-framework.spec.ts +++ b/cockpit/runtimes/microsoft-agent-framework/angular/e2e/microsoft-agent-framework.spec.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; +import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness'; // First proof outside unit tests that the neutral Agent contract's interrupt // path works against a genuinely non-LangGraph AG-UI backend: the Microsoft @@ -34,3 +35,22 @@ test.describe('cockpit runtimes/microsoft-agent-framework: expense approval', () await expect(page.getByText(/has been submitted for reimbursement/i)).toBeVisible({ timeout: 30_000 }); }); }); + +// Delegation over the same non-LangGraph bridge: the orchestrator's +// `research_policy` tool streams the tool-less `policy_researcher` +// specialist, and the queue-merge emitter (src/subagent_emitter.py) +// translates its deltas 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/microsoft-agent-framework: subagent delegation', () => { + test('rt-maf: delegated policy research renders a streaming subagent card', async ({ page }) => { + const bubble = await submitAndWaitForResponse( + page, + 'Should I submit a $900 conference travel expense? Research the policy first', + ); + await expect(page.locator('chat-subagent-card')).toHaveCount(1); + await expect(page.locator('chat-subagent-card')).toContainText('policy_researcher'); + await expect(bubble).toContainText(/policy|expense/i); + }); +}); From 2c89809c47313d45cf78e22eb96e7d4c79c9015c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:55:19 -0700 Subject: [PATCH 7/8] =?UTF-8?q?docs(runtimes):=20maf=20live=20browser=20ve?= =?UTF-8?q?rification=20=E2=80=94=20streaming=20subagent=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live run (real key) on :5330/:4330 driven headlessly with Playwright: the delegation prompt renders an inline anchored to the research_policy call, and 150ms innerText polling shows the specialist's transcript growing monotonically (66 -> 622 chars) while the badge reads running, confirming progressive delta rendering. Screenshot follows the Strands e2e/manual convention. Co-Authored-By: Claude Fable 5 --- .../angular/e2e/manual/subagent-card-live.png | Bin 0 -> 98211 bytes .../python/docs/wire-capture-subagents.md | 36 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 cockpit/runtimes/microsoft-agent-framework/angular/e2e/manual/subagent-card-live.png diff --git a/cockpit/runtimes/microsoft-agent-framework/angular/e2e/manual/subagent-card-live.png b/cockpit/runtimes/microsoft-agent-framework/angular/e2e/manual/subagent-card-live.png new file mode 100644 index 0000000000000000000000000000000000000000..e46f41cf5a85d82dfefaef0888c3510ba0c4f343 GIT binary patch literal 98211 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 literal 0 HcmV?d00001 diff --git a/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md index 550e37422..93207619b 100644 --- a/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md +++ b/cockpit/runtimes/microsoft-agent-framework/python/docs/wire-capture-subagents.md @@ -231,3 +231,39 @@ the whole sequence (`subagentRunId = -sub`, `parentToolCallId = `); the generated `sub-` fallback was not needed. Existing surfaces are untouched: PredictState CUSTOM, STATE_SNAPSHOT, both TOOL_CALL_RESULTs, and MESSAGES_SNAPSHOT all present as before. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :5330) + `nx +serve cockpit-runtimes-microsoft-agent-framework-angular` on :4330, driven +headlessly with Playwright. Screenshot: +`cockpit/runtimes/microsoft-agent-framework/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the delegation prompt (*"Should I submit a $900 conference +travel expense? Research the policy first"*) produced an inline +`` anchored to the `research_policy` tool call — header +`policy_researcher` + wire `toolCallId` + status badge — with the +specialist's 3-bullet policy transcript inside it, preceded by the +orchestrator's own `lookup_expense_policy` chip and followed by its 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 card mounting at 66 chars (header only) the moment +SUBAGENT_STARTED landed, then the specialist's message growing +monotonically while the run was live — one run sampled 66 → 163 → 277 → +398 → 422 → 553 → 622 chars between t≈1.5s and t≈3.0s; a second run +sampled 66 → 105 → 207 → 314 → 430 → 519 → 614 chars — before the badge +flipped to `complete` and the card collapsed to its 67-char summary row. +This confirms the queue-merge emitter's attributed `TEXT_MESSAGE_CONTENT` +deltas render progressively in the card during the run, not as one +post-hoc paste. + +Same `libs/chat` anchoring dependency as the Strands verification: the wire +and the `@threadplane/ag-ui` reducer were correct, but `chat-tool-calls` +anchored subagent cards by the adapter's `subagents()` map key (for native +SUBAGENT_* that is the `subagentRunId`, `-sub`) instead of the +contract field `Subagent.toolCallId`, so the card never mounted. The fix +(re-index on `Subagent.toolCallId`) plus its pinning spec are cherry-picked +onto this branch. From 09ae7101050e71afe0abd4a83b309aa886da91b3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 09:56:06 -0700 Subject: [PATCH 8/8] chore(deployments): regenerate ag-ui-dev with the maf delegation demo Co-Authored-By: Claude Fable 5 --- .../docs/wire-capture-subagents.md | 269 ++++++++++++ .../microsoft_agent_framework/pyproject.toml | 9 + .../microsoft_agent_framework/src/agent.py | 68 ++- .../microsoft_agent_framework/src/server.py | 7 +- .../src/subagent_emitter.py | 309 +++++++++++++ .../tests/test_delegation.py | 58 +++ .../tests/test_subagent_emitter.py | 410 ++++++++++++++++++ .../deps/microsoft_agent_framework/uv.lock | 77 ++++ 8 files changed, 1203 insertions(+), 4 deletions(-) create mode 100644 deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/wire-capture-subagents.md create mode 100644 deployments/ag-ui-dev/deps/microsoft_agent_framework/src/subagent_emitter.py create mode 100644 deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_delegation.py create mode 100644 deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_subagent_emitter.py diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/wire-capture-subagents.md b/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/wire-capture-subagents.md new file mode 100644 index 000000000..93207619b --- /dev/null +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/docs/wire-capture-subagents.md @@ -0,0 +1,269 @@ +# MAF delegation wire capture — subagent pattern decision (Task 0 spike) + +Date: 2026-09-02. Live capture against `src/server.py` (uvicorn, port 5330) with the +plain-OpenAI client path (`build_chat_client`, `gpt-4o-mini`). The scratch delegation +code described below was NOT committed; only this document is. + +Installed bridge inspected end to end: `agent-framework-ag-ui` 1.2.x in +`.venv/lib/python3.14/site-packages/agent_framework_ag_ui/` (all venv line numbers below +refer to that tree). + +## Verdict: Candidate A (agents-as-tools), emitter injects via a run-wrapper merge queue + +- **Candidate A works and is observable.** A specialist `Agent` invoked from an async + function tool streams its updates INTO the tool body in real time (101 streamed + updates observed in-tool for a ~550-char answer). That is everything the emitter + needs to synthesize the SUBAGENT_* sequence with streaming child deltas. +- **Candidate B (two-executor workflow) is not needed.** The endpoint does mount + workflows (`_endpoint.py:137-138` wraps a `Workflow` in `AgentFrameworkWorkflow`), + so B remains a fallback, but A is simpler and keeps the demo's existing + approval/predictive-state surfaces untouched. + +## Seam analysis (venv file:line) + +### (a) Where MAF run events become AG-UI events + +- Single entry point: `run_agent_stream` (`agent_framework_ag_ui/_agent_run.py:2259`), + reached from `AgentFrameworkAgent.run` (`_agent.py:147-166`). +- The wrapped agent is invoked at `_agent_run.py:2723` + (`response_stream = (a2ui_runner or agent).run(messages, stream=True, **run_kwargs)`); + updates are pulled at `_agent_run.py:2726` and each content item is converted to + AG-UI events by `_emit_content` (`_run_common.py:1166`, dispatched from + `_agent_run.py:2828`). `_emit_content` handles `text`, `function_call`, + `function_result`, `function_approval_request`, `usage`, reasoning, and MCP content + types (`_run_common.py:1174-1200`); anything else is dropped with a debug log + (`_run_common.py:1200`). +- The FastAPI endpoint consumes `protocol_runner.run(input_data)` and encodes each + yielded event generically (`_endpoint.py:212-242`). + +### (b) Can a function tool reach an event emitter/queue/context? + +**No.** There is no ContextVar, queue, writer, or middleware hook anywhere in +`agent_framework_ag_ui/*.py` or in `agent_framework/_tools.py` / `_middleware.py` / +`_agents.py` that a tool body could use to inject AG-UI events +(`grep -rn ContextVar` over those modules returns nothing). The event pipeline is a +pure pull-driven async generator; tools execute deep inside the framework's function +invocation loop within `agent.run(stream=True)` and only their return value surfaces +(as `function_result` content → `TOOL_CALL_RESULT`). + +**Injection seam (named):** wrap `AgentFrameworkAgent.run` — the exact method the +endpoint calls at `_endpoint.py:212`. Our emitter will be a small subclass (or +compositional wrapper) in the demo: + +1. `run()` creates an `asyncio.Queue` and sets a module-level `ContextVar` to it + before delegating to the inner `run_agent_stream` generator. Because the tool body + executes on the same async call chain (endpoint → wrapper → `run_agent_stream` → + `agent.run` → function invocation), the ContextVar value propagates into the tool. +2. The wrapper pumps the inner generator into the same queue from an + `asyncio.create_task` and yields from the merged queue. This is required for LIVE + interleaving: while the tool runs, the bridge generator is suspended awaiting the + next provider update, so a naive "drain queue between inner yields" design would + batch all child deltas until the tool returns. With the pump-task merge, a + `queue.put_nowait` from the tool body wakes the outer consumer immediately. +3. The tool body reads the ContextVar and enqueues + `SubagentStartedEvent {subagentRunId: -sub, name: "policy_researcher", + parentToolCallId: }` → attributed `TextMessageStart/Content×N/End` + (one delta per specialist update) → `SubagentFinishedEvent success` + (`SubagentErrorEvent` on exception). The tool's own `toolCallId` is available to + the body via the framework's function-call content on the update stream; the + emitter wrapper can also correlate it by observing the preceding + `TOOL_CALL_START` for the delegation tool on the bridge stream. + +This is the same "emit from inside the tool body" shape the Strands PR proved, with +the writer supplied by our own wrapper instead of the runtime (MAF's bridge provides +none). Reference translator: `cockpit/ag-ui/subagents/python/src/streaming/activity_emitting_agent.py`. + +### (c) Does the encoder accept ag_ui.core pydantic events generally? + +**Yes.** `EventEncoder.encode` takes any `BaseEvent` and does a generic +model-dump → SSE `data:` frame (`ag_ui/encoder/encoder.py`, `encode`/`_encode_sse`); +the endpoint applies it uniformly with no per-type allowlist (`_endpoint.py:224`). +`Subagent*` events are `BaseEvent` subclasses, so they pass through untouched. + +SDK check (in this venv): + +``` +$ 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'] +``` + +### (d) What does the bridge do with nested-agent activity inside a tool? + +**Nothing is observable.** The specialist's `run(stream=True)` updates are consumed +entirely inside the tool body; the bridge sees only the tool's `function_call` +(streamed as `TOOL_CALL_START/ARGS/END`) and its string return value +(`TOOL_CALL_RESULT`). No ACTIVITY_*, no per-child events, no specialist name on the +wire beyond the delegation tool's own name. This matches the "measured red upstream" +note in `src/agent.py` and is confirmed by the capture below. + +## Scratch setup (uncommitted, reverted after capture) + +Added to `src/agent.py`: a `policy_researcher` `Agent` (same `build_chat_client()`, +instructions: expense-policy researcher, 3 short bullets) plus an async +`@tool research_policy(category: str, amount: float) -> str` that ran +`specialist.run(prompt, stream=True)`, accumulated `update.text`, logged each update +to stderr, and returned the joined text; registered on the primary agent with one +instruction sentence about delegating policy research. + +## In-tool streaming datum + +The specialist's deltas DID stream into the tool body, token by token: + +``` +[spike] specialist update #2: '-' +[spike] specialist update #3: ' **' +[spike] specialist update #4: 'Pre' +... +[spike] specialist DONE: 101 streamed updates, 548 chars (attempt 2; attempt 1: 106 updates, 582 chars) +``` + +So the emitter can produce **streaming child deltas** (preferred contract), not just a +single final chunk. + +## Live wire capture (attempt 2 of 2; attempt 1 also delegated but was truncated client-side) + +Request: POST `/agent` with `threadId: spike-thread-2`, `runId: spike-run-2`, single +user message "Should I submit a $900 conference travel expense? Research the policy +first". The model delegated on the first turn in both attempts. Full event-type +census of the complete stream: + +``` +1 RUN_STARTED 1 STATE_SNAPSHOT 3 CUSTOM (PredictState, usage×2) +2 TEXT_MESSAGE_START 35 TEXT_MESSAGE_CONTENT 2 TEXT_MESSAGE_END +1 TOOL_CALL_START 9 TOOL_CALL_ARGS 1 TOOL_CALL_END 1 TOOL_CALL_RESULT +1 MESSAGES_SNAPSHOT 1 RUN_FINISHED +0 SUBAGENT_* / ACTIVITY_* / anything child-related +``` + +Abridged stream (ids as captured; no secrets present): + +``` +data: {"type":"RUN_STARTED","threadId":"spike-thread-2","runId":"spike-run-2"} +data: {"type":"CUSTOM","name":"PredictState","value":[{"state_key":"expense","tool":"submit_expense","tool_argument":"expense"}]} +data: {"type":"STATE_SNAPSHOT","snapshot":{"expense":{}}} +data: {"type":"TEXT_MESSAGE_START","messageId":"5cff954e-...","role":"assistant"} +data: {"type":"TOOL_CALL_START","toolCallId":"call_7sxPY1sC236nPyHRTWAZMJB9","toolCallName":"research_policy","parentMessageId":"5cff954e-..."} +data: {"type":"TOOL_CALL_ARGS","toolCallId":"call_7sxP...","delta":"{\""} +... (9 ARGS deltas spelling {"category":"travel","amount":900}) +data: {"type":"TOOL_CALL_END","toolCallId":"call_7sxP..."} + <-- specialist runs HERE; 101 updates streamed in-tool; NOTHING on the wire --> +data: {"type":"TOOL_CALL_RESULT","messageId":"c2c72fd2-...","toolCallId":"call_7sxP...","content":"1. **Pre-Approval Required**: Travel expenses exceeding $500 ...","role":"tool"} +data: {"type":"TEXT_MESSAGE_END","messageId":"5cff954e-..."} +data: {"type":"TEXT_MESSAGE_START","messageId":"534fe3b3-...","role":"assistant"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"534fe3b3-...","delta":"The"} +... (35 deltas: policy summary + ask to confirm approval) +data: {"type":"TEXT_MESSAGE_END","messageId":"534fe3b3-..."} +data: {"type":"MESSAGES_SNAPSHOT","messages":[...user, assistant toolCalls(research_policy), tool result, assistant text...]} +data: {"type":"RUN_FINISHED","threadId":"spike-thread-2","runId":"spike-run-2"} +``` + +### Explicit statements + +- **Native delegation on the wire:** an ordinary function tool call — + `TOOL_CALL_START(research_policy)` → streamed `TOOL_CALL_ARGS` → `TOOL_CALL_END` → + a single `TOOL_CALL_RESULT` carrying the specialist's complete final text. The + wall-clock gap between `TOOL_CALL_END` and `TOOL_CALL_RESULT` is where the + specialist runs, silently. +- **Child updates streamed in-tool:** YES — 101 streamed updates (attempt 2; 106 in + attempt 1), token-granular. +- **Anything child-related on the wire:** NO — zero events; the specialist is + invisible except as the tool's result string. + +## Emitter plan (for the implementation PR) + +Target sequence, injected by the wrapper-queue seam around the existing bridge stream +for tool call id ``: + +`SUBAGENT_STARTED {subagentRunId: "-sub", name: "policy_researcher", parentToolCallId: ""}` +→ `TEXT_MESSAGE_START/CONTENT×N/END` attributed to the subagent run (one CONTENT per +specialist update; live-interleaved via the pump-task merge) → `SUBAGENT_FINISHED +{outcome: success}` (or `SUBAGENT_ERROR` on tool-body exception), all before the +bridge's own `TOOL_CALL_RESULT` for `` reaches the client. + +## After the emitter + +Date: 2026-09-02, post-implementation. Live capture against the committed +`src/server.py` (uvicorn, port 5330; plain-OpenAI path, `gpt-4o-mini`), request +identical in shape to the spike: single user message "Should I submit a $900 +conference travel expense? Research the policy first" (`threadId: +smoke-thread-1`, `runId: smoke-run-1`). The model called +`lookup_expense_policy` first and then delegated via `research_policy` on the +same turn. Full event-type census of the complete stream: + +``` +1 RUN_STARTED 1 STATE_SNAPSHOT 4 CUSTOM (PredictState, usage×3) +4 TEXT_MESSAGE_START 128 TEXT_MESSAGE_CONTENT 4 TEXT_MESSAGE_END +2 TOOL_CALL_START 14 TOOL_CALL_ARGS 2 TOOL_CALL_END 2 TOOL_CALL_RESULT +1 SUBAGENT_STARTED 1 SUBAGENT_FINISHED 0 SUBAGENT_ERROR +1 MESSAGES_SNAPSHOT 1 RUN_FINISHED +``` + +**Child delta count: 100 streamed TEXT_MESSAGE_CONTENT events** attributed to +the subagent run (`messageId: -sub-m1`, token-granular — same order of +magnitude as the spike's 101/106 in-tool updates), live-interleaved between the +bridge's own events: the bridge yields `TOOL_CALL_END` for `research_policy` +only after the tool returns, and the entire SUBAGENT_* sequence lands between +`TOOL_CALL_ARGS` and that `TOOL_CALL_END` — proof the pump-task merge queue +delivered the deltas while the bridge generator was suspended inside the tool. + +Abridged stream around the delegation (ids as captured; no secrets present): + +``` +data: {"type":"TOOL_CALL_START","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx","toolCallName":"research_policy","parentMessageId":"c1a737b4-..."} +... (11 ARGS deltas spelling {"category":"travel","amount":900}) +data: {"type":"SUBAGENT_STARTED","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub","name":"policy_researcher","parentToolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx"} +data: {"type":"TEXT_MESSAGE_START","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","role":"assistant","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":"-","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":" **","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","delta":"Pre","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +... (100 CONTENT deltas total, token-granular) +data: {"type":"TEXT_MESSAGE_END","messageId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub-m1","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub"} +data: {"type":"SUBAGENT_FINISHED","subagentRunId":"call_drwxfbPJnrzSBBLnrcqehqfx-sub","outcome":{"type":"success"}} +data: {"type":"TOOL_CALL_END","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx"} +data: {"type":"TOOL_CALL_RESULT","messageId":"7dec6623-...","toolCallId":"call_drwxfbPJnrzSBBLnrcqehqfx","content":"- **Pre-Approval Required**: Travel expenses exceeding $500 ...","role":"tool"} +``` + +Tid derivation on the live run: the pump-recorded `TOOL_CALL_START` path +(`current_tool_call_id("research_policy")`) — the real wire toolCallId keys +the whole sequence (`subagentRunId = -sub`, `parentToolCallId = `); +the generated `sub-` fallback was not needed. Existing surfaces are +untouched: PredictState CUSTOM, STATE_SNAPSHOT, both TOOL_CALL_RESULTs, and +MESSAGES_SNAPSHOT all present as before. + +## Browser verification + +2026-09-02, live backend (real `OPENAI_API_KEY`, uvicorn on :5330) + `nx +serve cockpit-runtimes-microsoft-agent-framework-angular` on :4330, driven +headlessly with Playwright. Screenshot: +`cockpit/runtimes/microsoft-agent-framework/angular/e2e/manual/subagent-card-live.png`. + +What rendered: the delegation prompt (*"Should I submit a $900 conference +travel expense? Research the policy first"*) produced an inline +`` anchored to the `research_policy` tool call — header +`policy_researcher` + wire `toolCallId` + status badge — with the +specialist's 3-bullet policy transcript inside it, preceded by the +orchestrator's own `lookup_expense_policy` chip and followed by its 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 card mounting at 66 chars (header only) the moment +SUBAGENT_STARTED landed, then the specialist's message growing +monotonically while the run was live — one run sampled 66 → 163 → 277 → +398 → 422 → 553 → 622 chars between t≈1.5s and t≈3.0s; a second run +sampled 66 → 105 → 207 → 314 → 430 → 519 → 614 chars — before the badge +flipped to `complete` and the card collapsed to its 67-char summary row. +This confirms the queue-merge emitter's attributed `TEXT_MESSAGE_CONTENT` +deltas render progressively in the card during the run, not as one +post-hoc paste. + +Same `libs/chat` anchoring dependency as the Strands verification: the wire +and the `@threadplane/ag-ui` reducer were correct, but `chat-tool-calls` +anchored subagent cards by the adapter's `subagents()` map key (for native +SUBAGENT_* that is the `subagentRunId`, `-sub`) instead of the +contract field `Subagent.toolCallId`, so the card never mounted. The fix +(re-index on `Subagent.toolCallId`) plus its pinning spec are cherry-picked +onto this branch. diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/pyproject.toml b/deployments/ag-ui-dev/deps/microsoft_agent_framework/pyproject.toml index 8f718a771..dbf6404e4 100644 --- a/deployments/ag-ui-dev/deps/microsoft_agent_framework/pyproject.toml +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/pyproject.toml @@ -10,9 +10,18 @@ dependencies = [ "uvicorn[standard]>=0.29", ] +[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/microsoft_agent_framework/src/agent.py b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/agent.py index 9414d37f9..4d28e8c45 100644 --- a/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/agent.py +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/agent.py @@ -14,8 +14,13 @@ protocol-standard ``RUN_FINISHED.outcome = {type: 'interrupt', ...}`` and resumes from the client's top-level ``resume`` entries. -No subagents surface: the MAF bridge emits no per-subagent -ACTIVITY_SNAPSHOT/ACTIVITY_DELTA stream (measured red upstream). +- subagents: ``research_policy`` delegates to the tool-less + ``policy_researcher`` specialist Agent and streams its deltas through the + ``delegation_*`` helpers in src/subagent_emitter.py, which merge standard + ``SUBAGENT_*`` + attributed child ``TEXT_MESSAGE_*`` events into the run + stream at the run-wrapper seam (the MAF bridge natively emits NOTHING for + nested-agent activity inside a tool — measured red upstream and in + docs/wire-capture-subagents.md). Model client: Azure OpenAI is the DEFAULT path — when ``AZURE_OPENAI_ENDPOINT`` is set the client routes to Azure (key auth via @@ -32,6 +37,8 @@ from agent_framework.openai import OpenAIChatCompletionClient from pydantic import BaseModel, Field +from . import subagent_emitter + _POLICIES = { "meals": {"limit_usd": 300, "receipt_required_over_usd": 25, "notes": "Team meals require attendee count in the memo."}, "travel": {"limit_usd": 1500, "receipt_required_over_usd": 0, "notes": "Book through the travel portal when possible."}, @@ -97,6 +104,10 @@ def submit_expense(expense: Expense) -> str: _INSTRUCTIONS = """You are an expense approval copilot. +Before recommending whether to submit an expense, delegate the policy +research to the specialist by calling `research_policy` with the category +and amount. + When the user asks to file an expense: 1. FIRST call `lookup_expense_policy` with the expense category. 2. THEN call `submit_expense` with the complete structured expense @@ -138,12 +149,63 @@ def build_chat_client() -> OpenAIChatCompletionClient: ) +policy_researcher = Agent( + name="policy_researcher", + instructions=( + "You are an expense-policy researcher. Given an expense category and " + "amount, summarize the applicable policy rules in 3 short bullets." + ), + client=build_chat_client(), +) + + +@tool( + name="research_policy", + description="Delegate policy research for this expense to a specialist.", +) +async def research_policy(category: str, amount: float) -> str: + """Delegate policy research for this expense to a specialist. + + Streams the ``policy_researcher`` specialist and mirrors each text delta + onto the AG-UI wire as attributed SUBAGENT_* / TEXT_MESSAGE_* events via + src/subagent_emitter.py (no-ops outside the wrapped run). + + Args: + category: Expense category, e.g. 'meals' or 'travel'. + amount: Expense amount in USD. + + Returns: + The specialist's complete policy summary. + """ + # Deterministically recorded by the run wrapper's pump before this body + # runs (the bridge streams TOOL_CALL_START/ARGS/END first); None when + # invoked outside a wrapped run. + tid = subagent_emitter.current_tool_call_id("research_policy") + subagent_emitter.delegation_started(tid, policy_researcher.name) + parts: list[str] = [] + try: + prompt = ( + f"Expense category: {category}. Amount: ${amount:.2f}. " + "Summarize the applicable policy rules." + ) + async for update in policy_researcher.run(prompt, stream=True): + text = update.text + if text: + parts.append(text) + subagent_emitter.delegation_delta(tid, text) + except Exception as exc: + subagent_emitter.delegation_error(tid, str(exc)) + raise + subagent_emitter.delegation_finished(tid) + return "".join(parts) + + agent = AgentFrameworkAgent( agent=Agent( name="expense_approval_copilot", instructions=_INSTRUCTIONS, client=build_chat_client(), - tools=[lookup_expense_policy, submit_expense], + tools=[lookup_expense_policy, research_policy, submit_expense], ), name="ExpenseApprovalCopilot", description="Files expense reports with policy lookup, shared state, and human approval.", diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/server.py b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/server.py index 7b192ca0b..e26a54f02 100644 --- a/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/server.py +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/server.py @@ -3,9 +3,14 @@ from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint from .agent import agent +from .subagent_emitter import wrap_agent_run app = FastAPI(title="cockpit-runtimes-microsoft-agent-framework") -add_agent_framework_fastapi_endpoint(app, agent, path="/agent") +# The wrapper is the SUBAGENT_* injection seam: the endpoint consumes +# protocol_runner.run, and wrap_agent_run merges the delegation tool's +# enqueued child events into that stream (src/subagent_emitter.py). +wrapped_agent = wrap_agent_run(agent) +add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/agent") @app.get("/ok") diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/subagent_emitter.py b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/subagent_emitter.py new file mode 100644 index 000000000..0b231ab4c --- /dev/null +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/src/subagent_emitter.py @@ -0,0 +1,309 @@ +# SPDX-License-Identifier: MIT +"""SUBAGENT_* emitter for the `research_policy` delegation tool. + +The MAF AG-UI bridge is a pure pull-driven async generator with no writer a +tool body could reach: the specialist's streamed updates are consumed +entirely inside the tool and only the return string surfaces (as +TOOL_CALL_RESULT). Measured in docs/wire-capture-subagents.md, which also +names the injection seam implemented here: wrap ``AgentFrameworkAgent.run`` +— the exact method the FastAPI endpoint consumes (`_endpoint.py:212`) — +with a queue-merge generator. + +How the seam works (``SubagentEmittingAgent`` / ``wrap_agent_run``): + +1. ``run()`` creates an ``asyncio.Queue`` and publishes it (plus a small + correlation map) through a module-level ``ContextVar``. The delegation + tool executes on the same async call chain, so the value propagates + into the tool body. +2. A pump task drains the inner bridge generator into that queue. This is + required for LIVE interleaving: while the tool runs, the bridge + generator is suspended awaiting the next provider update, so a naive + "drain between inner yields" design would batch every child delta until + the tool returned. With the pump-task merge, a ``put_nowait`` from the + tool body wakes the outer consumer immediately. +3. The tool body calls the ``delegation_*`` helpers below, which build the + typed ``ag_ui.core`` events and enqueue them: + + SUBAGENT_STARTED {subagentRunId: -sub, parentToolCallId: } + TEXT_MESSAGE_START/CONTENT.../END (streamed specialist deltas) + SUBAGENT_FINISHED outcome=success (or SUBAGENT_ERROR on failure) + +Correlation: the pump appends every TOOL_CALL_START's ``toolCallId`` to a +per-tool-name FIFO as it passes through the queue, and each tool body pops +the oldest via ``current_tool_call_id`` — so a multi-tool batch calling the +same tool twice (MAF runs batches concurrently via ``asyncio.gather``, and +the bridge streams all TOOL_CALL_STARTs first) gives each invocation its +own tid and its own delegation. The bridge yields TOOL_CALL_START (and the +ARGS deltas) for the delegation call BEFORE the framework invokes the tool +on the same driving chain, so the FIFO is deterministically populated by +the time the tool body runs; TOOL_CALL_END arrives only AFTER the tool +returns (measured wire order — the SUBAGENT_* sequence lands between ARGS +and END), which is why correlation relies on START alone. If a caller ever +invokes the tool outside +the wrapped run, the helpers fall back to a generated ``sub-`` run id +with ``parentToolCallId`` omitted — and with no queue at all they are pure +no-ops, which is what keeps unit tests and direct agent runs side-effect +free. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import uuid +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator + +from ag_ui.core import ( + BaseEvent, + EventType, + SubagentErrorEvent, + SubagentFinishedEvent, + SubagentFinishedSuccessOutcome, + SubagentStartedEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, +) +from agent_framework.ag_ui import AgentFrameworkAgent + +DELEGATION_TOOL_NAME = "research_policy" + + +@dataclass +class _Delegation: + """Lifecycle of one delegation call, keyed by parent tool-call id.""" + + run_id: str + message_id: str + message_open: bool = False + finished: bool = False + + +@dataclass +class _EmitterSession: + """Per-run channel shared between the run wrapper and the tool body.""" + + queue: asyncio.Queue[Any] + # Per-tool-name FIFO of not-yet-claimed TOOL_CALL_START toolCallIds: + # the pump appends, each tool body pops the oldest — one tid per call + # even when a batch invokes the same tool twice. + tool_call_ids: dict[str, list[str]] = field(default_factory=dict) + runs: dict[str | None, _Delegation] = field(default_factory=dict) + + +_event_queue: ContextVar[_EmitterSession | None] = ContextVar( + "maf_subagent_emitter_session", default=None +) + + +def current_tool_call_id(tool_name: str) -> str | None: + """Claim the oldest unclaimed TOOL_CALL_START toolCallId for a tool. + + Pops from the per-name FIFO the pump fills, so each concurrent + invocation of the same tool gets its own tid. Deterministically + populated before the tool body runs (see module docstring); ``None`` + outside a wrapped run. + """ + session = _event_queue.get() + if session is None: + return None + pending = session.tool_call_ids.get(tool_name) + if not pending: + return None + return pending.pop(0) + + +def emit(event: BaseEvent) -> None: + """Enqueue one AG-UI event onto the live run stream; no-op unwrapped.""" + session = _event_queue.get() + if session is not None: + session.queue.put_nowait(event) + + +def delegation_started(tid: str | None, name: str) -> None: + """Announce the specialist run. Ids derive from the delegation tool-call + id; without one (unwrapped fallback) a ``sub-`` run id is generated + and ``parentToolCallId`` omitted.""" + session = _event_queue.get() + run_id = f"{tid}-sub" if tid else f"sub-{uuid.uuid4().hex[:8]}" + if session is not None: + session.runs[tid] = _Delegation(run_id=run_id, message_id=f"{run_id}-m1") + emit( + SubagentStartedEvent( + type=EventType.SUBAGENT_STARTED, + subagent_run_id=run_id, + name=name, + parent_tool_call_id=tid, + ) + ) + + +def _active(tid: str | None) -> _Delegation | None: + session = _event_queue.get() + if session is None: + return None + delegation = session.runs.get(tid) + if delegation is None or delegation.finished: + return None + return delegation + + +def delegation_delta(tid: str | None, text: str) -> None: + """Stream one specialist text delta, lazily opening the attributed + message (so a zero-delta run emits no empty message).""" + delegation = _active(tid) + if delegation is None or not text: + return + if not delegation.message_open: + delegation.message_open = True + emit( + TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=delegation.message_id, + role="assistant", + subagent_run_id=delegation.run_id, + ) + ) + emit( + TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=delegation.message_id, + delta=text, + subagent_run_id=delegation.run_id, + ) + ) + + +def _close_message(delegation: _Delegation) -> None: + if delegation.message_open: + delegation.message_open = False + emit( + TextMessageEndEvent( + type=EventType.TEXT_MESSAGE_END, + message_id=delegation.message_id, + subagent_run_id=delegation.run_id, + ) + ) + + +def delegation_finished(tid: str | None) -> None: + """Close the open child message and finish the subagent with success.""" + delegation = _active(tid) + if delegation is None: + return + delegation.finished = True + _close_message(delegation) + emit( + SubagentFinishedEvent( + type=EventType.SUBAGENT_FINISHED, + subagent_run_id=delegation.run_id, + outcome=SubagentFinishedSuccessOutcome(), + ) + ) + + +def delegation_error(tid: str | None, message: str) -> None: + """Close the open child message and report the specialist failure. The + tool re-raises afterwards, so the bridge's own tool-error path still + runs normally.""" + delegation = _active(tid) + if delegation is None: + return + delegation.finished = True + _close_message(delegation) + emit( + SubagentErrorEvent( + type=EventType.SUBAGENT_ERROR, + subagent_run_id=delegation.run_id, + message=message, + ) + ) + + +class _PumpFailure: + """Sentinel carrying an inner-generator exception across the queue.""" + + def __init__(self, exc: BaseException) -> None: + self.exc = exc + + +_DONE = object() + + +def _record_tool_call(session: _EmitterSession, event: Any) -> None: + if getattr(event, "type", None) == EventType.TOOL_CALL_START: + session.tool_call_ids.setdefault(event.tool_call_name, []).append( + event.tool_call_id + ) + + +class SubagentEmittingAgent(AgentFrameworkAgent): + """AgentFrameworkAgent whose ``run`` merges tool-enqueued SUBAGENT_* + events into the bridge stream via the pump-task queue. + + Constructed from an already-configured ``AgentFrameworkAgent`` (shares + its config and approval-state store rather than re-running ``__init__``), + so the endpoint's ``isinstance(agent, AgentFrameworkAgent)`` dispatch + and approval resume flow are untouched. + """ + + def __init__(self, inner: AgentFrameworkAgent) -> None: + self._inner = inner + self.agent = inner.agent + self.name = inner.name + self.description = inner.description + self.config = inner.config + self._approval_state_store = inner._approval_state_store + + async def run( + self, input_data: dict[str, Any] + ) -> AsyncGenerator[BaseEvent, None]: + queue: asyncio.Queue[Any] = asyncio.Queue() + session = _EmitterSession(queue=queue) + token = _event_queue.set(session) + inner_gen = self._inner.run(input_data) + + async def _pump() -> None: + try: + async for event in inner_gen: + _record_tool_call(session, event) + queue.put_nowait(event) + except asyncio.CancelledError: + raise + except BaseException as exc: # propagate to the consumer, never swallow + queue.put_nowait(_PumpFailure(exc)) + else: + queue.put_nowait(_DONE) + + # create_task copies the current context AFTER the ContextVar set, + # so the tool body (which executes on the pump's driving chain) + # sees this session. + pump = asyncio.create_task(_pump()) + try: + while True: + item = await queue.get() + if item is _DONE: + break + if isinstance(item, _PumpFailure): + raise item.exc + yield item + finally: + # Consumer break / client disconnect (GeneratorExit) or pump + # failure: cancel and await the pump so no task is orphaned, + # then close the inner generator. + pump.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump + with contextlib.suppress(Exception): + await inner_gen.aclose() + # reset raises ValueError if a GC-driven aclose runs the finally + # in a different context than the one that set the var. + with contextlib.suppress(ValueError): + _event_queue.reset(token) + + +def wrap_agent_run(agent: AgentFrameworkAgent) -> SubagentEmittingAgent: + """Wrap an AgentFrameworkAgent so its run stream carries SUBAGENT_*.""" + return SubagentEmittingAgent(agent) diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_delegation.py b/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_delegation.py new file mode 100644 index 000000000..16e465e26 --- /dev/null +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_delegation.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: MIT +"""Tests for the `research_policy` delegation scenario — the tool is a +registered async `@tool` that hands expense-policy research to the tool-less +`policy_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, policy_researcher, research_policy + + +def _tool_names() -> list[str]: + return [t.name for t in agent.agent.default_options["tools"]] + + +def test_research_policy_is_registered_on_the_agent(): + assert "research_policy" in _tool_names() + # Existing tools stay registered untouched. + assert "lookup_expense_policy" in _tool_names() + assert "submit_expense" in _tool_names() + + +def test_tool_name_and_docstring(): + assert research_policy.name == "research_policy" + assert research_policy.description.startswith( + "Delegate policy research for this expense to a specialist." + ) + schema = research_policy.parameters() + assert set(schema["required"]) == {"category", "amount"} + + +def test_tool_is_async(): + # The seam depends on it: the tool must be able to async-iterate the + # specialist's streamed updates and enqueue deltas as they arrive. + assert inspect.iscoroutinefunction(research_policy.func) + + +def test_specialist_is_toolless_researcher(): + assert policy_researcher.name == "policy_researcher" + assert policy_researcher.default_options.get("tools") == [] + assert "expense-policy researcher" in policy_researcher.default_options["instructions"] + + +def test_instructions_mention_delegation(): + instructions = agent.agent.default_options["instructions"] + assert "research_policy" in instructions + + +def test_untouched_surfaces_still_configured(): + # The subagent scenario must not disturb the existing shared-state and + # approval surfaces. + assert agent.config.state_schema == { + "expense": {"type": "object", "description": "The expense entry being drafted."}, + } + assert agent.config.predict_state_config == { + "expense": {"tool": "submit_expense", "tool_argument": "expense"}, + } diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_subagent_emitter.py b/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_subagent_emitter.py new file mode 100644 index 000000000..a9a28d0fa --- /dev/null +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/tests/test_subagent_emitter.py @@ -0,0 +1,410 @@ +# SPDX-License-Identifier: MIT +"""Tests for the SUBAGENT_* emitter — drives the `delegation_*` helpers and +the queue-merge run wrapper with a fake inner bridge generator plus a +scripted tool enqueue (the fake generator calls the helpers between its own +yields, exactly where the framework invokes the real tool on the pump's +driving chain) and asserts the exact merged sequence field-for-field.""" + +import asyncio + +import pytest + +from ag_ui.core import ( + EventType, + RunFinishedEvent, + RunStartedEvent, + ToolCallArgsEvent, + ToolCallEndEvent, + ToolCallResultEvent, + ToolCallStartEvent, +) + +from src import subagent_emitter +from src.subagent_emitter import ( + SubagentEmittingAgent, + current_tool_call_id, + delegation_delta, + delegation_error, + delegation_finished, + delegation_started, + wrap_agent_run, +) + +TID = "call_7sxPY1sC236nPyHRTWAZMJB9" +RUN_ID = f"{TID}-sub" +MESSAGE_ID = f"{TID}-sub-m1" + + +# --------------------------------------------------------------------------- +# Helper-level tests: install a session directly and inspect the queue. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def session(): + s = subagent_emitter._EmitterSession(queue=asyncio.Queue()) + token = subagent_emitter._event_queue.set(s) + yield s + subagent_emitter._event_queue.reset(token) + + +def _drain(session) -> list: + out = [] + while not session.queue.empty(): + out.append(session.queue.get_nowait()) + return out + + +def test_success_sequence_field_for_field(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- Pre") + delegation_delta(TID, "-approval") + delegation_delta(TID, " required") + delegation_finished(TID) + + out = _drain(session) + assert [ev.type for ev in out] == [ + 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 == "policy_researcher" + assert started.parent_tool_call_id == TID + + 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] == ["- Pre", "-approval", " required"] + 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" + + +def test_no_deltas_still_brackets_with_started_and_finished(session): + delegation_started(TID, "policy_researcher") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_error_closes_open_message_then_reports(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- Pre") + delegation_error(TID, "specialist exploded") + + out = _drain(session) + 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" + + +def test_events_after_terminal_are_ignored(session): + delegation_started(TID, "policy_researcher") + delegation_finished(TID) + delegation_delta(TID, "late straggler") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_empty_delta_is_dropped(session): + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "") + delegation_finished(TID) + assert [ev.type for ev in _drain(session)] == [ + EventType.SUBAGENT_STARTED, + EventType.SUBAGENT_FINISHED, + ] + + +def test_none_tid_falls_back_to_generated_run_id(session): + delegation_started(None, "policy_researcher") + delegation_delta(None, "- x") + delegation_finished(None) + + out = _drain(session) + started = out[0] + assert started.parent_tool_call_id is None + assert started.subagent_run_id.startswith("sub-") + assert len(started.subagent_run_id) == len("sub-") + 8 + # All subsequent events carry the same generated run id. + assert {ev.subagent_run_id for ev in out} == {started.subagent_run_id} + assert out[1].message_id == f"{started.subagent_run_id}-m1" + + +def test_helpers_are_noops_without_a_session(): + # Unit tests / direct agent runs: no wrapper, no queue — nothing raises. + assert subagent_emitter._event_queue.get() is None + assert current_tool_call_id("research_policy") is None + delegation_started(TID, "policy_researcher") + delegation_delta(TID, "- x") + delegation_finished(TID) + delegation_error(TID, "boom") + + +# --------------------------------------------------------------------------- +# Wrapper-level tests: queue-merge around a fake inner bridge generator. +# --------------------------------------------------------------------------- + + +class _FakeInner: + """Duck-typed AgentFrameworkAgent carrying the attributes the wrapper + copies plus a scripted `run` generator.""" + + def __init__(self, gen_fn): + self.agent = object() + self.name = "fake" + self.description = "" + self.config = object() + self._approval_state_store = object() + self._gen_fn = gen_fn + + def run(self, input_data): + return self._gen_fn(input_data) + + +def _run_started(): + return RunStartedEvent(type=EventType.RUN_STARTED, thread_id="t", run_id="r") + + +def _run_finished(): + return RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id="t", run_id="r") + + +async def _collect(agent) -> list: + return [ev async for ev in agent.run({"messages": []})] + + +async def test_wrapper_merges_tool_enqueued_events_mid_stream(): + async def inner(_input): + # Measured wire order: the bridge streams TOOL_CALL_START + ARGS + # before invoking the tool; TOOL_CALL_END arrives only AFTER the + # tool returns (docs/wire-capture-subagents.md, "After the emitter"). + yield _run_started() + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + yield ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, tool_call_id=TID, delta='{"category":"travel","amount":900}' + ) + # The framework invokes the tool HERE, on the pump's driving chain, + # while the outer consumer is awaiting the queue. + tid = current_tool_call_id("research_policy") + assert tid == TID # recorded by the pump from TOOL_CALL_START + delegation_started(tid, "policy_researcher") + delegation_delta(tid, "- Pre-approval") + delegation_delta(tid, " required") + delegation_finished(tid) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) + yield ToolCallResultEvent( + type=EventType.TOOL_CALL_RESULT, message_id="m", tool_call_id=TID, content="- Pre-approval required" + ) + yield _run_finished() + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + assert [ev.type for ev in out] == [ + EventType.RUN_STARTED, + EventType.TOOL_CALL_START, + EventType.TOOL_CALL_ARGS, + # Child events land BETWEEN inner generator items — before the + # bridge's own TOOL_CALL_END/RESULT reach the client. + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + EventType.TOOL_CALL_END, + EventType.TOOL_CALL_RESULT, + EventType.RUN_FINISHED, + ] + started = out[3] + assert started.subagent_run_id == RUN_ID + assert started.parent_tool_call_id == TID + assert [ev.delta for ev in out[5:7]] == ["- Pre-approval", " required"] + + +async def test_same_tool_double_call_gets_distinct_tids_and_delegations(): + # MAF runs multi-tool batches concurrently (asyncio.gather) and the + # bridge streams every TOOL_CALL_START before the tools execute — so + # two research_policy calls must each claim their OWN tid from the + # FIFO and drive their own delegation, never sharing one message. + tid2 = "call_secondResearchPolicyCall00" + + async def inner(_input): + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=tid2, tool_call_name="research_policy" + ) + # Both tool bodies run concurrently; their deltas interleave. + tid_a = current_tool_call_id("research_policy") + tid_b = current_tool_call_id("research_policy") + assert (tid_a, tid_b) == (TID, tid2) # FIFO: oldest first + delegation_started(tid_a, "policy_researcher") + delegation_started(tid_b, "policy_researcher") + delegation_delta(tid_a, "- travel rules") + delegation_delta(tid_b, "- meal rules") + delegation_delta(tid_a, " apply") + delegation_finished(tid_b) + delegation_finished(tid_a) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=TID) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=tid2) + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + + started = [ev for ev in out if ev.type == EventType.SUBAGENT_STARTED] + assert [ev.subagent_run_id for ev in started] == [f"{TID}-sub", f"{tid2}-sub"] + assert [ev.parent_tool_call_id for ev in started] == [TID, tid2] + + # Identity separation: every child event carries its own delegation's + # ids — interleaved ORDER between the two runs is fine. + for ev in out: + if ev.type == EventType.TEXT_MESSAGE_CONTENT and ev.delta.startswith("- travel"): + assert ev.message_id == f"{TID}-sub-m1" + if ev.type == EventType.TEXT_MESSAGE_CONTENT and ev.delta.startswith("- meal"): + assert ev.message_id == f"{tid2}-sub-m1" + a_events = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{TID}-sub"] + b_events = [ev for ev in out if getattr(ev, "subagent_run_id", None) == f"{tid2}-sub"] + assert [ev.type for ev in a_events] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert [ev.type for ev in b_events] == [ + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_FINISHED, + ] + assert {ev.message_id for ev in a_events if hasattr(ev, "message_id")} == {f"{TID}-sub-m1"} + assert {ev.message_id for ev in b_events if hasattr(ev, "message_id")} == {f"{tid2}-sub-m1"} + + +async def test_wrapper_error_path_emits_subagent_error_then_propagates(): + class _Boom(RuntimeError): + pass + + async def inner(_input): + yield _run_started() + yield ToolCallStartEvent( + type=EventType.TOOL_CALL_START, tool_call_id=TID, tool_call_name="research_policy" + ) + tid = current_tool_call_id("research_policy") + delegation_started(tid, "policy_researcher") + delegation_delta(tid, "- Pre") + delegation_error(tid, "specialist exploded") + raise _Boom("specialist exploded") + + agent = wrap_agent_run(_FakeInner(inner)) + out = [] + with pytest.raises(_Boom): + async for ev in agent.run({"messages": []}): + out.append(ev) + # Everything enqueued before the failure was delivered, ending in the + # SUBAGENT_ERROR (with the open child message closed first). + assert [ev.type for ev in out] == [ + EventType.RUN_STARTED, + EventType.TOOL_CALL_START, + EventType.SUBAGENT_STARTED, + EventType.TEXT_MESSAGE_START, + EventType.TEXT_MESSAGE_CONTENT, + EventType.TEXT_MESSAGE_END, + EventType.SUBAGENT_ERROR, + ] + assert out[-1].message == "specialist exploded" + + +async def test_wrapper_clean_shutdown_on_consumer_break(): + closed = asyncio.Event() + + async def inner(_input): + try: + yield _run_started() + while True: # endless stream: only a cancel/close ends it + await asyncio.sleep(0.01) + yield _run_started() + finally: + closed.set() + + agent = wrap_agent_run(_FakeInner(inner)) + gen = agent.run({"messages": []}) + first = await gen.__anext__() + assert first.type == EventType.RUN_STARTED + await gen.aclose() # consumer break / client disconnect + + await asyncio.wait_for(closed.wait(), timeout=1) + # No orphaned tasks: everything spawned by the wrapper is done. + pending = [ + t + for t in asyncio.all_tasks() + if t is not asyncio.current_task() and not t.done() + ] + assert pending == [] + # The ContextVar session was uninstalled. + assert subagent_emitter._event_queue.get() is None + + +async def test_wrapper_resets_contextvar_after_normal_completion(): + async def inner(_input): + yield _run_started() + yield _run_finished() + + out = await _collect(wrap_agent_run(_FakeInner(inner))) + assert [ev.type for ev in out] == [EventType.RUN_STARTED, EventType.RUN_FINISHED] + assert subagent_emitter._event_queue.get() is None + + +def test_wrap_agent_run_returns_agentframeworkagent_for_endpoint_dispatch(): + fake = _FakeInner(None) + wrapped = wrap_agent_run(fake) + assert isinstance(wrapped, SubagentEmittingAgent) + # The endpoint dispatches on isinstance(agent, AgentFrameworkAgent) and + # shares the config / approval-state store. + from agent_framework.ag_ui import AgentFrameworkAgent + + assert isinstance(wrapped, AgentFrameworkAgent) + assert wrapped.config is fake.config + assert wrapped._approval_state_store is fake._approval_state_store + + +def test_server_mounts_the_wrapped_agent(): + from src import server + + # The FastAPI endpoint consumes the wrapped run (protocol_runner is the + # SubagentEmittingAgent), so SUBAGENT_* events reach the wire. + assert isinstance(server.wrapped_agent, SubagentEmittingAgent) diff --git a/deployments/ag-ui-dev/deps/microsoft_agent_framework/uv.lock b/deployments/ag-ui-dev/deps/microsoft_agent_framework/uv.lock index d164f91fb..aafd57d70 100644 --- a/deployments/ag-ui-dev/deps/microsoft_agent_framework/uv.lock +++ b/deployments/ag-ui-dev/deps/microsoft_agent_framework/uv.lock @@ -121,6 +121,12 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "agent-framework-ag-ui", specifier = ">=1.2.1" }, @@ -130,6 +136,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" @@ -246,6 +258,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" @@ -385,6 +406,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/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 = "pydantic" version = "2.13.5" @@ -475,6 +514,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, ] +[[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 = "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-dotenv" version = "1.2.3"