---
title: 'Multi-Agent Patterns'
source: 'https://academia.sh/en/courses/ai-agents/multi-agent-patterns'
course: 'AI Agents'
language: en
updated: '2026-10-21T05:02:26+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds an arrangement that splits a job between two agents according to the tool set and defines the handoff packet between them explicitly.'
    - 'Counts what the second agent adds and what it costs on the same task set: success, total steps, tokens of the handed-over context and the number of fields lost in the handoff.'
    - 'Shows with a breakdown by task kind in which kind of task the split gains and in which it loses.'
    - 'Compares the token gain of making the handoff packet cheaper with the silent wrong answer caused by the lost field.'
---

# Multi-Agent Patterns

Measuring a single agent and two agents side by side on the same task set: when the eight-step budget is divided in two and the job is distributed between a reader and a calculator, overall success stays at 23/24 on both runs, but steps rise from 97 to 140 and the handed-over context carries 363 tokens. The split raises the four-tool record tasks from 2/3 to 3/3 while it lowers the two-tool consumption tasks from 8/8 to 7/8. When the handoff packet is made cheaper, 178 tokens are gained, 68 fields are lost in return and success falls from 23/24 to 18/24; because the lost field is the subscriber type, the charge of commercial subscribers comes out exactly half. When the four-step budget is divided in two, the arrangement falls from 21/24 to 19/24 and from 20/24 to 16/24.

The previous lesson left the checking eye inside the loop: the agent looked at its own trace itself and
saw nothing when the trace looked clean. The most direct way to take the eye outside is **to split the
job**.

Splitting is not free. In a single loop the accumulated state sits in a shared dictionary; two agents
do not share the same memory, and everything between them has to be **handed over explicitly**. What is
handed over is a text, so it is measured in tokens. The split here is by tool set; it is not the
planner–executor separation, both agents execute.

- **AA47.** The setup is common: the **fictional** subscriber records of the measurement network, the
  **fictional** document vocabulary, five **fictional** tools, twenty-four **fictional** tasks and an
  **agent simulator** written inside the lesson. No real model endpoint or tool server is called.
- **AA48.** The baseline is the **scaffolding-free loop**: a single agent, the same set, the same total
  budget.
- **AA49.** The arrangement is two roles. The **reader** sees only `read_meter`, `consumption` and
  `search_docs`, the **calculator** only the `tariff` and `write_record` tools; the loop of a role is
  the same as the common loop.
- **AA50.** The total step budget is **divided in two** and the handoff counts as **one step**.
- **AA51.** The **handoff packet** is a text: the fields to be carried are written in the form
  `name=value`, its cost is the token count of that text, and every field that is not carried is
  **lost**. The **full handoff** carries eight fields, the **summary handoff** only the fields
  `subscriber`, `column`, `m3` and `result`.
- **AA52.** The second run seed is **20260219**; in this set the run-to-run difference is **one task**
  (0.0417) and every difference below it **counts as unmeasured**.

## The Field Lost in the Handoff

First the two packet forms are put side by side on a single task. `G23` is a record task; its
subscriber is commercial.

