diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d6aa33..c680fd3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,12 +240,13 @@ jobs: # green without starlette/uvicorn). [otel] likewise: the GenAI-span # assertions (BG §1.6, #192) importorskip opentelemetry.sdk, so they only # actually execute in this job — the typecheck job stays [llm,cli] and - # relies on the absence-only opentelemetry mypy override. + # relies on the absence-only opentelemetry mypy override. [a2a] the same + # way: donkey.serve()'s round-trip tests (#224) importorskip a2a. # -c locks the resolve per Python version (ADR 0007 rule 3, #948); # refresh with scripts/compile_constraints.py (combo "test"). - run: >- python -m pip install --upgrade pip && - pip install -e ".[llm,cli,local,otel]" --group dev -c "constraints/test-py${PYTHON_VERSION}.txt" + pip install -e ".[llm,cli,local,otel,a2a]" --group dev -c "constraints/test-py${PYTHON_VERSION}.txt" - if: github.event_name == 'push' && steps.pip-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ca776bf5..4bbe8052 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -36,7 +36,7 @@ jobs: python-version: "3.11" - run: >- python -m pip install --upgrade pip && - pip install -e ".[llm,cli,local,otel]" --group dev -c constraints/test-py3.11.txt + pip install -e ".[llm,cli,local,otel,a2a]" --group dev -c constraints/test-py3.11.txt - run: pytest -q build: diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index e3163618..b3632896 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -40,7 +40,7 @@ jobs: python-version: "3.11" - run: >- python -m pip install --upgrade pip && - pip install -e ".[llm,cli,local,otel]" --group dev -c constraints/test-py3.11.txt + pip install -e ".[llm,cli,local,otel,a2a]" --group dev -c constraints/test-py3.11.txt - run: pytest -q build: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index edffdd9b..af45a19c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,9 +117,15 @@ bottom: cli the `donkey` console script (#730) conformance → simulator → _testing dev-only siblings donkey | experimental experimental: non-first-class names (ADR 0008) -integrations → tools → registry → llm → core +integrations | a2a → tools → registry → llm → core ``` +`a2a` (`donkey.serve()`, BG §2.9.1, #224) sits beside `integrations`, not in +it: it is the inbound A2A listener, not a framework adapter. It wraps the +official `a2a-sdk` (the `[a2a]` extra). The layers contract lists the two as +independent siblings (`integrations | a2a`), so neither may import the other +(ADR 0014). + It is `exhaustive`, so a new top-level module fails `lint-imports` until it is placed. Its one ignored import is `Donkey.simulate()`'s lazy import of `simulator.inject`: public API whose implementation lives in the simulator. diff --git a/README.md b/README.md index b41312b0..173cd40f 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,10 @@ pip install -e ".[llm,cli]" --group dev Extras are one per framework (`langgraph`, `adk`, `strands`, `agent_framework`, `openai-agents`, `anthropic`, `crewai`, `llamaindex`) plus `otel`, `cli`, `local`, -`test` (the [conformance pytest plugin](https://docs.donkey-kit.dev/testing) — +`a2a` (the official `a2a-sdk`, for `donkey.serve()`), `test` (the [conformance pytest plugin](https://docs.donkey-kit.dev/testing) — `pytest --donkey-conformance --donkey-agent=my_app.agent:build`), and `all`. `all` is everything a user runs that installs together — `llm`, `langgraph`, `otel`, `cli`, -`local` — with no test runner, so add `test` for the conformance plugin: +`local`, `a2a` — with no test runner, so add `test` for the conformance plugin: `donkey-kit[all,test]`. It leaves out the seven other framework extras, because whether they install together depends on their upstream releases. The generated [co-installability table](docs/co-installability.md) shows which combinations resolve, re-checked diff --git a/docs/adr/0014-a2a-server-package.md b/docs/adr/0014-a2a-server-package.md new file mode 100644 index 00000000..4f35d7b7 --- /dev/null +++ b/docs/adr/0014-a2a-server-package.md @@ -0,0 +1,84 @@ +# ADR 0014: `donkey.serve()` wraps the official a2a-sdk, in a top-level `a2a` package beside `integrations` + +- **Status:** Proposed +- **Date:** 2026-10-24 +- **Issue:** #224 (BG §2.9.1). Governed inbound is #225, `donkey expose` #226, + `donkey dev` #227. + +## Context + +BG §2.9.1 asks for an A2A listener in one line: `donkey.serve(handler)`. Today, +accepting a task from a partner agent means writing a server and an agent card +by hand, adding logging and tracing, and then getting a certificate, opening a +port and registering the agent. + +The A2A protocol already has an official Python implementation, the `a2a-sdk`. +It provides a request handler, a task store, the task lifecycle and the +Starlette routes. The build plan's *Do not build* list rules out a home-grown +protocol implementation. DDK's value is what a bare `a2a-sdk` server lacks: + +- an agent card generated from the `@donkey.agent` record that `donkey scan` + also reads (#217), so the two cannot drift; +- a localhost bind by default, so the gateway is the public face; +- (#225) inbound correlation, cost tags, tracing and typed refusals. + +The `layered architecture (§1.1)` contract in `python/pyproject.toml` is +`exhaustive`, so a new top-level module must be placed in it. `integrations/` +holds one adapter per agent framework, each returning native framework objects +(ADR 0004). An A2A server is not a framework adapter: it is not in the +`ADAPTERS` roster and returns no framework object. + +## Decision + +1. **The protocol is the `a2a-sdk`'s, never DDK's.** `donkey_kit.a2a` wraps + `AgentExecutor`, `DefaultRequestHandler`, `InMemoryTaskStore`, + `TaskUpdater`, `create_agent_card_routes`, `create_jsonrpc_routes` and + `AgentCard`, and reimplements none of them. Every wrapped name is a + `docs/verified-apis.md` §8.5 row. Enforced in review. +2. **The card comes only from the `@donkey.agent` record.** Its name, + description and skills are the `AgentSpec`'s name, docstring and skills. + An unmarked handler raises `TypeError`. Enforced by + `tests/unit/test_a2a_serve.py`. +3. **`donkey.serve()` binds to `127.0.0.1` by default.** Binding elsewhere + takes an explicit `host=`. Enforced by `tests/unit/test_a2a_serve.py`. +4. **`a2a` is a top-level package and an independent sibling of + `integrations`** (`"integrations | a2a"` in the layers contract). Neither + may import the other. `core` must never import `a2a` (the core + `forbidden` contract). Enforced by `lint-imports`. +5. **The `a2a-sdk` loads only when a server is built.** It is the `[a2a]` + extra (`a2a-sdk[http-server]`, `uvicorn`), imported inside `build_app()` + and `serve()`. `import donkey_kit` and `import donkey_kit.a2a` never load + it. A missing extra raises an `ImportError` that names + `pip install "donkey-kit[a2a]"`. The extra may bring pydantic and protobuf, + which `core` forbids; that is allowed because nothing below `a2a` imports + them. Enforced by `tests/unit/test_a2a_serve.py`. +6. **A handler failure fails the task, never the server.** The caller sees + only the exception's type name; the message goes to the + `donkey_kit.a2a._server` logger. Enforced by `tests/unit/test_a2a_serve.py`. + +### Alternatives considered + +- **Have users call the `a2a-sdk` directly.** Rejected: they get a bare + server, with a hand-written card that can drift from the `@donkey.agent` + marker, anonymous inbound tasks and a bind address left to them. The + differentiator is governed inbound and one-command exposure, not the + protocol. +- **Put the server in `integrations/a2a.py`.** Rejected: it would sit in the + adapter roster's package without being an adapter, and the + `integrations are mutually independent` contract and the `ADAPTERS` roster + tests assume every module there is one. +- **Implement the A2A protocol in DDK.** Rejected by the build plan's *Do not + build* list: the `a2a-sdk` tracks the spec, and a second implementation + would drift from it. + +## Consequences + +- `python/pyproject.toml` gains the `[a2a]` extra (also in `[all]`), the + layers placement, the core `forbidden` entry and the mypy `a2a.*` skip. + `dependency_allowlist.toml` gains `a2a-sdk`. +- The PR `test` matrix installs `[a2a]`, so the round-trip tests run there; the + base-only and typecheck jobs skip them through `importorskip`. +- `docs/verified-apis.md` §8.3 records the `a2a-sdk>=1.2.0` floor and §8.5 the + wrapped names. A new `a2a-sdk` major means re-verifying §8.5. +- #225 adds inbound governance to the executor this ADR introduces; it does not + change the package's place in the stack. diff --git a/docs/adr/README.md b/docs/adr/README.md index d2a7e43c..20289a46 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -85,3 +85,4 @@ is not rewritten. | [0011](0011-nightly-lock-refresh-pr.md) | A green nightly opens the lock-refresh PR, and the release toolchain is locked too | Accepted | #763 | | [0012](0012-custom-policy-registry.md) | Customer-registered refusal shapes, in one process-wide registry behind a hook slot | Accepted | #210 | | [0013](0013-ungoverned-mcp-binding.md) | Binding refuses an MCP server that is not governed, with no opt-out | Accepted | #1150 | +| [0014](0014-a2a-server-package.md) | `donkey.serve()` wraps the official a2a-sdk, in a top-level `a2a` package beside `integrations` | Proposed | #224 | diff --git a/docs/verified-apis.md b/docs/verified-apis.md index a1e51514..8cb75ddb 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -786,6 +786,7 @@ offline evidence, not a live round-trip. | `crewai` | `crewai>=1.15.3` | 1.15.3 added the `openai/` + explicit `base_url` route to the native `OpenAICompletion`. Earlier releases fall back to LiteLLM for any id that does not look like an OpenAI model, returning `crewai.LLM` and LiteLLM exceptions. Signature check against `OpenAICompletion` passes at 1.15.3. | CrewAI | | `llamaindex` | `llama-index-llms-openai-like>=0.8` | The verified release. | LlamaIndex | | `strands` | `strands-agents[openai]>=1.57.1` | The verified release, with Strands' own `openai` extra: the bare distribution does not install `openai`, so `model()` raised `ModuleNotFoundError`. `test_strands_real_model_is_built_and_called_through_our_transport` builds and calls the real model with only `[strands]` installed. | Strands | +| `a2a` | `a2a-sdk[http-server]>=1.2.0`, `uvicorn>=0.30` | 1.2.0 is the first a2a-sdk with `DefaultRequestHandler.aclose()`, which `build_app()`'s lifespan calls so a shutdown drains the SDK's per-task asyncio tasks (1.0.3 and 1.1.0 serve tasks but have no `aclose()`; without it, the tasks are still pending when the loop closes). Below 1.0.3, 1.0.1–1.0.2 lack `a2a.helpers.new_text_part` and 1.0.0 fails to import without `grpc`. Lowest-direct on Python 3.10.21 installs a2a-sdk 1.2.0 and uvicorn 0.30.0 and passes `pytest tests/unit`, including `test_a2a_serve.py` (#224). | §8.5 | | `cli` / dev group | `typer>=0.26.0`, `rich>=13.8.0` | Every typer up to 0.25.1 drops a `typer.Argument(help=...)` against click 8.5+, so `donkey scan --help` and the CLI reference lose the `PATH` description (#1196, bisected with `click==8.5.0`: 0.15.4–0.25.1 drop it, 0.26.0–0.27.3 keep it). 0.26.0 vendors click (fastapi/typer#1774). Older floors: below 0.15.4 typer breaks against a fresh click 8.2+ (`make_metavar() missing … 'ctx'`), and below 0.13 it rejects `Path \| None` options. rich 13.8.0 is typer 0.26.0's own floor, so `[cli]`'s old `rich>=13.0` could never be installed. | — | | `test` / dev group | `pytest>=8.0`, `pytest-asyncio>=0.23.1` | pytest-asyncio 0.23.0 crashes collection (`INTERNALERROR`) on a module-level `importorskip`; 0.23.1 runs the suite. pytest 8.0.0 is fine. | — | @@ -839,6 +840,32 @@ Conformance-tested. | LlamaIndex | `donkey.llamaindex.llm()` | LlamaIndex | `/frameworks/llamaindex` | +### 8.5 A2A server classes (BG §2.9.1) + +`donkey.serve()` wraps these `a2a-sdk` names and reimplements none of them +(#224). Each name below was read from the installed `a2a-sdk` 1.2.3, and the +whole path was run twice. First, in process over `httpx.ASGITransport` +(`tests/unit/test_a2a_serve.py`, also at the 1.2.0 floor). Second, on a real +port: `donkey.serve()` under uvicorn 0.54.0 on `127.0.0.1:8765`, called with +plain `curl` and no SDK on the caller side, which got back the generated card +and a `TASK_STATE_COMPLETED` task with its text artifact. That round trip is +local; no gateway fronted the agent. + +| Symbol | Used for | Status | Source | +|---|---|---|---| +| `a2a.server.agent_execution.AgentExecutor` (`execute`, `cancel`) | The base class of the executor that runs the handler | VERIFIED (local round-trip) | a2a-sdk 1.2.3, 2026-10-24, #224 | +| `a2a.server.agent_execution.RequestContext` (`.message`, `.current_task`, `.task_id`, `.context_id`) | Reading the caller's message and IDs | VERIFIED (local round-trip) | same | +| `a2a.server.events.EventQueue` | Passed to `TaskUpdater`; the executor enqueues a `Task` before any status update, or the SDK rejects the event | VERIFIED (local round-trip) | same | +| `a2a.server.tasks.TaskUpdater` (`start_work`, `add_artifact`, `complete`, `failed`, `cancel`) | The task lifecycle | VERIFIED (local round-trip) | same | +| `a2a.server.tasks.InMemoryTaskStore` | The task store | VERIFIED (local round-trip) | same | +| `a2a.server.request_handlers.DefaultRequestHandler(agent_executor=, task_store=, agent_card=)`, `.aclose()` | The JSON-RPC request handler; `aclose()` (1.2.0+) runs on lifespan shutdown | VERIFIED (local round-trip) | same | +| `a2a.server.routes.create_agent_card_routes(card)`, `create_jsonrpc_routes(handler, "/")` | The card at `/.well-known/agent-card.json` and JSON-RPC at `/` (method `SendMessage`, header `A2A-Version: 1.0`) | VERIFIED (local round-trip) | same | +| `a2a.types.AgentCard` | The card, built from the `@donkey.agent` record | VERIFIED (local round-trip) | same | +| `a2a.helpers` (`new_task_from_user_message`, `new_text_part`, `new_data_part`, `new_text_message`, `get_text_parts`, `get_data_parts`) | Building and reading message parts | VERIFIED (local round-trip) | same | + +Which A2A traffic Omni Gateway can front, and how, is not covered here: that is +`donkey expose` (#226). + ## 9. MCP tool binding classes (BG §2.7) — verify each name | Framework | Binding class | Status | Source | diff --git a/python/README.md b/python/README.md index 92b3b7e7..7972baa4 100644 --- a/python/README.md +++ b/python/README.md @@ -112,10 +112,10 @@ pip install -e ".[llm,cli]" --group dev Extras are one per framework (`langgraph`, `adk`, `strands`, `agent_framework`, `openai-agents`, `anthropic`, `crewai`, `llamaindex`) plus `otel`, `cli`, `local`, -`test` (the [conformance pytest plugin](https://docs.donkey-kit.dev/testing) — +`a2a` (the official `a2a-sdk`, for `donkey.serve()`), `test` (the [conformance pytest plugin](https://docs.donkey-kit.dev/testing) — `pytest --donkey-conformance --donkey-agent=my_app.agent:build`), and `all`. `all` is everything a user runs that installs together — `llm`, `langgraph`, `otel`, `cli`, -`local` — with no test runner, so add `test` for the conformance plugin: +`local`, `a2a` — with no test runner, so add `test` for the conformance plugin: `donkey-kit[all,test]`. It leaves out the seven other framework extras, because whether they install together depends on their upstream releases. The generated [co-installability table](https://github.com/Donkey-Development-Kit/donkey-development-kit/blob/main/docs/co-installability.md) shows which combinations resolve, re-checked diff --git a/python/constraints/test-py3.10.txt b/python/constraints/test-py3.10.txt index 3306aa8d..d695a36b 100644 --- a/python/constraints/test-py3.10.txt +++ b/python/constraints/test-py3.10.txt @@ -3,16 +3,23 @@ # Covers ci.yml job(s): test (matrix) # See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. # +a2a-sdk==1.2.3 +aiologic==0.17.1 annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 ast-serialize==0.13.1 backports-asyncio-runner==1.2.0 certifi==2026.10.23 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 coverage==7.16.2 +cryptography==50.0.2 +culsans==0.11.0 exceptiongroup==1.3.1 +google-api-core==2.43.0 +google-auth==2.62.0 googleapis-common-protos==1.76.0 grimp==3.17 h11==0.16.0 @@ -24,6 +31,7 @@ idna==3.20 import-linter==2.15 iniconfig==2.3.1 jiter==0.18.0 +json-rpc==1.15.0 librt==0.16.0 markdown-it-py==4.2.0 mdurl==0.1.2 @@ -41,7 +49,11 @@ opentelemetry-semantic-conventions==0.66b1 packaging==26.3 pathspec==1.1.1 pluggy==1.7.0 +proto-plus==1.29.0 protobuf==7.36.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pycparser==3.11 pydantic==2.14.1 pydantic-core==2.50.1 pygments==2.21.0 @@ -57,6 +69,7 @@ rich==15.0.0 ruff==0.17.2 shellingham==1.5.4 sniffio==1.3.1 +sse-starlette==3.5.1 starlette==1.7.0 tomli==2.5.0 truststore==0.10.5 @@ -66,3 +79,4 @@ typing-inspection==0.4.4 urllib3==2.8.0 uvicorn==0.54.0 vulture==2.16 +wrapt==2.5.1 diff --git a/python/constraints/test-py3.11.txt b/python/constraints/test-py3.11.txt index f9062df7..51bb8ddd 100644 --- a/python/constraints/test-py3.11.txt +++ b/python/constraints/test-py3.11.txt @@ -3,14 +3,21 @@ # Covers ci.yml job(s): test (matrix) # See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. # +a2a-sdk==1.2.3 +aiologic==0.17.1 annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 ast-serialize==0.13.1 certifi==2026.10.23 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 coverage==7.17.0 +cryptography==50.0.2 +culsans==0.11.0 +google-api-core==2.43.0 +google-auth==2.62.0 googleapis-common-protos==1.76.0 grimp==3.17 h11==0.16.0 @@ -22,6 +29,7 @@ idna==3.20 import-linter==2.15 iniconfig==2.3.1 jiter==0.18.0 +json-rpc==1.15.0 librt==0.16.0 markdown-it-py==4.2.0 mdurl==0.1.2 @@ -39,7 +47,11 @@ opentelemetry-semantic-conventions==0.66b1 packaging==26.3 pathspec==1.1.1 pluggy==1.7.0 +proto-plus==1.29.0 protobuf==7.36.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pycparser==3.12 pydantic==2.14.1 pydantic-core==2.50.1 pygments==2.21.0 @@ -55,6 +67,7 @@ rich==15.0.0 ruff==0.17.2 shellingham==1.5.4 sniffio==1.3.1 +sse-starlette==3.5.1 starlette==1.10.0 truststore==0.10.5 typer==0.27.3 @@ -63,3 +76,4 @@ typing-inspection==0.4.4 urllib3==2.8.0 uvicorn==0.54.0 vulture==2.16 +wrapt==2.5.1 diff --git a/python/constraints/test-py3.12.txt b/python/constraints/test-py3.12.txt index b09765e4..fb8e9ab0 100644 --- a/python/constraints/test-py3.12.txt +++ b/python/constraints/test-py3.12.txt @@ -3,14 +3,21 @@ # Covers ci.yml job(s): test (matrix) # See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. # +a2a-sdk==1.2.3 +aiologic==0.17.1 annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 ast-serialize==0.13.1 certifi==2026.10.23 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 coverage==7.17.0 +cryptography==50.0.2 +culsans==0.11.0 +google-api-core==2.43.0 +google-auth==2.62.0 googleapis-common-protos==1.76.0 grimp==3.17 h11==0.16.0 @@ -22,6 +29,7 @@ idna==3.20 import-linter==2.15 iniconfig==2.3.1 jiter==0.18.0 +json-rpc==1.15.0 librt==0.16.0 markdown-it-py==4.2.0 mdurl==0.1.2 @@ -39,7 +47,11 @@ opentelemetry-semantic-conventions==0.66b1 packaging==26.3 pathspec==1.1.1 pluggy==1.7.0 +proto-plus==1.29.0 protobuf==7.36.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pycparser==3.12 pydantic==2.14.1 pydantic-core==2.50.1 pygments==2.21.0 @@ -55,6 +67,7 @@ rich==15.0.0 ruff==0.17.2 shellingham==1.5.4 sniffio==1.3.1 +sse-starlette==3.5.1 starlette==1.10.0 truststore==0.10.5 typer==0.27.3 @@ -63,3 +76,4 @@ typing-inspection==0.4.4 urllib3==2.8.0 uvicorn==0.54.0 vulture==2.16 +wrapt==2.5.1 diff --git a/python/constraints/test-py3.13.txt b/python/constraints/test-py3.13.txt index c07a2027..3b1eb44b 100644 --- a/python/constraints/test-py3.13.txt +++ b/python/constraints/test-py3.13.txt @@ -3,14 +3,19 @@ # Covers ci.yml job(s): test (matrix) # See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. # +a2a-sdk==1.2.3 annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 ast-serialize==0.13.1 certifi==2026.10.23 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 coverage==7.17.0 +cryptography==50.0.2 +google-api-core==2.43.0 +google-auth==2.62.0 googleapis-common-protos==1.76.0 grimp==3.17 h11==0.16.0 @@ -22,6 +27,7 @@ idna==3.20 import-linter==2.15 iniconfig==2.3.1 jiter==0.18.0 +json-rpc==1.15.0 librt==0.16.0 markdown-it-py==4.2.0 mdurl==0.1.2 @@ -39,7 +45,11 @@ opentelemetry-semantic-conventions==0.66b1 packaging==26.3 pathspec==1.1.1 pluggy==1.7.0 +proto-plus==1.29.0 protobuf==7.36.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pycparser==3.12 pydantic==2.14.1 pydantic-core==2.50.1 pygments==2.21.0 @@ -55,6 +65,7 @@ rich==15.0.0 ruff==0.17.2 shellingham==1.5.4 sniffio==1.3.1 +sse-starlette==3.5.1 starlette==1.10.0 truststore==0.10.5 typer==0.27.3 diff --git a/python/constraints/test-py3.14.txt b/python/constraints/test-py3.14.txt index f162f06e..0362bd99 100644 --- a/python/constraints/test-py3.14.txt +++ b/python/constraints/test-py3.14.txt @@ -3,14 +3,19 @@ # Covers ci.yml job(s): test (matrix) # See scripts/compile_constraints.py and docs/adr/0007-dependency-policy.md rule 3. # +a2a-sdk==1.2.3 annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.1 ast-serialize==0.13.1 certifi==2026.10.23 +cffi==2.1.1 charset-normalizer==3.5.2 click==8.5.0 coverage==7.17.0 +cryptography==50.0.2 +google-api-core==2.43.0 +google-auth==2.62.0 googleapis-common-protos==1.76.0 grimp==3.17 h11==0.16.0 @@ -22,6 +27,7 @@ idna==3.20 import-linter==2.15 iniconfig==2.3.1 jiter==0.18.0 +json-rpc==1.15.0 librt==0.16.0 markdown-it-py==4.2.0 mdurl==0.1.2 @@ -39,7 +45,11 @@ opentelemetry-semantic-conventions==0.66b1 packaging==26.3 pathspec==1.1.1 pluggy==1.7.0 +proto-plus==1.29.0 protobuf==7.36.2 +pyasn1==0.6.4 +pyasn1-modules==0.4.2 +pycparser==3.12 pydantic==2.14.1 pydantic-core==2.50.1 pygments==2.21.0 @@ -55,6 +65,7 @@ rich==15.0.0 ruff==0.17.2 shellingham==1.5.4 sniffio==1.3.1 +sse-starlette==3.5.1 starlette==1.10.0 truststore==0.10.5 typer==0.27.3 diff --git a/python/dependency_allowlist.toml b/python/dependency_allowlist.toml index 06b42b6e..2aca1594 100644 --- a/python/dependency_allowlist.toml +++ b/python/dependency_allowlist.toml @@ -82,9 +82,15 @@ reason = "[local]: the pure-Python ASGI app donkey mock serves." reviewed = 2026-10-03 [packages.uvicorn] -reason = "[local]: serves the simulator's ASGI app (plain uvicorn, no C extensions)." +reason = "[local] and [a2a]: serves the simulator's and donkey.serve()'s ASGI apps (plain uvicorn, no C extensions)." reviewed = 2026-10-03 +# --- [a2a]: donkey.serve(), the inbound A2A listener (BG §2.9.1, #224) --- + +[packages.a2a-sdk] +reason = "[a2a]: the official A2A protocol SDK donkey.serve() wraps; its request handler, task store and routes do the A2A work (ADR 0014)." +reviewed = 2026-10-24 + # --- [otel]: GenAI spans and zero-config export (BG §1.6) --- [packages.opentelemetry-api] diff --git a/python/noxfile.py b/python/noxfile.py index 1570ac45..a6e1445e 100644 --- a/python/noxfile.py +++ b/python/noxfile.py @@ -99,7 +99,7 @@ def _drop_nox_tool_env_from_path() -> None: "commit-identities": None, "test": ( "python -m pip install --upgrade pip && " - 'pip install -e ".[llm,cli,local,otel]" --group dev ' + 'pip install -e ".[llm,cli,local,otel,a2a]" --group dev ' '-c "constraints/test-py${PYTHON_VERSION}.txt"' ), "openai-1x": ( diff --git a/python/pyproject.toml b/python/pyproject.toml index e6cbb9f1..5b8bb7d6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -98,6 +98,14 @@ strands = ["strands-agents[openai]>=1.57.1"] # pure-Python promise. Floors only, no upper pins. local = ["starlette>=0.37", "uvicorn>=0.30"] +# --- A2A server (BG §2.9.1): `donkey.serve()` --- +# The official a2a-sdk serves the protocol, never a home-grown one (#224). Its +# `http-server` extra brings starlette and sse-starlette; uvicorn runs the app, +# plain as in [local]. 1.2.0 is the lowest release with +# DefaultRequestHandler.aclose(), which build_app()'s lifespan calls to drain +# in-flight tasks on shutdown; see docs/verified-apis.md §8.3. +a2a = ["a2a-sdk[http-server]>=1.2.0", "uvicorn>=0.30"] + # --- Telemetry (BG §1.6) --- # The OTLP exporter is what makes zero-config export real (#194): with it and # `OTEL_EXPORTER_OTLP_ENDPOINT` set, spans flow to the customer's own sink with no @@ -142,7 +150,7 @@ test = ["pytest>=8.0", "pytest-asyncio>=0.23.1"] # docs/co-installability.md table, re-checked nightly (#769). See # docs/verified-apis.md §8.1. all = [ - "donkey-kit[llm,langgraph,otel,cli,local]", + "donkey-kit[llm,langgraph,otel,cli,local,a2a]", ] # Contributor tooling is a PEP 735 dependency group, not an extra, so it stays @@ -287,6 +295,9 @@ module = [ # public return type is a framework-free Protocol, so no Any leaks out. "starlette.*", "uvicorn.*", + # [a2a] — the same: build_app() imports the a2a-sdk inside its body and + # returns Any, so none of its types leak into the public signatures. + "a2a.*", # Never imported by src/, but reachable through a framework's own imports. "numpy.*", ] @@ -316,6 +327,7 @@ ignore_missing_imports = true # The Strands adapter's model subclasses the OpenAIModel class it is handed (#951), # and the ADK adapter's llm_client the LiteLLMClient class it is handed (#969). module = [ + "donkey_kit.a2a._server", "donkey_kit.core.transport.httpx2", "donkey_kit.integrations.adk", "donkey_kit.integrations.crewai", @@ -498,7 +510,10 @@ type = "layers" # directly or indirectly (#729). The production stack is donkey → integrations → # tools → registry → llm → core, with experimental (#730) beside donkey: it # re-exports registry and core names that are not first-class API (ADR 0008, -# docs/adr/0008-*.md), and neither of the two imports the other. Above it: +# docs/adr/0008-*.md), and neither of the two imports the other. a2a (the +# donkey.serve() A2A server, ADR 0014) sits beside integrations: it wraps a +# third-party protocol library like an adapter does, and the two are +# independent. Above it: # - cli: the `donkey` console script (#730). It may import any layer, and being # on top means no library module imports it back (typer stays off the base # install). @@ -514,7 +529,7 @@ layers = [ "simulator", "_testing", "donkey | experimental", - "integrations", + "integrations | a2a", "tools", "registry", "llm", @@ -566,6 +581,7 @@ type = "forbidden" # package import (pinned by test_import_donkey_kit_loads_only_the_production_layers). source_modules = ["donkey_kit.core"] forbidden_modules = [ + "a2a", "agent_framework", "agents", "anthropic", diff --git a/python/scripts/compile_constraints.py b/python/scripts/compile_constraints.py index 87a07ab6..cf5d8176 100644 --- a/python/scripts/compile_constraints.py +++ b/python/scripts/compile_constraints.py @@ -123,7 +123,7 @@ def file_name(self, python_version: str) -> str: Combo("typecheck-and-lint", ("llm", "cli"), True, ("3.11",), "typecheck-and-lint"), Combo( "test", - ("llm", "cli", "local", "otel"), + ("llm", "cli", "local", "otel", "a2a"), True, ("3.10", "3.11", "3.12", "3.13", "3.14"), "test (matrix)", diff --git a/python/src/donkey_kit/__init__.py b/python/src/donkey_kit/__init__.py index bd786f97..28803ca0 100644 --- a/python/src/donkey_kit/__init__.py +++ b/python/src/donkey_kit/__init__.py @@ -30,6 +30,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from .a2a.types import A2AResult, A2ATask from .core.approvals import ( Approval, ApprovalRejected, @@ -77,6 +78,8 @@ # every pytest run via its pytest11 entry point, so an eager import here would # pull the whole SDK into every test session in the environment (#746). _LAZY = { + "A2AResult": ".a2a.types", + "A2ATask": ".a2a.types", "AgentKilled": ".core.errors", "Approval": ".core.approvals", "ApprovalRequired": ".core.approvals", @@ -187,6 +190,8 @@ def __dir__() -> list[str]: __version__ = "0.2.0.dev1" __all__ = [ + "A2AResult", + "A2ATask", "AgentKilled", "AgentSpec", "Approval", diff --git a/python/src/donkey_kit/a2a/__init__.py b/python/src/donkey_kit/a2a/__init__.py new file mode 100644 index 00000000..49ff25c1 --- /dev/null +++ b/python/src/donkey_kit/a2a/__init__.py @@ -0,0 +1,21 @@ +"""Serve a ``@donkey.agent`` over A2A, wrapping the official ``a2a-sdk`` +(`BG §2.9.1`, #224). + +The protocol is the ``a2a-sdk``'s, never reimplemented here: its request +handler, task store, task lifecycle and routes do the A2A work, and this package +adds what a bare server lacks. The agent card comes from the same +``@donkey.agent`` record ``donkey scan`` reads, so the two cannot drift, and the +listener binds to localhost unless told otherwise, so the gateway, not the +agent, is the public face. + +``import donkey_kit.a2a`` needs no extra: the ``a2a-sdk`` loads only when +:func:`build_app` or :func:`serve` (``Donkey.serve()``) runs, and a missing ``[a2a]`` extra +raises an ``ImportError`` naming the ``pip install`` command. +""" + +from __future__ import annotations + +from ._server import build_app, serve +from .types import A2AResult, A2ATask + +__all__ = ["A2AResult", "A2ATask", "build_app", "serve"] diff --git a/python/src/donkey_kit/a2a/_server.py b/python/src/donkey_kit/a2a/_server.py new file mode 100644 index 00000000..2addd2e4 --- /dev/null +++ b/python/src/donkey_kit/a2a/_server.py @@ -0,0 +1,233 @@ +"""The A2A server behind ``donkey.serve()`` (`BG §2.9.1`, #224). + +Every A2A behaviour here is the official ``a2a-sdk``'s: ``DefaultRequestHandler`` +runs the task lifecycle over an ``InMemoryTaskStore``, ``TaskUpdater`` publishes +the status and artifact events, and ``create_agent_card_routes`` / +``create_jsonrpc_routes`` serve the card and the JSON-RPC endpoint. Never +reimplement any of it here (the build plan's *Do not build* list). The class +names were read from the installed ``a2a-sdk`` (``docs/verified-apis.md`` §8.5). + +The ``a2a-sdk`` is imported inside :func:`build_app`, so importing this module +needs no ``[a2a]`` extra; only building the server does. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator, Awaitable, Callable, MutableMapping +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, Protocol + +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 + +__all__ = ["build_app", "serve"] + +_log = logging.getLogger(__name__) + +# The media types a served agent accepts and produces: text parts and data parts, +# the two A2ATask and A2AResult carry. +_MODES = ("text/plain", "application/json") + + +class _ASGIApp(Protocol): + """The ASGI callable :func:`build_app` returns, so callers + (``httpx.ASGITransport``, ``uvicorn.run``, your own server) and ``mypy`` + never see a ``starlette`` or ``a2a-sdk`` type.""" + + async def __call__( + self, + scope: MutableMapping[str, Any], + receive: Callable[[], Awaitable[MutableMapping[str, Any]]], + send: Callable[[MutableMapping[str, Any]], Awaitable[None]], + ) -> None: + """Handle one ASGI connection.""" + ... + + +def _missing_extra(exc: ImportError) -> ImportError: + detail = f" (no module named {exc.name!r})" if exc.name else "" + return ImportError( + f"donkey.serve() needs the 'a2a' extra{detail}. Install it with:\n" + ' pip install "donkey-kit[a2a]"' + ) + + +def _spec_of(handler: Callable[..., Any]) -> AgentSpec: + spec = agent_spec(handler) + if spec is None: + name = getattr(handler, "__qualname__", repr(handler)) + raise TypeError( + f"{name} is not marked with @donkey.agent. The agent card is generated " + 'from that marker, so add it: @donkey.agent(name="my-agent", skills=[...]).' + ) + return spec + + +def _card_fields(spec: AgentSpec, *, url: str, version: str) -> dict[str, Any]: + """The agent card, from the ``@donkey.agent`` record and nothing else.""" + return { + "name": spec.name, + "description": spec.docstring, + "version": version, + "supported_interfaces": [ + {"url": url, "protocol_binding": "JSONRPC", "protocol_version": "1.0"} + ], + "capabilities": {"streaming": False}, + "default_input_modes": list(_MODES), + "default_output_modes": list(_MODES), + "skills": [ + {"id": skill, "name": skill, "description": skill, "tags": [skill]} + for skill in spec.skills + ], + } + + +async def _run(spec: AgentSpec, task: A2ATask) -> object: + if spec.is_async: + return await spec.func(task) + return await asyncio.to_thread(spec.func, task) + + +def build_app( + handler: Callable[..., Any], + *, + url: str = "http://127.0.0.1:8000/", + version: str = "0.0.0", +) -> _ASGIApp: + """Build the A2A server for a ``@donkey.agent`` handler as an ASGI app. + + ``Donkey.serve()`` runs this app on a port; call it directly to mount the + agent in your own ASGI server or to test it in process with + ``httpx.ASGITransport``. + + 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. + + 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. + + Returns: + A Starlette application serving the agent card at + ``/.well-known/agent-card.json`` and JSON-RPC at ``/``. Run it inside + its ASGI lifespan, as ``uvicorn`` does, so shutdown drains in-flight + tasks. + + Raises: + TypeError: ``handler`` is not marked with ``@donkey.agent``. + ImportError: The ``[a2a]`` extra is not installed. + + Docs: https://docs.donkey-kit.dev/a2a + """ + spec = _spec_of(handler) + try: + from a2a.helpers import ( + get_data_parts, + get_text_parts, + new_data_part, + new_task_from_user_message, + new_text_message, + new_text_part, + ) + from a2a.server.agent_execution import AgentExecutor + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes + from a2a.server.tasks import InMemoryTaskStore, TaskUpdater + from a2a.types import AgentCard + from starlette.applications import Starlette + except ImportError as exc: + raise _missing_extra(exc) from exc + + class _Executor(AgentExecutor): + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + message = context.message + if context.current_task is None: + await event_queue.enqueue_event(new_task_from_user_message(message)) + 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, + ) + 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, + task_id=context.task_id, + ) + ) + return + if result.text is not None: + await updater.add_artifact([new_text_part(result.text)]) + if result.data is not None: + await updater.add_artifact([new_data_part(result.data)]) + await updater.complete() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + updater = TaskUpdater(event_queue, context.task_id, context.context_id) + await updater.cancel() + + card = AgentCard(**_card_fields(spec, url=url, version=version)) + handler_ = DefaultRequestHandler( + agent_executor=_Executor(), task_store=InMemoryTaskStore(), agent_card=card + ) + routes = [*create_agent_card_routes(card), *create_jsonrpc_routes(handler_, "/")] + + @asynccontextmanager + async def lifespan(_app: object) -> AsyncIterator[None]: + # The a2a-sdk keeps per-task asyncio tasks alive past the response; + # draining them on shutdown leaves none pending when the loop closes. + yield + await handler_.aclose() + + app: _ASGIApp = Starlette(routes=routes, lifespan=lifespan) + return app + + +def serve( + handler: Callable[..., Any], + *, + host: str = "127.0.0.1", + port: int = 8000, + url: str | None = None, + version: str = "0.0.0", +) -> None: + """Serve a ``@donkey.agent`` handler over A2A on ``host:port`` via uvicorn, + blocking until the server stops. ``Donkey.serve()`` documents the arguments. + + Raises: + TypeError: ``handler`` is not marked with ``@donkey.agent``. + ImportError: The ``[a2a]`` extra is not installed. + + Docs: https://docs.donkey-kit.dev/a2a + """ + app = build_app(handler, url=url or f"http://{host}:{port}/", version=version) + try: + import uvicorn + except ImportError as exc: + raise _missing_extra(exc) from exc + uvicorn.run(app, host=host, port=port) diff --git a/python/src/donkey_kit/a2a/types.py b/python/src/donkey_kit/a2a/types.py new file mode 100644 index 00000000..5d18a87e --- /dev/null +++ b/python/src/donkey_kit/a2a/types.py @@ -0,0 +1,50 @@ +"""The values a served agent receives and returns (`BG §2.9.1`, #224). + +Frozen dataclasses (ADR 0001) with no ``a2a-sdk`` import, so naming them in a +type hint needs no ``[a2a]`` extra. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +__all__ = ["A2AResult", "A2ATask"] + + +@dataclass(frozen=True) +class A2ATask: + """One A2A task handed to a ``donkey.serve()`` handler. + + Attributes: + input: The text parts of the caller's message, joined by newlines; empty + when the message carries no text. + data: The structured (data) parts of the message, in order. + task_id: The A2A task ID the server assigned. + 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). + """ + + input: str + data: tuple[Any, ...] + task_id: str + context_id: str + message: Any + + +@dataclass(frozen=True) +class A2AResult: + """What a handler returns when a plain string is not enough. + + A handler may return a ``str`` instead, which is the same as + ``A2AResult(text=...)``. Each field that is set becomes one artifact on the + completed task. + + Attributes: + text: A text artifact. + data: A structured artifact: anything JSON-serialisable. + """ + + text: str | None = None + data: Any = None diff --git a/python/src/donkey_kit/donkey.py b/python/src/donkey_kit/donkey.py index 7729625d..a9ba2cc4 100644 --- a/python/src/donkey_kit/donkey.py +++ b/python/src/donkey_kit/donkey.py @@ -856,6 +856,54 @@ def decorate(fn: _Callable) -> _Callable: return decorate + def serve( + self, + handler: Callable[..., Any], + *, + host: str = "127.0.0.1", + port: int = 8000, + url: str | None = None, + version: str = "0.0.0", + ) -> None: + """Serve a ``@donkey.agent`` handler over A2A on ``host:port``, blocking + until the server stops (`BG §2.9.1`, #224):: + + @donkey.agent(name="support-triage", skills=["triage"]) + async def handle(task: A2ATask) -> str: + \"\"\"Triage a support ticket.\"\"\" + return await graph.ainvoke(task.input) + + donkey.serve(handle) # card at /.well-known/agent-card.json + + 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 + same server as an ASGI app, and :func:`donkey_kit.a2a.serve` is this + method as a function. + + Args: + handler: A callable marked with ``@donkey.agent``. It takes an + :class:`~donkey_kit.a2a.A2ATask` and returns a ``str`` or an + :class:`~donkey_kit.a2a.A2AResult`. + host: The interface to bind. Pass ``"0.0.0.0"`` only when the + gateway reaches the agent over the network. + port: The port to bind. + url: The URL the card advertises; defaults to + ``http://:/``. Pass the gateway's URL when one + fronts the agent. + version: The agent version the card advertises. + + Raises: + TypeError: ``handler`` is not marked with ``@donkey.agent``. + ImportError: The ``[a2a]`` extra is not installed. + + Docs: https://docs.donkey-kit.dev/a2a + """ + from .a2a import serve + + serve(handler, host=host, port=port, url=url, version=version) + @property def approvals(self) -> Approvals: """The pending tool approvals of this process (`BG §2.3`): resolve or diff --git a/python/tests/unit/test_a2a_serve.py b/python/tests/unit/test_a2a_serve.py new file mode 100644 index 00000000..09aef185 --- /dev/null +++ b/python/tests/unit/test_a2a_serve.py @@ -0,0 +1,187 @@ +"""``donkey.serve()`` — the A2A listener wrapping the official ``a2a-sdk`` +(BG §2.9.1, #224). + +Every round trip is raw JSON-RPC over ``httpx.ASGITransport``: the caller uses +no ``a2a-sdk`` client, so these tests pin the wire contract a partner agent in +any language sees, not just what the SDK's own client tolerates. +""" + +from __future__ import annotations + +import builtins +import logging +import subprocess +import sys +from typing import Any + +import httpx +import pytest + +pytest.importorskip("a2a", reason="needs the [a2a] extra") + +from donkey_kit import A2AResult, A2ATask, Donkey +from donkey_kit.a2a import build_app, serve + +_CARD = "/.well-known/agent-card.json" + + +def _send(text: str | None = None, data: Any = None) -> dict[str, Any]: + parts: list[dict[str, Any]] = [] + if text is not None: + parts.append({"text": text}) + if data is not None: + parts.append({"data": data}) + return { + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": {"message": {"messageId": "m1", "role": "ROLE_USER", "parts": parts}}, + } + + +async def _call(handler: Any, body: dict[str, Any]) -> dict[str, Any]: + app: Any = build_app(handler) + transport = httpx.ASGITransport(app=app) + # Inside the lifespan, as uvicorn runs it: shutdown drains the SDK's tasks. + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=transport, base_url="http://agent") as client, + ): + response = await client.post("/", json=body, headers={"A2A-Version": "1.0"}) + assert response.status_code == 200, response.text + payload: dict[str, Any] = response.json() + assert "error" not in payload, payload + task: dict[str, Any] = payload["result"]["task"] + return task + + +def _echo() -> Any: + @Donkey.agent(name="echo", skills=["echo", "shout"]) + async def echo(task: A2ATask) -> str: + """Echoes the caller's text back.""" + return f"echo: {task.input}" + + return echo + + +async def test_card_is_generated_from_the_agent_marker() -> None: + transport = httpx.ASGITransport(app=build_app(_echo(), url="https://gw.example/echo/")) + async with httpx.AsyncClient(transport=transport, base_url="http://agent") as client: + card = (await client.get(_CARD)).json() + assert card["name"] == "echo" + assert card["description"] == "Echoes the caller's text back." + assert [s["id"] for s in card["skills"]] == ["echo", "shout"] + assert card["supportedInterfaces"][0]["url"] == "https://gw.example/echo/" + assert card["supportedInterfaces"][0]["protocolBinding"] == "JSONRPC" + + +async def test_a_str_result_completes_the_task_with_a_text_artifact() -> None: + task = await _call(_echo(), _send("hello")) + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + assert task["artifacts"][0]["parts"] == [{"text": "echo: hello"}] + + +async def test_an_a2a_result_carries_text_and_data_artifacts() -> None: + seen: list[A2ATask] = [] + + @Donkey.agent(name="totals") + async def totals(task: A2ATask) -> A2AResult: + """Sums the numbers it is sent.""" + seen.append(task) + return A2AResult(text="done", data={"sum": sum(task.data[0]["n"])}) + + task = await _call(totals, _send("add these", {"n": [1, 2, 3]})) + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + assert [a["parts"] for a in task["artifacts"]] == [[{"text": "done"}], [{"data": {"sum": 6}}]] + assert seen[0].input == "add these" + assert seen[0].task_id == task["id"] + assert seen[0].context_id == task["contextId"] + + +async def test_a_sync_handler_runs_in_a_worker_thread() -> None: + @Donkey.agent(name="sync-echo") + def sync_echo(task: A2ATask) -> str: + """Echoes, synchronously.""" + return task.input.upper() + + task = await _call(sync_echo, _send("quiet")) + assert task["artifacts"][0]["parts"] == [{"text": "QUIET"}] + + +async def test_a_raising_handler_fails_the_task_with_the_type_name_only( + caplog: pytest.LogCaptureFixture, +) -> None: + @Donkey.agent(name="broken") + async def broken(task: A2ATask) -> str: + """Always fails.""" + raise RuntimeError("db password is hunter2") + + 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" + assert "hunter2" in caplog.text # the detail goes to the log, never the caller + + +async def test_a_result_that_is_not_str_or_a2a_result_fails_the_task() -> None: + @Donkey.agent(name="wrong-type") + async def wrong(task: A2ATask) -> Any: + """Returns the wrong type.""" + return 42 + + task = await _call(wrong, _send("hi")) + assert task["status"]["state"] == "TASK_STATE_FAILED" + assert task["status"]["message"]["parts"][0]["text"] == "the agent failed: TypeError" + + +def test_an_unmarked_handler_is_rejected() -> None: + async def plain(task: A2ATask) -> str: + return "" + + with pytest.raises(TypeError, match="not marked with @donkey.agent"): + build_app(plain) + + +def test_a_missing_extra_names_the_install_command(monkeypatch: pytest.MonkeyPatch) -> None: + real_import = builtins.__import__ + + def no_a2a(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "a2a" or name.startswith("a2a."): + raise ImportError(f"No module named {name!r}", name="a2a") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", no_a2a) + with pytest.raises(ImportError, match=r'pip install "donkey-kit\[a2a\]"'): + build_app(_echo()) + + +def test_serve_binds_to_localhost_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + import uvicorn + + calls: list[dict[str, Any]] = [] + monkeypatch.setattr(uvicorn, "run", lambda app, **kw: calls.append(kw)) + serve(_echo()) + assert calls == [{"host": "127.0.0.1", "port": 8000}] + + +def test_donkey_serve_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + import donkey_kit.a2a + + 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"}) + ] + + +def test_importing_donkey_kit_does_not_load_the_a2a_sdk() -> None: + code = ( + "import sys, donkey_kit\n" + "donkey_kit.A2ATask\n" + "import donkey_kit.a2a\n" + "assert not any(m == 'a2a' or m.startswith('a2a.') for m in sys.modules), 'a2a loaded'\n" + ) + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/python/tests/unit/test_house_style_config.py b/python/tests/unit/test_house_style_config.py index f0b1501b..f160f07c 100644 --- a/python/tests/unit/test_house_style_config.py +++ b/python/tests/unit/test_house_style_config.py @@ -66,6 +66,7 @@ def _declared_requirements() -> list[str]: "llamaindex": "llama_index", "strands": "strands", "local": "starlette", + "a2a": "a2a", "otel": "opentelemetry", "cli": "typer", "test": "pytest", diff --git a/python/vulture_whitelist.py b/python/vulture_whitelist.py index 3323a775..29ab58cb 100644 --- a/python/vulture_whitelist.py +++ b/python/vulture_whitelist.py @@ -160,3 +160,7 @@ _._bind_tool # unused method (src/donkey_kit/tools/session.py) _._check_approvals # unused method (src/donkey_kit/tools/session.py) _.__signature__ # unused attribute (src/donkey_kit/tools/approval.py) +# The a2a-sdk calls AgentExecutor.execute (BG §2.9.1, #224); a served handler +# reads A2ATask.input. +_.execute # unused method (src/donkey_kit/a2a/_server.py) +input # unused variable (src/donkey_kit/a2a/types.py) diff --git a/website/content/a2a.mdx b/website/content/a2a.mdx index 36982475..70f58c9d 100644 --- a/website/content/a2a.mdx +++ b/website/content/a2a.mdx @@ -1,18 +1,153 @@ --- -description: Make your agent callable by other agents over A2A — serve it locally, expose it through the gateway, and develop against a gateway on your laptop. -status: roadmap +description: Make your agent callable by other agents over A2A — serve it in one line, expose it through the gateway, and develop against a gateway on your laptop. +status: offline-verified --- -import { Callout } from 'nextra/components' +import { Callout, Steps } from 'nextra/components' +import { Badge } from '../components' # A2A agents -This capability is on the [Roadmap](/roadmap); the API shown here is the planned design. - Three steps take an agent from "runs on my laptop" to "callable by other agents, through the gateway": **serve** it, **expose** it, and **develop** against a -gateway locally. The protocol is the official `a2a-sdk`, wrapped; the SDK adds -the governance around it. +gateway locally. `donkey.serve()` ships today. `donkey expose` and `donkey dev` +are on the [Roadmap](/roadmap). + +## The setup it replaces + +Today, accepting a task from a partner agent means: + +1. writing a server +2. writing an agent card +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. + +## Why not have users call the a2a-sdk directly? + +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) | +| 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. + +In short, the a2a-sdk handles the protocol and DDK adds the governance. +`donkey.serve()` runs the official `a2a-sdk` (its request handler, task store +and routes) and never reimplements the protocol, so any A2A client can call +your agent. + +## `donkey.serve()` — the listener, in one line + + + +### Install the extra + +```bash +pip install "donkey-kit[a2a]" +``` + +The `[a2a]` extra brings the official `a2a-sdk` and `uvicorn`. `import donkey_kit` +never loads them, and calling `donkey.serve()` without the extra raises an +`ImportError` that names this command. + +### Mark the agent and serve it + +```python +from donkey_kit import A2AResult, A2ATask, Donkey + +donkey = Donkey() + + +@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) +async def handle(task: A2ATask) -> A2AResult: + """Triages a support ticket and drafts a reply.""" + return A2AResult(text=f"triaged: {task.input}", data={"priority": "high"}) + + +donkey.serve(handle) # A2A server on 127.0.0.1:8000, card generated +``` + +### Call it from any A2A client + +The card is served at `/.well-known/agent-card.json`, and tasks arrive as +JSON-RPC at `/`. Plain `curl` is enough: + +```bash +curl -s http://127.0.0.1:8000/ \ + -H 'A2A-Version: 1.0' -H 'content-type: application/json' \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", + "params": {"message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "printer on fire"}]}}}' +``` + + + +### What the card says + +The card is built from your `@donkey.agent` marker and nothing else, so there is +no second description of your agent to keep in sync: + +| Card field | Comes from | +|---|---| +| `name` | `@donkey.agent(name=...)`, the agent's service ID | +| `description` | the handler's docstring, which `@donkey.agent` requires | +| `skills` | `@donkey.agent(skills=...)` | +| `version` | `donkey.serve(version=...)`, `"0.0.0"` by default | +| `supportedInterfaces[0].url` | `donkey.serve(url=...)`, or `http://{host}:{port}/` | + +[`donkey scan`](/cli#donkey-scan) reads the same marker, which is why the two +can't drift. A handler without `@donkey.agent` is rejected with a `TypeError`. + +### What the handler gets and returns + +The handler takes an `A2ATask`: + +| Field | Holds | +|---|---| +| `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 | +| `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. + +### Where it listens + +`donkey.serve()` binds to `127.0.0.1:8000`, so your agent is never the public +face; the gateway is. Pass `host=` and `port=` to change that. When a gateway +fronts the agent, pass its public URL as `url=` so the card advertises the +gateway, not your laptop: + +```python +donkey.serve(handle, port=9000, url="https://gw.example.com/agents/support-triage/") +``` + +`donkey.serve()` blocks until the server stops. To mount the agent in your own +ASGI server, or to test it in process, build the same app with +`donkey_kit.a2a.build_app(handle)` and run it inside its ASGI lifespan, as +uvicorn does, so shutdown drains in-flight tasks. + + + **What "Offline-verified" means here.** `donkey.serve()` has been run end to + end on a real port: a plain `curl` fetched the generated card and got a + completed task back. It has not yet been run behind a governed gateway, which + is what `donkey expose` adds. + ## Why the agent still runs a listener @@ -31,28 +166,14 @@ a separate process in front of your agent, not as a library inside it. value depends on being outside the thing it governs. -## `donkey serve` — the listener, in one line - -```python -@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) -async def handle(task: A2ATask) -> A2AResult: - return await graph.ainvoke(task.input) - -donkey.serve(handle) # A2A server on 127.0.0.1:8000, card auto-generated -``` +## Governed inbound Roadmap -- **The card is generated from your code** — the same `@donkey.tool` / - `@donkey.agent` markers that [`donkey scan`](/publishing) reads — so there is - no second description of your agent to keep in sync. The - [`@donkey.agent`](/cli#donkeyagent) marker is live today; `serve` is the - planned part. -- **It binds to localhost by default**, so your agent is never the public face; - the gateway is. -- **Inbound calls are governed.** Every call arriving over A2A gets the same - treatment as an outgoing one: correlation ID, cost tags, an OTel span, and - typed refusals when the agent's own downstream calls are blocked. +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: `. -## `donkey expose` — the ingress, registered from code +## `donkey expose` — the ingress, registered from code Roadmap ``` $ donkey expose --env prod @@ -65,17 +186,17 @@ The gateway does the work — provisioning the A2A proxy, attaching the policy set, and registering the card. `donkey expose` turns that console session into one command that runs in CI. -## `donkey dev` — a gateway in front of your laptop +## `donkey dev` — a gateway in front of your laptop Roadmap -`donkey dev` puts a gateway in front of `donkey serve` on your machine, so you +`donkey dev` puts a gateway in front of `donkey.serve()` on your machine, so you can call your agent over A2A through governance before deploying. Two modes are planned: - **A real gateway.** Where a self-managed Omni Gateway image is available for - local development, `donkey dev` runs it alongside `donkey serve`, so A2A calls - pass through real policies. + local development, `donkey dev` runs it alongside `donkey.serve()`, so A2A + calls pass through real policies. - **Simulated ingress.** The [local simulator](/simulator) gains an A2A ingress - mode: a local fake gateway in front of `donkey serve`, replaying the same + mode: a local fake gateway in front of `donkey.serve()`, replaying the same rejection fixtures. A plain A2A client sees byte-identical responses, and every response carries `x-donkey-simulator: true`. @@ -84,3 +205,4 @@ planned: - [A2A agent tools](/tool-access/a2a) — call *other* A2A agents as tools. - [Scan & publish](/publishing) — register your agent card with Agent Registry. - [Local simulator](/simulator) — the fixture-replay server behind simulated ingress. +- [API reference](/reference/api#donkeyserve) — every `donkey.serve()` argument. diff --git a/website/content/cli.mdx b/website/content/cli.mdx index 56803427..c85b973e 100644 --- a/website/content/cli.mdx +++ b/website/content/cli.mdx @@ -81,8 +81,8 @@ required, because it is the agent's description. A blank or repeated skill raises `ValueError`. [`donkey scan`](#donkey-scan) reads the `@donkey.tool` and `@donkey.agent` -markers today. The planned [A2A](/a2a) agent-card generator will read the same -records, so marking your code now carries forward. +markers, and [`donkey.serve()`](/a2a) generates its A2A agent card from the same +`@donkey.agent` record, so the scan and the card can't drift. ## The CLI diff --git a/website/content/feature-overview.mdx b/website/content/feature-overview.mdx index 1053a457..822ad357 100644 --- a/website/content/feature-overview.mdx +++ b/website/content/feature-overview.mdx @@ -156,10 +156,12 @@ shared `Donkey` (`donkey.langgraph.chat_model(...)`), a module-level factory native framework tools. **Advantage:** allow/deny filtering, pinning and a lockfile — only governed tools reach your agent. - - **Purpose:** `serve`, `expose` and `dev` make your agent callable by other - agents, on the official `a2a-sdk`. **Advantage:** inbound tasks are - governed with the same correlation, spans and refusals. + + **Purpose:** `donkey.serve()` makes your agent callable by other agents in + one line, on the official `a2a-sdk`; `expose` and `dev` are on the + Roadmap. **Advantage:** the agent card is generated from your code, and + inbound tasks are to be governed with the same correlation, spans and + refusals. **Purpose:** `donkey scan` (live) derives a model of your agents, tools and diff --git a/website/content/index.mdx b/website/content/index.mdx index eed27271..603e10cf 100644 --- a/website/content/index.mdx +++ b/website/content/index.mdx @@ -107,7 +107,7 @@ is on the [Roadmap](/roadmap).
@@ -142,8 +142,9 @@ framework client your agent already uses and adds: joined to the gateway's audit record through the correlation ID. - **Registry and agent-to-agent** Roadmap — planned, not shipped: scanning your code to publish tools and agent cards - to the control plane, discovering governed MCP tools, and serving or - exposing your agent to other agents over A2A. + to the control plane, discovering governed MCP tools, and exposing your agent to other agents + over A2A through the gateway. Serving it over A2A, `donkey.serve()`, ships + today: see [A2A agents](/a2a). The division of labour is deliberate. The gateway enforces; DDK makes the enforcement legible and actionable inside the agent. Nothing in DDK diff --git a/website/content/quickstart.mdx b/website/content/quickstart.mdx index 94030ab3..e95d8fbc 100644 --- a/website/content/quickstart.mdx +++ b/website/content/quickstart.mdx @@ -27,7 +27,8 @@ Omni Gateway later is an environment change, not a code change. | Conformance testing with pytest | Live | [Testing & conformance](/testing) | | CLI (`init`, `doctor`, `mock`, `test`, `scan`) & decorators | Live | [CLI & decorators](/cli) | | Governed tool access (MCP) | Roadmap | [Tool access](/tool-access) | -| A2A agents (`serve`, `expose`, `dev`) | Roadmap | [A2A agents](/a2a) | +| A2A agents: `donkey.serve()` | Offline-verified | [A2A agents](/a2a) | +| A2A agents: `expose`, `dev` | Roadmap | [A2A agents](/a2a) | | On-behalf-of identity | Roadmap | [Identity](/identity) | | Human-in-the-loop (tool approvals) | Live | [Human-in-the-loop](/hitl) | | `donkey scan` (agents, tools and MCP servers from code) | Live | [Scan & publish](/publishing) | diff --git a/website/content/reference/api.mdx b/website/content/reference/api.mdx index bfc030ce..a330141f 100644 --- a/website/content/reference/api.mdx +++ b/website/content/reference/api.mdx @@ -219,6 +219,23 @@ Mark a callable as an agent entry point, recording its name, skills and docstrin Guide: [`/a2a`](/a2a) +### `donkey.serve()` + +```python nocheck +donkey.serve( + handler: Callable[..., Any], + *, + host: str = '127.0.0.1', + port: int = 8000, + url: str | None = None, + version: str = '0.0.0', +) -> None +``` + +Serve a `@donkey.agent` handler over A2A on `host:port`, blocking until the server stops. + +Guide: [`/a2a`](/a2a) + ### `donkey.approvals` ```python nocheck @@ -540,6 +557,8 @@ is raised. | Name | Kind | Meaning | |---|---|---| +| `A2AResult` | class | What a handler returns when a plain string is not enough. | +| `A2ATask` | class | One A2A task handed to a `donkey.serve()` handler. | | `AgentSpec` | class | An introspectable record of a `@donkey.agent`-marked entry point. | | `Approval` | class | One call of an approval-gated tool, waiting for a human. | | `Approvals` | class | `donkey.approvals`: this process's pending tool approvals. | diff --git a/website/content/tool-access/a2a.mdx b/website/content/tool-access/a2a.mdx index 045ada6a..d0765778 100644 --- a/website/content/tool-access/a2a.mdx +++ b/website/content/tool-access/a2a.mdx @@ -26,9 +26,9 @@ learning the A2A protocol. Under the hood, `as_tool()` uses the official ## Install -There is nothing to install yet. The `donkey-kit[a2a]` extra was removed in -0.1.2 because no code used it (#744); it returns, carrying `a2a-sdk`, in the -release that ships `as_tool()`. +There is nothing to install for `as_tool()` yet. The `donkey-kit[a2a]` extra +carrying `a2a-sdk` exists today for the server side, +[`donkey.serve()`](/a2a); `as_tool()` will use the same extra when it ships. ## Where this fits diff --git a/website/public/a2a.md b/website/public/a2a.md index 6a2e2f8a..678c988d 100644 --- a/website/public/a2a.md +++ b/website/public/a2a.md @@ -1,13 +1,139 @@ # A2A agents -Status: Roadmap. Planned design; not shipped yet. - -This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. Three steps take an agent from "runs on my laptop" to "callable by other agents, through the gateway": **serve** it, **expose** it, and **develop** against a -gateway locally. The protocol is the official `a2a-sdk`, wrapped; the SDK adds -the governance around it. +gateway locally. `donkey.serve()` ships today. `donkey expose` and `donkey dev` +are on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md). + +## The setup it replaces + +Today, accepting a task from a partner agent means: + +1. writing a server +2. writing an agent card +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. + +## Why not have users call the a2a-sdk directly? + +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) | +| 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. + +In short, the a2a-sdk handles the protocol and DDK adds the governance. +`donkey.serve()` runs the official `a2a-sdk` (its request handler, task store +and routes) and never reimplements the protocol, so any A2A client can call +your agent. + +## `donkey.serve()` — the listener, in one line + +### Install the extra + +```bash +pip install "donkey-kit[a2a]" +``` + +The `[a2a]` extra brings the official `a2a-sdk` and `uvicorn`. `import donkey_kit` +never loads them, and calling `donkey.serve()` without the extra raises an +`ImportError` that names this command. + +### Mark the agent and serve it + +```python +from donkey_kit import A2AResult, A2ATask, Donkey + +donkey = Donkey() + +@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) +async def handle(task: A2ATask) -> A2AResult: + """Triages a support ticket and drafts a reply.""" + return A2AResult(text=f"triaged: {task.input}", data={"priority": "high"}) + +donkey.serve(handle) # A2A server on 127.0.0.1:8000, card generated +``` + +### Call it from any A2A client + +The card is served at `/.well-known/agent-card.json`, and tasks arrive as +JSON-RPC at `/`. Plain `curl` is enough: + +```bash +curl -s http://127.0.0.1:8000/ \ + -H 'A2A-Version: 1.0' -H 'content-type: application/json' \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", + "params": {"message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "printer on fire"}]}}}' +``` + +### What the card says + +The card is built from your `@donkey.agent` marker and nothing else, so there is +no second description of your agent to keep in sync: + +| Card field | Comes from | +|---|---| +| `name` | `@donkey.agent(name=...)`, the agent's service ID | +| `description` | the handler's docstring, which `@donkey.agent` requires | +| `skills` | `@donkey.agent(skills=...)` | +| `version` | `donkey.serve(version=...)`, `"0.0.0"` by default | +| `supportedInterfaces[0].url` | `donkey.serve(url=...)`, or `http://{host}:{port}/` | + +[`donkey scan`](https://docs.donkey-kit.dev/cli.md#donkey-scan) reads the same marker, which is why the two +can't drift. A handler without `@donkey.agent` is rejected with a `TypeError`. + +### What the handler gets and returns + +The handler takes an `A2ATask`: + +| Field | Holds | +|---|---| +| `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 | +| `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. + +### Where it listens + +`donkey.serve()` binds to `127.0.0.1:8000`, so your agent is never the public +face; the gateway is. Pass `host=` and `port=` to change that. When a gateway +fronts the agent, pass its public URL as `url=` so the card advertises the +gateway, not your laptop: + +```python +donkey.serve(handle, port=9000, url="https://gw.example.com/agents/support-triage/") +``` + +`donkey.serve()` blocks until the server stops. To mount the agent in your own +ASGI server, or to test it in process, build the same app with +`donkey_kit.a2a.build_app(handle)` and run it inside its ASGI lifespan, as +uvicorn does, so shutdown drains in-flight tasks. + + **What "Offline-verified" means here.** `donkey.serve()` has been run end to + end on a real port: a plain `curl` fetched the generated card and got a + completed task back. It has not yet been run behind a governed gateway, which + is what `donkey expose` adds. ## Why the agent still runs a listener @@ -24,28 +150,14 @@ 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. -## `donkey serve` — the listener, in one line - -```python -@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) -async def handle(task: A2ATask) -> A2AResult: - return await graph.ainvoke(task.input) - -donkey.serve(handle) # A2A server on 127.0.0.1:8000, card auto-generated -``` +## Governed inbound Roadmap -- **The card is generated from your code** — the same `@donkey.tool` / - `@donkey.agent` markers that [`donkey scan`](https://docs.donkey-kit.dev/publishing.md) reads — so there is - no second description of your agent to keep in sync. The - [`@donkey.agent`](https://docs.donkey-kit.dev/cli.md#donkeyagent) marker is live today; `serve` is the - planned part. -- **It binds to localhost by default**, so your agent is never the public face; - the gateway is. -- **Inbound calls are governed.** Every call arriving over A2A gets the same - treatment as an outgoing one: correlation ID, cost tags, an OTel span, and - typed refusals when the agent's own downstream calls are blocked. +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: `. -## `donkey expose` — the ingress, registered from code +## `donkey expose` — the ingress, registered from code Roadmap ``` $ donkey expose --env prod @@ -58,17 +170,17 @@ The gateway does the work — provisioning the A2A proxy, attaching the policy set, and registering the card. `donkey expose` turns that console session into one command that runs in CI. -## `donkey dev` — a gateway in front of your laptop +## `donkey dev` — a gateway in front of your laptop Roadmap -`donkey dev` puts a gateway in front of `donkey serve` on your machine, so you +`donkey dev` puts a gateway in front of `donkey.serve()` on your machine, so you can call your agent over A2A through governance before deploying. Two modes are planned: - **A real gateway.** Where a self-managed Omni Gateway image is available for - local development, `donkey dev` runs it alongside `donkey serve`, so A2A calls - pass through real policies. + local development, `donkey dev` runs it alongside `donkey.serve()`, so A2A + calls pass through real policies. - **Simulated ingress.** The [local simulator](https://docs.donkey-kit.dev/simulator.md) gains an A2A ingress - mode: a local fake gateway in front of `donkey serve`, replaying the same + mode: a local fake gateway in front of `donkey.serve()`, replaying the same rejection fixtures. A plain A2A client sees byte-identical responses, and every response carries `x-donkey-simulator: true`. @@ -77,3 +189,4 @@ planned: - [A2A agent tools](https://docs.donkey-kit.dev/tool-access/a2a.md) — call *other* A2A agents as tools. - [Scan & publish](https://docs.donkey-kit.dev/publishing.md) — register your agent card with Agent Registry. - [Local simulator](https://docs.donkey-kit.dev/simulator.md) — the fixture-replay server behind simulated ingress. +- [API reference](https://docs.donkey-kit.dev/reference/api.md#donkeyserve) — every `donkey.serve()` argument. diff --git a/website/public/cli.md b/website/public/cli.md index e74fb262..8c9058f2 100644 --- a/website/public/cli.md +++ b/website/public/cli.md @@ -75,8 +75,8 @@ required, because it is the agent's description. A blank or repeated skill raises `ValueError`. [`donkey scan`](#donkey-scan) reads the `@donkey.tool` and `@donkey.agent` -markers today. The planned [A2A](https://docs.donkey-kit.dev/a2a.md) agent-card generator will read the same -records, so marking your code now carries forward. +markers, and [`donkey.serve()`](https://docs.donkey-kit.dev/a2a.md) generates its A2A agent card from the same +`@donkey.agent` record, so the scan and the card can't drift. ## The CLI diff --git a/website/public/feature-overview.md b/website/public/feature-overview.md index 09366ef6..a4f83fd4 100644 --- a/website/public/feature-overview.md +++ b/website/public/feature-overview.md @@ -136,9 +136,11 @@ shared `Donkey` (`donkey.langgraph.chat_model(...)`), a module-level factory lockfile — only governed tools reach your agent. - **Purpose:** `serve`, `expose` and `dev` make your agent callable by other - agents, on the official `a2a-sdk`. **Advantage:** inbound tasks are - governed with the same correlation, spans and refusals. + **Purpose:** `donkey.serve()` makes your agent callable by other agents in + one line, on the official `a2a-sdk`; `expose` and `dev` are on the + Roadmap. **Advantage:** the agent card is generated from your code, and + inbound tasks are to be governed with the same correlation, spans and + refusals. **Purpose:** `donkey scan` (live) derives a model of your agents, tools and diff --git a/website/public/index.md b/website/public/index.md index 2e076c53..9a146e99 100644 --- a/website/public/index.md +++ b/website/public/index.md @@ -111,8 +111,9 @@ framework client your agent already uses and adds: joined to the gateway's audit record through the correlation ID. - **Registry and agent-to-agent** Roadmap — planned, not shipped: scanning your code to publish tools and agent cards - to the control plane, discovering governed MCP tools, and serving or - exposing your agent to other agents over A2A. + to the control plane, discovering governed MCP tools, and exposing your agent to other agents + over A2A through the gateway. Serving it over A2A, `donkey.serve()`, ships + today: see [A2A agents](https://docs.donkey-kit.dev/a2a.md). The division of labour is deliberate. The gateway enforces; DDK makes the enforcement legible and actionable inside the agent. Nothing in DDK diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index 9bf2ca8f..a67ce266 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -121,8 +121,9 @@ framework client your agent already uses and adds: joined to the gateway's audit record through the correlation ID. - **Registry and agent-to-agent** Roadmap — planned, not shipped: scanning your code to publish tools and agent cards - to the control plane, discovering governed MCP tools, and serving or - exposing your agent to other agents over A2A. + to the control plane, discovering governed MCP tools, and exposing your agent to other agents + over A2A through the gateway. Serving it over A2A, `donkey.serve()`, ships + today: see [A2A agents](https://docs.donkey-kit.dev/a2a.md). The division of labour is deliberate. The gateway enforces; DDK makes the enforcement legible and actionable inside the agent. Nothing in DDK @@ -291,7 +292,8 @@ Omni Gateway later is an environment change, not a code change. | Conformance testing with pytest | Live | [Testing & conformance](https://docs.donkey-kit.dev/testing.md) | | CLI (`init`, `doctor`, `mock`, `test`, `scan`) & decorators | Live | [CLI & decorators](https://docs.donkey-kit.dev/cli.md) | | Governed tool access (MCP) | Roadmap | [Tool access](https://docs.donkey-kit.dev/tool-access.md) | -| A2A agents (`serve`, `expose`, `dev`) | Roadmap | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | +| A2A agents: `donkey.serve()` | Offline-verified | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | +| A2A agents: `expose`, `dev` | Roadmap | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | | On-behalf-of identity | Roadmap | [Identity](https://docs.donkey-kit.dev/identity.md) | | Human-in-the-loop (tool approvals) | Live | [Human-in-the-loop](https://docs.donkey-kit.dev/hitl.md) | | `donkey scan` (agents, tools and MCP servers from code) | Live | [Scan & publish](https://docs.donkey-kit.dev/publishing.md) | @@ -710,9 +712,11 @@ shared `Donkey` (`donkey.langgraph.chat_model(...)`), a module-level factory lockfile — only governed tools reach your agent. - **Purpose:** `serve`, `expose` and `dev` make your agent callable by other - agents, on the official `a2a-sdk`. **Advantage:** inbound tasks are - governed with the same correlation, spans and refusals. + **Purpose:** `donkey.serve()` makes your agent callable by other agents in + one line, on the official `a2a-sdk`; `expose` and `dev` are on the + Roadmap. **Advantage:** the agent card is generated from your code, and + inbound tasks are to be governed with the same correlation, spans and + refusals. **Purpose:** `donkey scan` (live) derives a model of your agents, tools and @@ -5724,8 +5728,8 @@ required, because it is the agent's description. A blank or repeated skill raises `ValueError`. [`donkey scan`](#donkey-scan) reads the `@donkey.tool` and `@donkey.agent` -markers today. The planned [A2A](https://docs.donkey-kit.dev/a2a.md) agent-card generator will read the same -records, so marking your code now carries forward. +markers, and [`donkey.serve()`](https://docs.donkey-kit.dev/a2a.md) generates its A2A agent card from the same +`@donkey.agent` record, so the scan and the card can't drift. ## The CLI @@ -6674,9 +6678,9 @@ learning the A2A protocol. Under the hood, `as_tool()` uses the official ## Install -There is nothing to install yet. The `donkey-kit[a2a]` extra was removed in -0.1.2 because no code used it (#744); it returns, carrying `a2a-sdk`, in the -release that ships `as_tool()`. +There is nothing to install for `as_tool()` yet. The `donkey-kit[a2a]` extra +carrying `a2a-sdk` exists today for the server side, +[`donkey.serve()`](https://docs.donkey-kit.dev/a2a.md); `as_tool()` will use the same extra when it ships. ## Where this fits @@ -6693,14 +6697,140 @@ Source: https://docs.donkey-kit.dev/a2a.md # A2A agents -Status: Roadmap. Planned design; not shipped yet. - -This capability is on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md); the API shown here is the planned design. +Status: Offline-verified. Shipped and verified offline; no live gateway round-trip yet. Three steps take an agent from "runs on my laptop" to "callable by other agents, through the gateway": **serve** it, **expose** it, and **develop** against a -gateway locally. The protocol is the official `a2a-sdk`, wrapped; the SDK adds -the governance around it. +gateway locally. `donkey.serve()` ships today. `donkey expose` and `donkey dev` +are on the [Roadmap](https://docs.donkey-kit.dev/roadmap.md). + +## The setup it replaces + +Today, accepting a task from a partner agent means: + +1. writing a server +2. writing an agent card +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. + +## Why not have users call the a2a-sdk directly? + +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) | +| 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. + +In short, the a2a-sdk handles the protocol and DDK adds the governance. +`donkey.serve()` runs the official `a2a-sdk` (its request handler, task store +and routes) and never reimplements the protocol, so any A2A client can call +your agent. + +## `donkey.serve()` — the listener, in one line + +### Install the extra + +```bash +pip install "donkey-kit[a2a]" +``` + +The `[a2a]` extra brings the official `a2a-sdk` and `uvicorn`. `import donkey_kit` +never loads them, and calling `donkey.serve()` without the extra raises an +`ImportError` that names this command. + +### Mark the agent and serve it + +```python +from donkey_kit import A2AResult, A2ATask, Donkey + +donkey = Donkey() + +@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) +async def handle(task: A2ATask) -> A2AResult: + """Triages a support ticket and drafts a reply.""" + return A2AResult(text=f"triaged: {task.input}", data={"priority": "high"}) + +donkey.serve(handle) # A2A server on 127.0.0.1:8000, card generated +``` + +### Call it from any A2A client + +The card is served at `/.well-known/agent-card.json`, and tasks arrive as +JSON-RPC at `/`. Plain `curl` is enough: + +```bash +curl -s http://127.0.0.1:8000/ \ + -H 'A2A-Version: 1.0' -H 'content-type: application/json' \ + -d '{"jsonrpc": "2.0", "id": 1, "method": "SendMessage", + "params": {"message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "printer on fire"}]}}}' +``` + +### What the card says + +The card is built from your `@donkey.agent` marker and nothing else, so there is +no second description of your agent to keep in sync: + +| Card field | Comes from | +|---|---| +| `name` | `@donkey.agent(name=...)`, the agent's service ID | +| `description` | the handler's docstring, which `@donkey.agent` requires | +| `skills` | `@donkey.agent(skills=...)` | +| `version` | `donkey.serve(version=...)`, `"0.0.0"` by default | +| `supportedInterfaces[0].url` | `donkey.serve(url=...)`, or `http://{host}:{port}/` | + +[`donkey scan`](https://docs.donkey-kit.dev/cli.md#donkey-scan) reads the same marker, which is why the two +can't drift. A handler without `@donkey.agent` is rejected with a `TypeError`. + +### What the handler gets and returns + +The handler takes an `A2ATask`: + +| Field | Holds | +|---|---| +| `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 | +| `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. + +### Where it listens + +`donkey.serve()` binds to `127.0.0.1:8000`, so your agent is never the public +face; the gateway is. Pass `host=` and `port=` to change that. When a gateway +fronts the agent, pass its public URL as `url=` so the card advertises the +gateway, not your laptop: + +```python +donkey.serve(handle, port=9000, url="https://gw.example.com/agents/support-triage/") +``` + +`donkey.serve()` blocks until the server stops. To mount the agent in your own +ASGI server, or to test it in process, build the same app with +`donkey_kit.a2a.build_app(handle)` and run it inside its ASGI lifespan, as +uvicorn does, so shutdown drains in-flight tasks. + + **What "Offline-verified" means here.** `donkey.serve()` has been run end to + end on a real port: a plain `curl` fetched the generated card and got a + completed task back. It has not yet been run behind a governed gateway, which + is what `donkey expose` adds. ## Why the agent still runs a listener @@ -6717,28 +6847,14 @@ 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. -## `donkey serve` — the listener, in one line +## Governed inbound Roadmap -```python -@donkey.agent(name="support-triage", skills=["triage", "draft-reply"]) -async def handle(task: A2ATask) -> A2AResult: - return await graph.ainvoke(task.input) +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: `. -donkey.serve(handle) # A2A server on 127.0.0.1:8000, card auto-generated -``` - -- **The card is generated from your code** — the same `@donkey.tool` / - `@donkey.agent` markers that [`donkey scan`](https://docs.donkey-kit.dev/publishing.md) reads — so there is - no second description of your agent to keep in sync. The - [`@donkey.agent`](https://docs.donkey-kit.dev/cli.md#donkeyagent) marker is live today; `serve` is the - planned part. -- **It binds to localhost by default**, so your agent is never the public face; - the gateway is. -- **Inbound calls are governed.** Every call arriving over A2A gets the same - treatment as an outgoing one: correlation ID, cost tags, an OTel span, and - typed refusals when the agent's own downstream calls are blocked. - -## `donkey expose` — the ingress, registered from code +## `donkey expose` — the ingress, registered from code Roadmap ``` $ donkey expose --env prod @@ -6751,17 +6867,17 @@ The gateway does the work — provisioning the A2A proxy, attaching the policy set, and registering the card. `donkey expose` turns that console session into one command that runs in CI. -## `donkey dev` — a gateway in front of your laptop +## `donkey dev` — a gateway in front of your laptop Roadmap -`donkey dev` puts a gateway in front of `donkey serve` on your machine, so you +`donkey dev` puts a gateway in front of `donkey.serve()` on your machine, so you can call your agent over A2A through governance before deploying. Two modes are planned: - **A real gateway.** Where a self-managed Omni Gateway image is available for - local development, `donkey dev` runs it alongside `donkey serve`, so A2A calls - pass through real policies. + local development, `donkey dev` runs it alongside `donkey.serve()`, so A2A + calls pass through real policies. - **Simulated ingress.** The [local simulator](https://docs.donkey-kit.dev/simulator.md) gains an A2A ingress - mode: a local fake gateway in front of `donkey serve`, replaying the same + mode: a local fake gateway in front of `donkey.serve()`, replaying the same rejection fixtures. A plain A2A client sees byte-identical responses, and every response carries `x-donkey-simulator: true`. @@ -6770,6 +6886,7 @@ planned: - [A2A agent tools](https://docs.donkey-kit.dev/tool-access/a2a.md) — call *other* A2A agents as tools. - [Scan & publish](https://docs.donkey-kit.dev/publishing.md) — register your agent card with Agent Registry. - [Local simulator](https://docs.donkey-kit.dev/simulator.md) — the fixture-replay server behind simulated ingress. +- [API reference](https://docs.donkey-kit.dev/reference/api.md#donkeyserve) — every `donkey.serve()` argument. --- @@ -13030,6 +13147,23 @@ Mark a callable as an agent entry point, recording its name, skills and docstrin Guide: [`/a2a`](https://docs.donkey-kit.dev/a2a.md) +### `donkey.serve()` + +```python nocheck +donkey.serve( + handler: Callable[..., Any], + *, + host: str = '127.0.0.1', + port: int = 8000, + url: str | None = None, + version: str = '0.0.0', +) -> None +``` + +Serve a `@donkey.agent` handler over A2A on `host:port`, blocking until the server stops. + +Guide: [`/a2a`](https://docs.donkey-kit.dev/a2a.md) + ### `donkey.approvals` ```python nocheck @@ -13351,6 +13485,8 @@ is raised. | Name | Kind | Meaning | |---|---|---| +| `A2AResult` | class | What a handler returns when a plain string is not enough. | +| `A2ATask` | class | One A2A task handed to a `donkey.serve()` handler. | | `AgentSpec` | class | An introspectable record of a `@donkey.agent`-marked entry point. | | `Approval` | class | One call of an approval-gated tool, waiting for a human. | | `Approvals` | class | `donkey.approvals`: this process's pending tool approvals. | diff --git a/website/public/llms.txt b/website/public/llms.txt index 9cee6e3f..73ef2e83 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -53,7 +53,7 @@ ## Registry & catalog -- [A2A agents](https://docs.donkey-kit.dev/a2a.md): (status: roadmap) Make your agent callable by other agents over A2A — serve it locally, expose it through the gateway, and develop against a gateway on your laptop. +- [A2A agents](https://docs.donkey-kit.dev/a2a.md): (status: offline-verified) Make your agent callable by other agents over A2A — serve it in one line, expose it through the gateway, and develop against a gateway on your laptop. - [Scan & publish](https://docs.donkey-kit.dev/publishing.md): (status: roadmap) donkey scan derives a deterministic model of your agents, tools and MCP servers from code; publishing registers it with Exchange / Agent Registry from CI, complementing Agent Scanners. ## Scenarios diff --git a/website/public/quickstart.md b/website/public/quickstart.md index 7b6c2cf2..8599974d 100644 --- a/website/public/quickstart.md +++ b/website/public/quickstart.md @@ -21,7 +21,8 @@ Omni Gateway later is an environment change, not a code change. | Conformance testing with pytest | Live | [Testing & conformance](https://docs.donkey-kit.dev/testing.md) | | CLI (`init`, `doctor`, `mock`, `test`, `scan`) & decorators | Live | [CLI & decorators](https://docs.donkey-kit.dev/cli.md) | | Governed tool access (MCP) | Roadmap | [Tool access](https://docs.donkey-kit.dev/tool-access.md) | -| A2A agents (`serve`, `expose`, `dev`) | Roadmap | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | +| A2A agents: `donkey.serve()` | Offline-verified | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | +| A2A agents: `expose`, `dev` | Roadmap | [A2A agents](https://docs.donkey-kit.dev/a2a.md) | | On-behalf-of identity | Roadmap | [Identity](https://docs.donkey-kit.dev/identity.md) | | Human-in-the-loop (tool approvals) | Live | [Human-in-the-loop](https://docs.donkey-kit.dev/hitl.md) | | `donkey scan` (agents, tools and MCP servers from code) | Live | [Scan & publish](https://docs.donkey-kit.dev/publishing.md) | diff --git a/website/public/reference/api.md b/website/public/reference/api.md index 3751de84..44dc5c21 100644 --- a/website/public/reference/api.md +++ b/website/public/reference/api.md @@ -213,6 +213,23 @@ Mark a callable as an agent entry point, recording its name, skills and docstrin Guide: [`/a2a`](https://docs.donkey-kit.dev/a2a.md) +### `donkey.serve()` + +```python nocheck +donkey.serve( + handler: Callable[..., Any], + *, + host: str = '127.0.0.1', + port: int = 8000, + url: str | None = None, + version: str = '0.0.0', +) -> None +``` + +Serve a `@donkey.agent` handler over A2A on `host:port`, blocking until the server stops. + +Guide: [`/a2a`](https://docs.donkey-kit.dev/a2a.md) + ### `donkey.approvals` ```python nocheck @@ -534,6 +551,8 @@ is raised. | Name | Kind | Meaning | |---|---|---| +| `A2AResult` | class | What a handler returns when a plain string is not enough. | +| `A2ATask` | class | One A2A task handed to a `donkey.serve()` handler. | | `AgentSpec` | class | An introspectable record of a `@donkey.agent`-marked entry point. | | `Approval` | class | One call of an approval-gated tool, waiting for a human. | | `Approvals` | class | `donkey.approvals`: this process's pending tool approvals. | diff --git a/website/public/tool-access/a2a.md b/website/public/tool-access/a2a.md index 393e6aae..1236f9a9 100644 --- a/website/public/tool-access/a2a.md +++ b/website/public/tool-access/a2a.md @@ -22,9 +22,9 @@ learning the A2A protocol. Under the hood, `as_tool()` uses the official ## Install -There is nothing to install yet. The `donkey-kit[a2a]` extra was removed in -0.1.2 because no code used it (#744); it returns, carrying `a2a-sdk`, in the -release that ships `as_tool()`. +There is nothing to install for `as_tool()` yet. The `donkey-kit[a2a]` extra +carrying `a2a-sdk` exists today for the server side, +[`donkey.serve()`](https://docs.donkey-kit.dev/a2a.md); `as_tool()` will use the same extra when it ships. ## Where this fits