Two bugs from review, the second in both agents because identity.py is a copy. The exchanged-token cache grew for the life of the process. It is keyed on (subject token, scope) and the subject token is minted fresh every turn, so every turn added keys nothing would ever read again — and expiry was checked on read but never collected. A slow leak that only shows in a pod up for days, which is the worst way to find one. It now drops expired entries first and evicts oldest beyond a bound. Expired first on purpose: an expired entry is dead weight wherever it sits in the ordering, so evicting by age alone would keep it while discarding a live one. The docstring was wrong before the code was: it claimed the cache lasted one turn. It never did. Corrected rather than quietly made true. message_id reused the context id, which is the conversation — and a conversation makes several calls to the same peer, relaying an approval being exactly that. A peer treating the second call as a replay of the first would surface as a stale answer rather than an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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'sapprovecomes 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.