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
View File
+20
View File
@@ -0,0 +1,20 @@
"""Entry point: `python -m assistant_agent`."""
from __future__ import annotations
import logging
import uvicorn
from .a2a_server import build_app
from .config import Settings
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
settings = Settings() # type: ignore[call-arg] # client_secret comes from the environment
uvicorn.run(build_app(settings), host=settings.host, port=settings.port)
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
"""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,
)
+57
View File
@@ -0,0 +1,57 @@
"""Runtime configuration, entirely from the environment.
Nothing here has a value that would be wrong to commit, so the defaults are the
lab's real endpoints. The one secret — this agent's Keycloak client secret — has
no default and fails loudly if unset.
"""
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="ASSISTANT_", env_file=".env")
# --- identity -----------------------------------------------------------
keycloak_realm_url: str = "https://keycloak.sttlab.local/realms/classic-models"
client_id: str = "classic-models-agent-assistant"
client_secret: str = Field(..., description="Keycloak client secret; no default on purpose")
# --- inference ----------------------------------------------------------
# This agent has no data of its own. The only thing it may reach besides
# other agents is the inference endpoint, and the realm enforces exactly
# that: `classic-models-agent-assistant` is granted two optional scopes, one
# per specialist plus this one. Every domain audience it could ask for comes
# back `Invalid scopes`.
llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1"
llm_model: str = "google/gemini-3.7-flash"
llm_scope: str = "llm-proxy-audience"
# --- transport ----------------------------------------------------------
# The lab uses a private CA. Point this at the bundle rather than disabling
# verification. See the note on `.local` names in the README: macOS resolves
# them through mDNS and takes ~5s per host, which is why the timeout is not
# the usual 5 or 10.
ca_bundle: str | None = None
http_timeout: float = 60.0
# A turn that fans out and loops has to stop somewhere. Counted in
# delegations, not in model calls — one specialist consulted twice for the
# same question is the loop worth catching.
max_delegations: int = 8
host: str = "0.0.0.0"
port: int = 8080
# Advertised in the Agent Card, so it must be the URL other callers reach,
# not the bind address.
public_url: str = "http://localhost:8080/"
@property
def token_endpoint(self) -> str:
return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
@property
def verify(self) -> str | bool:
return self.ca_bundle if self.ca_bundle else True
+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)
+184
View File
@@ -0,0 +1,184 @@
"""The orchestration graph: decide who to consult, consult them, compose.
A graph rather than a loop because the routing is the interesting part here.
Which specialist is asked, whether two are asked at once, and where their
answers are reconciled are the decisions this agent exists to make — and a graph
states them where a chain of calls would bury them.
The graph is built per turn. Its tools carry the user's token, and a graph held
across turns would be a token held across turns.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, AnyMessage, SystemMessage, ToolMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from .config import Settings
from .delegation import Answer, Delegator
from .specialists import SPECIALISTS, by_name, describe
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """\
You are the Assistant for the Classic Models estate. You are the single entry
point: you understand what the user wants, consult the specialists that can
settle it, and compose one answer.
You hold no data yourself. You cannot read the catalog, sales, customers,
payments or workforce — the specialists can, each within what the user is
allowed to see. When something is outside every specialist's remit, say so
rather than answering from memory: an answer you invent is indistinguishable
from one they gave you, and that is the failure mode this estate is built to
avoid.
How to work:
- Consult a specialist when the answer depends on data. Do not guess a figure,
a name or a price you were not given, even if it seems obvious.
- Ask several specialists in the same step when the question has independent
parts. Do not chain calls that could have been made together.
- Ask a complete question in plain language. A specialist answers questions; it
does not run queries for you.
- Report figures exactly as a specialist returned them, and say which one said
what when it matters.
- If a specialist asks for a decision — a write it is holding, a missing detail
— relay what it said, unchanged, and stop. You do not approve on the user's
behalf.
- If a specialist refuses, report the refusal. It is the user's own permissions
being applied, not an obstacle to work around.
"""
class State(TypedDict):
"""What flows through the graph.
`delegations` counts calls rather than model turns: one specialist consulted
twice for the same question is the loop worth catching, and it is invisible
in a message count.
"""
messages: Annotated[list[AnyMessage], add_messages]
subject_token: str
context_id: str
delegations: int
def _tool_schemas() -> list[dict[str, Any]]:
"""One tool per specialist, described by what it decides."""
return [
{
"type": "function",
"function": {
"name": f"ask_{s.name}",
"description": describe(s),
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": (
"A complete question in plain language, carrying every detail "
"the specialist needs. It cannot see the conversation."
),
}
},
"required": ["question"],
},
},
}
for s in SPECIALISTS
]
def build_graph(settings: Settings, model: ChatOpenAI, delegator: Delegator) -> Any:
"""Compile the graph for one turn."""
bound = model.bind_tools(_tool_schemas())
async def plan(state: State) -> dict[str, Any]:
"""Decide who to consult, or answer."""
reply = await bound.ainvoke([SystemMessage(SYSTEM_PROMPT), *state["messages"]])
return {"messages": [reply]}
async def delegate(state: State) -> dict[str, Any]:
"""Consult every specialist the last step named, concurrently.
Concurrency is the point of fanning out at all: "can we ship order
10248?" asks two specialists about independent things, and waiting for
one before starting the other buys nothing.
"""
last = state["messages"][-1]
assert isinstance(last, AIMessage)
calls = last.tool_calls
budget = settings.max_delegations - state["delegations"]
allowed, refused = calls[:budget], calls[budget:]
async def run(call: dict[str, Any]) -> ToolMessage:
specialist = by_name(call["name"].removeprefix("ask_"))
if specialist is None:
# The model named something that is not a specialist. Answer the
# tool call rather than raising: the run continues and the model
# is told, which is more useful than a stack trace.
return ToolMessage(
content=f"There is no specialist called {call['name']!r}.",
tool_call_id=call["id"],
)
answer = await delegator.ask(
specialist,
call["args"].get("question", ""),
state["subject_token"],
state["context_id"],
)
return ToolMessage(
content=_render(answer), tool_call_id=call["id"], name=call["name"]
)
results = list(await asyncio.gather(*(run(c) for c in allowed)))
results.extend(
ToolMessage(
content=(
"Not consulted: this turn has reached its limit of "
f"{settings.max_delegations} delegations. Answer with what you have, "
"and say what you could not check."
),
tool_call_id=call["id"],
name=call["name"],
)
for call in refused
)
return {"messages": results, "delegations": state["delegations"] + len(allowed)}
def next_step(state: State) -> str:
last = state["messages"][-1]
if isinstance(last, AIMessage) and last.tool_calls:
return "delegate"
return END
graph = StateGraph(State)
graph.add_node("plan", plan)
graph.add_node("delegate", delegate)
graph.add_edge(START, "plan")
graph.add_conditional_edges("plan", next_step, {"delegate": "delegate", END: END})
graph.add_edge("delegate", "plan")
return graph.compile()
def _render(answer: Answer) -> str:
"""What the model sees of a specialist's reply.
A specialist waiting on a decision is labelled as such, because the model's
instruction is to relay it unchanged — and an unlabelled reply reads like a
finished answer.
"""
if answer.needs_the_user:
return (
f"{answer.specialist} is waiting for the user to decide. Relay this "
f"verbatim and stop:\n\n{answer.text}"
)
return answer.text
+111
View File
@@ -0,0 +1,111 @@
"""Token exchange: the end user's token in, one narrowly-scoped token per
specialist out.
Copied from the Catalogue agent rather than shared. `classic-models-agent-platform`
holds documentation and no code today, and two copies of forty lines is cheaper
than a package that exists before anyone knows what belongs in it. Move it there
when a third agent needs it unchanged.
Two measured facts about Keycloak 26.6 shape this, both of which cost time to
discover and neither of which is obvious from the RFC:
**`actor_token` is silently ignored.** Standard Token Exchange accepts the
parameter, returns a valid token, and emits no `act` claim — no error, no
warning. Delegation therefore cannot be built on RFC 8693 `act`. The actor is
identified by `azp`, which carries this agent's client id.
**The audience is requested as a scope.** Passing `audience=mcp-catalog` returns
"Requested audience not available". Passing `scope=openid mcp-catalog-audience`
works, and the resulting token carries `aud: [mcp-catalog]`.
"""
from __future__ import annotations
import base64
import json
import time
from dataclasses import dataclass
import httpx
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
class TokenExchangeError(RuntimeError):
"""Keycloak refused the exchange. Carries its response, which names the cause."""
@dataclass(frozen=True)
class ExchangedToken:
access_token: str
expires_at: float
@property
def expired(self) -> bool:
# A conversation turn lasts seconds and a token five minutes, so this
# margin only matters for a turn that stalls on a slow model.
return time.monotonic() >= self.expires_at - 30
class TokenExchanger:
"""Exchanges a user token for audience-scoped tokens.
Caches per (subject token, scope) for the lifetime of one turn. Deliberately
not a long-lived cache keyed by user: a token that outlives the turn it was
minted for is a token that can be replayed after the user's session ended.
"""
def __init__(self, client: httpx.AsyncClient, *, token_endpoint: str, client_id: str, client_secret: str) -> None:
self._client = client
self._endpoint = token_endpoint
self._client_id = client_id
self._client_secret = client_secret
self._cache: dict[tuple[str, str], ExchangedToken] = {}
async def exchange(self, subject_token: str, audience_scope: str) -> str:
key = (subject_token, audience_scope)
cached = self._cache.get(key)
if cached and not cached.expired:
return cached.access_token
response = await self._client.post(
self._endpoint,
data={
"grant_type": GRANT_TYPE,
"client_id": self._client_id,
"client_secret": self._client_secret,
"subject_token": subject_token,
"subject_token_type": SUBJECT_TOKEN_TYPE,
# Not `audience=...`. See the module docstring.
"scope": f"openid {audience_scope}",
},
)
if response.status_code != 200:
raise TokenExchangeError(
f"exchange for {audience_scope!r} failed: HTTP {response.status_code} {response.text[:200]}"
)
payload = response.json()
token = ExchangedToken(
access_token=payload["access_token"],
expires_at=time.monotonic() + float(payload.get("expires_in", 60)),
)
self._cache[key] = token
return token.access_token
def caller_of(access_token: str) -> str:
"""The username inside a token, for the prompt and nothing else.
Read without verifying the signature on purpose: this is a label shown to
the model, never a decision. Every decision that depends on this identity is
taken by Keycloak or by the gateway, both of which do verify.
"""
try:
payload = access_token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
except Exception: # noqa: BLE001 — a malformed token is the gateway's problem
return "an unidentified caller"
return claims.get("preferred_username") or claims.get("sub") or "an unidentified caller"
+84
View File
@@ -0,0 +1,84 @@
"""One turn of the Assistant: exchange, build, run, remember.
Everything token-bearing is built per turn and dropped after it. What survives is
the conversation, keyed by A2A `contextId` — kept in memory and bounded, which is
why the deployment runs a single replica.
"""
from __future__ import annotations
from collections import OrderedDict
import httpx
from langchain_core.messages import AnyMessage, HumanMessage
from langchain_openai import ChatOpenAI
from .config import Settings
from .delegation import Delegator
from .graph import build_graph
from .identity import TokenExchanger
MAX_CONVERSATIONS = 200
class Assistant:
"""Holds what is stable across turns; rebuilds what must not be."""
def __init__(self, settings: Settings, http_client: httpx.AsyncClient) -> None:
self._settings = settings
self._exchanger = TokenExchanger(
http_client,
token_endpoint=settings.token_endpoint,
client_id=settings.client_id,
client_secret=settings.client_secret,
)
self._delegator = Delegator(settings, self._exchanger)
self._history: OrderedDict[str, list[AnyMessage]] = OrderedDict()
async def run(self, prompt: str, subject_token: str, conversation_id: str) -> str:
llm_token = await self._exchanger.exchange(subject_token, self._settings.llm_scope)
model = ChatOpenAI(
model=self._settings.llm_model,
base_url=self._settings.llm_base_url,
# An exchanged token, not a static key. The endpoint validates it as
# a bearer, so inference is attributable to the same (agent, end
# user) pair as every delegation.
api_key=llm_token,
timeout=self._settings.http_timeout,
# No retries here: the estate's endpoint answers 429 with a
# Retry-After the client library does not honour, and a blind retry
# would burn the budget faster. Handled at the transport in the
# specialists; left visible here rather than hidden behind a default.
max_retries=0,
)
graph = build_graph(self._settings, model, self._delegator)
history = self._history.get(conversation_id, [])
state = await graph.ainvoke(
{
"messages": [*history, HumanMessage(prompt)],
"subject_token": subject_token,
"context_id": conversation_id,
"delegations": 0,
}
)
self._remember(conversation_id, state["messages"])
final = state["messages"][-1]
return _text_of(final)
def _remember(self, conversation_id: str, messages: list[AnyMessage]) -> None:
self._history[conversation_id] = messages
self._history.move_to_end(conversation_id)
while len(self._history) > MAX_CONVERSATIONS:
self._history.popitem(last=False)
def _text_of(message: AnyMessage) -> str:
content = message.content
if isinstance(content, str):
return content
# Some providers return content as a list of blocks; keep the text ones.
return "".join(
block.get("text", "") for block in content if isinstance(block, dict)
) or str(content)
+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)