10  Choosing a Runtime

Two Postures, Five Axes, and the One Thing None of Them Ships

Author

AI-Powered SE Tutorial

Published

August 25, 2026

Abstract

Chapters 7–9 toured five frameworks one at a time and answered “how does this one work?” This chapter answers “which one, and why?” It is built from an evaluation of five runtimes — Burr, LangGraph, Agno, Pydantic AI, Microsoft Agent Framework — against one production shape, all five executed. The organising idea is that every agent system decides what runs next at some point on a spectrum: you decide it at design time (authored), an agent decides it once before the run (composed), or the model decides it call by call (improvised). That spectrum is a dial you control, not a choice of camp: decomposing a worker–reviewer loop into finer steps moves it toward the authored end, and putting an agent inside a plan step moves it back. Two runtimes are new to the book — Pydantic AI and Microsoft Agent Framework — and both get a full introduction. The chapter scores all five on five axes and closes on the one capability none of them provides: semantic undo.

10.1 Choosing Between Runtimes

By the end of Chapter 9 you have seen five agent runtimes and roughly forty API surfaces. What you do not have is a way to choose. The comparison tables in Section 7.12 and Section 8.5 are feature tables: they line up capabilities so you can match rows to needs. That works when your requirements are already a list of features. It works less well when the requirement is “this thing publishes to a real channel and I have to explain afterwards what it did.”

This chapter comes from an evaluation on exactly that kind of system: StudioBay, an agentic video-production pipeline. A request arrives, a producer plans an episode, a worker and a reviewer iterate until the cut is good, a render runs, and nothing reaches YouTube without a recorded yes. Five runtimes were built against that one shape.

NoteWhat was executed and what was not

Executed, assertions passed: Burr 0.42.0, LangGraph 1.2.11, Agno 3.0.0, Pydantic AI 2.34.0, Microsoft Agent Framework 1.0.0b251120. The Pydantic AI and MAF examples run without an API key, using each framework’s test-model or plain-function facilities, so the control flow is real even where the model output is scripted.

Where this chapter shows terminal output, that output is real. A comparison that claims a green run it did not get is worse than no comparison.

The first version of the evaluation asked: which framework can hold both a fixed pipeline and a worker–reviewer loop? That question assumes the two are different kinds of thing. They are not. They are the same thing at different granularities, and seeing that changes what you look for in a framework.

10.2 Where Control Flow Gets Decided

Every agent system decides, repeatedly, what runs next. The question is when that decision happens relative to the run.

Three answers, and they form a spectrum:

Authored Composed Improvised
Who fixes control flow you, writing code an agent, once, before the run the model, during the run
When design time run time, but before any effect continuously
What exists to inspect source code a plan, as data nothing until it happens
Adapts to surprises no, you edit and redeploy only by replanning yes, immediately

A worker–reviewer loop sits at the improvised end. The agent is inside the loop and picks each tool call as it goes. Nothing about the run is known before it happens, which has four consequences:

  • Undo is per call and defensive. You cannot pre-compute the inverse of a call you did not know was coming.
  • Guards go everywhere the agent might reach. The agent’s path is unpredictable, so permission checks have to be too.
  • Cost is discovered by spending it. There is no plan to price.
  • Approval is N interrupts scattered through the run, not one decision.

At the composed end, the producer agent runs once, emits a plan as ordinary data, and code turns that plan into an executable graph. The agent still decided everything. It decided all of it at a moment when the decision can be read, priced, policy-checked and approved before any effect lands.

flowchart TD
    subgraph S["IMPROVISED — agent inside the loop"]
        direction TB
        W["worker"] <--> R["reviewer"]
        W -.-> T["tools · subagents"]
        SN["each call decided during the run"]
    end
    subgraph C["COMPOSED — agent emits a plan first"]
        direction TB
        P["producer"] --> PL["plan (data)"]
        PL --> I["inspect · policy-check · cost"]
        I --> A["approve — one decision"]
        A --> G["compile → graph"]
        G --> X["run the graph"]
    end
    style S fill:#fef3c7,stroke:#92400e,color:#92400e
    style C fill:#dcfce7,stroke:#166534,color:#166534
    style A fill:#dbeafe,stroke:#1e40af,color:#1e40af
    style SN fill:#fef3c7,stroke:#92400e,color:#92400e

The same agents and tools, arranged two ways. They differ in when control flow is decided.

ImportantWhy one run is predictable and the other is not

Both diagrams use the same agents, the same tools, and the same models. Neither is a property of the framework. What differs is how much of the control flow is fixed before the run starts, and that determines how much you can inspect, approve, price or reverse ahead of time.

10.2.1 A loop and a plan are the same thing at different granularities

It is tempting to read the table above as two camps: loops are flexible and opaque, plans are rigid and legible. That reading is wrong in both directions. The next two sections show why, starting with the loop.

Take the StudioBay worker–reviewer loop and decompose it one step at a time. Nothing is added at any stage — the same work happens. Each stage only moves a decision out of the model’s turn and into the graph.

Stage 0. One node. The agent does everything inside one turn.

@action(reads=["request"], writes=["result"])
def do_the_episode(state: State) -> State:
    result = agent.run(state["request"])      # plans, edits, reviews, renders, publishes
    return state.update(result=result)

You know one thing before this runs: an agent will be called. Not which tools, not how many times, not whether a review happened.

Stage 1. Split the argument out. Now there are two roles and an edge between them.

graph = (GraphBuilder()
    .with_actions(worker_edits, reviewer_judges)
    .with_transitions(
        ("worker_edits", "reviewer_judges"),
        ("reviewer_judges", "worker_edits", expr("verdict == 'reject'")),
        ("reviewer_judges", "publish",      expr("verdict == 'accept'")))
    .build())

You now know something you did not know at stage 0: a review happens before publish, structurally. Not because the prompt asks for it, but because there is no edge from worker_edits to publish. That is a real guarantee, obtained by decomposing once.

Stage 2. Split the worker’s turn into its phases.

graph = (GraphBuilder()
    .with_actions(select_clips, apply_edits, mix_audio, render, reviewer_judges)
    .with_transitions(
        ("select_clips", "apply_edits"),
        ("apply_edits", "mix_audio"),
        ("mix_audio", "render"),
        ("render", "reviewer_judges"),
        ("reviewer_judges", "select_clips", expr("verdict == 'reject'")),
        ("reviewer_judges", "publish",      expr("verdict == 'accept'")))
    .build())

Now you know the phases, in order. You can attach a budget to render specifically. You can gate render behind a cost check. You can replay from mix_audio without re-running the clip selection. None of that was available at stage 1.

Stage 3. Give each action a typed contract — declared reads and writes.

