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:
@@ -0,0 +1,108 @@
|
||||
# Assistant — the orchestrating agent
|
||||
|
||||
The single entry point for users. Understands a request, consults the
|
||||
specialists that can settle it, and composes one answer.
|
||||
|
||||
**Framework:** LangGraph · **Mode:** conversational · **Client id:**
|
||||
`classic-models-agent-assistant`
|
||||
|
||||
## It holds nothing, and that is enforced by the realm
|
||||
|
||||
This is the component that sees raw user text, so it is the prompt-injection
|
||||
target. It has no MCP server, no domain scope, and no data of its own. That is
|
||||
not a convention in the prompt — it is what the realm grants:
|
||||
|
||||
```
|
||||
OBTAINED classic-models-agent-catalogue-audience aud=[classic-models-agent-catalogue]
|
||||
refused mcp-catalog-audience → Invalid scopes
|
||||
refused mcp-sales-audience → Invalid scopes
|
||||
refused catalog-api-audience → Invalid scopes
|
||||
```
|
||||
|
||||
An injection that succeeds against this agent finds no path to a domain,
|
||||
because there is none to find. Its two optional scopes are one specialist and
|
||||
the inference endpoint — inference is not a domain, and the agent has to reason.
|
||||
|
||||
Adding a specialist is an entry in `specialists.py` plus an audience scope
|
||||
granted in the realm. Add the entry without the grant and every call to it fails
|
||||
at the token exchange with `Invalid scopes`, loudly, before any data moves.
|
||||
|
||||
## The chain
|
||||
|
||||
Four hops, `sub` unchanged throughout — which is what makes the end user's own
|
||||
roles apply at the far end:
|
||||
|
||||
```
|
||||
1. user sub=pm-user azp=librechat aud=[…-agent-assistant]
|
||||
2. assistant→cat sub=pm-user azp=classic-models-agent-assistant aud=[…-agent-catalogue]
|
||||
3. cat→mcp sub=pm-user azp=classic-models-agent-catalogue aud=[mcp-catalog, token-exchange-broker]
|
||||
4. MCP call → 200
|
||||
```
|
||||
|
||||
`azp` names the last agent in the chain, not the first. Keycloak accepts
|
||||
`actor_token` and emits no `act` claim, so an orchestrator's identity does not
|
||||
survive a second exchange — the delegation is visible in the traces, not in the
|
||||
token.
|
||||
|
||||
## What it does with an answer
|
||||
|
||||
A specialist returns an answer, never data. The orchestrator composes; it does
|
||||
not aggregate records it was not given.
|
||||
|
||||
Two behaviours worth naming, both verified end to end:
|
||||
|
||||
- **Out of remit → it says so.** Asked for a company-wide revenue figure, it
|
||||
reports that no specialist can settle it rather than answering from what the
|
||||
model knows. An invented answer is indistinguishable from one the estate gave.
|
||||
- **A held write is relayed verbatim.** Catalogue parks a proposed write in
|
||||
`input-required`; this agent passes that message through unchanged and stops.
|
||||
The user's `approve` comes back through the same conversation and reaches the
|
||||
specialist, which owns the decision. The orchestrator never approves on
|
||||
anyone's behalf.
|
||||
|
||||
## The graph
|
||||
|
||||
`plan → delegate → plan → …` until the model stops calling tools. One tool per
|
||||
specialist, described by what it *decides*. Delegations in the same step run
|
||||
concurrently — that is the point of fanning out at all — and are capped by
|
||||
`ASSISTANT_MAX_DELEGATIONS` (8), counted in calls rather than model turns,
|
||||
because one specialist consulted twice for the same question is the loop worth
|
||||
catching.
|
||||
|
||||
The graph is rebuilt per turn: its tools carry the user's token, and a graph
|
||||
held across turns would be a token held across turns.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
uv venv && uv pip install -e '.[dev]'
|
||||
ASSISTANT_CLIENT_SECRET=… ASSISTANT_CA_BUNDLE=… \
|
||||
ASSISTANT_URL_CATALOGUE=http://localhost:8099/ \
|
||||
python -m assistant_agent
|
||||
```
|
||||
|
||||
`ASSISTANT_URL_<NAME>` overrides a specialist's address, for running outside the
|
||||
cluster against a port-forward. In the cluster nothing sets it.
|
||||
|
||||
macOS resolves every `.local` name through mDNS before falling back to
|
||||
`/etc/hosts`, at a flat ~5s per host. `ASSISTANT_HTTP_TIMEOUT` defaults to 60s
|
||||
for that reason; in the cluster the names resolve instantly.
|
||||
|
||||
## Deployment
|
||||
|
||||
```
|
||||
gitea.sttlab.eu/stt/classic-models-agent-assistant:v0.1.0
|
||||
```
|
||||
|
||||
`k8s/` is what runs, `dev/` is the Flux plumbing. Two manual applies close the
|
||||
loop once — the `agents` namespace and the `sttlab-ca` ConfigMap already exist,
|
||||
created by the catalogue agent's repo, and are deliberately not declared here so
|
||||
two Kustomizations do not fight over pruning them.
|
||||
|
||||
```
|
||||
kubectl apply -f dev/gitrepository.yaml
|
||||
kubectl apply -f dev/bootstrap-kustomization.yaml
|
||||
```
|
||||
|
||||
`replicas: 1` is a constraint: conversation history lives in the process, and a
|
||||
specialist's approval round trip passes back through here.
|
||||
Reference in New Issue
Block a user