diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index af45a19c..6e35d618 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -145,7 +145,7 @@ Beside it: allowlist twin is `test_core_depends_only_on_httpx_and_the_stdlib` (`tests/unit/test_architecture.py`): httpx and the stdlib backfills at module level, plus the optional `opentelemetry` lazily. -- **core is layered too:** `runtime` → `transport | approvals` → `budget | cache | +- **core is layered too:** `runtime` → `transport | approvals | inbound` → `budget | cache | cachecontrol | telemetry | toolspec` → `auth | correlation | spanflush` → `config` → `classifier | cost | endpoints | header_names | reactions | refusals` → `_policy_spec` → `errors` → `_refusal_body | lastcall | masking` → `_verify` → @@ -160,6 +160,10 @@ Beside it: a refusal. Inside a LangGraph graph the gate pauses with `interrupt()` instead (#212): `donkey.py` passes the LangGraph adapter's hook into `gate(natives=...)`, so core still imports no framework. + `inbound` binds the run for one inbound A2A task (#225): it adopts the + caller's `X-Correlation-Id` or mints one, enters `run_scope`, and opens the + `donkey.a2a.task` span. It takes headers as a plain mapping, so it imports no + `a2a-sdk` or `starlette`; `a2a` calls it, and so could another inbound surface. `spanflush` flushes the span providers on a kill (#220); it imports nothing from core, so the transport reaches it without telemetry exposing its provider. `_refusal_body` holds the refusal-body parsers (`errors`' JSON diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 8cb75ddb..34b485b2 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -320,6 +320,7 @@ no `UnverifiedValueWarning`. | Cost tag: project **request** header (`X-Anypoint-Cost-Project`, #196, #522) | `core/_verify.py` `COST_PROJECT_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding as the team row: no gateway ingestion; carrier is the `donkey.cost.project` span attribute. Overridable via `cost_project_header` / `DONKEY_COST_PROJECT_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | | Cost tag: env **request** header (`X-Anypoint-Cost-Env`, #196, #522) | `core/_verify.py` `COST_ENV_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding: no gateway ingestion; carrier is the `donkey.cost.env` span attribute. Overridable via `cost_env_header` / `DONKEY_COST_ENV_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | | Cost tag: enduser id **request** header (`X-Anypoint-Cost-Enduser-Id`, #196, #522) | `core/_verify.py` `COST_ENDUSER_HEADER` → transport | VERIFIED (LIVE) | **negative** — same finding: no gateway ingestion; carrier is the `donkey.cost.enduser.id` span attribute. Overridable via `cost_enduser_header` / `DONKEY_COST_ENDUSER_HEADER`. | 2026-09-22 | live probe + `view_api_instance_policies` (instance 21179672) | +| Inbound correlation id on a served A2A task (#225) | `core/inbound.py` `correlation_id_from_headers` ← `a2a/_server.py` | UNVERIFIED | `donkey.serve()` adopts an inbound `X-Correlation-Id` (or the `correlation_header` name) when it is 1–128 of `[A-Za-z0-9._:-]`, else mints one, and sends it on every downstream call. The header name is the verified LLM-proxy join key (row above). **Not verified:** whether an Omni Gateway A2A proxy in front of the agent forwards the caller's `X-Correlation-Id` to the upstream agent, or mints its own. Until a live A2A-proxy probe, adoption is proven only for a caller that sends the header directly. The a2a-sdk exposes inbound headers on `ServerCallContext.state["headers"]` (`DefaultServerCallContextBuilder`, a2a-sdk 1.2.2, read from the installed package). | — | offline: `tests/unit/test_a2a_governed_inbound.py` | The two #195 rows are **request** headers the SDK *sends* (the client→gateway join keys behind `donkey.run()` and `DonkeyError.correlation_id`/`.call_id`). As diff --git a/python/pyproject.toml b/python/pyproject.toml index 5b8bb7d6..009cb53e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -468,6 +468,7 @@ ignore-variadic-names = true "src/donkey_kit/conformance/**" = ["ANN401"] "src/donkey_kit/simulator/app.py" = ["ANN401"] "src/donkey_kit/core/telemetry.py" = ["ANN401"] +"src/donkey_kit/core/inbound.py" = ["ANN401"] # White-box tests inspect and swap private state by design (#719); docstring # rules apply to the shipped package only (#722). Tests also request fixtures for # their side effects and write doubles that mirror third-party signatures, so @@ -618,7 +619,9 @@ type = "layers" # #210) sits beside cost and the rest, over _policy_spec (its match-spec # checks); it fills a hook slot in errors, so errors never imports it. # approvals (the @donkey.tool approval gate, #211) sits beside transport: it -# reads correlation, errors and telemetry, and neither imports the other. Two +# reads correlation, errors and telemetry, and neither imports the other. +# inbound (the run an inbound A2A task binds, #225) sits there too: it binds +# correlation and opens a telemetry span, and imports neither sibling. Two # deviations from the order sketched in #729: lastcall sits BELOW errors, because a DonkeyError reads its request id # through lastcall.request_id; and _wire sits below _verify, because the # verification placeholders name wire headers. A sibling group with `|` is @@ -630,7 +633,7 @@ containers = ["donkey_kit.core"] exhaustive = true layers = [ "runtime", - "transport | approvals", + "transport | approvals | inbound", "budget | cache | cachecontrol | telemetry | toolspec", "auth | correlation | spanflush", "config", diff --git a/python/src/donkey_kit/a2a/_server.py b/python/src/donkey_kit/a2a/_server.py index 2addd2e4..90bbc857 100644 --- a/python/src/donkey_kit/a2a/_server.py +++ b/python/src/donkey_kit/a2a/_server.py @@ -19,12 +19,16 @@ from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, Protocol +from ..core.errors import DonkeyError, PolicyViolation +from ..core.inbound import InboundRun, inbound_task +from ..core.refusals import TypedRefusals from ..core.toolspec import AgentSpec, agent_spec from .types import A2AResult, A2ATask if TYPE_CHECKING: from a2a.server.agent_execution import RequestContext from a2a.server.events import EventQueue + from a2a.types import Message __all__ = ["build_app", "serve"] @@ -88,10 +92,48 @@ def _card_fields(spec: AgentSpec, *, url: str, version: str) -> dict[str, Any]: } -async def _run(spec: AgentSpec, task: A2ATask) -> object: +async def _run(spec: AgentSpec, task: A2ATask) -> A2AResult: if spec.is_async: - return await spec.func(task) - return await asyncio.to_thread(spec.func, task) + result = await spec.func(task) + else: + # to_thread copies the context, so the bound run and span reach the thread. + result = await asyncio.to_thread(spec.func, task) + if isinstance(result, str): + return A2AResult(text=result) + if not isinstance(result, A2AResult): + raise TypeError( + f"an A2A handler returns a str or an A2AResult, not {type(result).__name__}" + ) + return result + + +def _headers(context: RequestContext) -> dict[str, str]: + """The inbound HTTP request's headers: the ``a2a-sdk``'s default call-context + builder copies them into ``call_context.state["headers"]``.""" + headers = context.call_context.state.get("headers") + return dict(headers) if isinstance(headers, dict) else {} + + +def _failure(exc: Exception, run: InboundRun | None) -> tuple[str, dict[str, Any]]: + """The text and the structured ``error`` part a failed task carries. + + The exception's type name and the ids only: its message and a refusal's + remediation are for the agent's operator and go to the log, since either may + repeat request content or name internal configuration. + """ + name = type(exc).__name__ + refused = isinstance(exc, PolicyViolation) + text = ( + f"the agent's downstream call was refused: {name}" + if refused + else f"the agent failed: {name}" + ) + error: dict[str, Any] = {"type": name, "refusal": refused} + if run is not None: + error["correlation_id"] = run.correlation_id + if isinstance(exc, DonkeyError) and exc.call_id is not None: + error["call_id"] = exc.call_id + return text, {"error": error} def build_app( @@ -99,6 +141,9 @@ def build_app( *, url: str = "http://127.0.0.1:8000/", version: str = "0.0.0", + telemetry: bool = True, + correlation_header: str | None = None, + refusals: TypedRefusals | None = None, ) -> _ASGIApp: """Build the A2A server for a ``@donkey.agent`` handler as an ASGI app. @@ -108,14 +153,32 @@ def build_app( The handler takes an :class:`~donkey_kit.a2a.A2ATask` and returns a ``str`` or an :class:`~donkey_kit.a2a.A2AResult`; a sync handler runs in a worker - thread. If it raises, the task fails with the exception's type name only: - the message, which may hold internal detail, goes to this module's logger. + thread. + + Each task is governed (#225): it runs in a ``donkey.run()`` scope whose + correlation ID is the caller's ``X-Correlation-Id`` when it sent one and a + fresh ID when not (:attr:`A2ATask.correlation_id`), so every downstream call + sends it along with the configured cost tags. A ``donkey.a2a.task`` span wraps + the handler, parenting its calls' spans. If the handler raises, the task fails + with a text part naming the exception type and an ``error`` data part + (``type``, ``refusal``, ``correlation_id``, and the failed call's ``call_id`` + for a ``DonkeyError``); a governance refusal is re-raised as its typed + ``DonkeyError`` first, so ``type`` is ``PIIDetected``, not the framework's + wrapper. The message and stack trace go to this module's logger only. Args: handler: A callable marked with ``@donkey.agent``. url: The URL the card advertises for the JSON-RPC endpoint. When a gateway fronts the agent, pass the gateway's URL. version: The agent version the card advertises. + telemetry: Open the per-task span; ``Donkey.serve()`` passes the + ``telemetry`` setting. + correlation_header: The inbound correlation header's name; + ``Donkey.serve()`` passes the ``correlation_header`` setting. + ``None`` reads ``X-Correlation-Id``. + refusals: The typed-refusal bridge applied to a handler's exception. + ``Donkey.serve()`` passes ``donkey_kit.typed_refusals()``, which knows + every imported framework's wrapper; ``None`` applies the core bridge. Returns: A Starlette application serving the agent card at @@ -135,8 +198,8 @@ def build_app( get_data_parts, get_text_parts, new_data_part, + new_message, new_task_from_user_message, - new_text_message, new_text_part, ) from a2a.server.agent_execution import AgentExecutor @@ -148,6 +211,16 @@ def build_app( except ImportError as exc: raise _missing_extra(exc) from exc + bridge = refusals if refusals is not None else TypedRefusals() + + def _failed_message(exc: Exception, run: InboundRun | None, context: RequestContext) -> Message: + text, error = _failure(exc, run) + return new_message( + [new_text_part(text), new_data_part(error)], + context_id=context.context_id, + task_id=context.task_id, + ) + class _Executor(AgentExecutor): async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: message = context.message @@ -156,30 +229,36 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non updater = TaskUpdater(event_queue, context.task_id, context.context_id) await updater.start_work() parts = list(message.parts) if message is not None else [] - task = A2ATask( - input="\n".join(get_text_parts(parts)), - data=tuple(get_data_parts(parts)), - task_id=context.task_id, - context_id=context.context_id, - message=message, - ) + run: InboundRun | None = None try: - result = await _run(spec, task) - if isinstance(result, str): - result = A2AResult(text=result) - if not isinstance(result, A2AResult): - raise TypeError( - f"an A2A handler returns a str or an A2AResult, not {type(result).__name__}" - ) - except Exception as exc: # noqa: BLE001 — any handler failure fails the task, never the server - _log.exception("A2A agent %r failed task %s", spec.name, context.task_id) - await updater.failed( - new_text_message( - f"the agent failed: {type(exc).__name__}", - context_id=context.context_id, + with inbound_task( + _headers(context), + telemetry=telemetry, + refusals=bridge, + header=correlation_header, + attributes={ + "donkey.a2a.agent": spec.name, + "donkey.a2a.task_id": context.task_id, + "donkey.a2a.context_id": context.context_id, + }, + ) as run: + task = A2ATask( + input="\n".join(get_text_parts(parts)), + data=tuple(get_data_parts(parts)), task_id=context.task_id, + context_id=context.context_id, + message=message, + correlation_id=run.correlation_id, ) + result = await _run(spec, task) + except Exception as exc: # noqa: BLE001 — any handler failure fails the task, never the server + _log.exception( + "A2A agent %r failed task %s (correlation id %s)", + spec.name, + context.task_id, + run.correlation_id if run is not None else None, ) + await updater.failed(_failed_message(exc, run, context)) return if result.text is not None: await updater.add_artifact([new_text_part(result.text)]) @@ -215,9 +294,14 @@ def serve( port: int = 8000, url: str | None = None, version: str = "0.0.0", + telemetry: bool = True, + correlation_header: str | None = None, + refusals: TypedRefusals | None = None, ) -> None: """Serve a ``@donkey.agent`` handler over A2A on ``host:port`` via uvicorn, - blocking until the server stops. ``Donkey.serve()`` documents the arguments. + blocking until the server stops. ``Donkey.serve()`` documents the first four + arguments and :func:`build_app` the governance ones; ``Donkey.serve()`` fills + those from its configuration. Raises: TypeError: ``handler`` is not marked with ``@donkey.agent``. @@ -225,7 +309,14 @@ def serve( Docs: https://docs.donkey-kit.dev/a2a """ - app = build_app(handler, url=url or f"http://{host}:{port}/", version=version) + app = build_app( + handler, + url=url or f"http://{host}:{port}/", + version=version, + telemetry=telemetry, + correlation_header=correlation_header, + refusals=refusals, + ) try: import uvicorn except ImportError as exc: diff --git a/python/src/donkey_kit/a2a/types.py b/python/src/donkey_kit/a2a/types.py index 5d18a87e..63e61f66 100644 --- a/python/src/donkey_kit/a2a/types.py +++ b/python/src/donkey_kit/a2a/types.py @@ -24,6 +24,9 @@ class A2ATask: context_id: The A2A context (conversation) ID the task belongs to. message: The native ``a2a.types.Message``, for any part this view leaves out (files, URLs, metadata). + correlation_id: The run correlation ID the task is bound to (#225): the + caller's ``X-Correlation-Id`` when it sent one, a fresh ID when not. + Every governed call the handler makes sends it. """ input: str @@ -31,6 +34,7 @@ class A2ATask: task_id: str context_id: str message: Any + correlation_id: str = "" @dataclass(frozen=True) diff --git a/python/src/donkey_kit/core/inbound.py b/python/src/donkey_kit/core/inbound.py new file mode 100644 index 00000000..f219f9fe --- /dev/null +++ b/python/src/donkey_kit/core/inbound.py @@ -0,0 +1,164 @@ +"""Governance for the work an agent does on behalf of an inbound request +(BG §2.9.1, #225). + +An agent served over A2A makes its downstream model and tool calls for a caller. +:func:`inbound_task` turns the caller's request headers into the run state an +outbound ``donkey.run()`` binds, so those calls join the caller's run: + +* **Correlation.** The caller's ``X-Correlation-Id`` is adopted when it is a + well-formed id, and a fresh one is minted when it is absent or malformed + (:func:`correlation_id_from_headers`). Every downstream call in the task sends + it, so the gateway joins the caller's request and the agent's calls on one key. +* **Cost tags and business group.** The configured tags ride every downstream + call, as for any governed call. A ``donkey.run()`` or ``donkey.attribution()`` + inside the handler layers on top. A caller's own cost headers are not adopted: + who pays is the serving agent's configuration, not the caller's claim. +* **One span per task.** A ``donkey.a2a.task`` SERVER span wraps the handler, so + the ``donkey.llm.chat`` spans of its downstream calls are its children. A W3C + ``traceparent`` from the caller makes it a child of the caller's span. +* **Typed refusals.** The typed-refusal bridge applies on exit, so a framework's + wrapped refusal leaves the block as its typed ``DonkeyError``. + +Framework-free: headers are a plain mapping, so the A2A server is one caller and +an inbound MCP or HTTP surface can be another. +""" + +from __future__ import annotations + +import contextlib +import re +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from . import _verify +from .correlation import new_correlation_id, run_scope +from .telemetry import DONKEY_CORRELATION_ID + +if TYPE_CHECKING: + from .refusals import TypedRefusals + +__all__ = [ + "DONKEY_CORRELATION_SOURCE", + "SPAN_A2A_TASK", + "CorrelationSource", + "InboundRun", + "correlation_id_from_headers", + "inbound_task", +] + +#: The span one inbound A2A task opens around its handler. +SPAN_A2A_TASK = "donkey.a2a.task" +#: Whether the task's correlation id came from the caller or was minted here. +DONKEY_CORRELATION_SOURCE = "donkey.correlation_id.source" + +#: ``"adopted"``: the caller sent the id. ``"minted"``: this process made it. +CorrelationSource = Literal["adopted", "minted"] + +# What an adopted id may look like. It is sent on as a request header on every +# downstream call, so it must be header-safe, and it is the gateway's join key, so +# it must be short. The SDK's own ids (32 hex) and the gateway's dashed UUIDs fit. +_ADOPTABLE = re.compile(r"[A-Za-z0-9._:-]{1,128}") + + +@dataclass(frozen=True) +class InboundRun: + """The run an inbound task is bound to. + + Attributes: + correlation_id: The run id every downstream call in the task sends. + source: ``"adopted"`` when the caller sent it, ``"minted"`` when not. + """ + + correlation_id: str + source: CorrelationSource + + +def correlation_id_from_headers( + headers: Mapping[str, str], header: str | None = None +) -> InboundRun: + """The caller's ``X-Correlation-Id`` (or the ``correlation_header`` setting's + name, passed as ``header``), or a fresh id when it sent none or a malformed + one. Header names match case-insensitively.""" + wanted = (header or _verify.CORRELATION_ID_HEADER).lower() + for name, value in headers.items(): + if name.lower() == wanted: + candidate = value.strip() + if _ADOPTABLE.fullmatch(candidate): + return InboundRun(candidate, "adopted") + break + return InboundRun(new_correlation_id(), "minted") + + +@contextlib.contextmanager +def inbound_task( + headers: Mapping[str, str], + *, + telemetry: bool, + refusals: TypedRefusals | None, + header: str | None = None, + attributes: Mapping[str, str] | None = None, +) -> Iterator[InboundRun]: + """Bind the run for one inbound task and open its span. + + Yields the :class:`InboundRun`. The block runs inside ``run_scope`` with the + adopted or minted id, so every governed call in it sends that id, and inside + the ``donkey.a2a.task`` span when ``telemetry`` is on and OpenTelemetry is + installed. An exception leaving the block marks the span as an error with its + type name only, never its message, which may repeat request content. + + Args: + headers: The inbound request's headers. + telemetry: The ``telemetry`` setting; off means no span. + refusals: The typed-refusal bridge to apply on exit, or ``None``. + header: The ``correlation_header`` setting; ``None`` reads ``X-Correlation-Id``. + attributes: Extra span attributes, e.g. the A2A task id. + """ + run = correlation_id_from_headers(headers, header) + # Resolved per task, as every DDK span is, so a provider set later still wins. + from .telemetry import _tracer + + tracer = _tracer() if telemetry else None + if tracer is None: + with run_scope(run.correlation_id, refusals=refusals): + yield run + return + with tracer.start_as_current_span( + SPAN_A2A_TASK, + context=_parent(headers), + kind=_server_kind(), + attributes={ + DONKEY_CORRELATION_ID: run.correlation_id, + DONKEY_CORRELATION_SOURCE: run.source, + **(attributes or {}), + }, + # OTel's own exception handling would copy the message into the span. + record_exception=False, + set_status_on_exception=False, + ) as span: + try: + with run_scope(run.correlation_id, refusals=refusals): + yield run + except Exception as exc: + _mark_failed(span, exc) + raise + + +def _parent(headers: Mapping[str, str]) -> Any: + """The caller's trace context from a W3C ``traceparent``, if it sent one.""" + from opentelemetry.propagate import extract + + return extract({name.lower(): value for name, value in headers.items()}) + + +def _mark_failed(span: Any, exc: Exception) -> None: + from opentelemetry.trace import Status, StatusCode + + span.set_attribute("error.type", type(exc).__name__) + span.set_status(Status(StatusCode.ERROR)) + + +def _server_kind() -> Any: + from opentelemetry.trace import SpanKind + + return SpanKind.SERVER diff --git a/python/src/donkey_kit/donkey.py b/python/src/donkey_kit/donkey.py index a9ba2cc4..e94dc436 100644 --- a/python/src/donkey_kit/donkey.py +++ b/python/src/donkey_kit/donkey.py @@ -878,7 +878,14 @@ async def handle(task: A2ATask) -> str: The official ``a2a-sdk`` serves the protocol; the card is generated from the ``@donkey.agent`` marker, so it matches what ``donkey scan`` reads. It binds to localhost by default: the agent sits behind the gateway and - is never the public face. :func:`donkey_kit.a2a.build_app` returns the + is never the public face. + + Each task is governed (#225): its downstream calls share one run whose + correlation ID is the caller's ``X-Correlation-Id`` (or a fresh one) and + carry this client's cost tags, a ``donkey.a2a.task`` span parents their + spans, and a governance refusal fails the task with its typed name in a + structured ``error`` part, never a stack trace. + :func:`donkey_kit.a2a.build_app` returns the same server as an ASGI app, and :func:`donkey_kit.a2a.serve` is this method as a function. @@ -902,7 +909,16 @@ async def handle(task: A2ATask) -> str: """ from .a2a import serve - serve(handler, host=host, port=port, url=url, version=version) + serve( + handler, + host=host, + port=port, + url=url, + version=version, + telemetry=self._cfg.telemetry, + correlation_header=self._cfg.correlation_header, + refusals=_typed_refusals(), + ) @property def approvals(self) -> Approvals: diff --git a/python/tests/unit/test_a2a_governed_inbound.py b/python/tests/unit/test_a2a_governed_inbound.py new file mode 100644 index 00000000..1d72162d --- /dev/null +++ b/python/tests/unit/test_a2a_governed_inbound.py @@ -0,0 +1,333 @@ +"""Governed inbound for ``donkey.serve()`` (BG §2.9.1, #225). + +An inbound A2A task binds a run: the caller's correlation ID (or a fresh one), +the configured cost tags on every downstream call, one ``donkey.a2a.task`` span +parenting the downstream spans, and a downstream refusal surfacing as a typed, +structured A2A failure. Raw JSON-RPC over ``httpx.ASGITransport`` for the inbound +side; ``httpx.MockTransport`` behind the governed client for the downstream side. +""" + +from __future__ import annotations + +import re +from typing import Any + +import httpx +import pytest + +pytest.importorskip("a2a", reason="needs the [a2a] extra") + +from donkey_kit import A2ATask, Donkey +from donkey_kit._testing import swap_transport +from donkey_kit.a2a import build_app +from donkey_kit.core import telemetry +from donkey_kit.core.config import DonkeyConfig +from donkey_kit.core.cost import CostTags +from donkey_kit.core.inbound import correlation_id_from_headers + +_CHAT = { + "id": "c", + "object": "chat.completion", + "created": 0, + "model": "m", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ], +} +_PII_BODY = {"error": {"type": "pii_detected", "message": 'blocked: [{"pii_type": "EMAIL"}]'}} +_HEX32 = re.compile(r"[0-9a-f]{32}") + + +def _send(text: str) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": {"message": {"messageId": "m1", "role": "ROLE_USER", "parts": [{"text": text}]}}, + } + + +async def _calls(app: Any, *headers: dict[str, str]) -> list[dict[str, Any]]: + """One inbound task per ``headers`` mapping, all inside one ASGI lifespan.""" + transport = httpx.ASGITransport(app=app) + tasks: list[dict[str, Any]] = [] + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=transport, base_url="http://agent") as client, + ): + for extra in headers: + response = await client.post( + "/", json=_send("hi"), headers={"A2A-Version": "1.0", **extra} + ) + assert response.status_code == 200, response.text + payload: dict[str, Any] = response.json() + assert "error" not in payload, payload + tasks.append(payload["result"]["task"]) + return tasks + + +async def _call(app: Any, headers: dict[str, str] | None = None) -> dict[str, Any]: + (task,) = await _calls(app, headers or {}) + return task + + +def _id_echo() -> Any: + @Donkey.agent(name="id-echo") + async def id_echo(task: A2ATask) -> str: + """Returns the task's correlation ID.""" + return task.correlation_id + + return id_echo + + +def _artifact_text(task: dict[str, Any]) -> str: + assert task["status"]["state"] == "TASK_STATE_COMPLETED", task + text: str = task["artifacts"][0]["parts"][0]["text"] + return text + + +def _donkey( + seen: list[httpx.Request], + response: httpx.Response | None = None, + *, + send_cost_headers: bool = False, +) -> Donkey: + donkey = Donkey( + DonkeyConfig( + llm_proxy_url="https://proxy", + llm_proxy_client_id="cid", + llm_proxy_client_secret="csecret", + cost=CostTags(team="support", project="triage"), + send_cost_headers=send_cost_headers, + ) + ) + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return response if response is not None else httpx.Response(200, json=_CHAT) + + swap_transport(donkey._http, httpx.MockTransport(handler)) + return donkey + + +def _calling(donkey: Donkey) -> Any: + client = donkey.llm.client() + + @Donkey.agent(name="caller") + async def caller(task: A2ATask) -> str: + """Makes one downstream model call.""" + await client.chat.completions.create( + model="m", messages=[{"role": "user", "content": task.input}] + ) + return "done" + + return caller + + +@pytest.fixture +def spans(monkeypatch: pytest.MonkeyPatch) -> Any: + pytest.importorskip("opentelemetry.sdk") + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + tracer = provider.get_tracer("donkey_kit.test") + monkeypatch.setattr(telemetry, "_tracer", lambda: tracer) + return exporter + + +# --- correlation: adopt the caller's, mint when absent ----------------------- + + +async def test_the_callers_correlation_id_is_adopted() -> None: + task = await _call(build_app(_id_echo()), {"X-Correlation-Id": "caller-run-42"}) + assert _artifact_text(task) == "caller-run-42" + + +async def test_a_correlation_id_is_minted_when_the_caller_sends_none() -> None: + task = await _call(build_app(_id_echo())) + assert _HEX32.fullmatch(_artifact_text(task)) + + +async def test_a_malformed_correlation_id_is_replaced_not_forwarded() -> None: + task = await _call(build_app(_id_echo()), {"X-Correlation-Id": "bad id; x" + "y" * 200}) + assert _HEX32.fullmatch(_artifact_text(task)) + + +async def test_the_configured_correlation_header_is_the_one_read() -> None: + app = build_app(_id_echo(), correlation_header="X-Run-Id") + task = await _call(app, {"X-Run-Id": "via-custom-header", "X-Correlation-Id": "ignored"}) + assert _artifact_text(task) == "via-custom-header" + + +@pytest.mark.parametrize( + ("headers", "source"), + [ + ({"x-correlation-id": "abc-123"}, "adopted"), + ({"X-CORRELATION-ID": " abc-123 "}, "adopted"), + ({}, "minted"), + ({"X-Correlation-Id": ""}, "minted"), + ({"X-Correlation-Id": "a\r\nb"}, "minted"), + ], +) +def test_correlation_id_from_headers(headers: dict[str, str], source: str) -> None: + run = correlation_id_from_headers(headers) + assert run.source == source + if source == "adopted": + assert run.correlation_id == "abc-123" + + +# --- downstream calls carry the run and the cost tags ------------------------ + + +async def test_downstream_calls_send_the_inbound_correlation_id() -> None: + pytest.importorskip("openai") + seen: list[httpx.Request] = [] + donkey = _donkey(seen) + try: + task = await _call(build_app(_calling(donkey)), {"X-Correlation-Id": "caller-run-7"}) + finally: + await donkey.aclose() + assert _artifact_text(task) == "done" + (request,) = seen + assert request.headers["X-Correlation-Id"] == "caller-run-7" + + +async def test_downstream_calls_carry_the_configured_cost_tags(spans: Any) -> None: + """The ``donkey.cost.*`` span attributes are the authoritative carrier + (docs/verified-apis.md §3); the headers go out only with send_cost_headers.""" + pytest.importorskip("openai") + seen: list[httpx.Request] = [] + donkey = _donkey(seen, send_cost_headers=True) + try: + await _call(build_app(_calling(donkey))) + finally: + await donkey.aclose() + (chat_span,) = [s for s in spans.get_finished_spans() if s.name == "donkey.llm.chat"] + assert chat_span.attributes["donkey.cost.team"] == "support" + assert chat_span.attributes["donkey.cost.project"] == "triage" + (request,) = seen + assert request.headers["X-Anypoint-Cost-Team"] == "support" + assert request.headers["X-Anypoint-Cost-Project"] == "triage" + + +async def test_two_inbound_tasks_never_share_a_minted_run() -> None: + pytest.importorskip("openai") + seen: list[httpx.Request] = [] + donkey = _donkey(seen) + try: + await _calls(build_app(_calling(donkey)), {}, {}) + finally: + await donkey.aclose() + first, second = (r.headers["X-Correlation-Id"] for r in seen) + assert first != second + + +# --- one span per task, parenting the downstream spans ------------------------ + + +async def test_one_task_span_parents_the_downstream_model_span(spans: Any) -> None: + pytest.importorskip("openai") + from opentelemetry.trace import SpanKind + + seen: list[httpx.Request] = [] + donkey = _donkey(seen) + try: + await _call(build_app(_calling(donkey)), {"X-Correlation-Id": "caller-run-9"}) + finally: + await donkey.aclose() + finished = spans.get_finished_spans() + (task_span,) = [s for s in finished if s.name == "donkey.a2a.task"] + (chat_span,) = [s for s in finished if s.name == "donkey.llm.chat"] + assert task_span.kind is SpanKind.SERVER + assert task_span.attributes["donkey.correlation_id"] == "caller-run-9" + assert task_span.attributes["donkey.correlation_id.source"] == "adopted" + assert task_span.attributes["donkey.a2a.agent"] == "caller" + assert chat_span.parent is not None + assert chat_span.parent.span_id == task_span.context.span_id + assert chat_span.context.trace_id == task_span.context.trace_id + + +async def test_a_traceparent_from_the_caller_parents_the_task_span(spans: Any) -> None: + trace_id = "4bf92f3577b34da6a3ce929d0e0e4736" + traceparent = f"00-{trace_id}-00f067aa0ba902b7-01" + await _call(build_app(_id_echo()), {"traceparent": traceparent}) + (task_span,) = [s for s in spans.get_finished_spans() if s.name == "donkey.a2a.task"] + assert format(task_span.context.trace_id, "032x") == trace_id + assert task_span.parent is not None + assert format(task_span.parent.span_id, "016x") == "00f067aa0ba902b7" + assert task_span.attributes["donkey.correlation_id.source"] == "minted" + + +async def test_telemetry_off_opens_no_task_span(spans: Any) -> None: + task = await _call(build_app(_id_echo(), telemetry=False), {"X-Correlation-Id": "r1"}) + assert _artifact_text(task) == "r1" # the run is still bound + assert [s.name for s in spans.get_finished_spans()] == [] + + +async def test_a_failed_task_marks_its_span_with_the_error_type_only(spans: Any) -> None: + from opentelemetry.trace import StatusCode + + @Donkey.agent(name="broken") + async def broken(task: A2ATask) -> str: + """Always fails.""" + raise RuntimeError("db password is hunter2") + + await _call(build_app(broken)) + (task_span,) = [s for s in spans.get_finished_spans() if s.name == "donkey.a2a.task"] + assert task_span.status.status_code is StatusCode.ERROR + assert task_span.attributes["error.type"] == "RuntimeError" + assert "hunter2" not in str(task_span.to_json()) + + +# --- a downstream refusal is a typed, well-formed A2A failure ---------------- + + +async def test_a_downstream_refusal_fails_the_task_with_its_typed_name() -> None: + pytest.importorskip("openai") + seen: list[httpx.Request] = [] + donkey = _donkey(seen, httpx.Response(403, json=_PII_BODY)) + try: + task = await _call(build_app(_calling(donkey)), {"X-Correlation-Id": "caller-run-3"}) + finally: + await donkey.aclose() + assert task["status"]["state"] == "TASK_STATE_FAILED" + text, error = task["status"]["message"]["parts"] + assert text["text"] == "the agent's downstream call was refused: PIIDetected" + details = error["data"]["error"] + assert details["type"] == "PIIDetected" + assert details["refusal"] is True + assert details["correlation_id"] == "caller-run-3" + assert details["call_id"] == seen[0].headers["X-Donkey-Request-Id"] + assert "EMAIL" not in str(task) # the gateway's body is not echoed to the caller + assert "Traceback" not in str(task) + + +async def test_donkey_serve_bridge_types_a_refusal_with_framework_translators() -> None: + """``Donkey.serve()`` passes ``typed_refusals()``; ``build_app(refusals=...)`` + applies whatever bridge it is given.""" + from donkey_kit import typed_refusals + + pytest.importorskip("openai") + seen: list[httpx.Request] = [] + donkey = _donkey(seen, httpx.Response(403, json=_PII_BODY)) + try: + task = await _call(build_app(_calling(donkey), refusals=typed_refusals())) + finally: + await donkey.aclose() + assert task["status"]["message"]["parts"][1]["data"]["error"]["type"] == "PIIDetected" + + +async def test_the_run_reaches_a_sync_handlers_worker_thread() -> None: + from donkey_kit.core.correlation import current_correlation_id + + @Donkey.agent(name="sync-id") + def sync_id(task: A2ATask) -> str: + """Reports the run bound in its worker thread.""" + return current_correlation_id() or "unbound" + + task = await _call(build_app(sync_id), {"X-Correlation-Id": "threaded-run"}) + assert _artifact_text(task) == "threaded-run" diff --git a/python/tests/unit/test_a2a_serve.py b/python/tests/unit/test_a2a_serve.py index 09aef185..e7f5671c 100644 --- a/python/tests/unit/test_a2a_serve.py +++ b/python/tests/unit/test_a2a_serve.py @@ -119,8 +119,11 @@ async def broken(task: A2ATask) -> str: with caplog.at_level(logging.ERROR, logger="donkey_kit.a2a._server"): task = await _call(broken, _send("hi")) assert task["status"]["state"] == "TASK_STATE_FAILED" - reply = task["status"]["message"]["parts"][0]["text"] - assert reply == "the agent failed: RuntimeError" + text, error = task["status"]["message"]["parts"] + assert text["text"] == "the agent failed: RuntimeError" + assert error["data"]["error"]["type"] == "RuntimeError" + assert error["data"]["error"]["refusal"] is False + assert "hunter2" not in str(task) assert "hunter2" in caplog.text # the detail goes to the log, never the caller @@ -165,16 +168,29 @@ def test_serve_binds_to_localhost_by_default(monkeypatch: pytest.MonkeyPatch) -> assert calls == [{"host": "127.0.0.1", "port": 8000}] -def test_donkey_serve_delegates(monkeypatch: pytest.MonkeyPatch) -> None: +def test_donkey_serve_delegates_with_its_governance_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: import donkey_kit.a2a + from donkey_kit.core.config import DonkeyConfig + from donkey_kit.core.refusals import TypedRefusals calls: list[tuple[Any, dict[str, Any]]] = [] monkeypatch.setattr(donkey_kit.a2a, "serve", lambda h, **kw: calls.append((h, kw))) handler = _echo() - Donkey.__new__(Donkey).serve(handler, port=9001) - assert calls == [ - (handler, {"host": "127.0.0.1", "port": 9001, "url": None, "version": "0.0.0"}) - ] + donkey = Donkey(DonkeyConfig(telemetry=False, correlation_header="X-Run-Id")) + donkey.serve(handler, port=9001) + ((got, kwargs),) = calls + assert got is handler + assert isinstance(kwargs.pop("refusals"), TypedRefusals) + assert kwargs == { + "host": "127.0.0.1", + "port": 9001, + "url": None, + "version": "0.0.0", + "telemetry": False, + "correlation_header": "X-Run-Id", + } def test_importing_donkey_kit_does_not_load_the_a2a_sdk() -> None: diff --git a/website/content/a2a.mdx b/website/content/a2a.mdx index 70f58c9d..b5542d31 100644 --- a/website/content/a2a.mdx +++ b/website/content/a2a.mdx @@ -22,9 +22,10 @@ Today, accepting a task from a partner agent means: 3. adding logging and tracing 4. getting a certificate, opening a port and registering the agent by hand -`donkey.serve()` takes care of the first two today. Logging and tracing come -with governed inbound, and `donkey expose` hands the fourth to the gateway; both -are on the Roadmap. +`donkey.serve()` takes care of the first three today: +[governed inbound](#governed-inbound) gives every task a correlation ID and a +tracing span. `donkey expose`, which hands the fourth to the gateway, is on the +Roadmap. ## Why not have users call the a2a-sdk directly? @@ -33,8 +34,8 @@ They could, but they'd get a bare server. The wrapper adds four things: | Raw a2a-sdk | `donkey.serve()` | |---|---| | You write the agent card by hand | The card is generated from the same `@donkey.agent` / `@donkey.tool` markers `donkey scan` reads, so they can't drift | -| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls Roadmap (#225) | -| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands Roadmap (#225) | +| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls | +| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands | | It binds wherever you configure it | It binds to localhost by default, behind the gateway | > The differentiator is governed inbound and one-command exposure, not the protocol. @@ -115,16 +116,17 @@ The handler takes an `A2ATask`: | `input` | the text parts of the caller's message, joined by newlines | | `data` | the structured (data) parts, in order | | `task_id`, `context_id` | the A2A task and conversation IDs | +| `correlation_id` | the run ID every governed call in the task sends ([governed inbound](#governed-inbound)) | | `message` | the native `a2a-sdk` message, for anything else (files, metadata) | It returns a `str`, or an `A2AResult(text=..., data=...)` when it has structured output. Each field that is set becomes one artifact on the completed task. A sync handler works too: it runs in a worker thread. -If the handler raises, the task fails and the caller sees only -`the agent failed: `. The exception's message can hold internal -detail, so it goes to your logs (the `donkey_kit.a2a` logger), never to the -caller. The server keeps serving. +If the handler raises, the task fails and the caller sees only the exception's +type, in the shape [governed inbound](#governed-inbound) describes. The +exception's message can hold internal detail, so it goes to your logs (the +`donkey_kit.a2a` logger), never to the caller. The server keeps serving. ### Where it listens @@ -166,12 +168,80 @@ a separate process in front of your agent, not as a library inside it. value depends on being outside the thing it governs. -## Governed inbound Roadmap +## Governed inbound -Every call arriving over A2A will get the same treatment as an outgoing one: a -correlation ID, cost tags, an OTel span, and typed refusals when the agent's own -downstream calls are blocked. Today a served task is untracked, and a handler -failure reaches the caller as the generic `the agent failed: `. +A task that arrives over A2A gets the same governance as a call your agent makes. +`donkey.serve()` wraps each task's handler in a `donkey.run()` scope, so the +downstream calls it makes are grouped and attributed with no code in the handler. + +**One correlation ID per task.** If the caller sends `X-Correlation-Id`, the task +adopts it. If the header is missing or malformed, the task gets a fresh ID. +Malformed means anything other than 1 to 128 letters, digits, `.`, `_`, `:` or +`-`. Every governed call in the handler sends that ID, so the gateway joins the +caller's request and your agent's model calls on one key. The handler reads it as +`task.correlation_id`. If you renamed the header with the `correlation_header` +setting, the renamed header is the one read. + +**Cost tags.** The client's configured cost tags ride every downstream call, as +the `donkey.cost.*` span attributes, and as headers when `send_cost_headers` is +on. A `donkey.run(team=...)` or `donkey.attribution(...)` block inside the +handler layers on top for its block. The caller's own cost headers are never +adopted: who pays is your agent's configuration, not the caller's claim. + +**One span per task.** A `donkey.a2a.task` SERVER span wraps the handler, so the +`donkey.llm.chat` spans of its model calls are its children. If the caller sends +a W3C `traceparent`, the task span joins the caller's trace. The span carries: + +| Attribute | Value | +|---|---| +| `donkey.correlation_id` | the task's run ID | +| `donkey.correlation_id.source` | `adopted` (the caller sent it) or `minted` | +| `donkey.a2a.agent` | the `@donkey.agent` name | +| `donkey.a2a.task_id`, `donkey.a2a.context_id` | the A2A IDs | +| `error.type` | the exception type, on a failed task only | + +A failed task marks the span as an error with the exception's type only, never +its message. With `telemetry` off, no span is opened, but the run is still bound. + +**Typed refusals, as a well-formed A2A failure.** When a downstream call is +refused, the framework's wrapped error is re-raised as its typed `DonkeyError` +first (the [typed-refusal bridge](/errors)). The task then fails with a status +message of two parts: a text part, and an `error` data part the caller can +branch on: + +```json +{ + "state": "TASK_STATE_FAILED", + "message": { + "parts": [ + {"text": "the agent's downstream call was refused: PIIDetected"}, + {"data": {"error": { + "type": "PIIDetected", + "refusal": true, + "correlation_id": "caller-run-42", + "call_id": "9f1c0e…" + }}} + ] + } +} +``` + +`refusal` is `true` for a governance refusal (a `PolicyViolation`), and `false` +for any other failure. Any other failure reads `the agent failed: ` +and carries the same `error` part. `call_id` appears when the failure was a +governed call's. The refusal's `remediation` and the gateway's response body go +to your logs, not to the caller: they name your configuration and can repeat +the request's content. + +`donkey_kit.a2a.build_app()` does the same for an app you mount yourself. Pass +`telemetry=`, `correlation_header=` and `refusals=donkey_kit.typed_refusals()` to +match `donkey.serve()`, which fills them from the client's configuration. + + + **Not inbound enforcement.** Nothing here blocks an inbound task. Inbound + policy (auth, rate limits, PII screening of the caller's message) is the + gateway's job, in front of the agent. + ## `donkey expose` — the ingress, registered from code Roadmap diff --git a/website/public/a2a.md b/website/public/a2a.md index 678c988d..4747553c 100644 --- a/website/public/a2a.md +++ b/website/public/a2a.md @@ -16,9 +16,10 @@ Today, accepting a task from a partner agent means: 3. adding logging and tracing 4. getting a certificate, opening a port and registering the agent by hand -`donkey.serve()` takes care of the first two today. Logging and tracing come -with governed inbound, and `donkey expose` hands the fourth to the gateway; both -are on the Roadmap. +`donkey.serve()` takes care of the first three today: +[governed inbound](#governed-inbound) gives every task a correlation ID and a +tracing span. `donkey expose`, which hands the fourth to the gateway, is on the +Roadmap. ## Why not have users call the a2a-sdk directly? @@ -27,8 +28,8 @@ They could, but they'd get a bare server. The wrapper adds four things: | Raw a2a-sdk | `donkey.serve()` | |---|---| | You write the agent card by hand | The card is generated from the same `@donkey.agent` / `@donkey.tool` markers `donkey scan` reads, so they can't drift | -| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls Roadmap (#225) | -| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands Roadmap (#225) | +| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls | +| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands | | It binds wherever you configure it | It binds to localhost by default, behind the gateway | > The differentiator is governed inbound and one-command exposure, not the protocol. @@ -103,16 +104,17 @@ The handler takes an `A2ATask`: | `input` | the text parts of the caller's message, joined by newlines | | `data` | the structured (data) parts, in order | | `task_id`, `context_id` | the A2A task and conversation IDs | +| `correlation_id` | the run ID every governed call in the task sends ([governed inbound](#governed-inbound)) | | `message` | the native `a2a-sdk` message, for anything else (files, metadata) | It returns a `str`, or an `A2AResult(text=..., data=...)` when it has structured output. Each field that is set becomes one artifact on the completed task. A sync handler works too: it runs in a worker thread. -If the handler raises, the task fails and the caller sees only -`the agent failed: `. The exception's message can hold internal -detail, so it goes to your logs (the `donkey_kit.a2a` logger), never to the -caller. The server keeps serving. +If the handler raises, the task fails and the caller sees only the exception's +type, in the shape [governed inbound](#governed-inbound) describes. The +exception's message can hold internal detail, so it goes to your logs (the +`donkey_kit.a2a` logger), never to the caller. The server keeps serving. ### Where it listens @@ -150,12 +152,78 @@ a separate process in front of your agent, not as a library inside it. lived inside the agent, the agent's own code could bypass it. The gateway's value depends on being outside the thing it governs. -## Governed inbound Roadmap +## Governed inbound -Every call arriving over A2A will get the same treatment as an outgoing one: a -correlation ID, cost tags, an OTel span, and typed refusals when the agent's own -downstream calls are blocked. Today a served task is untracked, and a handler -failure reaches the caller as the generic `the agent failed: `. +A task that arrives over A2A gets the same governance as a call your agent makes. +`donkey.serve()` wraps each task's handler in a `donkey.run()` scope, so the +downstream calls it makes are grouped and attributed with no code in the handler. + +**One correlation ID per task.** If the caller sends `X-Correlation-Id`, the task +adopts it. If the header is missing or malformed, the task gets a fresh ID. +Malformed means anything other than 1 to 128 letters, digits, `.`, `_`, `:` or +`-`. Every governed call in the handler sends that ID, so the gateway joins the +caller's request and your agent's model calls on one key. The handler reads it as +`task.correlation_id`. If you renamed the header with the `correlation_header` +setting, the renamed header is the one read. + +**Cost tags.** The client's configured cost tags ride every downstream call, as +the `donkey.cost.*` span attributes, and as headers when `send_cost_headers` is +on. A `donkey.run(team=...)` or `donkey.attribution(...)` block inside the +handler layers on top for its block. The caller's own cost headers are never +adopted: who pays is your agent's configuration, not the caller's claim. + +**One span per task.** A `donkey.a2a.task` SERVER span wraps the handler, so the +`donkey.llm.chat` spans of its model calls are its children. If the caller sends +a W3C `traceparent`, the task span joins the caller's trace. The span carries: + +| Attribute | Value | +|---|---| +| `donkey.correlation_id` | the task's run ID | +| `donkey.correlation_id.source` | `adopted` (the caller sent it) or `minted` | +| `donkey.a2a.agent` | the `@donkey.agent` name | +| `donkey.a2a.task_id`, `donkey.a2a.context_id` | the A2A IDs | +| `error.type` | the exception type, on a failed task only | + +A failed task marks the span as an error with the exception's type only, never +its message. With `telemetry` off, no span is opened, but the run is still bound. + +**Typed refusals, as a well-formed A2A failure.** When a downstream call is +refused, the framework's wrapped error is re-raised as its typed `DonkeyError` +first (the [typed-refusal bridge](https://docs.donkey-kit.dev/errors.md)). The task then fails with a status +message of two parts: a text part, and an `error` data part the caller can +branch on: + +```json +{ + "state": "TASK_STATE_FAILED", + "message": { + "parts": [ + {"text": "the agent's downstream call was refused: PIIDetected"}, + {"data": {"error": { + "type": "PIIDetected", + "refusal": true, + "correlation_id": "caller-run-42", + "call_id": "9f1c0e…" + }}} + ] + } +} +``` + +`refusal` is `true` for a governance refusal (a `PolicyViolation`), and `false` +for any other failure. Any other failure reads `the agent failed: ` +and carries the same `error` part. `call_id` appears when the failure was a +governed call's. The refusal's `remediation` and the gateway's response body go +to your logs, not to the caller: they name your configuration and can repeat +the request's content. + +`donkey_kit.a2a.build_app()` does the same for an app you mount yourself. Pass +`telemetry=`, `correlation_header=` and `refusals=donkey_kit.typed_refusals()` to +match `donkey.serve()`, which fills them from the client's configuration. + + **Not inbound enforcement.** Nothing here blocks an inbound task. Inbound + policy (auth, rate limits, PII screening of the caller's message) is the + gateway's job, in front of the agent. ## `donkey expose` — the ingress, registered from code Roadmap diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index a67ce266..5663686d 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -6713,9 +6713,10 @@ Today, accepting a task from a partner agent means: 3. adding logging and tracing 4. getting a certificate, opening a port and registering the agent by hand -`donkey.serve()` takes care of the first two today. Logging and tracing come -with governed inbound, and `donkey expose` hands the fourth to the gateway; both -are on the Roadmap. +`donkey.serve()` takes care of the first three today: +[governed inbound](#governed-inbound) gives every task a correlation ID and a +tracing span. `donkey expose`, which hands the fourth to the gateway, is on the +Roadmap. ## Why not have users call the a2a-sdk directly? @@ -6724,8 +6725,8 @@ They could, but they'd get a bare server. The wrapper adds four things: | Raw a2a-sdk | `donkey.serve()` | |---|---| | You write the agent card by hand | The card is generated from the same `@donkey.agent` / `@donkey.tool` markers `donkey scan` reads, so they can't drift | -| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls Roadmap (#225) | -| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands Roadmap (#225) | +| Incoming tasks arrive anonymous and untracked | They get the same correlation ID, cost tags and tracing span as outgoing calls | +| A blocked downstream call becomes a generic error | It becomes a typed refusal the caller understands | | It binds wherever you configure it | It binds to localhost by default, behind the gateway | > The differentiator is governed inbound and one-command exposure, not the protocol. @@ -6800,16 +6801,17 @@ The handler takes an `A2ATask`: | `input` | the text parts of the caller's message, joined by newlines | | `data` | the structured (data) parts, in order | | `task_id`, `context_id` | the A2A task and conversation IDs | +| `correlation_id` | the run ID every governed call in the task sends ([governed inbound](#governed-inbound)) | | `message` | the native `a2a-sdk` message, for anything else (files, metadata) | It returns a `str`, or an `A2AResult(text=..., data=...)` when it has structured output. Each field that is set becomes one artifact on the completed task. A sync handler works too: it runs in a worker thread. -If the handler raises, the task fails and the caller sees only -`the agent failed: `. The exception's message can hold internal -detail, so it goes to your logs (the `donkey_kit.a2a` logger), never to the -caller. The server keeps serving. +If the handler raises, the task fails and the caller sees only the exception's +type, in the shape [governed inbound](#governed-inbound) describes. The +exception's message can hold internal detail, so it goes to your logs (the +`donkey_kit.a2a` logger), never to the caller. The server keeps serving. ### Where it listens @@ -6847,12 +6849,78 @@ a separate process in front of your agent, not as a library inside it. lived inside the agent, the agent's own code could bypass it. The gateway's value depends on being outside the thing it governs. -## Governed inbound Roadmap +## Governed inbound -Every call arriving over A2A will get the same treatment as an outgoing one: a -correlation ID, cost tags, an OTel span, and typed refusals when the agent's own -downstream calls are blocked. Today a served task is untracked, and a handler -failure reaches the caller as the generic `the agent failed: `. +A task that arrives over A2A gets the same governance as a call your agent makes. +`donkey.serve()` wraps each task's handler in a `donkey.run()` scope, so the +downstream calls it makes are grouped and attributed with no code in the handler. + +**One correlation ID per task.** If the caller sends `X-Correlation-Id`, the task +adopts it. If the header is missing or malformed, the task gets a fresh ID. +Malformed means anything other than 1 to 128 letters, digits, `.`, `_`, `:` or +`-`. Every governed call in the handler sends that ID, so the gateway joins the +caller's request and your agent's model calls on one key. The handler reads it as +`task.correlation_id`. If you renamed the header with the `correlation_header` +setting, the renamed header is the one read. + +**Cost tags.** The client's configured cost tags ride every downstream call, as +the `donkey.cost.*` span attributes, and as headers when `send_cost_headers` is +on. A `donkey.run(team=...)` or `donkey.attribution(...)` block inside the +handler layers on top for its block. The caller's own cost headers are never +adopted: who pays is your agent's configuration, not the caller's claim. + +**One span per task.** A `donkey.a2a.task` SERVER span wraps the handler, so the +`donkey.llm.chat` spans of its model calls are its children. If the caller sends +a W3C `traceparent`, the task span joins the caller's trace. The span carries: + +| Attribute | Value | +|---|---| +| `donkey.correlation_id` | the task's run ID | +| `donkey.correlation_id.source` | `adopted` (the caller sent it) or `minted` | +| `donkey.a2a.agent` | the `@donkey.agent` name | +| `donkey.a2a.task_id`, `donkey.a2a.context_id` | the A2A IDs | +| `error.type` | the exception type, on a failed task only | + +A failed task marks the span as an error with the exception's type only, never +its message. With `telemetry` off, no span is opened, but the run is still bound. + +**Typed refusals, as a well-formed A2A failure.** When a downstream call is +refused, the framework's wrapped error is re-raised as its typed `DonkeyError` +first (the [typed-refusal bridge](https://docs.donkey-kit.dev/errors.md)). The task then fails with a status +message of two parts: a text part, and an `error` data part the caller can +branch on: + +```json +{ + "state": "TASK_STATE_FAILED", + "message": { + "parts": [ + {"text": "the agent's downstream call was refused: PIIDetected"}, + {"data": {"error": { + "type": "PIIDetected", + "refusal": true, + "correlation_id": "caller-run-42", + "call_id": "9f1c0e…" + }}} + ] + } +} +``` + +`refusal` is `true` for a governance refusal (a `PolicyViolation`), and `false` +for any other failure. Any other failure reads `the agent failed: ` +and carries the same `error` part. `call_id` appears when the failure was a +governed call's. The refusal's `remediation` and the gateway's response body go +to your logs, not to the caller: they name your configuration and can repeat +the request's content. + +`donkey_kit.a2a.build_app()` does the same for an app you mount yourself. Pass +`telemetry=`, `correlation_header=` and `refusals=donkey_kit.typed_refusals()` to +match `donkey.serve()`, which fills them from the client's configuration. + + **Not inbound enforcement.** Nothing here blocks an inbound task. Inbound + policy (auth, rate limits, PII screening of the caller's message) is the + gateway's job, in front of the agent. ## `donkey expose` — the ingress, registered from code Roadmap