Skip to content
academia.sh

Lesson 03 / 22

The Planner and Executor Split

What separating the side that decides from the side that runs adds and what it costs: on 24 fictional tasks the single loop gives a task success of 0.9583, 97 steps, 74 tool calls and 11978 tokens; once planner and executor are separated, success becomes 0.8750, steps 118, calls 71 and tokens 10345. The difference in success equals the split arrangement's own run-to-run difference and counts as unmeasured. The extra steps are exactly one planning step per task. Tokens fall by more than thirteen percent on both runs, because the executor carries the plan, not the reasoning trace. 0.8868 of the plan's positions are right and eighteen of the twenty-four plans are right from start to finish; since a wrong position is executed blindly, correction starts only after the plan runs out.

Published Updated

In this lesson

  • Builds an arrangement that splits an agent's job into a side that writes the plan and a side that runs the plan.
  • Compares the single loop and the split arrangement on the same task set in success, steps, tool calls, tokens and tool errors.
  • Calculates the step the split adds by separating the planning step from the change in tool calls.
  • Shows that the difference in success stays inside the run-to-run band and does not count that difference as a finding.
Contents

The previous lesson measured how a single loop deteriorates with depth: at the fourth step of the sequence the share of picking the right tool was cut in half. One reason is structural — the same call carries both the plan of the whole job and the single next action.

The first arrangement that comes to mind is to split the job in two. Let one side put out the plan of the job in one go, and let the other side execute the steps of that plan and not pick tools by itself. This lesson builds the split and asks two questions together: does the split raise success, and what does it make one pay. The baseline is the scaffolding-free loop — the single loop of the previous lesson.

  • AA22. The environment, the tool set, the task set and the single loop are the same definition as in the previous lessons and are fictional. The seed is 20260218, the second run 20260219.
  • AA23. The split arrangement is two parts: the planner writes the whole plan in a single call, and the executor runs the plan in order and does not pick tools by itself.
  • AA24. The planner sees no observation. It picks all positions at the same time, with the same information; so the depth penalty does not apply to it and it picks every position with the flat share selection(1).
  • AA25. Planning is a step and is deducted from the budget. Both setups are run with the same budget of eight steps.
  • AA26. The executor runs the plan blindly: a wrong position is executed too.
  • AA27. If the plan ends and the job is not done, the remaining positions are tried by the rule of the single loop; correction starts only after the plan runs out.
  • AA28. Tokens are counted separately in the two setups. A step of the single loop is 60 + 22 x trace length, the planner’s call is 60 + 25 x plan length, a step of the executor is 40 + 10 x trace length; every observation is 30 tokens. The executor carries the plan, not the reasoning trace.
  • AA29. Plan length is the sequence length of the task; the planner does not know the sequence, it knows its length.
  • AA30. The counted units are success, steps, tool calls, tokens and tool errors; a difference below the run-to-run difference counts as unmeasured.

The Split: One Plan, One Execution

In the split arrangement the decision is made once. The planner reads the request and writes a sequence such as read_meter -> consumption -> tariff. The executor runs that sequence in order, and because it makes no selection its step is cheap: it carries the plan and the accumulated state, not the reasoning trace. The cost of the split is written in the same sentence — because the plan is written before the observation, a wrong position is not understood to be wrong until it has been executed.

# SIMULATOR -- FICTIONAL. No real model endpoint, tool server or runtime
# is called; the environment, the tools and the tasks are all fictional.
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)}



def plan_and_execute(g, max_steps=8, seed=SEED):
    """SPLIT arrangement: the PLANNER writes the whole plan in a single call; since
    it sees no observation it picks every position with the flat selection(1) share.
    The EXECUTOR runs the plan in order and makes no selection; if the plan ends
    and the job is not done, the remaining positions are tried by the single
    loop's rule."""
    r = rng(seed ^ trace_id("plan" + g["name"]))
    seq = g["sequence"]
    plan = [d if r() < selection(1) else TOOL_NAMES[int(r() * len(TOOL_NAMES))] for d in seq]
    state = {"subscriber": g["subscriber"], "query": g["request"], "column": "charge"}
    trace, step, errors, k = [], 1, 0, 0       # planning is a step
    tok = 60 + 25 * len(plan)                  # the planner's call
    while step < max_steps:
        step += 1
        tok += 40 + 10 * len(trace)            # the executor carries the plan, not the trace
        target = seq[k] if k < len(seq) else None
        if target is None:
            if r() < 0.88:
                break
            choice = TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        elif len(trace) < len(plan):
            choice = plan[len(trace)]          # the executor follows the plan's order
        else:
            choice = target if r() < selection(k + 1) else TOOL_NAMES[int(r() * len(TOOL_NAMES))]
        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
            continue
        state.update({kk: vv for kk, vv in s.items() if kk != "error"})
        for a, b in (("consumption", "m3"), ("tariff", "amount"), ("search_docs", "code")):
            if choice == a:
                state["result"] = s[b]
        if choice == target:
            k += 1
    return {"steps": step, "trace": trace, "tokens": tok, "errors": errors, "plan": plan,
            "answer": state.get("result")}


g = [x for x in TASKS if x["name"] == "G05"][0]
s = plan_and_execute(g)
print(f"{g['name']} ({g['kind']}) correct sequence :", " -> ".join(g["sequence"]))
print("  plan     :", " -> ".join(s["plan"]))
print("  executed :", " -> ".join(f"{a}{' [error]' if h else ''}" for a, h in s["trace"]))
print(f"  steps {s['steps']}  tokens {s['tokens']}  tool errors {s['errors']}"
      f"  answer {s['answer']}  correct {correct_answer(g)}")
