What you'll learn: how to test a bounded workflow by injecting failures with deterministic fakes, which five failure classes to cover, what the capstone runner does for each, and how to bound retries so they cannot break the time limit.
At a glance
Figure 1. The test harness: everything the runner talks to is a fake
How to read this: the fakes decide what goes wrong and when; the test checks the status, the step log and the call counts.
- Tests with a real model or tool are slow, cost money and fail for outside reasons.
- Five failure classes cover most of the loop: tool errors, malformed arguments, duplicate calls, timeouts and a misbehaving planner.
- The runner turns tool failures into observations. It never retries a call itself.
- Retry inside the tool, with a fixed number of attempts, and keep the total under the time limit.
Scenario
Composite scenario, not a real company. At Lumen Industrial Supply, a fictional distributor, the product-discovery assistant passes a demo. In the pilot, the catalog service times out twice in an afternoon, the model returns half a JSON reply, and one request repeats a lookup. Each failure is rare, so manual testing never meets it.
The team wants a millisecond test for every failure class before the pilot widens. Numbers are illustrative.
What you will be able to do
- Drive
WorkflowRunnerwith a fake tool, a fake clock and a scripted planner. - Write tests for tool failure, malformed arguments, duplicate calls, timeouts and an unreadable plan.
- Say what each failure leaves in the step log.
- Put retries inside a tool and show they are bounded.
Prerequisites
- Tool definitions and validated arguments, Limits, state and step recording and Human approval for sensitive actions.
- Theory: Reliability and scaling and Agents, tools and MCP.
Core ideas
Inject, run, assert
A fake tool plays a script of outcomes, each a result or an exception to raise.
Verified: capstone/fde-product-discovery-copilot/lessons/M4-L6/m4_l6_fakes.py
@dataclass
class FakeTool:
"""Plays `outcomes` in order and repeats the last one. An Exception instance is raised, anything else is returned."""
outcomes: list[Any]
clock: FakeClock | None = None
cost_s: float = 0.0
calls: int = 0
def __call__(self, args: LookupArgs, ctx: ToolContext) -> dict[str, Any]:
self.calls += 1
if self.clock is not None:
self.clock.advance(self.cost_s)
outcome = self.outcomes[min(self.calls, len(self.outcomes)) - 1]
if isinstance(outcome, Exception):
raise outcome
return outcome
The calls counter is the most useful assertion. The fake clock moves only when a test says so, so a "slow" tool is a number, not a sleep. The planner is ScriptedProvider from the capstone's tests/helpers.py.
The failure table
Figure 2. Failure classes, what to inject and what to expect
| Failure | Injected with | Status | What the log and counters show |
|---|---|---|---|
| Tool raises an error | FakeTool([TimeoutError(...)]) |
completed |
Step not ok, error: TimeoutError: ...; the next prompt contains it |
| Malformed arguments | Empty, wrong type, bad pattern, null, a list |
completed |
Tool never called; Invalid arguments: sku |
| Unknown tool name | delete_catalog |
completed |
Unknown tool 'delete_catalog'. |
| Duplicate call | Same call twice | completed |
Tool ran once; error: duplicate call skipped |
| Slow tools | 4 s each, limit 5 s | timeout |
Two steps ran, none after |
| Unreadable plan once | "this is not json" |
completed |
Two model calls for one step |
| Unreadable plan twice | Two bad replies | planner_error |
Earlier observations kept |
| Endless new calls | New arguments each time | max_steps |
Exactly the cap in tool calls |
How to read this: each row is one test in test_m4_l6_failure_injection.py; the last column is what it asserts.
An extra argument such as {"sku": "LUM-VS-100", "admin": true} is not rejected: Pydantic ignores unknown fields unless a model sets extra="forbid".
Duplicates and retries pull in different directions
The runner skips a repeated call, whether or not the first call succeeded.
Predict first. A tool raises TimeoutError on its first call and would succeed on its second. The planner issues the identical call twice, then finishes. How many times does the tool run, and what does step 2 record?
Answer
The tool runs once. Step 2 records error: duplicate call skipped (test_a_failed_call_repeated_exactly_is_skipped_too). For a retry, put it inside the tool.