Add implementation instructions

Design agreed in discussion, nothing built yet. Records the agent's role, its
scopes and MCP servers, its A2A surface, and the framework choice with its
reason — so the next session starts from the decisions rather than re-deriving
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-26 15:14:41 +02:00
commit ddc0f24cb9
+180
View File
@@ -0,0 +1,180 @@
# classic-models-agent-platform
Shared ground for the classic-models agent estate: templates, conventions, and
the identity plumbing every agent repeats. Plays the role `kong-k8s-platform`
plays for the gateways.
**Nothing runs from this repo.** It holds the `${VAR}`-parameterised manifests a
domain's Flux `Kustomization` builds, plus the reference documentation below.
## The estate this plugs into
The agents replace LibreChat as the MCP client and orchestrator. Everything
below them already exists and does not change: Kong gateways per domain, MCP
servers served by Kong's `ai-mcp-proxy`, Keycloak, and the domain APIs.
| Agent | Repo | Framework | Mode |
|---|---|---|---|
| Assistant | `classic-models-agent-assistant` | LangGraph | conversational |
| Ventes | `classic-models-agent-ventes` | CrewAI | conversational |
| Catalogue | `classic-models-agent-catalogue` | PydanticAI + FastA2A | conversational |
| Support | `classic-models-agent-support` | Google ADK | conversational |
| Approvisionnement | `classic-models-agent-flows` | n8n | autonomous |
| Recouvrement | `classic-models-agent-flows` | n8n | autonomous |
Five runtimes is deliberate. The point is proving A2A interoperability between
stacks that do not know each other; a single framework would prove nothing.
## Topology
A2A between agents, MCP between an agent and data.
```
Assistant ──→ every agent (orchestration; holds no scopes itself)
Ventes ──→ Approvisionnement, Recouvrement
Support ──→ Ventes
Catalogue ──→ Approvisionnement
```
**An A2A call returns an answer, not data.** Ventes asks Approvisionnement "can
I promise delivery on 15 March?" and receives yes/no — it never gains read
access to the catalog domain. Agent Cards therefore expose narrow,
decision-shaped skills (`check_availability`, `check_creditworthiness`), never a
generic `query`. An agent that exposes "ask me anything about my domain" has
become a data proxy and defeats the scoping.
The Assistant holds **no scopes at all**. It is the component that sees raw user
text, so it is the prompt-injection target, and it must be unable to exfiltrate
anything.
## Identity
Each agent is its own Keycloak client. That is what makes the actor
identifiable: the exchanged token carries `azp` = the agent's client id
alongside `sub` = the end user.
```json
{ "sub": "alice", "azp": "classic-models-agent-ventes",
"aud": ["mcp-catalog"], "scope": "openid profile email mcp-catalog-audience" }
```
Two measured facts that constrain the implementation:
**`actor_token` is silently ignored.** Keycloak 26.6's Standard Token Exchange
accepts the parameter, returns a valid token, and emits **no `act` claim**. No
error. Do not build delegation on RFC 8693 `act` — use `azp`.
**The audience is requested via a scope, not the `audience` parameter.** Passing
`audience=mcp-catalog` returns `Requested audience not available`. Pass
`scope=openid mcp-catalog-audience`.
### The exchange
Do what LibreChat already does — a POST per target audience, roughly twenty
lines. Do not build a sidecar or move the exchange into Kong; both were
considered and rejected as over-engineering, the second also because it would
leave the agent holding one broad token instead of several narrow ones.
```
POST https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=<the agent's client id>
client_secret=<from the agent's Secret>
subject_token=<the end user's access token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
scope=openid mcp-<domain>-audience
```
Neither LangGraph nor CrewAI injects a per-call MCP token natively
(langchain-mcp-adapters issues #172/#194/#235; CrewAI's `MCPServerAdapter` fixes
headers at construction). This is a non-issue: build the MCP client **once per
conversation turn** with freshly exchanged tokens. A turn lasts seconds, an
access token five minutes.
## Endpoints
MCP servers, one per domain, served by Kong:
| Domain | URL | Audience scope |
|---|---|---|
| catalog | `https://catalog-dev.sttlab.local/catalog-mcp` | `mcp-catalog-audience` |
| customers | `https://customers-dev.sttlab.local/customers-mcp` | `mcp-customers-audience` |
| payments | `https://payments-dev.sttlab.local/payments-mcp` | `mcp-payments-audience` |
| sales | `https://sales-dev.sttlab.local/sales-mcp` | `mcp-sales-audience` |
| workforce | `https://workforce-dev.sttlab.local/workforce-mcp` | `mcp-workforce-audience` |
LLM inference goes through the Kong AI gateway, never direct to a provider:
```
https://llm-dev.sttlab.local/llm/v1/chat/completions
```
It applies rate limiting, prompt guards and semantic guards, and routes to
OpenRouter. Sending inference anywhere else bypasses all of them.
These hostnames are lab-only with a self-signed certificate. `/etc/hosts`:
```
192.168.139.103 catalog-dev.sttlab.local
192.168.139.105 llm-dev.sttlab.local
192.168.139.106 customers-dev.sttlab.local
192.168.139.107 payments-dev.sttlab.local
192.168.139.108 sales-dev.sttlab.local
192.168.139.109 workforce-dev.sttlab.local
192.168.139.101 keycloak.sttlab.local
```
The CA is `sttlab-local-ca` (cert-manager ClusterIssuer). Trust that one CA
rather than disabling verification.
## Adding an agent
An agent is a declaration first. Put it here, let the generator produce the rest
— the Keycloak client is generated from this, never hand-written in the agent's
own repo, so the realm stays the single source of truth.
```yaml
name: ventes
client_id: classic-models-agent-ventes
framework: crewai
surfaces: [conversational]
mcp_servers: [catalog, sales]
scopes: [catalog:read, sales:read, sales:write]
a2a_calls: [approvisionnement, recouvrement]
limits:
max_tool_calls_per_run: 20
max_records_read_per_run: 500
```
`scopes` becomes the client's `optionalClientScopes`; `mcp_servers` becomes the
audience scopes it may request; `a2a_calls` becomes the A2A authorisations.
The realm actually deployed is `infra-keycloak/k8s/keycloak/configmap-realm.yaml`
— note that `kong-v2/classic-models-realm.yaml` is an older partial copy and is
**not** authoritative.
## Known blockers
**Konnect is not persisting configuration.** The operator loops on the same
objects without converging and the dataplanes stay behind the control plane, so
no new Kong config reaches the gateways — including any new agent audience.
Resolve this before expecting an end-to-end run.
**No `employeeNumber` in tokens.** Sales territory scoping (a rep sees their
territory, a manager sees all) resolves through
`employee → officeCode → offices.territory`, but the token carries no employee
identity, so the API cannot filter server-side. The realm's users
(`admin-user`, `pm-user`, …) are role placeholders, not real employees.
**The `kong-token-exchange-broker` secret does not match the realm** — its
`client_credentials` returns `unauthorized_client`.
## Conventions
- Deliverables in English: code, comments, READMEs, Agent Cards. Conversation
with the maintainer is in French.
- GitOps throughout. Never `kubectl apply` an object carrying
`kustomize.toolkit.fluxcd.io/name` — Flux reverts it within its interval, and
tests run against config that no longer exists.
- Secrets never committed. Follow the `secret.example.yaml` pattern used by the
`infra-*` repos.