```python
# COMMON FICTIONAL SETUP -- the 60 fictional subscribers of the measurement network,
# 5 fictional documents, 5 fictional tools, 24 fictional tasks and the agent simulator
# written inside the lesson. No real model endpoint, tool server or runtime is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


def tokenize(m):
    b = []
    for s in str(m).lower().replace(",", " , ").split():
        while len(s) > 5:
            b.append(s[:5])
            s = s[5:]
        b.append(s)
    return b


def trace_id(m):
    h = 2166136261
    for c in str(m):
        h = ((h ^ ord(c)) * 16777619) & M32
    return h


# ---- FICTIONAL environment: records of the measurement network
REGION = ["east", "west", "north", "south", "central"]
TYPE = ["residential", "commercial"]
SUBSCRIBERS = {}
for i in range(60):
    r = rng(SEED + 31 * i)
    previous = 10000 + int(r() * 80000)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)],
                                    "previous": previous, "current": previous + int(r() * 70)}
TIERS, ABOVE = ((10, 4), (25, 7), (40, 11)), 16
COMMERCIAL_FACTOR = 2
DOCUMENTS = {"TR01": "tarife basamaklari 10 25 40 metrekup ve ustu",
             "TR02": "isyeri abonesinde birim ucret iki katidir",
             "SY01": "endeks farki tuketimi verir sayac donusu 100000 eklenir",
             "AR01": "ariza kaydi acildiginda saha ekibi iki gun icinde gider",
             "KS01": "planli kesinti duyurusu bir hafta once yayimlanir"}

CALLS = [0]                              # tool call counter


def _charge(m3, type_):
    remaining, lower, total = m3, 0, 0
    for upper, unit in TIERS:
        band = min(remaining, upper - lower)
        if band <= 0:
            break
        total += band * unit
        remaining -= band
        lower = upper
    total += remaining * ABOVE
    return total * (COMMERCIAL_FACTOR if type_ == "commercial" else 1)


def t_read_meter(subscriber=None, **_):
    a = SUBSCRIBERS.get(subscriber)
    if a is None:
        return {"error": "no such subscriber"}
    return {"previous": a["previous"], "current": a["current"], "type": a["type"],
            "region": a["region"]}


def t_consumption(previous=None, current=None, **_):
    if previous is None or current is None:
        return {"error": "missing field"}
    f = current - previous
    return {"m3": f + 100000 if f < 0 else f}


def t_tariff(m3=None, type="residential", **_):
    if m3 is None:
        return {"error": "missing field"}
    return {"amount": _charge(m3, type)}


def t_document(query=None, **_):
    if not query:
        return {"error": "missing field"}
    q = set(tokenize(query))
    best, code = -1, None
    for k, m in DOCUMENTS.items():
        o = len(q & set(tokenize(m)))
        if o > best:
            best, code = o, k
    return {"code": code, "text": DOCUMENTS[code]} if best > 0 else {"error": "not found"}


def t_record(subscriber=None, column=None, value=None, **_):
    if not (subscriber and column and value is not None):
        return {"error": "missing field"}
    return {"written": True, "subscriber": subscriber, "column": column, "value": value}


TOOLS = {
    "read_meter":    {"function": t_read_meter, "fields": ("subscriber",), "side_effect": False},
    "consumption":   {"function": t_consumption, "fields": ("previous", "current"),
                      "side_effect": False},
    "tariff":        {"function": t_tariff, "fields": ("m3", "type"), "side_effect": False},
    "search_docs":   {"function": t_document, "fields": ("query",), "side_effect": False},
    "write_record":  {"function": t_record, "fields": ("subscriber", "column", "value"),
                      "side_effect": True},
}
TOOL_NAMES = list(TOOLS)


def call_tool(name, state):
    """Fields outside the schema are filtered out; a missing field is the tool's own error."""
    CALLS[0] += 1
    if name not in TOOLS:
        return {"error": "no such tool"}
    kw = {a: state[a] for a in TOOLS[name]["fields"] if a in state}
    return TOOLS[name]["function"](**kw)


# ---- FICTIONAL tasks: each one is solved by a known tool sequence
TASKS = []
for i in range(24):
    r = rng(SEED + 9973 * i)
    sub = f"A{1 + int(r() * 60):03d}"
    t = pick(r(), [0.30, 0.22, 0.20, 0.28])
    if t == 0:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "charge", "subscriber": sub,
                      "request": f"{sub} abonesinin bu donem bedeli nedir",
                      "sequence": ("read_meter", "consumption", "tariff")})
    elif t == 1:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "consumption", "subscriber": sub,
                      "request": f"{sub} abonesinin bu donem tuketimi kac metrekup",
                      "sequence": ("read_meter", "consumption")})
    elif t == 2:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "document", "subscriber": sub,
                      "request": "sayac donusunde endeks farki nasil hesaplanir",
                      "sequence": ("search_docs",)})
    else:
        TASKS.append({"name": f"G{i + 1:02d}", "kind": "record", "subscriber": sub,
                      "request": f"{sub} abonesinin bedelini hesapla ve kayda yaz",
                      "sequence": ("read_meter", "consumption", "tariff", "write_record")})


def correct_answer(g):
    a = SUBSCRIBERS[g["subscriber"]]
    f = a["current"] - a["previous"]
    m3 = f + 100000 if f < 0 else f
    if g["kind"] == "consumption":
        return m3
    if g["kind"] == "document":
        return "SY01"
    return _charge(m3, a["type"])


# ---- FICTIONAL agent: at each step it picks a tool or finishes
def selection(d):
    return max(0.34, 0.80 - 0.09 * (d - 1))       # share of picking the right tool, falls with depth


def agent(g, max_steps=8, seed=SEED, self_critique=False, approval=False):
    """FICTIONAL agent loop. No real model is called; tool selection
    comes from a single draw that depends on the task's sequence and the depth."""
    r = rng(seed ^ trace_id(g["name"]))
    state = {"subscriber": g["subscriber"], "query": g["request"], "column": "charge"}
    trace, step, tok, errors = [], 0, 0, 0
    seq, k = g["sequence"], 0
    while step < max_steps:
        step += 1
        tok += 60 + 22 * len(trace)
        target = seq[k] if k < len(seq) else None
        if target is None:                         # the job is done , the agent should stop
            if r() < 0.88:
                return {"done": True, "steps": step, "trace": trace, "tokens": tok,
                        "errors": errors, "answer": state.get("result"),
                        "approvals": state.get("approvals", 0)}
            choice = TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        else:
            choice = target if r() < selection(k + 1) else TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        if TOOLS[choice]["side_effect"] and approval:
            state["approvals"] = state.get("approvals", 0) + 1
        if choice == "write_record":
            state["value"] = state.get("result")
        s = call_tool(choice, state)
        trace.append((choice, "error" in s))
        tok += 30
        if "error" in s:
            errors += 1
            if self_critique:
                tok += 40
            continue
        state.update({kk: vv for kk, vv in s.items() if kk != "error"})
        if choice == "consumption":
            state["result"] = s["m3"]
        if choice == "tariff":
            state["result"] = s["amount"]
        if choice == "search_docs":
            state["result"] = s["code"]
        if choice == target:
            k += 1
    return {"done": False, "steps": step, "trace": trace, "tokens": tok, "errors": errors,
            "answer": state.get("result"), "approvals": state.get("approvals", 0)}




# ---- Part added in this lesson: the two-agent arrangement and the handoff packet

ROLE = {"okuyucu": ("read_meter", "consumption", "search_docs"),
        "hesapci": ("tariff", "write_record")}
HANDOFF_FIELDS = ("subscriber", "column", "previous", "current", "type", "region", "m3", "result")
SUMMARY_FIELDS = ("subscriber", "column", "m3", "result")
# The packet carries Turkish labels and type/region values: their length enters the
# token count (abone = subscriber, sutun = column, onceki = previous, simdiki = current,
# tip = type, bolge = region, sonuc = result; mesken = residential, isyeri = commercial,
# dogu/bati/kuzey/guney/merkez = east/west/north/south/central).
PACKET_LABEL = dict(zip(HANDOFF_FIELDS, ("abone", "sutun", "onceki", "simdiki", "tip", "bolge", "m3", "sonuc")))
PACKET_VALUE = dict(zip(TYPE + REGION, ["mesken", "isyeri"] + ["dogu", "bati", "kuzey", "guney", "merkez"]))
FIELD_OF = {v: k for k, v in PACKET_LABEL.items()}
VALUE_OF = {v: k for k, v in PACKET_VALUE.items()}


def role_agent(g, role, state, max_steps, seed):
    """The same as the common loop; the tool set and the starting state are given from outside."""
    r = rng(seed ^ trace_id(role + "|" + g["name"]))
    names = ROLE[role]
    seq = [a for a in g["sequence"] if a in names]
    trace, step, tok, k = [], 0, 0, 0
    while step < max_steps:
        step += 1
        tok += 60 + 22 * len(trace)
        target = seq[k] if k < len(seq) else None
        if target is None:
            if r() < 0.88:
                break
            choice = names[int(r() * len(names))]
        else:
            choice = target if r() < selection(k + 1) else names[int(r() * len(names))]
        if choice == "write_record":
            state["value"] = state.get("result")
        s = call_tool(choice, state)
        trace.append(choice)
        tok += 30
        if "error" in s:
            continue
        state.update({kk: vv for kk, vv in s.items() if kk != "error"})
        if choice == "consumption":
            state["result"] = s["m3"]
        if choice == "tariff":
            state["result"] = s["amount"]
        if choice == "search_docs":
            state["result"] = s["code"]
        if choice == target:
            k += 1
    return state, step, tok


def handoff_packet(state, carried):
    """The handed-over context is a text; its cost is measured in tokens."""
    p = " ".join(f"{PACKET_LABEL[a]}={PACKET_VALUE.get(state[a], state[a])}" for a in carried if a in state)
    lost = [a for a in HANDOFF_FIELDS if a in state and a not in carried]
    return p, len(tokenize(p)), lost


def unpack_packet(p):
    d = {}
    for part in p.split():
        a, _, v = part.partition("=")
        d[FIELD_OF.get(a, a)] = int(v) if v.lstrip("-").isdigit() else VALUE_OF.get(v, v)
    return d


g = TASKS[22]
state = {"subscriber": g["subscriber"], "query": g["request"], "column": "charge"}
state, step, tok = role_agent(g, "okuyucu", state, 4, SEED)
print(g["name"], g["kind"], "-- reader finished, step", step, "tokens", tok)
for label, carried in (("full   ", HANDOFF_FIELDS), ("summary", SUMMARY_FIELDS)):
    p, tl, lost = handoff_packet(state, carried)
    b2, a2, e2 = role_agent(g, "hesapci", unpack_packet(p), 4, SEED)
    print(f"{label} packet {tl:>2} tokens, lost {len(lost)} {lost}"
          f" -- answer {b2.get('result')}, correct {correct_answer(g)}")
```

