Files
classic-models-agent-assistant/src/assistant_agent/a2a_server.py
T
Stéphane Tailland ebf864f7ba feat: the orchestrating agent
Entry point for the estate: understands a request, consults the specialists
that can settle it, composes one answer. LangGraph 1.2.11, a2a-sdk 1.1.2, both
current as of August 2026.

It holds no data, and the realm is what enforces that rather than the prompt.
classic-models-agent-assistant is granted two optional scopes — one per
specialist, plus the inference endpoint — and every domain audience it could
request comes back Invalid scopes. An injection that succeeds here finds no
path to a domain because there is none. "No scopes at all" was too absolute:
the agent has to reason, and inference is not a domain.

The two-hop token chain was the heaviest unproven thing in the design and it
holds: sub stays the end user through all four hops, so their own roles apply
at the far end and the MCP server filters tool discovery for the right human.
azp names the last agent, not the first — Keycloak accepts actor_token and
emits no act claim, so an orchestrator's identity survives only in the traces.

Verified end to end: a routed question answered from the specialist's data; a
question outside every remit refused rather than answered from memory; and a
write proposed through the orchestrator, held by the specialist in
input-required, relayed here unchanged, approved by the user through the same
conversation, and written. The orchestrator never owns that decision.

The graph is plan → delegate → plan, tools described by what a specialist
decides rather than what it holds, delegations in one step run concurrently and
capped at eight counted in calls. Rebuilt per turn, because its tools carry the
user's token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:26:20 +02:00

185 lines
7.1 KiB
Python

"""The A2A facade: how other agents reach this one.
Notes on a2a-sdk 1.1, none of which are guessable and all of which were
established against the running library:
- `a2a.types` are **protobuf** messages, not pydantic models.
- Use `DefaultRequestHandlerV2`. `DefaultRequestHandler` speaks A2A 0.3 and
rejects 1.0 traffic.
- The JSON-RPC methods are `SendMessage`, `GetTask`, … The 0.3 spelling
(`message/send`) is available through `enable_v0_3_compat=True`, kept on here
so a client written against the older spelling still works.
- **A client must send `A2A-Version: 1.0`.** With the header absent the server
assumes 0.3 and answers `VERSION_NOT_SUPPORTED` — a confusing failure, since
nothing in the request is actually wrong.
- An executor must enqueue a `Task` before any status update when
`context.current_task` is None, or the SDK answers
"Agent should enqueue Task before TaskStatusUpdateEvent".
"""
from __future__ import annotations
import contextlib
import logging
from collections.abc import AsyncIterator
import httpx
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandlerV2
from a2a.server.routes.agent_card_routes import create_agent_card_routes
from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Part,
Task,
TaskState,
TaskStatus,
)
from starlette.applications import Starlette
from .config import Settings
from .orchestrator import Assistant
logger = logging.getLogger(__name__)
# The bearer the caller presents, which this agent exchanges for MCP tokens.
# It is the END USER's token, not the calling agent's: the whole scoping model
# rests on `sub` staying the human throughout the chain while `azp` names the
# agent doing the asking.
SUBJECT_TOKEN_HEADER = "authorization"
def build_agent_card(settings: Settings) -> AgentCard:
"""One skill, and it is deliberately about routing rather than data.
This agent holds nothing. What it offers is that a caller can ask one
question and have the right specialists consulted — which is a capability,
not a data surface. Naming it `answer_about_the_business` rather than
`query` keeps the distinction visible.
"""
return AgentCard(
name="assistant",
description=(
"The entry point for the Classic Models estate. Understands a request, "
"consults the specialists that can settle it, and composes one answer. "
"Holds no data and no domain scope of its own."
),
version="0.1.0",
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
capabilities=AgentCapabilities(streaming=False),
supported_interfaces=[
AgentInterface(
url=settings.public_url,
protocol_binding="JSONRPC",
protocol_version="1.0",
)
],
skills=[
AgentSkill(
id="answer_about_the_business",
name="Answer a question about the business",
description=(
"Route a question to the specialists that can settle it and compose "
"their answers. Every figure comes from a specialist, within what the "
"asking user is allowed to see."
),
tags=["orchestration", "routing"],
examples=[
"How many products are in the Classic Cars line, and which is dearest?",
"Can we ship order 10248?",
],
)
],
)
class AssistantExecutor(AgentExecutor):
def __init__(self, agent: Assistant) -> None:
self._agent = agent
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
# The SDK requires the Task to exist before any status update.
if context.current_task is None:
await event_queue.enqueue_event(
Task(
id=context.task_id,
context_id=context.context_id,
status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
)
)
await updater.start_work()
subject_token = _subject_token(context)
if subject_token is None:
# Refused rather than answered from the model's own knowledge. This
# agent holds no data at all, so an answer produced without a
# specialist is necessarily invented — and would be indistinguishable
# from one that came from the estate.
await updater.failed(
message=updater.new_agent_message(
parts=[Part(text="No bearer token on the request; cannot consult any specialist.")]
)
)
return
try:
answer = await self._agent.run(
context.get_user_input(), subject_token, context.context_id
)
except Exception as exc: # noqa: BLE001 — surfaced to the caller, logged here
logger.exception("turn failed")
await updater.failed(
message=updater.new_agent_message(parts=[Part(text=f"Turn failed: {exc}")])
)
return
# Always `completed`, never `input-required`. A specialist holding a
# write parks its own task and its message is relayed here verbatim —
# the user answers that specialist through this agent's next turn, and
# the orchestrator never owns the decision.
await updater.complete(message=updater.new_agent_message(parts=[Part(text=answer)]))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
await TaskUpdater(event_queue, context.task_id, context.context_id).cancel()
def _subject_token(context: RequestContext) -> str | None:
headers = {}
call = context.call_context
if call is not None:
headers = {k.lower(): v for k, v in (call.state.get("headers") or {}).items()}
raw = headers.get(SUBJECT_TOKEN_HEADER)
if not raw:
return None
return raw[7:] if raw.lower().startswith("bearer ") else raw
def build_app(settings: Settings) -> Starlette:
http_client = httpx.AsyncClient(verify=settings.verify, timeout=settings.http_timeout)
card = build_agent_card(settings)
handler = DefaultRequestHandlerV2(
agent_executor=AssistantExecutor(Assistant(settings, http_client)),
task_store=InMemoryTaskStore(),
agent_card=card,
)
# lifespan rather than on_shutdown: Starlette 1.x removed the latter.
@contextlib.asynccontextmanager
async def lifespan(_app: Starlette) -> AsyncIterator[None]:
yield
await http_client.aclose()
return Starlette(
routes=create_agent_card_routes(card)
+ create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True),
lifespan=lifespan,
)