Files
classic-models-agent-assistant/README.md
T
Stéphane Tailland 9aa5559e45 Correct the image version in the README
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:40:51 +02:00

4.4 KiB

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.2.1

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.