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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""Which tool calls need a human, and what a valid draft looks like.
|
||||
|
||||
Two things live here, and they are deliberately separate from the system prompt.
|
||||
A prompt asks the model to behave; these decide. The model can be talked out of
|
||||
an instruction — by a confused user, or by product data carrying an injection —
|
||||
and cannot be talked out of either of the checks below.
|
||||
|
||||
Neither of them is authorisation. Who may write is settled by the MCP server
|
||||
against the end user's own roles, before this agent sees a tool at all. What is
|
||||
here makes the agent predictable, not privileged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
|
||||
# Fallback only. Tool names come from the API's operationId, so the verb is the
|
||||
# first segment — but the annotations below are the real source, and this is
|
||||
# what answers when a server declares none.
|
||||
WRITE_VERBS = frozenset({"create", "update", "patch", "delete", "post", "put"})
|
||||
|
||||
|
||||
def is_write(tool: ToolDefinition) -> bool:
|
||||
"""True when calling this tool changes something.
|
||||
|
||||
Read from the MCP annotations the server publishes — `readOnlyHint` is part
|
||||
of the MCP specification, and the gateway now emits it for every tool. So the
|
||||
server decides what counts as a write, and the agent obeys; it does not carry
|
||||
its own list of tool names to keep in step.
|
||||
"""
|
||||
annotations = (tool.metadata or {}).get("annotations") or {}
|
||||
hint = annotations.get("readOnlyHint")
|
||||
if isinstance(hint, bool):
|
||||
return not hint
|
||||
# No annotation: fall back to the verb, and treat anything unrecognised as a
|
||||
# write. Asking for approval needlessly is a nuisance; skipping it is not.
|
||||
name = tool.name.rsplit("_", 1)[-1]
|
||||
return name.split("-", 1)[0].lower() in WRITE_VERBS or "-" not in name
|
||||
|
||||
|
||||
def needs_approval(ctx: Any, tool: ToolDefinition, args: dict[str, Any]) -> bool:
|
||||
"""The signature `ApprovalRequiredToolset` expects. Writes stop; reads run."""
|
||||
return is_write(tool)
|
||||
|
||||
|
||||
class ProductDraft(BaseModel):
|
||||
"""What `create-product` is allowed to be called with.
|
||||
|
||||
The MCP server already validates against the API's own schema — required
|
||||
fields, string lengths. This adds what a schema cannot say: that a price is
|
||||
a number above zero, that the margin is not inverted, that nothing arrived
|
||||
as an empty string because the model had nothing to put there.
|
||||
|
||||
It runs after the user approves and before the call leaves, so a draft that
|
||||
fails here is refused with a reason rather than written and regretted.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
productcode: str = Field(min_length=1, max_length=15)
|
||||
productname: str = Field(min_length=1, max_length=70)
|
||||
productline: str = Field(min_length=1)
|
||||
productscale: str = Field(min_length=1, max_length=10)
|
||||
productvendor: str = Field(min_length=1, max_length=50)
|
||||
productdescription: str = Field(min_length=1)
|
||||
quantityinstock: int = Field(ge=0, le=32767)
|
||||
buyprice: Decimal = Field(gt=0)
|
||||
msrp: Decimal = Field(gt=0)
|
||||
|
||||
@field_validator("buyprice", "msrp", mode="before")
|
||||
@classmethod
|
||||
def _decimal_from_string(cls, v: Any) -> Any:
|
||||
# The API takes these as strings; a model that sends "" or "n/a" must
|
||||
# fail here rather than at the backend, where the error is a 400 with no
|
||||
# useful shape.
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return Decimal(v)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"not a number: {v!r}") from exc
|
||||
return v
|
||||
|
||||
@field_validator("msrp")
|
||||
@classmethod
|
||||
def _sells_above_cost(cls, v: Decimal, info: Any) -> Decimal:
|
||||
buy = info.data.get("buyprice")
|
||||
if buy is not None and v <= buy:
|
||||
raise ValueError(f"msrp {v} is not above buyprice {buy}")
|
||||
return v
|
||||
|
||||
|
||||
#: Tool name (unprefixed) → the model its arguments must satisfy. A write with no
|
||||
#: entry here is approved on the user's word alone; that is the current state for
|
||||
#: every domain but catalog, and it is visible rather than implied.
|
||||
DRAFTS: dict[str, type[BaseModel]] = {"create-product": ProductDraft}
|
||||
|
||||
|
||||
def validate_draft(tool_name: str, args: dict[str, Any]) -> str | None:
|
||||
"""Return why this call must not proceed, or None if it may.
|
||||
|
||||
Arguments arrive shaped by the gateway: a request body sits under `body`,
|
||||
query parameters under `query_*`, path parameters under `path_*`. Only the
|
||||
body is checked — the rest is the server's own schema to enforce.
|
||||
"""
|
||||
model = DRAFTS.get(tool_name.rsplit("_", 1)[-1])
|
||||
if model is None:
|
||||
return None
|
||||
try:
|
||||
model.model_validate(args.get("body") or {})
|
||||
except Exception as exc: # noqa: BLE001 — the message is the product here
|
||||
return str(exc)
|
||||
return None
|
||||
Reference in New Issue
Block a user