LangGraph compiler¶
Goal¶
Given a ResolvedTopology (the M1 output), produce an executable
LangGraph StateGraph that:
- Creates one node per agent.
- Routes messages along the hierarchy (parent delegates to children, children respond to parent).
- Dispatches skills as tool calls through the
ModelProviderinterface. - Gates every action through
GovernanceProvider.evaluate_action. - Records every step through
GovernanceProvider.record_event. - Checkpoints state to SQLite for resume-after-crash.
The compiler is in packages/runtime/src/swarmkit_runtime/compiler/.
Design reference: §14.3, §14.5, §5.3, §8.
Non-goals¶
- MCP integration. M3 uses mock MCP tool responses. Real MCP server lifecycle lands in M5.
- Decision skills (LLM judges). Tier 2/3 evaluation lands in M4.
- Streaming to the user. M3 returns the final state. Streaming UX lands with the CLI observability primitives (M4 tasks #35/#36).
- Eject.
swarmkit eject(M9) needs the compiler to produce readable code. For M3, the compiler produces a runnable graph object in memory, not source code.
Context model — leader-mediated interaction¶
This is the most important architectural decision in the compiler.
Principle¶
The topology declares capabilities. The leader decides interactions.
The YAML says which agents exist, what skills they have, and what scopes they hold. It does NOT declare which agents talk to which agents at runtime. The leader (root/supervisor) observes task requirements and dynamically decides when workers should collaborate.
Default flow: hierarchical message passing¶
Every topology starts with this pattern. Parent sends a task message to a child. Child processes it and returns a result. Parent collects results from all children and produces its output.
User → Root
Root → Worker A: "Do X" (delegation)
Worker A → Root: "X done, here's the result"
Root → Worker B: "Do Y" (delegation)
Worker B → Root: "Y done"
Root → User: "Final answer combining X and Y"
Each agent sees only: - The message its parent sent (the delegation) - Its own tool/skill results - Its own conversation history within this execution
The parent controls what context to include in each delegation. This is Option C from the design discussion — minimal context, maximum governance, simplest compiler.
Leader-mediated worker collaboration¶
When a leader determines that two workers need to resolve something
together, it invokes the coordinate-workers skill:
Leader detects conflict between Worker A and Worker B's results.
Leader invokes: coordinate-workers(
participants=["worker-a", "worker-b"],
topic="Reconcile security fix with performance constraint",
context={a_finding: "...", b_finding: "..."},
)
The runtime handles this as follows:
-
Governance check. The leader's
coordinate-workersinvocation goes throughevaluate_action. The leader must have thecoordination:facilitatescope. The named participants must be in the leader's subtree. -
Sub-conversation. The runtime creates a temporary exchange:
- Sends Worker A: the topic + context + Worker B's relevant finding
- Worker A responds
- Sends Worker B: Worker A's response
- Worker B responds
-
Repeat until convergence or max rounds (configurable, default 3)
-
Audit. Every message in the sub-conversation is recorded through
record_eventwithevent_type="coordination.exchange". -
Result. The converged output returns to the leader as the skill's result. The leader then uses it in its own reasoning.
What the YAML declares vs what the leader decides¶
| YAML (static, authoring-time) | Leader (dynamic, runtime) |
|---|---|
| Which agents exist and their roles | When to delegate to which child |
| Which skills each agent has | When workers should collaborate |
Whether a leader can coordinate (coordinate-workers skill) |
Which workers participate |
| IAM scopes per agent | What context each worker receives |
| Model assignments | When to end a collaboration |
Why not static YAML interaction declarations?¶
Declaring "Worker A can talk to Worker B" in YAML means the topology author must predict every interaction pattern at authoring time. That's brittle — a code review topology might need reviewers to collaborate on one PR but work independently on another. Same topology, different runtime behaviour.
The leader has the full task context. It is the right entity to decide when collaboration is needed. The YAML author declares the capability ("this leader can facilitate collaboration"). The leader decides the rest.
Why not full shared state?¶
Shared state (every agent reads a global dict) violates least-privilege. Worker B could silently read Worker A's internal scratchpad. The governance pillar exists to prevent this.
Leader-mediated interaction means every piece of context an agent sees is traceable to a specific delegation or coordination event. The audit log can answer "what did Worker B know, and who gave it to them?"
Future: shared data channels (v1.1)¶
For topologies where the root-as-relay pattern creates excessive token
overhead (e.g. 10 workers all consuming the same research output), v1.1
introduces opt-in shared channels declared in runtime_config:
runtime_config:
shared_channels:
- id: audience-profile
writers: [researcher]
readers: [greeter, summariser]
Channels are governance-gated (every read is an evaluate_action) and
audited. This is an optimisation, not a change in model — the leader
still orchestrates; channels just reduce re-transmission cost.
Not in M3 scope. Noted here so the state schema leaves room for it.
State schema¶
The LangGraph state is a TypedDict:
class SwarmState(TypedDict):
# The user's original input
input: str
# Per-agent message histories, keyed by agent id.
# Only the agent's own node and its parent's node access its key.
agent_messages: dict[str, list[Message]]
# Per-agent results (written by the agent, read by its parent)
agent_results: dict[str, str | None]
# The final output returned to the user
output: str | None
# Metadata for checkpointing / resume
current_agent: str | None
execution_status: Literal["running", "completed", "failed"]
LangGraph reducers handle merge semantics:
- agent_messages: per-key append (each agent appends to its own list)
- agent_results: per-key overwrite (each agent writes its final result)
- output: overwrite (root writes the final answer)
Node construction¶
Each agent in the ResolvedTopology becomes one LangGraph node.
def _build_agent_node(
agent: ResolvedAgent,
model_provider: ModelProvider,
governance: GovernanceProvider,
) -> Callable[[SwarmState], SwarmState]:
"""Build the node function for one agent."""
async def node_fn(state: SwarmState) -> dict:
# 1. Governance: evaluate whether this agent can execute
decision = await governance.evaluate_action(
agent_id=agent.id,
action=f"agent:execute",
scopes_required=frozenset(agent.iam.get("base_scope", [])),
)
if not decision.allowed:
await governance.record_event(AuditEvent(...))
return {"agent_results": {agent.id: f"DENIED: {decision.reason}"}}
# 2. Build the prompt from agent config + parent's delegation
messages = _build_messages(agent, state)
# 3. Call the model via ModelProvider
tools = _agent_tools(agent) # skills → ToolSpec
response = await model_provider.complete(
CompletionRequest(
model=agent.model["name"],
messages=messages,
system=agent.prompt.get("system"),
tools=tools,
)
)
# 4. Handle tool use (agentic loop)
while response.stop_reason == "tool_use":
tool_results = await _execute_tools(response, agent, governance)
messages.extend(_tool_result_messages(response, tool_results))
response = await model_provider.complete(
CompletionRequest(model=agent.model["name"], messages=messages, tools=tools)
)
# 5. Record completion event
await governance.record_event(AuditEvent(
event_type="agent.completed",
agent_id=agent.id,
...
))
# 6. Return result to state
result_text = _extract_text(response)
return {
"agent_messages": {agent.id: messages},
"agent_results": {agent.id: result_text},
}
return node_fn
Edge construction¶
Edges follow the agent hierarchy:
def _build_edges(topology: ResolvedTopology) -> list[Edge]:
edges = []
def walk(agent: ResolvedAgent, parent_id: str | None):
if parent_id:
# Parent delegates to child, child returns to parent
edges.append(Edge(parent_id, agent.id)) # delegation
edges.append(Edge(agent.id, parent_id)) # return
for child in agent.children:
walk(child, agent.id)
walk(topology.root, None)
return edges
The root node is the entry point. The root decides (via its model call)
which children to delegate to. LangGraph's conditional edges route
based on the root's tool calls (delegation = tool call to
delegate_to_worker).
Skill dispatch¶
Skills surface as tools to the agent's model. The _agent_tools
function maps resolved skills to ToolSpec objects:
def _agent_tools(agent: ResolvedAgent) -> list[ToolSpec]:
tools = []
for skill in agent.skills:
tools.append(ToolSpec(
name=skill.id,
description=skill.metadata.description,
input_schema=_skill_input_schema(skill),
))
# Leaders with children get a delegation tool
if agent.children:
for child in agent.children:
tools.append(ToolSpec(
name=f"delegate_to_{child.id}",
description=f"Delegate a task to {child.id} ({child.role})",
input_schema={"type": "object", "properties": {"task": {"type": "string"}}},
))
return tools
When the model returns a tool_use block:
- If the tool name matches a skill → execute the skill (mock MCP in M3)
- If the tool name matches delegate_to_<child> → route to that
child's node in the graph
- If the tool name is coordinate_workers → run the sub-conversation
handler
Coordination skill handler¶
When a leader invokes coordinate_workers:
async def _handle_coordination(
participants: list[str],
topic: str,
context: dict,
agents: dict[str, ResolvedAgent],
model_provider: ModelProvider,
governance: GovernanceProvider,
max_rounds: int = 3,
) -> str:
"""Run a temporary multi-round exchange between workers."""
# Governance gate
# ... evaluate_action with coordination:facilitate scope ...
conversation: list[Message] = [
Message(role="user", content=f"Topic: {topic}\nContext: {context}")
]
for round_num in range(max_rounds):
for agent_id in participants:
agent = agents[agent_id]
response = await model_provider.complete(
CompletionRequest(
model=agent.model["name"],
messages=conversation,
system=agent.prompt.get("system"),
)
)
result = _extract_text(response)
conversation.append(Message(role="assistant", content=f"[{agent_id}]: {result}"))
await governance.record_event(AuditEvent(
event_type="coordination.exchange",
agent_id=agent_id,
payload={"round": round_num, "topic": topic},
))
# Return the final exchange as the skill result
return conversation[-1].content
The leader sees the converged output as a tool result, just like any other skill. The workers' internal exchange is audited but doesn't pollute the leader's main conversation.
Checkpointing¶
LangGraph's built-in SqliteSaver checkpoints state after each node
execution:
from langgraph.checkpoint.sqlite import SqliteSaver
checkpointer = SqliteSaver.from_conn_string(".swarmkit/state/hello.db")
graph = compiled_graph.compile(checkpointer=checkpointer)
swarmkit run --resume loads the last checkpoint and continues from
where execution stopped. Useful for long-running swarms that crash
mid-execution.
Long-lived pauses (approval gates)¶
The runtime does not manage state freezing manually. When an agent
triggers an approval gate (human-in-the-loop scope like
skills:activate or topologies:modify), the runtime relies entirely
on LangGraph's native checkpointer.
Flow:
- Agent hits approval gate → runtime serializes full graph state to disk via the checkpointer.
- Runtime process can safely terminate — state is durable.
- Approval arrives via Rynko webhook, CLI input, or polling — hours or days later.
- Runtime rehydrates from the checkpoint and resumes execution from exactly where it paused.
Implementation requirement: approval gates must compile strictly as LangGraph interrupt points. The compiler emits an interrupt node wherever the topology declares a governance scope reserved for human identity (§8.7). The interrupt serializes state and yields control; resumption is a standard LangGraph checkpoint restore.
For the Rynko cloud deployment model, the approval event surfaces in the Rynko UI's review queue. For CLI-only mode, the runtime prompts in the terminal and blocks until the user responds. Same checkpointer, different approval surface.
swarmkit run CLI¶
- Resolves the workspace (reuses M1
resolve_workspace) - Finds the named topology
- Builds the
ProviderRegistryfrom workspace config - Instantiates
GovernanceProvider(AGT or mock based on config) - Compiles the topology to a
StateGraph - Invokes with the user's input
- Prints the final output
Structured output governance (M4, architectural note)¶
Skills that declare an outputs block in their YAML get deterministic
output validation in the compiler's tool-use loop. This is an
architectural commitment — the compiler is where it's enforced, even
though the design note lands in M4.
Why — the Rynko insight¶
Production experience with Rynko gate validation shows that
structured constraints + field-specific error feedback eliminates
most hallucination without any LLM judge. When a model is constrained
to produce {verdict: "pass"|"fail", confidence: 0.0-1.0} via
structured generation, and the response is validated against the
schema, shape-level hallucination is impossible. The remaining errors
(wrong value, not wrong shape) are caught by deterministic business
rules and fed back as targeted corrections.
The four-tier output governance model¶
| Tier | What | Cost | When |
|---|---|---|---|
| 0 | Structured generation — provider JSON mode / tool_use constrains the model at generation time | Zero extra | Always, when skill declares outputs |
| 1 | Schema validation — JSON Schema check on the response | Near-zero | Always, after model response |
| 2 | Business rules — deterministic field-level checks (ranges, enums, cross-field consistency) | Near-zero | When skill declares validation_rules |
| 3 | LLM judge — semantic evaluation against a rubric | Tokens | When configured (Tier 2/3 judicial) |
Auto-correction via field-specific errors¶
When Tier 1 or 2 validation fails, the error is field-specific:
"confidence must be between 0 and 1, got 1.5". The compiler feeds
this back to the model as a tool_result error in the agentic loop:
Model returns: {verdict: "pass", confidence: 1.5}
Validation: FAIL — confidence out of range [0, 1]
Re-prompt: "Validation error on field 'confidence': must be between 0 and 1, got 1.5. Correct this field."
Model returns: {verdict: "pass", confidence: 0.85}
Validation: PASS
The model corrects one field — not the entire response. This is: - Cheaper than regenerating from scratch (fewer tokens) - More reliable than a generic "try again" (the error is specific) - Deterministic in the validation step (no LLM judge needed for shape/range errors)
The retry budget is configurable per skill (max_retries, default 2).
If the model can't produce a valid response after retries, it
escalates to the judicial pillar (Tier 3 LLM judge or HITL).
Where this lives in the compiler¶
In the agentic tool-use loop inside _build_agent_node:
1. Model call (with structured generation if skill has outputs)
2. Parse response
3. If tool_use → execute tool → validate output → if invalid, re-prompt with field errors
4. If text → return (no output governance on free-text responses)
Output governance only fires for skills with declared outputs blocks.
Free-text agent responses (e.g. the root's final answer to the user)
are not schema-validated — they're evaluated by Tier 3 judges if
configured.
Implementation lands in M4. This section is an architectural commitment so the compiler's loop design leaves room for it.
Implementation plan (PRs)¶
- This PR: design note only.
- PR 2: compiler core —
compile_topology()→StateGraph. Node construction, edge construction, delegation tool dispatch. UsesMockModelProvider+MockGovernanceProvider. Tests assert graph structure and mock execution flow. - PR 3: governance middleware — every node calls
evaluate_actionbefore executing andrecord_eventafter. Tests assert deny → audit flow through a compiled graph. - PR 4: coordination skill handler + agentic tool-use loop.
- PR 5:
swarmkit runCLI + SQLite checkpointing + exit demo.
Exit demo¶
Two-agent hello-swarm topology:
1. swarmkit run examples/hello-swarm/workspace hello --input "Greet the engineering team"
2. Root delegates to greeter worker.
3. Worker produces a greeting.
4. Root returns the final greeting to the user.
5. Checkpoint file created at .swarmkit/state/hello.db.
6. swarmkit run ... --resume picks up from checkpoint.
Test plan¶
- Compiler unit tests: given a
ResolvedTopologywith N agents, the compiled graph has N nodes + correct edges. Tested with mock providers. - Delegation test: root agent delegates to a worker via
delegate_to_<child>tool call. Mock model returns a tool_use block targeting the worker, worker's mock model returns a text result, root collects it. - Governance deny test: agent with insufficient scopes → node returns DENIED result, audit event recorded.
- Coordination test: leader invokes
coordinate_workers, two mock workers exchange messages for 2 rounds, converged result returns to leader. - Checkpoint test: run to completion, verify
.dbfile exists. Corrupt mid-run, resume from checkpoint, verify completion. - CLI integration:
swarmkit runon the hello-swarm example exits 0, prints a greeting, creates a checkpoint file.