```
G23 record -- reader finished, step 3 tokens 306
full    packet 18 tokens, lost 0 [] -- answer 1132, correct 1132
summary packet  8 tokens, lost 4 ['previous', 'current', 'type', 'region'] -- answer 566, correct 1132
```

The difference is ten tokens, and its counterpart is a completely wrong answer. Because the summary
packet does not carry the `type` field, the `tariff` tool falls back to its own default and calculates
the charge at the residential tariff; the result comes out exactly **half**. The tool gives no error,
and that value is written to the record. **A lost field shows up not as a tool error but as a wrong
but valid result.**

## What the Second Agent Adds and What It Costs

Now the whole set is run; the breakdown by kind also stays in the table. Document tasks come out 7/7 in
every arrangement and are not in the table.

```python
KINDS = ("document", "consumption", "charge", "record")


def run(seed, budget, carried=None):
    """If carried is None it is a single agent. Otherwise the budget is divided in two: the
    reader finishes its own job, writes the handoff packet, and the calculator starts with the packet."""
    CALLS[0] = 0
    d = {"right": 0, "steps": 0, "tokens": 0, "handoff": 0, "lost": 0,
         "kind": {t: [0, 0] for t in KINDS}}
    for g in TASKS:
        if carried is None:
            s = agent(g, max_steps=budget, seed=seed)
            answer, d["steps"], d["tokens"] = (s["answer"], d["steps"] + s["steps"],
                                               d["tokens"] + s["tokens"])
        else:
            state = {"subscriber": g["subscriber"], "query": g["request"], "column": "charge"}
            state, a1, b1 = role_agent(g, "okuyucu", state, budget // 2, seed)
            packet, pb, lost = handoff_packet(state, carried)
            final, a2, b2 = role_agent(g, "hesapci", unpack_packet(packet),
                                       budget // 2, seed)
            d["steps"] += a1 + 1 + a2
            d["tokens"] += b1 + pb + b2
            d["handoff"] += pb
            d["lost"] += len(lost)
            answer = final.get("result", state.get("result"))
        d["kind"][g["kind"]][1] += 1
        d["kind"][g["kind"]][0] += answer == correct_answer(g)
        d["right"] += answer == correct_answer(g)
    d["calls"] = CALLS[0]
    return d


for t in (SEED, SEED + 1):
    print(f"\nseed {t}")
    print("budget setup               consumption charge      record      total  step calls  tokens handoff  lost")
    for budget in (4, 8):
        for name, carried in (("one agent", None), ("two agents, full", HANDOFF_FIELDS),
                              ("two agents, summary", SUMMARY_FIELDS)):
            d = run(t, budget, carried)
            p = "".join(f"{d['kind'][x][0]}/{d['kind'][x][1]:<10}" for x in KINDS[1:])
            print(f"{budget:>6} {name:<20}{p}{d['right']:>2}/24{d['steps']:>6}"
                  f"{d['calls']:>6}{d['tokens']:>8}{d['handoff']:>8}{d['lost']:>6}")
```

