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>
This commit is contained in:
Stéphane Tailland
2026-08-29 12:26:20 +02:00
parent 8af9e92fe0
commit ebf864f7ba
35 changed files with 1380 additions and 4 deletions
+99
View File
@@ -0,0 +1,99 @@
"""Calling a specialist over A2A, with a token minted for that specialist alone.
One token per callee, exchanged per turn. Several narrow tokens rather than one
broad one: a token minted for Catalogue is refused by every other agent, so a
prompt injection that talks this orchestrator into consulting the wrong
specialist fails at the realm rather than succeeding quietly.
The client is built per call for the same reason the Catalogue agent builds its
MCP client per turn — a client is where the header lives, and a long-lived
client is a long-lived token.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import httpx
from a2a import types as a2a_types
from a2a.client import ClientConfig, ClientFactory
from .config import Settings
from .identity import TokenExchanger
from .specialists import Specialist
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Answer:
"""What a specialist said, and whether it finished saying it."""
specialist: str
text: str
state: str
@property
def needs_the_user(self) -> bool:
"""The specialist is holding something for a decision.
Catalogue parks a proposed write in `input-required` rather than making
it. The orchestrator must relay that verbatim and stop — it has no
standing to approve a write on the user's behalf, and paraphrasing the
proposal would hide what is actually about to happen.
"""
return self.state == "TASK_STATE_INPUT_REQUIRED"
def _text_of(message: a2a_types.Message) -> str:
return "".join(part.text for part in message.parts if part.text)
class Delegator:
"""Sends one question to one specialist and waits for its answer."""
def __init__(self, settings: Settings, exchanger: TokenExchanger) -> None:
self._settings = settings
self._exchanger = exchanger
async def ask(
self, specialist: Specialist, question: str, subject_token: str, context_id: str
) -> Answer:
token = await self._exchanger.exchange(subject_token, specialist.audience_scope)
async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {token}"},
verify=self._settings.verify,
timeout=self._settings.http_timeout,
) as http:
factory = ClientFactory(ClientConfig(httpx_client=http, streaming=False))
client = await factory.create_from_url(specialist.url)
try:
request = a2a_types.SendMessageRequest(
message=a2a_types.Message(
message_id=f"{context_id}-{specialist.name}",
# Carried across so the specialist keeps one conversation
# per user conversation rather than one per question —
# which is what lets its own approval flow resume.
context_id=context_id,
role=a2a_types.Role.ROLE_USER,
parts=[a2a_types.Part(text=question)],
)
)
text, state = "", "TASK_STATE_UNSPECIFIED"
async for event in client.send_message(request):
if event.HasField("task"):
state = a2a_types.TaskState.Name(event.task.status.state)
if event.task.status.HasField("message"):
text = _text_of(event.task.status.message)
elif event.HasField("message"):
text = _text_of(event.message)
state = "TASK_STATE_COMPLETED"
finally:
await client.close()
if not text:
text = f"({specialist.name} returned no answer; state {state})"
logger.info("delegated to %s -> %s", specialist.name, state)
return Answer(specialist=specialist.name, text=text, state=state)