class EditState(BaseModel):
    clips: list[Clip] = []
    audio_track: Path | None = None
    rendered: Path | None = None
    verdict: Literal["pending", "accept", "reject"] = "pending"

@action.pydantic(reads=["clips"], writes=["audio_track"])
def mix_audio(state: EditState) -> EditState:
    state.audio_track = ffmpeg_mix(state.clips)
    return state

At stage 3 the graph carries the same information a composed plan carries: the steps, their order, what each one reads and writes, which are reversible, where the gates are. The only thing still decided at run time is which branch each predicate takes.

A stage-3 graph and a compiled plan carry the same information. They differ in who wrote them:

Stage-3 graph Compiled plan
Who wrote it a person, at design time a producer agent, at run time
When it is known before deployment before any effect
Changes between runs no yes — a new plan each time
Can be inspected before running yes yes

Decomposition therefore gives you inspectability, gates, budgets per phase, and replay from a chosen point — most of what the composed posture gives you. It does not give you variation. A hand-authored graph has the same shape on every run. If the work differs run to run, you either write every branch by hand or accept that one shape fits some inputs badly. A producer agent solves that specific problem and no other.

The practical reading: granularity is a dial you control, and it is usually the cheaper move. Before reaching for a producer agent, ask whether splitting one node into three gets you the guarantee you wanted. Often it does.

10.2.2 A composed graph is not a chain of deterministic functions

The other half of the correction. A compiled plan is frequently drawn as boxes of pure Python in a row. Real ones contain two other kinds of node.

Agent nodes. A step in the plan can have an agent as its executor, or a whole improvised loop. The plan fixes that an editing loop occupies position 3, with a budget, an iteration cap and a review gate. Inside those bounds the loop improvises freely.

PLAN = {"steps": [
    {"tool": "ingest",   "args": {"src": "raw/a.mp4"}, "reversible": True},
    {"kind": "agent_loop",                                  # <- not deterministic
     "executor": "edit_review_loop",
     "budget_usd": 2.00, "max_iterations": 5,
     "gate": "human_review", "reversible": True},
    {"tool": "render",   "args": {"preset": "1080p"}, "reversible": True},
    {"tool": "publish",  "args": {"channel": "youtube"}, "reversible": False},
]}

The plan does not say which clips get trimmed or how many revisions the reviewer demands. It says the loop runs at position 3, costs at most $2.00, iterates at most 5 times, and cannot reach publish without passing the gate. Those are the properties you wanted to check before running. The improvisation inside is not a problem — it is contained.

System nodes. The producer emits intent. The compiler decides what wraps each step, and the producer never sees these:

def compile_step(step: dict, world: dict):
    """One plan step becomes a stack of nodes: the work, plus its cross-cutting wrappers."""
    node = TOOLBOX[step["tool"]] if "tool" in step else AGENTS[step["executor"]]
    node = with_journal(node, inverse=make_inverse(step))   # undo, attached here
    node = with_budget(node, limit=step.get("budget_usd"))
    node = with_retry(node, attempts=step.get("retries", 1))
    node = with_redaction(node)
    node = with_audit(node, plan_id=world["plan_id"])
    if step.get("gate"):
        node = with_approval_gate(node, kind=step["gate"])
    return node

Undo, budget, retry, redaction, audit and approval are attached in one place, to every step, by construction. Nobody has to remember them at the fifteenth call site. That is the concrete payoff of axis C below, and it is available precisely because the plan is data the compiler can walk.

So the honest picture is not two camps. It is a dial, with agents living on both sides of it:

flowchart LR
    A["AUTHORED<br/>you write the graph<br/>same every run"]
    C["COMPOSED<br/>an agent writes the graph<br/>new plan each run"]
    I["IMPROVISED<br/>the model picks each call<br/>nothing fixed"]
    A -->|"producer emits the plan"| C
    C -->|"steps become agent loops"| I
    I -->|"decompose a node into phases"| C
    C -->|"pin the plan, stop replanning"| A
    style A fill:#dbeafe,stroke:#1e40af,color:#1e40af
    style C fill:#dcfce7,stroke:#166534,color:#166534
    style I fill:#fef3c7,stroke:#92400e,color:#92400e

One dial. Decomposition moves left; agent nodes and producers move right.

Real systems sit in several places at once: a hand-authored outer pipeline, one step of which is a composed plan, one step of which is an improvised loop. Which makes the framework question concrete — can it express all three, and can one contain another?

10.3 What Goes in the Graph and What Goes in the Toolbox

One modelling mistake changed the evaluation’s conclusions, and it is easy to repeat.

The first StudioBay graph drew mix_audio and render_video as nodes beside the agents. That gives six nodes. Four is correct. The worker calls render_video during its own turn, to see what it just edited, on every pass of the loop. Rendering is a tool call inside the loop, not a stage after it.

flowchart TD
    RQ["request"] --> PP["producer_plans<br/>[LLM]"]
    PP --> WE["worker_edits"]
    WE --> RJ["reviewer_judges<br/>[LLM]"]
    RJ -->|"verdict = reject<br/>(with feedback)"| WE
    RJ -->|"verdict = accept"| PG["publish_gate<br/>[GATE] irreversible"]
    WE -.->|calls| TB["the worker's toolbox<br/>place_clip · trim_clip · set_narration<br/>mix_audio · render_video<br/>+ subagents"]
    style PP fill:#fef3c7,stroke:#92400e,color:#92400e
    style RJ fill:#fef3c7,stroke:#92400e,color:#92400e
    style TB fill:#dcfce7,stroke:#166534,color:#166534
    style PG fill:#fce7f3,stroke:#9d174d,color:#9d174d

Four nodes. Rendering happens inside the worker’s turn, on every pass.

Three consequences:

  1. render_video returns the same output for the same input. An agent calling it does not change that, and putting it in a graph node does not make it more predictable. What varies is who decides when it runs and how often — which is the granularity dial from Section 10.2.1, not a property of the function.
  2. The graph stays small while the toolbox grows. Adding silence_cut or voice_clone touches no edges.
  3. Permissions attach to the toolbox, not the topology. “Who may publish” is a question about the tool seam. Answering it by looking at which node has an inbound edge gets it wrong as soon as two nodes can call the same tool.

Subagents follow the same rule. A track editor and a system agent are invocations the worker makes, out of the same box as trim_clip, not peers in a mesh. One dispatch path means guards and audit are registered once. Promoting a capability into a graph node gives it a second dispatch path that the guards do not cover.

10.4 The Five Axes

Five questions to ask before “which framework.” Each is independent, and a runtime can be strong on one and absent on another.

