Commit Graph

6 Commits

Author SHA1 Message Date
Stéphane Tailland 8051b78647 feat: consult Approvisionnement before dropping a reference
Closes the last item on this agent's backlog. A reference about to be reordered
is not a reference to discontinue, and the catalog's own quantity field does not
say whether it is — that is the supply agent's decision, and it answers rather
than handing back its stock table.

One narrow tool, not a generic call_agent(name, text): a generic one lets the
model reach any peer with any prompt, which is how an agent becomes a proxy onto
its neighbours. The peer's token is minted per call for that peer alone, so an
injection that talks this agent into consulting the wrong one fails at the
realm.

A peer ending in any state but completed is reported as such, never smoothed
over: a specialist that escalated has not answered, and presenting its
escalation as an answer is how a refusal turns into a fact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:35:18 +02:00
Stéphane Tailland 1a13743078 feat: make the write path a mechanism instead of a prompt rule
The agent asked for confirmation because the system prompt told it to, and read
the answer itself. Both halves are now enforced.

Each MCP toolset is wrapped in ApprovalRequiredToolset whose predicate reads the
readOnlyHint the server publishes — the gateway decides what counts as a write,
and the agent keeps no list of tool names in step with it.
output_type=[str, DeferredToolRequests] lets a turn end holding a proposed call
instead of making it; the executor parks the A2A task in input-required and
renders the arguments the model actually sent, not what it said it would do.
The reply is matched against a closed set of words, so "oui mais change le prix"
denies the call and the correction comes back for approval on its own merits —
verified end to end, along with a draft the user approved that was still refused
because msrp sat below buyprice.

Also wired: a ProductDraft validated after approval and before the call leaves;
UsageLimits on every run; FilteredToolset denying sales' write tools inside the
agent as well as at the gateway; PrefixedToolset namespacing the two servers;
dynamic instructions naming the caller; Agent.instrument_all() for a span per
model call and per tool call; and pydantic_ai.retries on inference only, so a
429 from the gateway is waited out rather than failing mid-reasoning.

Inference gets its own httpx client: validate_response raises on any 4xx, and
applying that to the shared client would turn a token-exchange refusal into an
exception carrying no body — which is where Keycloak says what was wrong. Its
transport is built explicitly, because a transport passed to the client makes
verify= inert and the lab CA would otherwise go undeclared.

Tests grew from 7 to 31, covering what approval decides and what a draft must
satisfy. evals/catalogue_evals.py adds a behavioural suite, kept out of pytest
because every case spends real tokens; its first case checks that a count comes
from the tool rather than from what the model remembers of a public dataset.

CLAUDE.md corrected: instrument=True is not an Agent parameter in 2.35.3,
pydantic_ai.retries needs tenacity, TemplateStr needs pydantic-handlebars, and
approval does not remove the in-memory history — resuming still needs the
messages, so replicas: 1 stands until they live in the A2A task store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 09:15:33 +02:00
Stéphane Tailland aace433d7e refactor: request the neutrally-named token-exchange broker
The broker client was renamed in Keycloak so that the contract an agent has to
honour no longer names the middleware serving it. This is the last consumer to
move; the gateways and LibreChat went first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:41:36 +02:00
Stéphane Tailland 31020e25dc refactor(deploy): publish the image to gitea rather than ghcr
The image now sits beside the source it was built from, in gitea's own
registry, and inherits the repository's visibility. The repo is public, so the
cluster pulls with no credentials — which removes the imagePullSecret entirely,
along with the hand-copied GitHub token it depended on and the cross-namespace
copy that kept it alive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:20:02 +02:00
Stéphane Tailland fd2df93683 fix(deploy): the ghcr package is private, so declare the pull secret
Same arrangement classic-models already uses: a hand-created ghcr-pull-secret
rather than one committed or held in Vault, since the credential is a GitHub
token that belongs to no estate in particular. Without it the pod would sit in
ImagePullBackOff with no hint as to why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:16:40 +02:00
Stéphane Tailland 9c3bdba3f9 feat: deploy the agent — image, manifests, and the Flux plumbing
Two-stage Dockerfile: the runtime image carries neither uv nor a build
toolchain, runs as uid 10001, drops every capability and needs no writable
filesystem (verified with --read-only and no tmpfs).

k8s/ is what runs, dev/ is the Flux plumbing that applies it, self-managing
after three manual applies — the same shape every kong-* repo settled on.

Three choices worth naming:

- One namespace for the estate. What isolates an agent is its Keycloak client
  and the audiences it may request, not a namespace boundary.
- The GitRepository uses anonymous HTTPS rather than SSH with a Vault-held
  deploy key. The repo is already anonymously readable on gitea, so a key would
  guard nothing while adding two objects to create by hand before anything
  deploys. If the repos ever go private this becomes the kong-* SSH pattern.
- The lab CA certificate is committed. It is a certificate, never a key, and
  checking it in means the deployment has no hidden prerequisite.

replicas: 1 is a constraint, not a default — conversation history lives in the
process, so a second replica would route a user's "yes" to a pod that never saw
the question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:50:02 +02:00