```
seed 20260218
budget setup               consumption charge      record      total  step calls  tokens handoff  lost
     4 one agent           8/8         5/6         1/3         21/24    84    64    9402       0     0
     4 two agents, full    6/8         4/6         2/3         19/24   109    63    8162     358     0
     4 two agents, summary 6/8         1/6         1/3         15/24   109    63    7984     180    68
     8 one agent           8/8         6/6         2/3         23/24    97    74   11978       0     0
     8 two agents, full    7/8         6/6         3/3         23/24   140    69   11703     363     0
     8 two agents, summary 7/8         2/6         2/3         18/24   140    69   11525     185    68

seed 20260219
budget setup               consumption charge      record      total  step calls  tokens handoff  lost
     4 one agent           8/8         4/6         1/3         20/24    83    67    9366       0     0
     4 two agents, full    6/8         1/6         2/3         16/24   108    63    8083     361     0
     4 two agents, summary 6/8         0/6         1/3         14/24   108    63    7895     173    72
     8 one agent           8/8         6/6         2/3         23/24    98    74   11994       0     0
     8 two agents, full    8/8         5/6         3/3         23/24   143    75   12358     372     0
     8 two agents, summary 8/8         2/6         2/3         19/24   143    75   12170     184    72
```

At the budget of eight steps overall success is **23/24** in both arrangements and the same on both
runs: the second agent adds nothing to the total. The cost is clear — steps from 97 to **140**, the
handed-over context **363 tokens**. Calls, on the other hand, fall from 74 to 69, because in the
fifteen tasks that are not the calculator's job the agent spends one step and leaves without calling
any tool. The communication cost is not a calls item.

