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
10 Choosing a Runtime
Two Postures, Five Axes, and the One Thing None of Them Ships
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.
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.
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 stateAt 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 nodeUndo, 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
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
Three consequences:
render_videoreturns 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.- The graph stays small while the toolbox grows. Adding
silence_cutorvoice_clonetouches no edges. - 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.
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.
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.
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 strThe 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_typedeclares what runtime values the agent’s tools may reach. Whatever you pass todeps=arrives at every tool asctx.deps, correctly typed. This is dependency injection, in the FastAPI sense. - 2
-
output_typedeclares 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.acceptis abooland 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 DepsUse @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
-
DeferredToolRequestsinoutput_typeis what makes approvals surface. Omit it and the pause never reaches your code. - 2
-
The reviewer is called from inside a tool.
usage=ctx.usagethreads token accounting through, so the outer run’s usage limits cover the inner agent too. - 3
-
requires_approval=Truestops 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
-
@executorturns a function into a node. Both parameters need type annotations; the framework reads them to determine what the node accepts and emits, and raisesValueErrorat 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.Nevermeans 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
RequestInfoMessageand add the fields the human needs to see. All fields need defaults, since the base class suppliesrequest_id. - 2
-
Sending that message to the
RequestInfoExecutorsuspends 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 deliveredand 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.
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 problemsStep 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(), nameswith_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.
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 undoneThe 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
- 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.
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:
- 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.
- 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.
- Score on the axes, not the feature list. Start with axis B — it determines whether half the design space is even reachable.
- Identify what none of them ships. In this evaluation it was semantic undo. Build that above the runtime, so it survives changing your mind.
- 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.
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 revertis 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_typefor injection,output_typefor a validated result, tools as annotated functions. It ships the cleanest gate of the five —requires_approval=TrueplusDeferredToolRequestssurfaces 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’sinterrupt(). 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