1a13743078
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>
116 lines
4.7 KiB
Python
116 lines
4.7 KiB
Python
"""The write path, tested where it is decided rather than where it is described.
|
|
|
|
Every rule here used to live in the system prompt, which is to say it used to be
|
|
a request. These tests are the difference: they fail if the mechanism stops
|
|
enforcing what the prompt merely asks for.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic_ai.tools import ToolDefinition
|
|
|
|
from catalogue_agent.a2a_server import _reads_as_approval
|
|
from catalogue_agent.approval import is_write, validate_draft
|
|
|
|
|
|
def tool(name: str, *, read_only: bool | None = None) -> ToolDefinition:
|
|
metadata: dict[str, Any] = {}
|
|
if read_only is not None:
|
|
metadata["annotations"] = {"readOnlyHint": read_only}
|
|
return ToolDefinition(name=name, parameters_json_schema={}, metadata=metadata)
|
|
|
|
|
|
class TestIsWrite:
|
|
def test_the_servers_annotation_decides(self) -> None:
|
|
# Not the name. The gateway publishes readOnlyHint for every tool, and
|
|
# that is what the agent obeys — so renaming a tool cannot silently
|
|
# change whether it needs approval.
|
|
assert is_write(tool("catalog_get-product", read_only=True)) is False
|
|
assert is_write(tool("catalog_create-product", read_only=False)) is True
|
|
|
|
def test_an_annotation_beats_a_reassuring_name(self) -> None:
|
|
assert is_write(tool("catalog_list-products", read_only=False)) is True
|
|
|
|
def test_the_verb_answers_when_nothing_is_annotated(self) -> None:
|
|
assert is_write(tool("catalog_create-product")) is True
|
|
assert is_write(tool("catalog_list-products")) is False
|
|
|
|
def test_an_unrecognisable_unannotated_tool_is_treated_as_a_write(self) -> None:
|
|
# Asking for approval needlessly is a nuisance. Skipping it is not.
|
|
assert is_write(tool("catalog_reconcile")) is True
|
|
|
|
|
|
class TestValidateDraft:
|
|
def valid(self) -> dict[str, Any]:
|
|
return {
|
|
"productcode": "S99_TEST",
|
|
"productname": "Test Roadster",
|
|
"productline": "Classic Cars",
|
|
"productscale": "1:18",
|
|
"productvendor": "Lab Fixtures",
|
|
"productdescription": "a test",
|
|
"quantityinstock": 10,
|
|
"buyprice": "50.00",
|
|
"msrp": "99.00",
|
|
}
|
|
|
|
def test_a_complete_draft_passes(self) -> None:
|
|
assert validate_draft("catalog_create-product", {"body": self.valid()}) is None
|
|
|
|
def test_a_read_tool_has_nothing_to_validate(self) -> None:
|
|
assert validate_draft("catalog_list-products", {"query_page": 2}) is None
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[("buyprice", "0"), ("msrp", "0"), ("quantityinstock", -1), ("productname", "")],
|
|
)
|
|
def test_values_a_json_schema_would_accept_are_refused(self, field: str, value: Any) -> None:
|
|
# The MCP server already checks types and required fields. These are the
|
|
# ones only a validator can catch.
|
|
body = self.valid() | {field: value}
|
|
assert validate_draft("catalog_create-product", {"body": body}) is not None
|
|
|
|
def test_a_price_that_is_not_a_number_is_refused(self) -> None:
|
|
body = self.valid() | {"msrp": "ask the vendor"}
|
|
problem = validate_draft("catalog_create-product", {"body": body})
|
|
assert problem is not None and "not a number" in problem
|
|
|
|
def test_selling_below_cost_is_refused(self) -> None:
|
|
body = self.valid() | {"buyprice": "99.00", "msrp": "50.00"}
|
|
problem = validate_draft("catalog_create-product", {"body": body})
|
|
assert problem is not None and "not above buyprice" in problem
|
|
|
|
def test_a_missing_field_is_refused_rather_than_defaulted(self) -> None:
|
|
body = self.valid()
|
|
del body["buyprice"]
|
|
assert validate_draft("catalog_create-product", {"body": body}) is not None
|
|
|
|
def test_an_unexpected_field_is_refused(self) -> None:
|
|
body = self.valid() | {"discount": "20%"}
|
|
assert validate_draft("catalog_create-product", {"body": body}) is not None
|
|
|
|
|
|
class TestReadsAsApproval:
|
|
@pytest.mark.parametrize("reply", ["approve", "Approve.", " oui ", "OK", "confirme"])
|
|
def test_an_unambiguous_yes_approves(self, reply: str) -> None:
|
|
assert _reads_as_approval(reply) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"reply",
|
|
[
|
|
"oui mais change le prix", # a condition, not an approval
|
|
"why are you asking?",
|
|
"no",
|
|
"",
|
|
"approuve le suivant plutôt",
|
|
],
|
|
)
|
|
def test_anything_else_cancels(self, reply: str) -> None:
|
|
# Silence dressed as agreement is the failure this guards against: a
|
|
# reply that merely contains "oui" must not be read as consent to the
|
|
# record as proposed.
|
|
assert _reads_as_approval(reply) is False
|