Testing a support agent’s tool boundary and release gate

A reproducible Python workflow checks account scope, tool proposals, retries and category gates without claiming to measure model reliability.
A refusal cannot undo an earlier lookup
Suppose a support assistant looks up another customer’s order, then ends with a polite refusal. Grading only the final answer would miss the disclosure. The useful question is whether the application fetched data outside the authenticated task before the refusal arrived.
This demonstration gives a scripted model one read tool and asks it to retrieve an order status. The application keeps the account identity and requested order outside the model’s arguments. It returns a structured draft for review, not a generated email. Ulties builds software and AI workflows. These are synthetic tests, not reported client results.
OWASP recommends checking tool calls against user permissions and session context. Here that principle becomes an account-scoped lookup plus a check that the proposed order matches the task. A real application must establish the session and perform that scoped query; the in-memory store only demonstrates the interface.
Make the permitted operation explicit
The complete boundary.py below uses the Python standard library. The model adapter receives a copy of the trace and returns either a lookup proposal or a finish signal. The lookup adapter accepts the trusted account and order directly. Neither adapter receives a refund, email or arbitrary SQL capability.
A proposal with extra identity fields, an unknown tool or a different order is rejected before the store is called. Missing and cross-account orders produce the same not_found result. Successful responses expose only an allowed status. The synthetic store’s free-text note never enters the trace. This projection prevents that particular data path, but does not establish a real model’s resistance to prompt injection.
A finish signal produces a draft only after the last lookup succeeded. The draft’s status comes from the recorded tool result. The model cannot replace it with a delivery promise or change it by editing its copy of the trace. Natural-language answer quality is outside this example.
"""Synthetic read-only support workflow; no model SDK, transport or credentials."""
import asyncio
import copy
import math
import re
async def run(model, lookup, account, requested_order, *, max_calls=2,
deadline=1.0, dependency_timeout=0.1):
# account and requested_order come from the application's authenticated task.
if not isinstance(account, str) or not account:
raise ValueError("authenticated account required")
if not isinstance(requested_order, str) or not re.fullmatch(r"order-[0-9]{1,12}", requested_order):
raise ValueError("invalid requested order")
if type(max_calls) is not int or not 0 <= max_calls <= 10:
raise ValueError("invalid call budget")
for seconds in (deadline, dependency_timeout):
if type(seconds) not in (int, float) or not math.isfinite(seconds) or not 0 < seconds <= 60:
raise ValueError("invalid timeout")
trace, calls = [], 0
def stop(reason):
return {"outcome": reason, "trace": trace}
try:
async with asyncio.timeout(deadline):
while True:
try:
proposal = await asyncio.wait_for(model(copy.deepcopy(trace)), dependency_timeout)
except TimeoutError:
return stop("model_timeout")
except OSError:
return stop("model_unavailable")
if type(proposal) is not dict:
return stop("invalid_proposal")
if set(proposal) == {"finish"}:
# Return a status draft, never free-form model prose.
if proposal["finish"] is not True or not trace or not trace[-1]["ok"]:
return stop("unresolved")
return {"outcome": "draft", "order_id": requested_order,
"status": trace[-1]["status"], "trace": trace}
if calls >= max_calls:
return stop("call_limit")
calls += 1 # Count denied attempts too; retries consume the same budget.
if set(proposal) != {"tool", "arguments"} or proposal["tool"] != "lookup_order":
return stop("tool_denied")
args = proposal["arguments"]
if type(args) is not dict or set(args) != {"order_id"}:
return stop("invalid_arguments")
if type(args["order_id"]) is not str or args["order_id"] != requested_order:
return stop("task_mismatch")
try:
raw = await asyncio.wait_for(lookup(account, requested_order), dependency_timeout)
except TimeoutError:
trace.append({"ok": False, "error": "lookup_timeout"})
continue
except OSError:
trace.append({"ok": False, "error": "lookup_unavailable"})
continue
if raw is None:
trace.append({"ok": False, "error": "not_found"})
elif type(raw) is dict and raw.get("status") in ("processing", "shipped"):
# Do not pass arbitrary database notes or instructions to the model.
trace.append({"ok": True, "status": raw["status"]})
else:
return stop("invalid_tool_result")
except TimeoutError:
return stop("deadline")
def gate(results, required_categories):
"""Fail closed on missing categories, failures or an empty evaluation."""
return bool(required_categories) and set(results) == set(required_categories) and all(
type(values) is list and bool(values) and all(value is True for value in values)
for values in results.values()
)
A retry spends the existing budget
The demonstration permits two lookup attempts. A timed-out lookup spends an attempt just as a successful one does. After the first lookup returns lookup_timeout, the scripted model requests another lookup. It gets shipped and finishes. Repeated timeouts exhaust the same budget instead of creating a fresh allowance on each retry.
The outer deadline covers the whole workflow, while each model or lookup call has its own timeout. The sample defaults are one second overall and one tenth of a second per dependency. These are demonstration settings, not production latency targets. The tests use blocked coroutines to observe cancellation and distinguish dependency timeout from overall deadline without asserting an exact elapsed duration.
Python’s asyncio timeouts use cancellation. They are not a hard wall-clock boundary for blocking code or a coroutine that suppresses cancellation. I recommend configuring transport timeouts and response-size limits in real adapters, and using an external worker limit where termination must be enforceable. Those adapters and limits are not implemented here.
A known dependency outage becomes an explicit unresolved result. A caller’s cancellation still propagates. An unexpected programming exception is allowed to fail the run rather than being turned into a plausible status. No write occurs, so this fixture makes no claim about idempotency, payment recovery or retracting sent messages.
Fail the release gate by category
The gate at the end of boundary.py requires every declared category to be present and nonempty, with every result exactly True. A missing category, an empty list or a numeric 1 fails. The fixture checks that an authorization failure still rejects the run even when another category contains a hundred passing results. This is a deterministic regression gate, not a statistical model-quality threshold.
The pinned run passes 37 assertions: three authorization, fourteen validation, four budget, six dependency, four output and six release-gate checks. Four altered copies fail in the intended category: forcing the account identity, allowing an extra lookup, exposing raw stored notes and accepting any passing category instead of requiring them all.
Download the source and tests, extract the archive and run python3 verify.py with Docker available. The runner executes both the normal module and those altered copies in the same immutable Python 3.12.12 image. It reports category counts, source hashes and failure evidence, then removes its test containers and temporary directories.
What a real model still has to demonstrate
These scripted proposals deliberately separate application enforcement from model behavior. They do not measure whether a model selects the right tool, understands an ambiguous request or writes a useful reply. There is no provider SDK, production database, HTTP transport, token accounting or model-judged score in this bundle.
I recommend keeping this boundary suite alongside a separate evaluation using the actual model and prompt versions. Hold back cases from prompt development, repeat nondeterministic cases and report failures by category. Include missing orders, ambiguous requests, malicious retrieved content and dependency failures. Keep authorization failures visible even if answer quality improves.
Before granting a write tool, define its authorization, approval and recovery rules and add evidence for those exact operations. A passing read-only fixture cannot authorize a refund workflow. The immediate result here is narrower and inspectable: known bad proposals fail before the prohibited lookup, retries remain bounded, and a failed category cannot disappear inside an average.
Reproduce the examples
The exact Python module, scripted adapters, 37 assertions, four deliberate defects and a pinned Docker runner. No model credentials or external services are used.
Download the source and tests (.zip)
