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>
81 lines
4.2 KiB
Python
81 lines
4.2 KiB
Python
"""Runtime configuration, entirely from the environment.
|
|
|
|
Nothing here has a value that would be wrong to commit, so the defaults are the
|
|
lab's real endpoints. The two secrets — the agent's Keycloak client secret and
|
|
nothing else — have no default and fail loudly if unset.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pydantic import Field
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(env_prefix="CATALOGUE_", env_file=".env")
|
|
|
|
# --- identity -----------------------------------------------------------
|
|
keycloak_realm_url: str = "https://keycloak.sttlab.local/realms/classic-models"
|
|
client_id: str = "classic-models-agent-catalogue"
|
|
client_secret: str = Field(..., description="Keycloak client secret; no default on purpose")
|
|
|
|
# --- data ---------------------------------------------------------------
|
|
# One entry per MCP server this agent may reach. The audience is requested
|
|
# as a SCOPE, not through the `audience` parameter — Keycloak answers
|
|
# "Requested audience not available" to the latter. See identity.py.
|
|
#
|
|
# Two audiences per server, not one. The MCP endpoint exchanges the token
|
|
# again on its own account before reaching the domain API, so the token has
|
|
# to name the broker that performs that second exchange as well as the
|
|
# server itself. Without it the call fails with a bare 401 and no usable
|
|
# detail. This is part of the endpoint's contract, not of this agent's
|
|
# design — if the middleware serving these URLs is replaced by something
|
|
# that does not re-exchange, the second scope simply becomes unnecessary.
|
|
mcp_catalog_url: str = "https://catalog-dev.sttlab.local/catalog-mcp"
|
|
mcp_catalog_scope: str = "mcp-catalog-audience token-exchange-broker-audience"
|
|
mcp_sales_url: str = "https://sales-dev.sttlab.local/sales-mcp"
|
|
mcp_sales_scope: str = "mcp-sales-audience token-exchange-broker-audience"
|
|
|
|
# --- inference ----------------------------------------------------------
|
|
# Always the estate's LLM endpoint, never a provider directly: it carries
|
|
# the rate limits, the prompt guards and the audit trail. It speaks the
|
|
# OpenAI protocol, which is why the OpenAI provider is used against it.
|
|
llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1"
|
|
llm_model: str = "google/gemini-3.7-flash"
|
|
# The LLM endpoint requires this audience. The bearer it validates is an
|
|
# exchanged token, not a static API key, so inference carries the same
|
|
# `azp`/`sub` pair as every tool call and lands in the same audit trail.
|
|
# Static-key authentication is also accepted there, for callers that cannot
|
|
# do OIDC outbound, but it proves nothing about the end user — this agent
|
|
# can do better and does.
|
|
llm_scope: str = "llm-proxy-audience"
|
|
# A turn that loops has to stop somewhere. Twenty tool calls is far
|
|
# beyond any legitimate catalogue question and well under the point
|
|
# where the token budget would notice.
|
|
max_tool_calls: int = 20
|
|
|
|
# --- transport ----------------------------------------------------------
|
|
# The lab uses a private CA (sttlab-local-ca). Point this at the CA bundle
|
|
# rather than disabling verification.
|
|
ca_bundle: str | None = None
|
|
# Generous on purpose, and it is not paranoia. macOS resolves any `.local`
|
|
# name through mDNS before falling back to /etc/hosts, and that costs a flat
|
|
# ~5s per hostname — right on httpx's 5s default connect timeout. The result
|
|
# is an intermittent ConnectTimeout that fastmcp reports only as
|
|
# "Failed to initialize server session", with the real cause swallowed. In
|
|
# the cluster the names resolve instantly and this timeout never binds.
|
|
http_timeout: float = 60.0
|
|
host: str = "0.0.0.0"
|
|
port: int = 8080
|
|
# Advertised in the Agent Card, so it must be the URL other agents reach,
|
|
# not the bind address.
|
|
public_url: str = "http://localhost:8080/"
|
|
|
|
@property
|
|
def token_endpoint(self) -> str:
|
|
return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
|
|
|
|
@property
|
|
def verify(self) -> str | bool:
|
|
return self.ca_bundle if self.ca_bundle else True
|