- Step 12 of 17 in the agent-building course
- Step 8 of 10 in the Agent Engineer path
Agent Design Patterns: Run Four Control Flows and Their Failures

Every agent design answers two questions. Who picks the next step? And what happens when a step it needed comes back empty?
A diagram can hide both answers. A trace of the run when something fails shows them plainly. That's what this lesson gives you.
You'll run four small programs against fake support tickets: a fixed sequence, a router, a loop with limits, and two tasks that run side by side and meet before review. There are eleven scenarios, and you'll compare what each one does.
Here's what's real and what's simulated. The handlers are real Python functions. The messages in the loop are scripted, not written by a model. The side-by-side tasks are Python threads. None of these is a live model agent.
Run the pattern examples
You need Python 3.10 or newer. Nothing beyond the standard library. Download and unzip the control-flow examples, then run:
python3 patterns.py > observed.json
If your machine calls it python, use that. Don't add the -O flag, because the checks inside the script use assertions and -O turns them off.
The script prints a trace for each case and confirms that no ticket was actually changed. It needs no API keys and no network.
The bigger versions of these ideas live in the planning, approval and evaluation lessons. This one strips everything else away so you can compare the shapes without installing a framework.
1. Fixed sequence: make the order explicit
fixed_pipeline() does three things in order. It reads a ticket, reads the labeling policy, and drafts a proposed label:
read ticket -> read policy -> propose -> await review
|
+ failure -> stop
When everything works, the trace ends with ticket T100, expected version 1, label billing and policy version P1.
Now break it. When the ticket read fails with ticket_unavailable, the function stops right there. The trace has one row, the failed read. The policy lookup never runs.
That's the right call when the order is known and the later steps are useless without the earlier ones. No ticket, no point reading the policy.
The planning lesson makes a different choice. Its workflow lets an independent policy read finish even after the ticket read fails. Neither choice is automatically correct. Pick the one your task needs, then test it.
A model can still do work inside a fixed sequence, like sorting or summarizing a ticket. What makes it fixed is that your code decides the order, not the model. Anthropic's workflow-patterns article draws the line between workflows and agents the same way.
2. Routing: preserve an uncertain path
routed_request() looks at the message and picks a path. It uses two tiny keyword checks on purpose. If the message says "charged", it goes down the billing path. If it says "crash", it goes to the documentation path.
If the message has both words, or neither, the router doesn't guess. It returns needs_review before starting either path.
classify -> billing -> read policy -> await review
-> technical -> read docs -> await review
-> both/neither -> needs review
Run routing_ambiguous and look at its single trace row. "Charged twice and app crashes" doesn't just take whichever branch happens to come first in the code. It stops and asks for a person.
Then look at routing_missing_docs. It picks the technical path, finds the docs missing, records docs_unavailable, and returns needs_review. It does not quietly fall back to billing just because billing would have worked.
Use a router when you know the possible paths and the input decides which one fits. The keyword checks here only show how the branching works. They are not a good ticket classifier. If you swap in a model later, test it on unclear messages first, and keep the "send it to a person" path.
3. Bounded loop: let observations precede completion
A loop lets the agent look at something, then decide what to do next. The danger is a loop that says "done" without doing the work, or one that never stops.
bounded_loop() reads scripted messages. There are only two kinds: lookup and finish. The run that succeeds looks like this:
bounded_loop(["lookup", "finish"])
The lookup saves a fake ticket observation. A finish message is only allowed to draft a proposal after that observation exists. If finish comes first, the loop returns invalid_message. Saying "I'm done" doesn't count as proof the lookup happened.
The loop also has two hard limits. It allows at most two lookups, and by default at most three messages. These calls hit each limit:
bounded_loop(["lookup", "lookup", "lookup", "finish"]) # tool_limit
bounded_loop(["lookup", "finish"], max_turns=1) # turn_limit
The first one stops before the third lookup, with exactly two successful lookups recorded. In the second, the lookup runs, but the finish that follows is one message over the budget.
This is a picture of the control flow, not of how a model reasons. The tool-use lesson has the fuller version, with message IDs, argument checks and matched results. Only bring in a real model when its choice of what to do next adds something a fixed sequence can't.
A few rules if you build a loop of your own:
- Keep the limits in your code, where the model can't change them.
- Keep whatever partial results you have when the loop stops.
- Decide what an unfinished run means to whoever called it.
Hitting a limit only means the loop stopped. It doesn't mean the job got done.
4. Parallel tasks: make the join responsible for completeness
Some steps don't depend on each other, so you can run them at the same time. The ticket read and the policy read are like that.
fan_out() hands both reads to a ThreadPoolExecutor with two workers. Then a join step waits for both. It needs both results before it drafts a proposal:
/-> read ticket --\
start -< >- join -> propose -> await review
\-> read policy -/
|
+ failure -> join_failed, no proposal
In fanout_failed_policy, the ticket read works and the policy read fails. You can still see both results in the trace, but no proposal gets made. If the join went ahead with only the ticket, it would be labeling tickets without checking the policy.
A few honest limits on this example:
- The trace always lists the ticket first, then the policy. That's for readability. It doesn't tell you which thread finished first.
- The example waits for two quick local functions and doesn't measure any speedup.
- It can't kill a remote request that hangs, and it can't pick up after a crash. A version that calls the network needs time limits and a rule for results that arrive partial or late.
You'll see this same shape when one model hands work to several others. There, each worker also needs a limited input, a clear format for its output, and a check before its work is accepted. The threads here don't show any of that.
Compare the executed failure behavior
Open observed.json. The eleven cases should end like this:
| 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 |
Notice that every successful path stops at review. None of them changes a ticket. Picking a good pattern doesn't replace the stored approval checks that have to pass before a change goes through.
The same goes for saving progress. A framework that saves where a run left off doesn't make a change to an outside system all-or-nothing. LangGraph persistence is a good reference for saving a run's state, but it says nothing about whether this small script could pick up after a crash.
Change the condition and predict the path
Open a Python session in the same folder as patterns.py and 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))
Before you look, guess what each one returns. You should get needs_review, no_finish and join_failed.
Two of those are easy to mix up. no_finish means the script ran out of messages without ever saying finish. invalid_message means it sent a message that wasn't allowed, like finishing before a lookup.
Both matter because a tool call can succeed while the whole run is still unfinished. When you report results, keep those two apart. Don't count a successful tool call as a finished task.
Now try it on your own task. Write a few lines that answer four questions:
- Which decision changes from one input to the next?
- What does the agent need to see before it can act?
- What can run at the same time?
- What makes the run stop?
Start with the simplest shape that fits those answers. Add another worker or another loop only when a real case shows you need it.
Next, open the starter repositories lesson to see how an existing project handles these same decisions. The course index has the full sequence.



