64fcd85b24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
7.9 KiB
Markdown
184 lines
7.9 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 | `Approvisionnement.check_availability`, before recommending a discontinuation |
|
|
| 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 **`replicas: 1`** in `k8s/deployment.yaml` is a constraint, not a default:
|
|
conversation history lives in the process, and so does a pending write waiting
|
|
for approval. Moving either out is what would allow a second replica.
|
|
- The Keycloak client is still named `classic-models-agent-catalogue` while the
|
|
Kubernetes objects say `catalog-agent`. Either is fine — pick one.
|
|
|
|
Everything this section used to list is done: the Keycloak client exists, writes
|
|
and the A2A call to the supply agent work, and `k8s/` deploys through Flux.
|
|
|
|
## Deployment
|
|
|
|
```
|
|
gitea.sttlab.eu/stt/classic-models-agent-catalogue:v0.3.2
|
|
```
|
|
|
|
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 |
|
|
|