Agent Design Patterns: Run Four Control Flows and Their Failures
An agent pattern determines who chooses the next step and what happens when a required result is missing. A diagram can hide both decisions. A failure trace makes them much easier to inspect.
This lesson implements four control flows around synthetic support tickets: a fixed sequence, a router, a bounded tool loop and two independent tasks joined before review. You will run eleven scenarios and inspect the differences. The handlers are real Python functions; the tool-loop messages are scripted fixtures, and the parallel tasks are Python threads. None is a live model agent.
Run the pattern examples
You need Python 3.10 or newer and the standard library. Download and extract the control-flow examples, then run:
python3 patterns.py > observed.json
Use python if that is your installed command. Run without -O, because the scenario checks use assertions. The script prints the trace for each case and confirms that no ticket change was applied. It requires no credentials or network service.
The planning, approval and evaluation lessons provide the larger versions of those boundaries. This artifact isolates control flow so you can compare it without installing a framework.
1. Fixed sequence: make the order explicit
fixed_pipeline() reads a ticket, reads its label policy and prepares a proposal:
read ticket -> read policy -> propose -> await review
|
+ failure -> stop
The normal trace ends with ticket T100, expected version 1, label billing and policy version P1. When the ticket read raises ticket_unavailable, the function returns immediately. There is only one trace row: the failed read. The policy lookup does not run.
This is a useful choice when the task's sequence is known and later work would have no value without the earlier result. It differs from the dependency workflow in the planning lesson, which permits an independent policy read to finish after the ticket branch fails. Neither failure policy is automatic; choose the one the task needs and test it.
A model can still classify or summarize inside a fixed sequence. The defining feature is that code chooses the order. Anthropic's workflow-patterns article uses that distinction when discussing workflows and agents.
2. Routing: preserve an uncertain path
routed_request() uses two deliberately small keyword tests. A message containing “charged” takes the billing path. One containing “crash” takes a documentation path. If both or neither category is present, the router returns needs_review before dispatching a branch.
classify -> billing -> read policy -> await review
-> technical -> read docs -> await review
-> both/neither -> needs review
Run routing_ambiguous and inspect its single trace row. “Charged twice and app crashes” does not silently take whichever branch appears first in the source code. Then inspect routing_missing_docs: it selects the technical route, records docs_unavailable and returns needs_review. It does not fall through to billing as a convenient fallback.
Use routing when the paths are known but the input determines which one is appropriate. The keywords here illustrate the branch policy; they are not evidence of a reliable ticket classifier. Before replacing them with a model, evaluate ambiguous inputs and keep a reviewed abstention path.
3. Bounded loop: let observations precede completion
bounded_loop() consumes exact fixture messages: lookup and finish. The successful script is:
bounded_loop(["lookup", "finish"])
The lookup stores a synthetic ticket observation. A finish message may prepare a proposal only after that observation exists. A premature finish returns invalid_message. This avoids treating a declaration of completion as evidence that the required lookup happened.
The executor permits at most two lookup calls and, by default, three messages. These calls exercise the two limits:
bounded_loop(["lookup", "lookup", "lookup", "finish"]) # tool_limit
bounded_loop(["lookup", "finish"], max_turns=1) # turn_limit
The first stops before a third lookup, with exactly two successful calls recorded. In the second, the lookup runs but the following finish exceeds the turn budget.
This fixture is a control-flow illustration, not an implementation of model reasoning or a full tool protocol. The tool-use lesson supplies the richer message IDs, argument checks and matched results. Add a real model only when its choice of next action provides value beyond a known sequence.
Use a loop when observations can change which action is useful next. Keep the executor's budgets outside model control, retain partial results when stopping and define what an unfinished run means to its caller. A limit is a stopping condition, not proof that the intended task was completed.
4. Parallel tasks: make the join responsible for completeness
fan_out() submits the ticket and policy reads to a ThreadPoolExecutor with two workers. The tasks do not depend on each other. The join requires both results before creating a proposal:
/-> read ticket --\
start -< >- join -> propose -> await review
\-> read policy -/
|
+ failure -> join_failed, no proposal
In fanout_failed_policy, the ticket read succeeds and the policy read raises an error. Both branch results remain visible, but no proposal is produced. Silently joining the available half would remove the policy prerequisite.
The trace is written in a stable ticket-then-policy order. That is not an observation of which thread finished first. The example waits for both finite local functions and measures no speedup. It cannot terminate a stuck remote request or recover work after a process crash. A network version needs bounded calls and a policy for partial or late results.
This shape also appears in delegated model work, but the boundary becomes larger: each worker needs a limited input, an output contract and an acceptance check. The threads here do not demonstrate model delegation, context isolation or reviewer quality.
Compare the executed failure behavior
The eleven cases in observed.json should have these end states:
| Case | End state | What the trace establishes |
|---|---|---|
pipeline_ok | awaiting_review | Both reads precede the proposal |
pipeline_missing_ticket | stopped | Failed ticket read prevents the policy read |
routing_billing | awaiting_review | Billing selects the policy branch |
routing_ambiguous | needs_review | Mixed categories dispatch no branch |
routing_missing_docs | needs_review | Missing docs do not fall through to billing |
loop_ok | awaiting_review | Lookup precedes the finish message |
loop_repeated_calls | tool_limit | Two lookups run; the third is refused |
loop_premature_finish | invalid_message | Finish without an observation is refused |
loop_turn_limit | turn_limit | One-turn budget prevents the finish message |
fanout_ok | awaiting_review | Join has both required results |
fanout_failed_policy | join_failed | Ticket result survives, but no proposal is made |
All successful paths stop at a review result. A control-flow pattern does not replace the stored approval checks that precede an applied change. Likewise, adding a checkpointing framework does not make a remote side effect atomic. LangGraph persistence is a framework reference for saved execution state, not evidence that this small script can resume after a crash.
Change the condition and predict the path
In a Python session beside patterns.py, run:
from patterns import routed_request, bounded_loop, fan_out
print(routed_request("Please change my display name"))
print(bounded_loop(["lookup"]))
print(fan_out(missing_policy=True))
The expected states are needs_review, no_finish and join_failed. no_finish means the script ended without a finish message; invalid_message means it supplied an invalid message, such as finishing before a lookup. A tool can succeed while the overall run remains unfinished. Retain that distinction in the evaluation report instead of counting any successful tool call as a completed task.
Write a short decision record for your own task: which decision varies, which observations are required, what runs independently, and what stops the run. Start with the simplest shape that expresses those facts. Add another worker or loop only when a concrete case demonstrates why it is needed.
Continue with starter repositories to inspect how an existing project implements these decisions. The course index links the full sequence.