Axis Question The spectrum
A · Posture When is control flow decided? authored ─ composed ─ improvised
B · Graph as value Can code build a graph at runtime from data and pass it around? value ··· declaration fixed at import
C · Cross-cutting seam Where do audit, undo, budget, retry, redaction attach? one seam ··· per call site
D · Knowable ahead Which steps, what cost, which are irreversible, does policy permit? the whole plan ··· the next call, maybe
E · Nesting Can one posture contain another, in one mechanism? graph-as-node ··· two separate runtimes

Axis A is the dial from Section 10.2.1, and it governs the other four. Axis B decides whether the composed posture is reachable at all: if a graph cannot be built from data at run time, a producer agent has nowhere to put its plan. Axis C is the difference between registering audit once (Section 10.2.2) and writing it at the fifteenth call site. Axis D has an honest answer of nothing at the improvised end, and improves as you decompose. Axis E decides whether nesting the postures requires a second vocabulary that will drift from the first.

TipHow undo depends on the axes

An inverse has to be constructed before its act is applied. When the plan is known ahead, inverses attach as the plan is compiled (axis D) and one journal covers everything (axis C). When each call is decided in the moment, the inverse has to be produced at that call site by whoever wrote that tool, and that invariant erodes. Section 10.8 develops this.

10.5 The Five API Shapes

What each framework’s vocabulary actually is, small enough to hold in your head. Burr and Agno are covered in depth in Chapters 7 and 8; the treatment here is the shape and the axis reading. Pydantic AI and Microsoft Agent Framework are new to the book and get the fuller pass.

10.5.1 Burr — a graph is a value you build

Executed. See Section 7.9 for the full API.

The vocabulary: @action (a function declaring what state it reads and writes), immutable State, (from, to, condition) transition triples, GraphBuilder (builds a Graph value, separately from the app), lifecycle hooks (the cross-cutting seam), and halt_before (the gate).

from burr.core import action, State, ApplicationBuilder, when, default

@action(reads=["draft"], writes=["verdict"])
def reviewer(state: State) -> State:
    ok = state["draft"]["duration"] <= 250
    return state.update(verdict="accept" if ok else "reject")

app = (ApplicationBuilder()
    .with_actions(worker, reviewer, publish_gate)
    .with_transitions(
        ("worker", "reviewer", default),
        ("reviewer", "worker", when(verdict="reject")),    # the loop
        ("reviewer", "publish_gate", when(verdict="accept")))
    .with_entrypoint("worker").with_state(passes=0).build())

app.run(halt_before=["publish_gate"])

Running the full StudioBay spine in Burr produced this — and note what is not encoded anywhere in the graph:

$ python sketch_burr.py
halted before : publish_gate
worker passes : 2  (2 == the reviewer sent it back once)
llm turns     : 3  (producer 1 + reviewer 2)
rendered      : {'path': '/nas/ep01.mp4', 'seconds': 240.0, 'mix': -14.0}
  pass 1 tools : ['place_clip','place_clip','set_narration','mix_audio','render_video']
  pass 2 tools : ['trim_clip','set_narration','mix_audio','render_video']
determinism   : render(timeline) == render(timeline) -> 240.0s
sketch_burr.py: all assertions passed

Nothing says “review twice.” The reviewer rejected a 300s cut, the worker trimmed to 240s, the second review accepted — the loop ran because state said so. Both passes called the same deterministic render tools.

ImportantA correction to a claim this book made earlier

Burr is often described as a deterministic-workflow library. It is a state machine. Whether a run is predictable depends on what you put in the actions: a pure function is predictable, an LLM call is not. The same primitive expresses a fixed pipeline and a worker–reviewer loop, because “which agent goes next” is a conditional transition over state. Burr reaches all three points on the dial in Section 10.2.1.

The property that matters most is axis B: GraphBuilder().build() returns a value, so “compose a graph from a plan” is a for loop, not metaprogramming. Section 10.6 builds exactly that. Nesting (axis E) comes from RunnableGraph, which makes a graph usable as a node — so a composed plan can contain a spine step in one mechanism.

10.5.2 LangGraph — Pregel supersteps, channels, checkpoints

Executed. See Section 7.12 for the head-to-head with Burr.

The vocabulary: StateGraph (typed state where each key is a channel with a merge rule), Annotated (the reducer — how concurrent writes to one key combine), add_conditional_edges, Send (dynamic fan-out), Command (update + goto + delegate-to-parent in one return value), interrupt / Command(resume=), and a checkpointer that persists every superstep.

class Production(TypedDict, total=False):
    passes: int
    results: Annotated[list, operator.add]   # reducer: concurrent writes append

def route(state) -> str:
    return "worker" if state["verdict"] == "reject" else "publish"

g = StateGraph(Production)
g.add_node("worker", worker); g.add_node("reviewer", reviewer)
g.add_conditional_edges("reviewer", route, ["worker", "publish"])
app = g.compile(checkpointer=InMemorySaver())

Its distinctive move is Send, for fan-out decided at runtime:

def fan_out(state) -> list[Send]:
    return [Send("ingest", {"src": s}) for s in state["sources"]]
g.set_conditional_entry_point(fan_out, path_map=["ingest"])

N is unknown until the state is read. Note carefully what this is not: the node set is still fixed at compile time. Send varies how many copies run, not what the graph contains. That is real dynamism, but it is a partial credit on axis B, not a pass.

Time travel is the strongest reversal story of the five:

history = list(app.get_state_history(cfg))
before = [s for s in history if s.next == ("publish",)][-1]
app.invoke(None, before.config)                # fork; original branch preserved
app.invoke(Command(resume="deny"), cfg)        # same gate, other answer
$ python sketch_langgraph.py
checkpoints   : 8
rewind to     : next=('publish',) passes=2
fork          : re-entered the gate, interrupt re-fired
denied branch : published={}  (gate said no)
checkpoints   : 10  (original branch still there)

Eight checkpoints became ten. Both branches survive — the one that published and the one that declined.

WarningRead this before trusting it as undo

Forking rewinds the graph, not the world. The sketch forked past a publish; the video stayed published. No checkpoint reaches out and retracts it.

Also: replay re-executes nodes after the checkpoint, so every node must be safe to run twice — a real constraint when a node shells out to ffmpeg.

10.5.3 Pydantic AI — typed agents, delegation as a tool call

Verified against pydantic-ai 2.34.0. New to this book.

10.5.3.1 What it is

Pydantic AI is an agent framework from the Pydantic team — the same people who maintain the validation library that FastAPI is built on. That lineage shows in the design. An Agent is a typed object: you declare the type of its dependencies, the type of its output, and its tools are ordinary Python functions whose signatures become the tool schemas.

Install it with pip install pydantic-ai. The smallest program:

from pydantic_ai import Agent

agent = Agent("anthropic:claude-sonnet-4-6", instructions="Be concise.")
result = agent.run_sync("What is context engineering?")
print(result.output)                       # a str