The breakdown shows what the total hides. The four-tool **record** tasks go from 2/3 to **3/3** on both
runs: the split starts the depth from zero for each agent, and a wrong selection comes from two or
three tools instead of five. The same mechanism works in reverse on short sequences — the
**consumption** tasks go from 8/8 to 7/8 on the first run, and the **charge** tasks from 6/6 to 5/6 on
the second. The record subset is three tasks; the direction is the same, its size **counts as
unmeasured**.

The summary handoff row writes the second cost. Making the packet cheaper brings 363 tokens down to
**185**; in return come **68 lost fields** and a success that falls from 23/24 to **18/24**. The loss
gathers in the charge tasks: from 6/6 to **2/6**. **A saving measured in tokens is paid for in tasks.**

In the rows with the budget of four steps the arrangement stays **below** the single agent: 19/24 and
16/24. The share left to each agent is not enough to finish its sequence. **Splitting a job is an
arrangement only if there is enough budget to split.**

## Summary

- Two agents do not share a common memory; every field between them is **handed over explicitly** and
  its cost is measured in tokens.
- At an equal total budget the second agent adds **nothing** to success on both runs (23/24 to 23/24);
  steps go from 97 to 140 and the handed-over context holds 363 tokens. Calls, on the other hand,
  **fall** from 74 to 69: the communication cost is in the steps item.
- The breakdown opens what the total hides: the record tasks go from 2/3 to 3/3 while the consumption
  and charge tasks lose one task each.
- Making the handoff packet cheaper gains 178 tokens, loses 68 fields and lowers success to 18/24; the
  lost field shows up as **a wrong but valid result**.
- When the four-step budget is divided in two, the arrangement falls below the single agent (19/24 and
  16/24).

## Next Step

In two lessons the checking was first given to the inside of the loop and then to a second agent; in
both, the decision stayed in **the scaffolding itself**. In the `G23` task a wrong value was written to
a record and nothing stopped it, because `write_record` is the only tool **with a side effect** and
was treated the same as the other tools. The next lesson puts an **approval point** in front of that
tool: how many records are written with approval open and closed, how many wrong writes the approval
stops, and how many times the human is asked for it.
