Skip to content
Zarif Automates

Agent Design Patterns: Run Four Control Flows and Their Failures

ZarifZarif
Published Updated

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:

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

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.

Zarif Choudhury

Zarif Choudhury

Zarif builds AI agents and automation workflows. He writes about what keeps working once real people use it: who is worth reading, the new jobs AI is creating, and agent workflows you can check step by step.