The model is a string identifier: "anthropic:claude-sonnet-4-6", "openai:gpt-5.2", "google:gemini-2.5-pro". Swapping providers is swapping that string.

Three constructor parameters carry the framework’s design:

from dataclasses import dataclass
from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

@dataclass
1class Deps:
    store: object
    journal: object

2class Verdict(BaseModel):
    accept: bool
    reason: str

reviewer = Agent(
    "anthropic:claude-sonnet-4-6",
    deps_type=Deps,
    output_type=Verdict,
    instructions="Judge the draft against the plan.",
)

result = reviewer.run_sync("Review this cut...", deps=Deps(store=s, journal=j))
3result.output.accept
1
deps_type declares what runtime values the agent’s tools may reach. Whatever you pass to deps= arrives at every tool as ctx.deps, correctly typed. This is dependency injection, in the FastAPI sense.
2
output_type declares what the run returns. Give it a Pydantic model and the framework enforces the schema, retrying the model if validation fails.
3
The result is a real typed object, not a dict you parse. result.output.accept is a bool and your type checker knows it.

Tools are decorated functions. The docstring becomes the description, the signature becomes the schema:

@reviewer.tool
async def fetch_plan(ctx: RunContext[Deps], plan_id: str) -> dict:
    """Load the episode plan this draft is being judged against."""
    return await ctx.deps.store.get(plan_id)     # ctx.deps is typed as Deps

Use @agent.tool when the function needs ctx, and @agent.tool_plain when it does not.

10.5.3.2 Multi-agent means one agent calling another

A common first impression is that Pydantic AI is Burr-shaped. That is half right, and the wrong half matters. The package pydantic_graph ships with it and is genuinely graph-shaped — 2.34 gives you GraphBuilder, Step, Decision, Fork, Join and reducers, with state persistence. But the idiomatic production shape is not the graph. It is an agent with typed tools, where delegating to another agent happens inside a tool call.

reviewer = Agent("openai:gpt-5.2", name="reviewer", deps_type=Deps, output_type=str,
                 instructions="Judge the draft against the plan. Be specific when rejecting.")

producer = Agent("openai:gpt-5.2", name="producer", deps_type=Deps,
1                 output_type=[str, DeferredToolRequests],
                 instructions="Plan and compose the episode. Iterate until the "
                              "reviewer accepts, then render and publish.")

@producer.tool
2async def review_draft(ctx: RunContext[Deps], draft: str) -> str:
    r = await reviewer.run(f"Review: {draft}", deps=ctx.deps, usage=ctx.usage)
    return r.output          # "reject: runs long" — the producer loops on its own

3@producer.tool_plain(requires_approval=True)
def publish(path: str, channel: str) -> str:
    """Irreversible. The framework will not run this without an approval."""
    return f"published {path} to {channel}"
1
DeferredToolRequests in output_type is what makes approvals surface. Omit it and the pause never reaches your code.
2
The reviewer is called from inside a tool. usage=ctx.usage threads token accounting through, so the outer run’s usage limits cover the inner agent too.
3
requires_approval=True stops the run before this function executes.

10.5.3.3 The approval gate

The pending approval arrives as the run’s typed output, not through a callback you registered:

result = await producer.run("make the episode", deps=deps)

while isinstance(result.output, DeferredToolRequests):
    results = DeferredToolResults()
    for call in result.output.approvals:
        # the audit row is written here — before any effect
        results.approvals[call.tool_call_id] = (
            True if operator_approves(call) else ToolDenied("operator declined"))
    result = await producer.run(message_history=result.all_messages(),
                                deferred_tool_results=results, deps=deps)

This runs without an API key, using FunctionModel to script the model’s replies while the framework’s real control flow executes:

from pydantic_ai.models.function import FunctionModel, AgentInfo
from pydantic_ai.messages import ModelResponse, ToolCallPart, TextPart, ModelMessage

calls = []

