feat: enable catalog writes, with the conversation history they require

The gateway already authorised writes for a product_manager — ai-mcp-proxy
filters tool discovery by consumer group, so create-product is simply absent
from a read-only user's turn. Only the system prompt was holding the agent
back, and it now describes how to write instead: restate the record, ask for
confirmation, never invent a missing field, report the identifier, and stop on
a refusal rather than working around it.

That flow cannot terminate without history. Each turn built a fresh Agent with
no message_history, so the user's "yes" arrived at an agent that had no idea
what it was agreeing to. History is now kept per A2A contextId in a bounded
in-memory map — enough for confirm-then-act, deliberately not durable, and
documented as needing sticky routing beyond one replica.

Verified end to end: pm-user creates S99_TEST1 across two turns and the row
appears in MySQL; ro-user asking for the same thing is told no write tool
exists, because for that token none does. Both test rows removed afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-28 17:39:51 +02:00
parent 0bc06512f5
commit 6ffab873e2
4 changed files with 63 additions and 7 deletions
+3 -1
View File
@@ -129,7 +129,9 @@ class CatalogueExecutor(AgentExecutor):
return
try:
answer = await self._agent.run(context.get_user_input(), subject_token)
answer = await self._agent.run(
context.get_user_input(), subject_token, context.context_id
)
except Exception as exc: # noqa: BLE001 — surfaced to the caller, logged here
logger.exception("turn failed")
await updater.failed(
+36 -4
View File
@@ -2,8 +2,11 @@
from __future__ import annotations
from collections import OrderedDict
import httpx2
from pydantic_ai import Agent
from pydantic_ai.messages import ModelMessage
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
@@ -26,8 +29,15 @@ How to work:
- When a decision depends on stock or open orders, say that it does and what is
missing. Do not infer availability from what you can see.
You currently have read access only. If asked to change anything, say plainly
that writes are not enabled yet.
Writing to the catalog:
- You may create products. Before doing so, restate exactly what you are about
to create and ask the user to confirm. Never write on an implied instruction.
- Every field you write comes from the user or from a tool. If a required field
is missing, ask for it — do not choose a plausible value, a price least of all.
- After a write, report what was created, including the identifier the tool
returned, so the user can check it.
- A write may be refused. That is the gateway applying the user's own role, not
a mistake to work around: report the refusal and stop.
"""
@@ -57,6 +67,15 @@ def build_model(
)
# Conversations kept in memory, oldest evicted. Enough for the confirm-then-act
# exchange a write requires, and deliberately not more: this is a single-replica
# store, so more than one replica needs sticky routing on context id or a shared
# store. Nothing here is durable, and that is the right default — the history
# holds catalog data the end user was entitled to see for the length of one
# conversation, not something to persist and re-authorise later.
MAX_CONVERSATIONS = 200
class CatalogueAgent:
"""Holds what is stable across turns; rebuilds what must not be."""
@@ -69,18 +88,31 @@ class CatalogueAgent:
client_secret=settings.client_secret,
)
self._http_client = http_client
self._history: OrderedDict[str, list[ModelMessage]] = OrderedDict()
async def run(self, prompt: str, subject_token: str) -> str:
async def run(self, prompt: str, subject_token: str, conversation_id: str) -> str:
"""One turn: exchange tokens, build the toolsets, answer, drop them.
The Agent is constructed here rather than once in __init__ because its
toolsets carry this turn's tokens. Construction is cheap; a long-lived
agent holding a long-lived token is not.
History is carried across turns of the same conversation. Without it the
confirm-before-writing rule cannot terminate: the agent restates what it
is about to create, the user says yes, and a historyless agent has no
idea what "yes" refers to.
"""
toolsets = await build_toolsets(self._settings, self._exchanger, subject_token)
llm_token = await self._exchanger.exchange(subject_token, self._settings.llm_scope)
model = build_model(self._settings, self._http_client, llm_token)
agent = Agent(model, instructions=SYSTEM_PROMPT, toolsets=toolsets)
async with agent:
result = await agent.run(prompt)
result = await agent.run(prompt, message_history=self._history.get(conversation_id))
self._remember(conversation_id, result.all_messages())
return result.output
def _remember(self, conversation_id: str, messages: list[ModelMessage]) -> None:
self._history[conversation_id] = messages
self._history.move_to_end(conversation_id)
while len(self._history) > MAX_CONVERSATIONS:
self._history.popitem(last=False)
+2 -1
View File
@@ -74,7 +74,8 @@ async def build_toolsets(
StreamableHttpTransport(
url=target.url,
headers={"Authorization": f"Bearer {token}"},
verify=settings.verify,
# No `verify=` here: the factory below sets it, and passing
# both makes fastmcp ignore this one with a warning.
httpx_client_factory=client_factory,
),
id=target.name,