601bd6343e
An agent talks to URLs that honour a contract: an MCP endpoint, an OIDC token endpoint, an OpenAI-shaped LLM endpoint. Whatever serves those URLs is replaceable, and naming it in an agent's code or docs implies a coupling that does not exist. Every product name is gone from prose, comments and identifiers, replaced by what the agent actually depends on. Where a behaviour of the current middleware leaks into the contract — the second audience scope, the tool ACL — the text now says what the caller must do and why, not who imposes it. One string survives and cannot be fixed here: the broker client an agent must name in its audience scopes is itself named after the middleware. Closing that means renaming it in Keycloak and following the rename through the gateway configuration and LibreChat. Recorded as a blocker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
7.8 KiB
Markdown
183 lines
7.8 KiB
Markdown
# Catalogue
|
|
|
|
Product management agent for the Classic Models catalog domain. Serves a product
|
|
manager: what exists, how it is priced, whether it sells, what to discontinue.
|
|
|
|
Reached over **A2A**; reads data over **MCP** from the domain servers; thinks
|
|
through the estate's **LLM endpoint**. It is one MCP client among others, as
|
|
LibreChat is — nothing under it changes to accommodate it.
|
|
|
|
## Running it
|
|
|
|
```bash
|
|
uv venv && uv pip install -e ".[dev]"
|
|
export CATALOGUE_CLIENT_SECRET=... # Keycloak client secret, no default
|
|
export CATALOGUE_CA_BUNDLE=/path/to/sttlab-local-ca.crt
|
|
python -m catalogue_agent
|
|
```
|
|
|
|
Everything else has a working default in `config.py` and is overridable with
|
|
`CATALOGUE_`-prefixed variables.
|
|
|
|
```bash
|
|
curl localhost:8080/.well-known/agent-card.json
|
|
|
|
curl -X POST localhost:8080/ \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'A2A-Version: 1.0' \
|
|
-H "Authorization: Bearer $USER_TOKEN" \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"SendMessage",
|
|
"params":{"message":{"messageId":"m1","role":"ROLE_USER",
|
|
"parts":[{"text":"How many product lines are there?"}]}}}'
|
|
```
|
|
|
|
**`A2A-Version: 1.0` is required.** Without it the SDK assumes 0.3 and answers
|
|
`VERSION_NOT_SUPPORTED`, which reads as a protocol error even though the request
|
|
is fine.
|
|
|
|
## How a turn works
|
|
|
|
```
|
|
A2A SendMessage ──► exchange the user's token, once per audience
|
|
──► build one MCP toolset per server, with that turn's tokens
|
|
──► PydanticAI runs the model through the LLM endpoint
|
|
──► answer, toolsets dropped
|
|
```
|
|
|
|
The MCP client is built **per turn**. A token that outlives the turn it was
|
|
minted for is a token that can be replayed after the user's session ended, and
|
|
holding one long-lived connection would mean one long-lived token.
|
|
|
|
Several narrow tokens rather than one broad one: a token minted for
|
|
`mcp-catalog` is refused by the sales gateway, so a prompt injection that talks
|
|
the agent into reaching the wrong domain is refused at the endpoint rather than
|
|
succeeding quietly.
|
|
|
|
## What it can and cannot do
|
|
|
|
| | |
|
|
|---|---|
|
|
| Reads | catalog (products, product lines), sales (read-only, to judge whether a product sells) |
|
|
| Writes | catalog: `create-product`, and only after the user confirms the exact record |
|
|
| Calls | nothing yet. `Approvisionnement.check_availability` comes with discontinuation decisions |
|
|
| Exposes | `describe_catalog` |
|
|
|
|
Who may write is decided by the MCP server, not by this agent. It authorises
|
|
each tool against the roles in the end user's own token — `create-product`
|
|
needs `admin` or `product_manager` — and it does so at **discovery**, not only
|
|
at call time:
|
|
|
|
```
|
|
pm-user catalog ['create-product', 'get-product', 'list-products']
|
|
ro-user catalog ['get-product', 'list-products']
|
|
```
|
|
|
|
So a read-only user's turn has no write tool in it at all, and no prompt
|
|
injection can conjure one. The agent holds no standing write access of its own;
|
|
the system prompt's confirm-before-writing rule is a courtesy on top of that,
|
|
not the control.
|
|
|
|
Writing needs conversation history — the agent restates the record, the user
|
|
says yes, and "yes" only means something if the previous turn is still there.
|
|
History is kept in memory, keyed by A2A `contextId`, bounded, and never
|
|
persisted. More than one replica therefore needs sticky routing on `contextId`
|
|
or a shared store.
|
|
|
|
Skills are decision-shaped on purpose. An agent exposing a generic `query` has
|
|
become a data proxy and defeats the per-domain scoping the estate is built on,
|
|
so skills get added when another agent has a specific question — not in advance.
|
|
|
|
## Two Keycloak facts this depends on
|
|
|
|
Both measured against Keycloak 26.6, both costly to rediscover:
|
|
|
|
**`actor_token` is silently ignored.** Standard Token Exchange accepts it,
|
|
returns a valid token and emits no `act` claim — no error. Delegation cannot be
|
|
built on RFC 8693 `act`; the actor is identified by `azp`, which carries this
|
|
agent's client id while `sub` stays the human.
|
|
|
|
**The audience is requested as a scope.** `audience=mcp-catalog` returns
|
|
"Requested audience not available". `scope=openid mcp-catalog-audience` works.
|
|
|
|
## Library choices
|
|
|
|
Pinned to what was current on 2026-08-28, ranges below the next major so patch
|
|
fixes arrive but an upgrade stays deliberate.
|
|
|
|
**`a2a-sdk` rather than FastA2A.** The earlier design said FastA2A, on the
|
|
grounds that `Agent.to_a2a()` was going away in PydanticAI v2 — which has since
|
|
happened, so that argument no longer distinguishes them. This estate exists to
|
|
prove A2A interoperability across five runtimes, and the reference
|
|
implementation is the safer thing for four other stacks to interoperate with.
|
|
|
|
**`httpx2` rather than `httpx`.** PydanticAI deprecates httpx clients for
|
|
OpenAI-compatible providers and removes them in v3.
|
|
|
|
Notes on `a2a-sdk` 1.1 that are not guessable, all established against the
|
|
running library:
|
|
|
|
- `a2a.types` are protobuf messages, not pydantic models.
|
|
- Use `DefaultRequestHandlerV2`; `DefaultRequestHandler` speaks 0.3.
|
|
- JSON-RPC methods are `SendMessage`, `GetTask`… The 0.3 spelling
|
|
(`message/send`) still works — `enable_v0_3_compat` is on.
|
|
- An executor must enqueue a `Task` before any status update when
|
|
`context.current_task` is None.
|
|
|
|
## Not done yet
|
|
|
|
- **The Keycloak client does not exist.** `classic-models-agent-catalogue` has
|
|
to be declared in `infra-keycloak/k8s/keycloak/configmap-realm.yaml`, with
|
|
`mcp-catalog-audience` and `mcp-sales-audience` as optional scopes. Until then
|
|
the exchange fails and the agent says so.
|
|
- Writes, and the A2A call to Approvisionnement.
|
|
- Deployment manifests — they belong in `classic-models-agent-platform` once a
|
|
second agent shows what is actually shared.
|
|
|
|
## Deployment
|
|
|
|
```
|
|
gitea.sttlab.eu/stt/classic-models-agent-catalogue:v0.1.0
|
|
```
|
|
|
|
gitea's own registry rather than ghcr, so the image sits beside the source it
|
|
was built from and inherits the repo's visibility. The repo is public, so the
|
|
cluster pulls it with no credentials at all — no `imagePullSecret` to create by
|
|
hand, and no GitHub token to keep in step.
|
|
|
|
Built from the `Dockerfile` here — two stages, so the runtime image carries no
|
|
build toolchain; non-root, read-only root filesystem, all capabilities dropped.
|
|
Built and pushed by hand for now: gitea Actions is not set up on this instance,
|
|
so there is no pipeline to do it.
|
|
|
|
```
|
|
docker login gitea.sttlab.eu # gitea username + a token with package:write
|
|
docker build -t gitea.sttlab.eu/stt/classic-models-agent-catalogue:vX.Y.Z .
|
|
docker push gitea.sttlab.eu/stt/classic-models-agent-catalogue:vX.Y.Z
|
|
```
|
|
|
|
`k8s/` is what runs; `dev/` is the Flux plumbing that applies it. Three manual
|
|
applies close the loop once — nothing can watch `./dev` before the namespace and
|
|
the source that serves it exist:
|
|
|
|
```
|
|
kubectl apply -f k8s/namespace.yaml
|
|
kubectl apply -f dev/gitrepository.yaml
|
|
kubectl apply -f dev/bootstrap-kustomization.yaml
|
|
```
|
|
|
|
After that every file here reconciles from git, `dev/` included. Never
|
|
`kubectl apply` the objects under `k8s/` afterwards — Flux reverts them within
|
|
its interval and the cluster silently stops matching what you edited.
|
|
|
|
What the deployment assumes:
|
|
|
|
| | |
|
|
|---|---|
|
|
| Namespace | `agents` — one for the estate; what isolates an agent is its Keycloak client, not its namespace |
|
|
| Secret | `CATALOGUE_CLIENT_SECRET`, from the same Vault entry the realm import reads, through the `vault-keycloak` store |
|
|
| CA | `k8s/ca-configmap.yaml` holds the lab CA **certificate** — public by construction, checked in so the deployment has no hidden prerequisite. Regenerate it when the CA rotates (expires 2026-11-24) |
|
|
| Image pull | none — the gitea package is public, like its repo |
|
|
| DNS | every `*.sttlab.local` name resolves in-cluster already; no `hostAliases` needed |
|
|
| Replicas | exactly one. Conversation history lives in the process, so a second replica would route a user's "yes" to a pod that never saw the question |
|
|
|