G05 (charge) correct sequence : read_meter -> consumption -> tariff
  plan     : tariff -> consumption -> tariff
  executed : tariff [error] -> consumption [error] -> tariff [error] -> read_meter -> consumption -> tariff
  steps 8  tokens 805  tool errors 3  answer 550  correct 550

The first position of the plan is wrong, and this single mistake wastes the whole plan. tariff gives an error because it finds no m3 in the state, consumption gives an error because the meter readings were not read, and the third position is tariff again and an error again. Since the executor is bound to the plan, it has to run all three; correction starts only when the plan runs out. The correct answer comes out in the end, but only after three tool errors were paid.

What the Split Adds and What It Costs

The same twenty-four tasks are run in two setups, with two seeds and with the same budget of eight steps.

def measure(build, seed=SEED):
    CALLS[0] = 0
    success = steps = tok = errors = 0
    for t in TASKS:
        y = build(t, seed=seed)
        steps, tok, errors = steps + y["steps"], tok + y["tokens"], errors + y["errors"]
        success += y["answer"] == correct_answer(t)
    return success / len(TASKS), steps, CALLS[0], tok, errors


print("setup                    run  success   step  calls  tokens error")
for name, build in (("single loop", agent), ("planner + executor", plan_and_execute)):
    for label, seed in (("A", SEED), ("B", SEED + 1)):
        m = measure(build, seed)
        print(f"{name:<22} {label:>5} {m[0]:>8.4f} {m[1]:>6} {m[2]:>6} {m[3]:>9} {m[4]:>5}")
total = sum(len(t["sequence"]) for t in TASKS)
for label, seed in (("A", SEED), ("B", SEED + 1)):
    plans = [plan_and_execute(t, seed=seed)["plan"] for t in TASKS]
    right = sum(a == b for pl, t in zip(plans, TASKS) for a, b in zip(pl, t["sequence"]))
    whole = sum(list(pl) == list(t["sequence"]) for pl, t in zip(plans, TASKS))
    print(f"plan {label} : right positions {right}/{total} = {right / total:.4f} ;"
          f" fully right plans {whole}/{len(TASKS)}")
setup                    run  success   step  calls  tokens error
single loop                A   0.9583     97     74     11978     8
single loop                B   0.9583     98     74     11994     7
planner + executor         A   0.8750    118     71     10345    10
planner + executor         B   0.9583    116     70     10135    12
plan A : right positions 47/53 = 0.8868 ; fully right plans 18/24
plan B : right positions 47/53 = 0.8868 ; fully right plans 18/24

The success column yields no finding. The single loop is 0.9583 on both runs; the split arrangement is 0.8750 and 0.9583. The split arrangement’s own run-to-run difference is 0.0833; the difference between the two setups is exactly 0.0833 on the first run and zero on the second. The difference is inside the band, and the effect of the split on success counts as unmeasured. A difference that can be seen with the eye is not a measured difference.

The difference in the step column is measured, and where it comes from can be calculated exactly. The split arrangement adds one planning step to every task, that is twenty-four steps; 97 plus 24 is 121, and the realized figure is 118. The counterpart of the three missing steps is in the neighboring column: tool calls fall from 74 to 71. The plan protects the executor from a number of wasted calls, but planning itself takes back eight times that gain.

The token column is where the split adds: against 11978 and 11994 stand 10345 and 10135, a fall of 13.6 percent and 15.5 percent. The two setups’ own bands are 16 and 210 tokens; the difference stands far above the band and is measured. The reason is in the definition — in the single loop every step carries the growing reasoning trace again, while in the executor a step carries a fixed plan.

Tool errors go the other way: against 8 and 7 stand 10 and 12. The direction is the same on both runs and its mechanism is clear; a blindly executed wrong position calls a tool whose fields are missing from the state. But with bands of 1 and 2, the difference on the first run is 2: we know the direction from the mechanism, we cannot measure the size at this scale.

The last two lines give the limit of the split. 0.8868 of the plan’s positions are right, but only 18 of the twenty-four plans are right from start to finish. As the plan grows longer, the chance of all of it being right falls, and a single wrong position can waste the whole plan as in G05. Splitting gathers the decision at a single point; it also gathers the cost of an error at that point at a single point.

Summary

  • In the split arrangement the planner writes the whole plan before the observation and the executor runs it in order and blindly; correction starts only after the plan runs out.
  • Success is 0.9583 and 0.9583 in the single loop and 0.8750 and 0.9583 in the split arrangement; the difference equals the split arrangement’s own run-to-run difference and counts as unmeasured.
  • The step the split adds is exactly one planning step per task: 24 is added to 97 steps, and since tool calls fall from 74 to 71, the total stays at 118.
  • Tokens fall from 11978 to 10345 and from 11994 to 10135; the fall stands far above the band, because the executor carries the plan, not the reasoning trace.
  • Tool errors are high in the split arrangement on both runs (10 and 12 against 8 and 7); the direction is clear from the mechanism, the size cannot be measured at this scale.
  • 0.8868 of the plan’s positions are right, but the plans that are fully right are eighteen of twenty-four; a single wrong position can waste the whole plan.

Next Step

Both setups stumble in the same place, and that place has not been asked about for three lessons: where will the loop stop. Eight steps was said and eight steps was written, but that number was a decision and its reason was not written. After the plan ran out, G05 needed three more steps to correct itself; with a budget of six it would have given a wrong answer. On the other hand, an agent that has finished its job and cannot manage to stop can corrupt the correct result it holds. The next lesson sweeps the step budget over the values two, four, six, eight and twelve, and writes the stopping criterion as the rule of the course.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close