Skip to content
Zarif Automates

Agent Design Patterns: Run Four Control Flows and Their Failures

ZarifZarif
|Published

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:

CaseEnd stateWhat the trace establishes
pipeline_okawaiting_reviewBoth reads precede the proposal
pipeline_missing_ticketstoppedFailed ticket read prevents the policy read
routing_billingawaiting_reviewBilling selects the policy branch
routing_ambiguousneeds_reviewMixed categories dispatch no branch
routing_missing_docsneeds_reviewMissing docs do not fall through to billing
loop_okawaiting_reviewLookup precedes the finish message
loop_repeated_callstool_limitTwo lookups run; the third is refused
loop_premature_finishinvalid_messageFinish without an observation is refused
loop_turn_limitturn_limitOne-turn budget prevents the finish message
fanout_okawaiting_reviewJoin has both required results
fanout_failed_policyjoin_failedTicket 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.

Zarif

Zarif

Zarif builds AI agents and automation workflows and writes about what holds up in production: the sources worth following, the roles the AI era is creating, and agent workflows you can inspect end to end.