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