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
+27
View File
@@ -0,0 +1,27 @@
# Everything here is an endpoint or a public identifier. The one secret the
# agent holds arrives through assistant-agent-secrets; nothing in this file is
# sensitive, which is the point of keeping the split visible.
apiVersion: v1
kind: ConfigMap
metadata:
name: assistant-agent-config
namespace: agents
data:
ASSISTANT_KEYCLOAK_REALM_URL: https://keycloak.sttlab.local/realms/classic-models
ASSISTANT_CLIENT_ID: classic-models-agent-assistant
ASSISTANT_LLM_BASE_URL: https://llm-dev.sttlab.local/llm/v1
ASSISTANT_LLM_MODEL: google/gemini-3.7-flash
# Trust the lab CA rather than disabling verification. Mounted from the
# sttlab-ca ConfigMap.
ASSISTANT_CA_BUNDLE: /etc/sttlab-ca/ca.crt
# What callers dial, so it is the Service DNS name and not the bind address.
# Advertised in the Agent Card; get it wrong and A2A callers are pointed at
# nothing.
ASSISTANT_PUBLIC_URL: http://assistant-agent.agents.svc.cluster.local:8080/
# Specialists are reached at their own Service names, compiled into
# specialists.py. ASSISTANT_URL_<NAME> overrides one, and exists only for
# running the orchestrator outside the cluster against a port-forward.
+74
View File
@@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: assistant-agent
namespace: agents
spec:
# One replica, and that is a constraint rather than a default. Conversation
# history lives in the process, keyed by A2A contextId — and a specialist's
# approval round trip passes back through here, so a second replica would
# route the user's "approve" to a pod holding none of the exchange. Scaling
# out needs sticky routing on contextId or a shared store first.
replicas: 1
selector:
matchLabels:
app: assistant-agent
template:
metadata:
labels:
app: assistant-agent
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: agent
# gitea's own registry: the image sits beside the source it was built
# from and inherits the repo's public visibility, so the pull needs no
# credentials at all.
image: gitea.sttlab.eu/stt/classic-models-agent-assistant:v0.1.0
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: assistant-agent-config
- secretRef:
name: assistant-agent-secrets
volumeMounts:
- name: sttlab-ca
mountPath: /etc/sttlab-ca
readOnly: true
# The Agent Card needs no auth and is served by the same app, so a 200
# means the A2A routes are genuinely mounted — not merely that the
# process is alive.
readinessProbe:
httpGet:
path: /.well-known/agent-card.json
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /.well-known/agent-card.json
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 192Mi
limits:
cpu: "1"
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: sttlab-ca
configMap:
name: sttlab-ca
+24
View File
@@ -0,0 +1,24 @@
# The agent's Keycloak client secret, and nothing else. It comes from the same
# Vault entry the realm import reads, so there is exactly one copy of this
# secret in the estate and rotating it in Vault rotates it everywhere.
#
# vault-keycloak rather than a store of the agents' own: this value is a
# Keycloak client secret, owned by the realm, and inventing a second AppRole to
# read the same path would add a credential without adding a boundary.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: assistant-agent-secrets
namespace: agents
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-keycloak
target:
name: assistant-agent-secrets
data:
- secretKey: ASSISTANT_CLIENT_SECRET
remoteRef:
key: keycloak/classic-models-realm
property: KC_SECRET_CLASSIC_MODELS_AGENT_ASSISTANT
+10
View File
@@ -0,0 +1,10 @@
# The `agents` namespace and the sttlab-ca ConfigMap are created by the
# catalogue agent's repo, which got there first. Declaring them here too
# would give two Kustomizations the same objects to prune.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- externalsecret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: assistant-agent
namespace: agents
spec:
selector:
app: assistant-agent
ports:
- name: http
port: 8080
targetPort: http