Workflow Binder — Open Executor Factory
How an authored WorkflowDefinition is bound onto the live Microsoft Agent Framework (MAF) run graph (Feature 015, US1).
Before US1 the binder switched on five hardcoded node ids (agent, rai, review, merge, scribe) and literal edge keys ("agent->rai:"). Any other node id hit a default → throw, and the loader rejected fan_out / fan_in / serial / peer_review outright. The generalized binder instead resolves each node's executor from its type and wires edges from (from, to, when) triples — so an authored workflow whose node ids differ runs unchanged, while the existing default workflow still produces a byte-for-byte identical graph.
Pieces
| File | Responsibility |
|---|---|
NodeClassifier | Maps a WorkflowNode to a NodeKind from its type (+ gate kind), never its id. |
INodeExecutorFactory / NodeExecutorRegistry | Resolves a node's primary executor (its entry point) from its kind. |
RunWorkflowGraphBinder | Iterates nodes/edges, expands each transition into raw executor wiring, declares terminal outputs. |
WorkflowBindException | Node-scoped, fail-closed error when a node/edge cannot be bound. |
1. How the executor factory resolves node types
NodeClassifier.Classify(node) derives a NodeKind from WorkflowNode.Type (and, for check nodes, the canonical gate_kind):
Node type | Gate kind | NodeKind | Primary executor (RunWorkflowBindings) |
|---|---|---|---|
prompt | — | Agent | AgentBinding |
check | rai | Rai | per-node policy gate, else RaiBinding |
check | human-review | HumanReview | per-node policy gate, else ReviewBinding |
check | rubberduck | Rubberduck | per-node policy gate |
merge | — | Merge | MergeBinding |
scribe | — | Scribe | ScribeBindingMerge |
terminal | — | Terminal | resolved from incoming edges (see §3) |
peer_review | — | PeerReview (verdict-routed) or Agent (plain turn) | per-node peer-review executor, else AgentBinding — wired (see §2a) |
fan_out / fan_in / serial / coordinator_composed | — | the matching kind | load-accepted, runtime pending (see §5) |
NodeExecutorRegistry.ResolveExecutor(node, bindings) returns the executor a node is entered at. It draws from the real, pre-built executors in RunWorkflowBindings (Principle VII: bind to real executors, never mocks). The multi-executor plumbing a logical edge expands into is owned by the binder, not the factory.
The start node is resolved this way too — the entry edge is AgentInputStorer → factory.ResolveExecutor(startNode), so the start is the declared start id, not a hardcoded "agent".
2. How edge wiring works: (from, to, when) → subgraph expansion
For each WorkflowEdge, the binder classifies both endpoints and dispatches on the tuple (fromKind, toKind, when). Each logical edge expands into a subgraph of raw executor-to-executor edges plus hidden plumbing (adapters, storers, terminals) that the GraphDescriptorBuilder later collapses.
The default workflow's transitions and their expansions:
(fromKind, toKind, when) | Raw expansion |
|---|---|
(Agent, Rai, ∅) | agent → rai |
(Rai, Agent, revise) | rai →[predicate] raiRevisionAdapter → agent (idempotent loop) |
(Rai, Terminal, safety-failed) | rai →[predicate] terminalSafetyFailed |
(Rai, Scribe, no-changes) | rai →[predicate] terminalNoOp → scribeInputNoChanges → scribeNoChanges → scribeOutputNoChanges |
(Rai, HumanReview, review) | rai →[predicate] reviewAdapter → review |
(HumanReview, Merge, approved) | review →[predicate] mergeAdapter → merge |
(HumanReview, Agent, request-changes) | review →[predicate] reviewChangesAdapter → agent (idempotent loop) |
(HumanReview, Terminal, declined) | review →[predicate] terminalDeclined |
(Merge, Scribe, merged) | merge →[predicate] terminalMerge → scribeInputMerge → scribeMerge → scribeOutputMerge |
(Merge, HumanReview, blocked) | merge →[predicate] blockedAdapter → review (idempotent loop) |
(Scribe, Terminal, ∅) | no raw edge — the scribe output executors are the graph outputs |
The edge predicates (e.g. RAI revise iff RaiRevisionRequired && Iteration < MaxIterations) are the exact lambdas the previous hand-coded pipeline used; they are not altered (parity).
Terminal outputs (WithOutputFrom) are resolved from a terminal's incoming edges, not its id: a safety-failed verdict → the safety terminal, a declined verdict → the declined terminal, a scribe-sourced edge → both scribe-output executors (done sink). So a renamed terminal still binds.
Review-policy multi-gate workflows (Feature 010): extra or renamed gate nodes that received a dedicated per-node policy binding keep their existing policy plumbing (PolicyAgentTurnStorer, PolicyAgentOutputAdapter, …). The canonical rai/review gates never get a policy binding, so the default workflow always takes the canonical path above.
2a. Peer-review nodes and generic catalog topologies (Feature 015 US3)
The §2 table is the default five-stage workflow. The library/catalog workflows (software-delivery, bug-fix, code-review, content-authoring, pm-discovery, incident-response) bind through an additional set of generic transitions wired by RunWorkflowGraphBinder.TryWireCanonicalEdge. These are fully wired and runnable today.
peer_review effective kind. EffectiveKind decides how a peer_review node wires:
- It is a real AI review verdict gate (
NodeKind.PeerReview) only when it has at least one outgoing edge whosewhenis a verdict —approved,request-changes,declined,pass, orfail. - A
peer_reviewnode with only unconditional outgoing edge(s) is a plain producing turn and wires identically to anagentnode (NodeKind.Agent). Soreview (peer_review) → feedbackwith no verdict routing is just a turn.
Generic transitions (in addition to the default five-stage table):
(fromKind, toKind, when) | Meaning |
|---|---|
(Agent, Agent, ∅) | Sequential agent turn (output feeds the next turn) |
(Agent, PeerReview, ∅) | Producer turn → peer-review verdict gate |
(Agent, Scribe, ∅) | Direct completion (record outcome, no merge) |
(Agent, HumanReview, ∅) | Producer turn → human review gate (no RAI in between) |
(Rai, Merge, review) | RAI cleared → merge directly (publish-style, no human gate) |
(Rai, Agent, review) | RAI cleared → next agent turn |
(Rai, PeerReview, review) | RAI cleared → peer-review verdict gate |
(PeerReview, Merge, approved|pass) | Peer-review passed → merge |
(PeerReview, Rai, pass) | Peer-review passed → RAI safety gate |
(PeerReview, Agent, request-changes|fail) | Peer-review rejected → loop back to producer turn |
(PeerReview, Terminal, declined) | Peer-review hard-declined → terminal |
(HumanReview, Agent, approved) | Human approved → next agent turn (e.g. postmortem) |
(HumanReview, Scribe, approved) | Human approved → direct completion (no merge) |
(Merge, PeerReview, blocked) | Merge blocked → re-enter peer-review gate |
(Merge, Agent, blocked) | Merge blocked → re-enter producer turn |
Any (fromKind, toKind, when) outside both tables fails closed with a WorkflowBindException.
3. How to author a new node type (extension point)
- Add the member to
WorkflowNodeTypeand itsTryParseNodeTypemapping inWorkflowDefinitionLoader. - Map the type to a
NodeKindinNodeClassifier.Classify. - Construct the real executor(s) in
RunWorkflowFactory.BuildWorkflow, add them toRunWorkflowBindings, and resolve them inNodeExecutorRegistry.ResolveExecutor. - Add the
(fromKind, toKind, when)expansion(s) toRunWorkflowGraphBinder.TryWireCanonicalEdgeand, if the node is a graph output, toWireOutputs.
A node that classifies to a kind with no executor mapping (or an edge with no matching expansion) fails closed with a WorkflowBindException naming the offending node — the binder never silently skips, mis-wires, or partially executes a graph. This is also the governance guard: an authored node's fields can never weaken the sandbox boundary, the human-approval gate, or RAI content-safety, because those guarantees live in the executors the binder wires, not in the definition.
4. Parity guarantee — what it means and how it's verified
Parity means the default workflow, built through the generalized binder, emits the identical raw GraphDescriptorBuilder edges, predicates, idempotent flags, and outputs as the pre-change hand-wired pipeline — so the collapsed GraphDescriptor, the workflow.step stage stream, and the terminal states are unchanged. This is mandatory because the binder is on the live run pipeline (the highest-risk change).
Verified by RunWorkflowGraphBinderTests:
DefaultWorkflow_RealPath_ProducesCanonicalFiveStageGraph— builds the descriptor through the realRunWorkflowFactory(real executors) and asserts the canonical five-stage graph (nodesagent, rai, review, merge, scribe; startagent; the eight edges with the three expected loopbacks).DefaultDefinition_Binder_ProducesCanonicalFiveStageGraph— pins the same graph at the binder/unit level over the built-in default definition.RenamedNodeIds_ResolveByType_ProduceIdenticalGraph— a definition whose node ids are all renamed (types unchanged) collapses to the same graph, proving resolution is by type, not id.UnwiredNodeType_FailsClosed_WithNodeScopedError— an unbindable node throws a node-scopedWorkflowBindException.Loader_Accepts_PreviouslyRejectedNodeTypes—fan_out/fan_in/serial/peer_reviewload.
The reflection-based drift guard (RunWorkflowDefinitionBindingTests, CoordinatorWorkflowGraphDriftGuardTests) continues to assert the built MAF graph matches the descriptor.
5. Status of fan_out / fan_in / serial / coordinator_composed
peer_review is fully wired (see §2a) — both as a verdict gate and as a plain producing turn.
fan_out, fan_in, serial, and coordinator_composed are accepted by the loader (the bindable-type gate was removed) and modeled by the schema, but are not yet wired to a runtime executor. They map onto existing seams — fan_out → the coordinator's SubtaskFrontier, fan_in → AssemblyPlanning, serial → a sequential chain — which require dispatch infrastructure beyond the per-run graph. Until that lands, a workflow that actually wires one of these nodes fails closed at build time (RejectUnwiredKind) with a clear WorkflowBindException, rather than being rejected at load time. This is the deliberate "load-accepted, runtime-pending" boundary for US1.
