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
+82
View File
@@ -0,0 +1,82 @@
"""The specialists this agent may consult, and how to reach one.
A specialist is described here by what it *decides*, not by what data it holds.
That is the difference the estate is built on: an A2A call returns an answer, so
the orchestrator learns "38 products, the dearest is S10_1949" and never gains
read access to the catalog domain.
Adding a specialist is three things: an entry below, an audience scope granted to
`classic-models-agent-assistant` in the realm, and nothing else. If a specialist
appears here without the realm grant, every call to it fails at the token
exchange with `Invalid scopes` — loudly, and before any data moves.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Specialist:
"""One agent this orchestrator can delegate to."""
name: str
url: str
audience_scope: str
serves: str
"""What it is for, in the words the model routing on it will read."""
decides: tuple[str, ...]
"""The questions it can settle. Deliberately narrow.
These become the tool description. A specialist advertised as "ask it
anything about products" turns the orchestrator into a data proxy by the
back door — the very thing the topology exists to prevent.
"""
CATALOGUE = Specialist(
name="catalogue",
url="http://catalogue-agent.agents.svc.cluster.local:8080/",
audience_scope="classic-models-agent-catalogue-audience",
serves="product management: products, product lines, pricing, discontinuation",
decides=(
"how many products a product line holds, and which are the dearest or cheapest",
"a product's details: name, vendor, scale, stock, buy price, MSRP",
"whether a product sells well enough to justify a pricing or discontinuation decision",
"creating a product, which it holds for the user's approval before writing",
),
)
#: Everything the design foresees, minus what is not built. Ventes, Support,
#: Approvisionnement and Recouvrement belong here as they arrive; the graph
#: needs no change for them, only this list and a realm grant.
SPECIALISTS: tuple[Specialist, ...] = (CATALOGUE,)
def by_name(name: str) -> Specialist | None:
"""The specialist called `name`, with its URL overridable per environment.
`ASSISTANT_URL_<NAME>` exists for running the orchestrator outside the
cluster against a port-forwarded specialist. In the cluster nothing sets it
and the Service DNS name below is used as written.
"""
found = next((s for s in SPECIALISTS if s.name == name), None)
if found is None:
return None
override = os.environ.get(f"ASSISTANT_URL_{found.name.upper()}")
return replace(found, url=override) if override else found
def describe(specialist: Specialist) -> str:
"""The tool description the routing model reads."""
lines = [f"Consult the {specialist.name} agent. It serves {specialist.serves}."]
lines.append("It can settle:")
lines.extend(f"- {d}" for d in specialist.decides)
lines.append(
"Ask it a complete question in plain language, as you would ask a colleague. "
"It answers; it does not hand back raw records."
)
return "\n".join(lines)