def scripted(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
    if sum(1 for m in messages if m.kind == "response") == 0:
        return ModelResponse(parts=[ToolCallPart("publish",
                                    {"path": "cut.mp4", "channel": "main"})])
    return ModelResponse(parts=[TextPart("done")])

producer = Agent(FunctionModel(scripted), output_type=[str, DeferredToolRequests])

@producer.tool_plain(requires_approval=True)
def publish(path: str, channel: str) -> str:
    """Irreversible. Publishes the cut."""
    calls.append((path, channel))
    return f"published {path} to {channel}"

result = producer.run_sync("make the episode")
print(type(result.output).__name__, [(c.tool_name, c.args) for c in result.output.approvals])
print("tool executed yet?", calls)
DeferredToolRequests [('publish', {'path': 'cut.mp4', 'channel': 'main'})]
tool executed yet? []

The tool has not run. Approve it and it does; deny it and it does not:

res = DeferredToolResults()
for call in result.output.approvals:
    res.approvals[call.tool_call_id] = True
result = producer.run_sync(message_history=result.all_messages(), deferred_tool_results=res)
print(result.output, calls)
done [('cut.mp4', 'main')]

Replacing True with ToolDenied("operator declined") gives done [] — the model is told the call was refused and continues without the effect.

Two details the shape alone does not tell you: the agent must declare DeferredToolRequests in its output_type or the pause never surfaces, and for conditional approval you raise ApprovalRequired from inside the tool based on ctx.tool_call_approved, rather than flagging the whole tool.

On the axes. Strongest at the improvised end of axis A, weakest elsewhere. Compare the code above to Burr, LangGraph and MAF and one thing is missing: there is no worker → reviewer edge. The loop lives in the model’s reasoning, so nothing structurally forces a review before render. Axis D reads nothing. Decomposition in the sense of Section 10.2.1 is available — via pydantic_graph — but that means writing a Burr-shaped machine in a second vocabulary inside the same project. Durability means adopting Temporal, DBOS or a similar backend, which is real infrastructure the other four do not require.

10.5.4 Microsoft Agent Framework — executors on typed edges

Verified against agent-framework 1.0.0b251120. New to this book.

10.5.4.1 What it is

Microsoft Agent Framework (MAF) is Microsoft’s consolidation of two earlier projects: AutoGen, its multi-agent research framework, and Semantic Kernel, its production SDK. It is .NET-first with a Python port; the version number above is still a beta, and the Python API moves.

Install it with pip install agent-framework. There are two layers, and the workflow layer is the one this chapter is about.

The agent layer is conventional — a chat agent with tools:

from agent_framework import ChatAgent

agent = ChatAgent(chat_client=client, instructions="Be concise.", tools=[my_tool])

The workflow layer is a graph of executors connected by edges. An executor receives one typed message and sends typed messages onward. The type is the contract between two executors, and the framework checks it when you build the graph.

from dataclasses import dataclass
from agent_framework import WorkflowBuilder, executor, WorkflowContext
from typing_extensions import Never

@dataclass
class Draft:
    text: str

1@executor(id="worker")
async def worker(topic: str, ctx: WorkflowContext[Draft]) -> None:
2    await ctx.send_message(Draft(text=f"cut of {topic}"))

@executor(id="finish")
async def finish(draft: Draft, ctx: WorkflowContext[Never, str]) -> None:
3    await ctx.yield_output(f"done: {draft.text}")

workflow = (WorkflowBuilder()
    .set_start_executor(worker)
    .add_edge(worker, finish)
    .build())
1
@executor turns a function into a node. Both parameters need type annotations; the framework reads them to determine what the node accepts and emits, and raises ValueError at decoration time if they are missing.
2
ctx.send_message(...) passes a value along outgoing edges. The value’s type decides which edges can carry it.
3
WorkflowContext[T_send, T_output] — the first type argument is what this node sends onward, the second is what it can yield as a workflow result. Never means it sends nothing further.

Building the graph type-checks it. Connect two executors whose types do not line up and build() refuses:

TypeCompatibilityError: [TYPE_COMPATIBILITY] Type incompatibility between executors
'reviewer' -> 'worker'. Source executor outputs types ["Draft", "ReviewRequest"]
but target executor can only handle types ["str"].

That is a real error from a real build, and it is a genuine advantage: a mis-wired edge fails before the run rather than during it.

WorkflowBuilder also offers add_chain, add_fan_out_edges, add_fan_in_edges, add_switch_case_edge_group, set_max_iterations and with_checkpointing. Three prebuilt patterns ship as separate builders: SequentialBuilder, ConcurrentBuilder, and MagenticBuilder (a manager-led team, from the AutoGen lineage).

10.5.4.2 Human review is a node, not a flag

MAF’s distinguishing feature is that pausing for a human is a real suspension of the workflow, and the mechanism is a node type: RequestInfoExecutor. Route a message to it and the workflow stops and hands you a pending request.

An executor that needs two message types uses a class with @handler methods rather than the @executor decorator:

from agent_framework import (WorkflowBuilder, Executor, handler, WorkflowContext,
                             RequestInfoExecutor, RequestInfoMessage, RequestInfoEvent,
                             RequestResponse)

@dataclass
1class ReviewRequest(RequestInfoMessage):
    draft: str = ""

class Reviewer(Executor):
    @handler
    async def ask(self, draft: Draft, ctx: WorkflowContext[ReviewRequest]) -> None:
2        await ctx.send_message(ReviewRequest(draft=draft.text))

    @handler
3    async def decide(self, resp: RequestResponse[ReviewRequest, str],
                     ctx: WorkflowContext[Draft, str]) -> None:
        if resp.data == "accept":
            await ctx.yield_output(f"PUBLISHED {resp.original_request.draft}")
        else:
            await ctx.send_message(Draft(text=resp.original_request.draft))

workflow = (WorkflowBuilder()
    .set_start_executor(worker)
    .add_edge(worker, reviewer)
    .add_edge(reviewer, review)          # review = RequestInfoExecutor(id="review")
4    .add_edge(review, reviewer)
    .add_edge(reviewer, worker)          # the reject loop
    .build())
1
Subclass RequestInfoMessage and add the fields the human needs to see. All fields need defaults, since the base class supplies request_id.
2
Sending that message to the RequestInfoExecutor suspends the workflow.
3
The answer arrives as RequestResponse[TRequest, TAnswer], carrying both .data (what the human said) and .original_request (what was asked).
4
The response routes back to the executor that sent the request, not onward down the graph. Wire the return edge or the answer has nowhere to go — the framework logs could not be delivered and the workflow stalls.

Running it, rejecting once, then accepting:

events = await workflow.run("episode 12")
reqs = [e for e in events if isinstance(e, RequestInfoEvent)]
print(trace, "|", reqs[0].data.draft)

events = await workflow.send_responses({reqs[0].request_id: "reject"})
reqs = [e for e in events if isinstance(e, RequestInfoEvent)]
print(trace, "|", reqs[0].data.draft)

result = await workflow.send_responses({reqs[0].request_id: "accept"})
print(trace, "|", result.get_outputs())
['worker.start', 'reviewer.ask'] | cut of episode 12
['worker.start', 'reviewer.ask', 'reviewer.decide=reject', 'worker.revise',
 'reviewer.ask'] | cut of episode 12 (revised)
['worker.start', 'reviewer.ask', 'reviewer.decide=reject', 'worker.revise',
 'reviewer.ask', 'reviewer.decide=accept'] | ['PUBLISHED cut of episode 12 (revised)']

Read the trace: worker.start appears once. Resuming does not re-run completed executors — the pause is at a node boundary, and the workflow picks up from there. That is cleaner than LangGraph’s interrupt(), where resumption re-executes the node containing the interrupt and everything above the call has to be idempotent.

NoteA cycle needs an iteration cap

The reject edge makes the graph cyclic, and build() warns about it:

WARNING Cycle detected in the workflow graph involving:
review -> reviewer -> worker -> review. Ensure termination or iteration limits exist.

WorkflowBuilder.set_max_iterations(n) is the cap. Without it a reviewer that never accepts loops until something else stops it.

10.5.4.3 The graph is serializable

Workflow has to_dict(), to_json(), from_dict(), from_json() and clone(). A built workflow serializes to a plain structure:

>>> list(workflow.to_dict())
['id', 'start_executor_id', 'max_iterations', 'edge_groups', 'executors']

That matters for axis B. A MAF graph is data you can persist, diff between versions, and reconstruct — which is most of what the composed posture needs. The gap is that WorkflowBuilder is a builder you call, not a graph value you can assemble from a plan in a single expression, so compiling a producer’s plan into a MAF workflow means driving the builder in a loop rather than mapping over the plan.

On the axes. Cleanest suspend/resume of the five, and the typed edges are a real strength — mis-wiring fails at build time. Serializable graphs put axis B in reach. The weak spot is walkback: checkpointing is keyed to a session with run-forward or resume-from-checkpoint semantics, and there is no fork from an arbitrary past point, so branching a run to compare two paths is yours to build. Python is the younger half of a .NET-first framework and the version is a beta. And in a stack that already runs LangGraph under a deep-agent brain, adopting MAF means a second graph vocabulary beside the first.

10.5.5 Agno — declared steps and loops, plus a control plane

Signatures verified against agno 3.0.0; the pause/resume flow below was executed. See Section 8.2 for the full platform tour.

Agno’s distinguishing move in this comparison: the review loop is a component you declare, not a graph you wire. Per-iteration review, reject-with-feedback and resumption arrive without drawing a single edge.

workflow = Workflow(
    name="produce_episode", db=SqliteDb(db_file="wf.db"),   # db required for HITL
    steps=[
        Step(name="plan", agent=producer),
        Loop(name="edit_review",
             steps=[Step(agent=worker), Step(agent=reviewer)],
             max_iterations=5, forward_iteration_output=True,
             human_review=HumanReview(requires_iteration_review=True,
                                      on_reject=OnReject.retry)),
        Step(name="render", executor=render_fn),
        Step(name="publish", agent=producer,
             human_review=HumanReview(requires_confirmation=True,
                                      on_reject=OnReject.skip)),
    ])

run = workflow.run("episode on the scheduler")
while run.is_paused:
    for req in run.steps_requiring_output_review:
        req.confirm()                     # or req.reject(feedback="runs long")
    run = workflow.continue_run(run)

On the axes. Fastest route to a working reviewer loop with human review at every iteration, and the only candidate shipping a control plane (sessions, RBAC, tracing) in the box. But the control flow is Agno’s vocabulary: a Loop is a loop. Burr routes to any action on any predicate; here you get the shapes that ship (axis A constrained, axis B closed — the composed posture has no natural expression). Forking exists at the agent level — checkpoint=, continue_run(fork=True, continue_from=<index>), fork_session() — but what it forks is a message history, not a typed state you can read, diff, or policy-check before resuming.

10.6 Running the Composed Posture

The sections above describe the three postures. This one runs the composed posture end to end. Every number in the output below came from an execution.

Step 1 — the producer emits a plan. In production this is one LLM call returning JSON that a Pydantic model validates. The plan is a versioned artifact: you can store it, diff two of them, show it to an operator, and refuse to run one that fails a policy check. None of that is available when the plan exists only in the model’s reasoning.

def producer_plans(request: str, sources: list[str]) -> dict[str, Any]:
    """[LLM in real life] Decide the whole shape up front."""
    return {
        "request": request,
        "steps": [{"tool": "ingest", "args": {"src": s}, "reversible": True}
                  for s in sources]
        + [
            {"tool": "assemble", "args": {"style": "lesson"},  "reversible": True},
            {"tool": "narrate",  "args": {"voice": "cloned-a"}, "reversible": True},
            {"tool": "render",   "args": {"preset": "1080p"},  "reversible": True},
            {"tool": "publish",  "args": {"channel": "youtube"}, "reversible": False},
        ],
    }

Step 2 — refuse the plan before it runs. This check is impossible in the spine posture: there, you learn about the publish when the agent is already calling it.

def policy_check(plan: dict) -> list[str]:
    problems = []
    for i, s in enumerate(plan["steps"]):
        if not s["reversible"] and i != len(plan["steps"]) - 1:
            problems.append(f"irreversible {s['tool']} is not the last step")
        if s["tool"] not in TOOLBOX:
            problems.append(f"unknown tool {s['tool']}")
    return problems

Step 3 — compile the plan into a graph. This is the whole trick.

def step_action(step: dict, world: dict):
    """One plan step becomes one Burr action."""
    @action(reads=["done"], writes=["done"])
    def _run(state: State) -> State:
        JOURNAL.do(world, make_act(step))          # inverse attached at plan time
        return state.update(done=state.get("done", []) + [step["tool"]])
    return _run

def compile_plan(plan: dict, world: dict):
    """Plan (data) -> Graph (value)."""
    steps = plan["steps"]
    named = {f"s{i}_{s['tool']}": step_action(s, world) for i, s in enumerate(steps)}
    names = list(named)
    gb = GraphBuilder().with_actions(**named)
    for a, b in zip(names, names[1:]):             # the plan IS the topology
        gb = gb.with_transitions((a, b, default))
    return gb.build(), names

with_actions(**named) takes the node name from the keyword, so the plan’s own step order names the graph. Nothing here is metaprogramming — a Burr graph is an ordinary value you build with an ordinary loop. This is axis B cashing out.

Step 4 — attach cross-cutting concerns once. In the spine posture, “audit every tool call” means remembering to do it in every branch the agent might take. Here the steps are known before anything runs, so a hook covers all of them by construction.

class AuditHook(PreRunStepHook, PostRunStepHook):
    def __init__(self): self.trail: list[str] = []
    def pre_run_step(self, *, state, action, **kw):  self.trail.append(f"-> {action.name}")
    def post_run_step(self, *, state, action, result, exception, **kw):
        self.trail.append(f"<- {action.name}{' FAILED' if exception else ''}")

Step 5 — run it, halting before the irreversible step, which is knowable from the plan.

graph, names = compile_plan(plan, world)
app = (ApplicationBuilder().with_graph(graph).with_entrypoint(names[0])
       .with_state(done=[]).with_hooks(audit)
       .with_identifiers(app_id="composed-1").build())

last_reversible = names[len(plan["steps"]) - 1]
act, _, state = app.run(halt_before=[last_reversible])
$ python sketch_composed.py
policy check  : clean
graph nodes   : 6 (one per plan step, built at runtime)
executed      : ['ingest','ingest','assemble','narrate','render']
halted before : s5_publish  (the publish — known before we started)
audit trail   : 10 entries from ONE hook
undo 5 acts   : ['render','narrate','assemble','ingest','ingest']
world after   : {'sources': []}  (back to the start)

The plan was policy-checked before a byte moved, one hook audited every step (5 steps × pre + post = 10 entries), the run halted at a gate identified in advance from the plan, and the whole thing reversed in order. None of that needed the agent to cooperate.

TipThis is not an argument against agents

The agent still decided everything — the sources, the style, the voice, the preset, the channel. It decided all of it in one call, somewhere the decision could be read. The composed posture doesn’t remove the model from the loop; it moves the model’s decision earlier, to where a human, a policy engine, and a cost estimator can all see it.

10.7 The Matrix

Five runtimes against five axes. ✓ = yes, ~ = partial, ✗ = no.

Axis Burr LangGraph Pydantic AI Agno MAF
A · postures reachable ✓ all three ✓ authored, improvised; composed via Send ~ improvised natively; composed only via pydantic_graph ~ authored + declared loops ✓ authored, improvised
B · graph is a value ✓ GraphBuilder → value ~ nodes fixed at compile; Send varies count ✗ no graph in the main idiom ✗ steps declared, not built ~ builder, not a value; but to_dict()/from_dict() round-trips
C · one seam for cross-cutting ✓ lifecycle hooks ~ callbacks; per-node config ✓ tool layer + capabilities ~ per-step config ~ middleware
D · knowable before running ✓ whole plan, if composed ~ topology yes, path no ✗ nothing — model decides ~ step list, not iterations ~ topology yes, path no
E · postures nest ✓ RunnableGraph as node ✓ compiled graph as node ~ agent-in-tool only ~ teams inside steps ✓ Workflow.as_agent(), sub-workflows
— semantic undo ✗ ✗ ✗ ✗ ✗

And the operational view — the same five scored on what a system with irreversible acts actually needs:

Capability Burr LangGraph Agno MAF Pydantic AI
Pinned pipeline ✓ native ✓ native ~ steps ✓ native ~ via graph
Agent collaboration ✓ transitions ✓ edges ✓ teams ✓ edges ✓ delegation
Both in one mechanism ✓ proven ✓ proven ~ loop only ✓ ✗ two idioms
Structural review guarantee ✓ edge ✓ edge ✓ component ✓ edge ✗ model’s choice
Gate on irreversible act ✓ halt_before ✓ interrupt ✓ HumanReview ✓ RequestInfoExecutor ✓ best of five
Fork from any past point ✓ sequence_id ✓ best of five ~ message index, untyped ~ from head ~ replay
Semantic undo ✗ ✗ ✗ ✗ ✗
Safe to resume effectful steps ✓ no re-exec ~ re-executes ✓ ✓ no re-exec (verified) ✓ durable
New infrastructure required ✓ none ✓ none ~ AgentOS ✓ none ✗ Temporal/DBOS

Axis B decides whether the composed posture is reachable at all, and the other four axes improve when it is. It is the row to read first.

One row is identical across all five columns: semantic undo. The next section explains what that means and why the choice of runtime does not hinge on it.

10.8 Semantic Undo, Which No Runtime Provides

Every framework here has a replay feature with a name that suggests undo. Replay restores the runtime’s record of what happened. It does not touch the world the run acted on. The LangGraph fork in Section 10.5.2 is the proof: it rewound the graph past a publish, and the video stayed published.

For a system whose acts land on a timeline and a channel, that difference is the whole game. The idea none of the five ships:

Every act produces its own inverse at the moment it is applied. Record the pairs in order, and undo is walking the record backwards.

One rule makes the journal trustworthy: an act is not applied unless its inverse was constructed first. A tool that cannot say how to undo itself does not run. This forces irreversible acts to declare themselves instead of hiding among the reversible ones.

@dataclass
class Act:
    name: str
    apply: Callable[[dict], None]
    invert: Callable[[dict], None] | None
    describes: str = ""
    reversible: bool = True      # publish/delete/spend: no honest inverse

@dataclass
class Journal:
    entries: list[Act] = field(default_factory=list)
    snapshots: dict[str, dict] = field(default_factory=dict)

    def do(self, state: dict, act: Act) -> None:
        """Apply an act. The inverse must exist BEFORE the act runs."""
        if act.reversible and act.invert is None:
            raise ValueError(f"{act.name}: no inverse — refusing to apply")
        act.apply(state)
        self.entries.append(act)

    def undo(self, state: dict, n: int = 1) -> list[str]:
        undone = []
        for _ in range(n):
            if not self.entries: break
            act = self.entries[-1]
            if not act.reversible:
                # Stop rather than lie. This is where walkback takes over.
                raise ValueError(f"{act.name} is irreversible — sequential undo "
                                 f"cannot pass it. Use walkback to a snapshot.")
            act.invert(state); self.entries.pop(); undone.append(act.name)
        return undone

The capture-before-mutate discipline is the whole implementation:

def trim_clip(track: str, idx: int, new_end: float) -> Act:
    saved = {}
    def apply(s):
        saved["end"] = s["timeline"][track][idx]["end"]   # capture BEFORE mutating
        s["timeline"][track][idx]["end"] = new_end
    def invert(s):
        s["timeline"][track][idx]["end"] = saved["end"]
    return Act("trim_clip", apply, invert)

def publish(target: str) -> Act:
    # no honest inverse exists — an unpublish is not a rerun
    return Act("publish", apply, invert=None, reversible=False)
$ python journal.py
journal.py: all assertions passed
  sequential undo reverted 2 acts exactly
  irreversible act refused to be undone (gate, not undo)
  walkback restored the whole state past the irreversible act

10.8.1 Three Layers of Reversal

flowchart TD
    Q["something went wrong"] --> A{"how far back?"}
    A -->|"the last few acts"| SU["Sequential undo<br/>act journal, inverses in reverse order<br/>exact · cheap · ordered"]
    A -->|"the whole project, to Tuesday"| WB["Version walkback<br/>copy-on-write snapshot restore<br/>coarse · always available"]
    A -->|"the world already moved"| GT["The gate<br/>not recovery at all —<br/>a refusal to proceed without a recorded yes"]
    style SU fill:#dcfce7,stroke:#166534,color:#166534
    style WB fill:#dbeafe,stroke:#1e40af,color:#1e40af
    style GT fill:#fce7f3,stroke:#9d174d,color:#9d174d

Reversal is three named mechanisms, not one — and most systems only implement the middle one.

  • Sequential undo — “take back the last two things.” Handles the overwhelming majority of what an operator actually wants, and is the layer they touch daily.
  • Version walkback — “put the project back how it was on Tuesday.” Coarse, always available, discards the journal past it. The last resort, not the first tool reached for.
  • The gate — for acts where neither works. Not a recovery mechanism; a refusal.

10.8.2 Where the Act Journal Belongs

An inverse is semantic. Only the editing tool knows that undoing a trim means restoring the previous out-point, and that undoing a publish means nothing at all. A checkpointer cannot know that — it sees bytes of state, not meaning.

So the journal is yours. It sits above whichever runtime you pick and works identically under all five, which means it never becomes a reason to switch frameworks.

NoteStale inverses, and how to detect them

Where does the journal sit relative to your store? An inverse that references rows a later act already changed is stale, and applying it corrupts data. The fix: acts journal against a snapshot id, and an undo is valid only while no conflicting act has landed since. That is optimistic concurrency, made tractable by a single-writer lease. Under a composed plan the step order gives you the conflict order for free.

10.9 What the Evaluation Concluded

For StudioBay specifically, with a stack that already had LangGraph underneath a deep-agent brain:

Burr for the graph layer, and the composed posture as the default.

Not because Burr wins a feature count — it doesn’t, on several rows above. Because a graph being a value (axis B) is what makes the producer-composes-a-plan posture expressible at all, and that posture is what turns undo, audit, cost and approval from per-call vigilance into properties of a plan. LangGraph stays where it already is: inside the agent brain, for the improvised steps. Pydantic AI’s requires_approval pattern gets stolen for the tool seam without adopting the runtime — the infrastructure cost of Temporal buys durability that Burr’s persister already provides.

Your conclusion will differ, because your rows differ. What transfers is the method:

  1. Write down one end-to-end shape your system must express, with both a loose span and a pinned span in it. One shape, all candidates — so the differences belong to the frameworks and not to the examples.
  2. Sketch it in each candidate. Install the two or three you’re serious about and run them. Mark the rest as read-not-run, and say so in the write-up.
  3. Score on the axes, not the feature list. Start with axis B — it determines whether half the design space is even reachable.
  4. Identify what none of them ships. In this evaluation it was semantic undo. Build that above the runtime, so it survives changing your mind.
  5. Let the sketches correct your model. The seats-are-nodes correction in Section 10.3 came from writing the graph wrong first. Prose would never have caught it.
TipComposing rather than choosing

The pairings that actually work follow from the postures. Burr + a deep-agent brain: composed plan on the outside, a spine step in the middle where the work isn’t knowable — one mechanism, via RunnableGraph. Burr + mcp-agent (Section 8.3): fork-from-state for the outer loop, AugmentedLLM patterns inside the actions. What doesn’t compose well is two graph vocabularies in one project — MAF beside LangGraph, or pydantic_graph beside Burr. They will drift.

10.10 Forward Link: Ralph and the Improvised Posture

The next chapter introduces Ralph — load a spec, pick the next task, execute it, observe, repeat. Ralph sits at the improvised end of the dial. The agent is inside the loop, every edit is a run-time decision, and nothing about iteration 137 is known at iteration 1.

That is why Ralph works on problems whose plan cannot be written in advance, and it explains what Ralph harnesses need:

  • Ralph’s cost is discovered by spending it (axis D). Budget caps are therefore a harness feature rather than a plan property, which is what Chapter 12 is about.
  • Ralph’s undo is per-call defensive (axis C), which is why the harness runs in a git worktree with a commit per iteration — git is the act journal, and git revert is the inverse that the tool didn’t build for itself.
  • Ralph’s approval is N interrupts (axis A). Running it unattended overnight carries a different risk than running one task, and the gate belongs on the tool seam — who may push, who may deploy — rather than on the loop.
  • Ralph plus a composed outer plan is the mature shape. A spec is a plan; a task list is a compiled graph; the loop is the spine step inside it. Chapter 13’s fleet is that structure with the fan-out made explicit.

The book’s path is therefore: Chapter 7 picks the state machine, Chapters 8–9 survey the platforms, this chapter picks the posture, Chapter 11 wraps the loop around it, and Chapters 13–14 scale the loop into a fleet and a career.

10.11 Key Takeaways

  • Every agent system fixes its control flow somewhere on a spectrum. You fix it at design time (authored), an agent fixes it once before the run (composed), or the model fixes it call by call (improvised). What you can inspect, price, approve or reverse ahead of time follows directly from where on that spectrum you sit.
  • The spectrum is a dial, not two camps. Decompose a worker–reviewer loop into finer steps and it gains structural guarantees — a review edge, a per-phase budget, replay from a chosen point — until it carries the same information a compiled plan carries. Splitting one node into three is usually cheaper than adding a producer agent.
  • A producer agent buys exactly one thing: variation. A hand-authored graph has the same shape every run. If the work differs run to run, a producer emitting a fresh plan is the answer. If it does not, decomposition already got you there.
  • A compiled plan is not a chain of pure functions. Plan steps can be agent loops, bounded by budget, iteration cap and gate. The compiler also inserts system nodes the producer never sees — journal, budget, retry, redaction, audit, approval — which is how cross-cutting concerns get attached once instead of at every call site.
  • Put agents in the graph and the functions they call in the toolbox. Subagents go in the toolbox too. The graph stays small, new capabilities are toolbox entries rather than edge changes, and the permission question lands on the tool seam where two callers of the same tool get the same check.
  • Five axes beat a feature table: when control flow is decided (A), whether a graph is a value (B), where cross-cutting concerns attach (C), what is knowable before running (D), whether postures nest (E). Axis B decides whether the composed posture is reachable at all.
  • Pydantic AI is a typed-agent framework from the Pydantic team: deps_type for injection, output_type for a validated result, tools as annotated functions. It ships the cleanest gate of the five — requires_approval=True plus DeferredToolRequests surfaces the pending call as the run’s typed output. Its cost is that the loop lives in the model’s reasoning: no edge forces a review before a render.
  • Microsoft Agent Framework is Microsoft’s merge of AutoGen and Semantic Kernel. Executors pass typed messages along declared edges, and build() rejects a mis-wired graph before the run. Human review is a node — RequestInfoExecutor — and resuming does not re-run completed executors, unlike LangGraph’s interrupt(). It is a beta, and Python is the younger half.
  • No framework ships semantic undo. All five offer replay or fork, which rewinds the runtime’s state and leaves the world untouched — demonstrated by forking LangGraph past a publish and watching the video stay published.
  • Reversal is three named layers: sequential undo from an act journal, version walkback from a store snapshot, and the gate where neither applies. Most systems build only the middle one.
  • The act journal belongs in your domain, not the runtime. An inverse is semantic — only the tool knows what undoing a trim means. Build it above the framework and it never becomes a reason to switch.
  • The invariant that makes it hold: an act is not applied unless its inverse was constructed first. Enforce it at the tool seam, where it can be checked.

10.12 Concept Map

flowchart TD
    F["Ch 7–9: five frameworks,<br/>forty API surfaces"] --> Q["Which one? — feature tables stall"]
    Q --> P["The posture question"]
    P --> SP["SPINE<br/>agent in the loop"]
    P --> CP["COMPOSED<br/>agent emits a plan"]
    SP --> C1["per-call undo · guards everywhere<br/>budget by spending · N interrupts"]
    CP --> C2["inspect · policy-check · cost<br/>ONE approval · one audit hook"]
    CP --> AXB["Axis B: graph is a value"]
    AXB --> BR["Burr: GraphBuilder → value"]
    AXB --> NEST["Axis E: RunnableGraph<br/>a spine INSIDE a composed plan"]
    P --> AX["Five axes A–E"]
    AX --> MTX["The matrix"]
    MTX --> GAP["Every column agrees:<br/>NO semantic undo"]
    GAP --> J["Act journal — yours, above the runtime"]
    J --> L1["sequential undo"]
    J --> L2["version walkback"]
    J --> L3["the gate"]
    SP --> RL["Ralph (Ch 11)<br/>the spine, taken to its end"]
    CP --> RL2["spec = plan · tasks = compiled graph<br/>loop = the spine step inside"]
    style P fill:#fef3c7,stroke:#92400e,color:#92400e
    style CP fill:#dcfce7,stroke:#166534,color:#166534
    style AXB fill:#dbeafe,stroke:#1e40af,color:#1e40af
    style GAP fill:#fce7f3,stroke:#9d174d,color:#9d174d
    style RL fill:#f3e8ff,stroke:#6b21a8,color:#6b21a8

How the posture question reorganizes the framework chapters