« Phase 23 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes
Phase 23 — Deep Dive: AutoGen & the Microsoft Agent Framework
This phase has two coordination engines, and the whole point is that they are different kinds of machine. AutoGen's team is a conversation loop over a shared list. MSAF's Workflow is a super-step message-passing runtime over a typed graph. Put them on one bench, run each by hand, and the reason MSAF exists — and the reason a checkpoint and a human-in-the-loop pause turn out to be the same object — falls out mechanically. Everything below is at the level of the data structures and the control flow, not the pitch.
Machine 1 — AutoGen's conversation loop
The state is one ordered list, messages: list[TextMessage], and each TextMessage carries a source (who wrote it) and content. There is no graph, no adjacency, no node set. Coordination is two pure functions over that list: a selector select(messages) -> Agent and a termination terminate(messages) -> reason | None. An agent is a function reply(messages) -> TextMessage, in the labs an injected policy: Callable[[list[TextMessage]], str] so the model is replaced by a deterministic stand-in.
The loop is five moves — seed, check, select, reply, check:
messages = [TextMessage("user", task)] # seed
stop = terminate(messages) # check (maybe already done)
while stop is None:
speaker = select(messages) # select
messages.append(speaker.reply(messages))# reply against the WHOLE list
stop = terminate(messages) # check
return TaskResult(messages, stop_reason=stop)
Two invariants make or break this. First, the reply is a function of the entire conversation, not of a passed-in "turn" — that is what lets an agent see and build on everything said so far, and it is why an unbounded team can drift. Second, the loop has no hidden step cap. The while stop is None is the only guard; if terminate never returns a reason, the machine runs until your token budget is gone. That is the mechanism-level reason MaxMessageTermination(n) is mandatory, not stylistic: it is the sole thing standing between you and a non-halting program.
Selection is what turns a shared chat into a coordinated one. RoundRobinGroupChat computes speaker i mod n on turn i — a for loop over participants, fully deterministic. SelectorGroupChat calls an injected selector(messages, participants) -> name and then validates the name against the roster; a selector that names a non-participant is a routing bug that must raise, not silently stall the loop on a speaker who does not exist.
The termination algebra
A condition is a small object that returns a reason string or None. The composition operators are the elegant part:
a | bfires the instant either fires; the reason is the sub-condition that fired first.a & bfires only once both have fired.
TextMentionTermination("TERMINATE") | MaxMessageTermination(10) is the canonical shape: "stop when the agents declare done, or at ten messages, whichever comes first." The OR-with-a-max is a bounded loop wearing a fluent API. (The lab models conditions as pure functions of the whole transcript; §"reset" in the Core Contributor notes covers why the real objects are stateful — but the algebra is exactly this.)
Machine 2 — MSAF's super-step Workflow runtime
Now the opposite machine. State is a directed graph of executors plus a mutable WorkflowContext.shared. An executor is a node handler(payload, ctx) -> output. An edge source -> target carries two independent properties that must be kept separate:
message_type— a type contract. When the edge fires with a payload of the wrong type it raisesWorkflowTypeError. This is a correctness guarantee that errors on violation.condition— a router. The edge fires only ifcondition(payload)holds. This is control flow that selects a branch.
The runtime is Pregel / Bulk-Synchronous-Parallel — the same super-step model Phase 18 built for LangGraph. The load-bearing data structure is a queue of (target_executor, payload) messages. A super-step is one atomic beat:
- Take all messages currently in the queue (the current batch).
- For each, run its target executor against its payload; collect the output.
- Route each output along the node's outgoing edges — type-check first, then apply the condition — enqueuing
(next_target, output)pairs into the queue for the next super-step. An output with no firing outgoing edge means the node is a leaf, and its output is collected as a workflow result. - Repeat until the queue is empty (completed), bounded by
max_superstepsso a cyclic graph cannot loop forever.
The invariant that makes everything else possible: deliver-all-then-route. Every message in a super-step is delivered before any routed output is delivered. Nothing is half-run at a boundary. That is not a convenience — it is the property that makes a checkpoint consistent.
The load-bearing insight: a checkpoint is a boundary snapshot
Because a super-step is atomic, freezing the machine between super-steps captures exactly: the pending queue, the shared state, any deferred human requests, the outputs collected so far, and the superstep index. That five-field record is the checkpoint.
@dataclass
class Checkpoint:
shared: dict # session state
queue: list # pending (executor, payload) messages
pending: list # deferred human-input requests
outputs: list # results so far
superstep: int # where we froze
Restore that record and the loop resumes byte-identically, because there was never a partial executor to reconstruct. Now the punchline: human-in-the-loop is the identical mechanism. A RequestInfoExecutor, when reached, does not block a thread on input(). It defers — it emits a request, and the driver suspends by producing a checkpoint. run(...) returns with status="paused". The application collects the human answer out of band (a web form, a Slack approval, possibly hours later) and calls resume(checkpoint, {node_id: response}); the response becomes that node's output and routes downstream like any other. "Pause for a crash" and "pause for a human" are one code path (_drive returns a checkpoint either way). That equivalence is the single most important thing to be able to say in this phase.
A worked trace: even/odd routing
Graph: classify -> even (condition n % 2 == 0), classify -> odd (condition n % 2 == 1), start classify, input 4.
- Super-step 0. Queue =
[(classify, 4)]. Deliver:classify(4)returns4. Route along both outgoing edges: type-check passes;even's condition4 % 2 == 0is true so enqueue(even, 4);odd's condition is false so it does not fire. Queue for next step =[(even, 4)]. - Super-step 1. Deliver
even(4)->"4 is even".evenhas no outgoing edge, so it is a leaf: collect"4 is even"as an output. Queue empties. - Completed.
outputs == ["4 is even"].
Insert pause_after=1 and the driver freezes at the boundary after super-step 0 with queue == [(even, 4)]; resume(checkpoint) picks up at super-step 1 and yields the identical result. Same shape for HITL: put a RequestInfoExecutor("approve") between draft and publish; the run suspends with the approve request in pending, and resume(cp, {"approve": "yes"}) injects "yes" as approve's output, which routes to publish.
Why the naive versions fail at the mechanism level
- A team with no termination. With
terminatenever firing, thewhile stop is Nonenever exits. This is not "slow" — it is non-halting. The fix is structural: aMaxMessageTerminationcomposed with|is a proof of termination. - Using the type as the router. If you route by matching on message type instead of a
condition, then a payload of the wrong type produces "that edge just didn't fire" — a silently dropped message and a stalled branch — instead of the loudWorkflowTypeErroryou actually want. The type must raise; the condition must select. Conflating them converts a correctness bug into a routing bug that surfaces three steps downstream as garbage. - Checkpointing mid-executor. If you snapshot anywhere other than a super-step boundary, you capture a half-run node and resume is no longer identity. The BSP boundary is what earns clean checkpointing, HITL, and replay in one stroke.
Two machines, one lesson: AutoGen makes the path emergent and pays for it with a mandatory termination guard; MSAF makes the path explicit and is rewarded with typed edges, consistent snapshots, and durability — all falling out of the deliver-all-then-route boundary.