Files
classic-models-agent-catalogue/pyproject.toml
T
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

43 lines
1.6 KiB
TOML

[project]
name = "classic-models-agent-catalogue"
version = "0.2.0"
description = "Product management agent for the Classic Models catalog domain"
requires-python = ">=3.12"
# Pinned to what was current on 2026-08-28 and resolved together in one
# environment. Ranges rather than exact pins so patch-level security fixes are
# picked up, capped below the next major so an upgrade is a deliberate act.
dependencies = [
# The A2A facade. The official SDK rather than FastA2A: this estate exists
# to prove A2A interoperability across five runtimes, so the reference
# implementation is the safer base for the others to interoperate with.
"a2a-sdk[http-server]>=1.1.2,<2",
# Reasoning and tool calling. -slim keeps the dependency surface to the
# providers actually used; [openai] because the LLM endpoint speaks the
# OpenAI protocol, [mcp] for the MCP client.
"pydantic-ai-slim[openai,mcp]>=2.35.3,<3",
"httpx2>=2.12.0,<3",
# httpx 0.x as well, and not redundantly: fastmcp builds its own
# transport client and hands us an httpx.AsyncClient to configure.
"httpx>=0.28.1,<1",
"pydantic-settings>=2.7",
# pydantic_ai.retries ships in pydantic-ai-slim but imports tenacity,
# which the slim extras do not pull. Needed for the LLM gateway's 429s.
"tenacity>=9.1,<10",
"uvicorn>=0.52.4",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.24", "pydantic-evals>=2.35.3,<3"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/catalogue_agent"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]