ADR-0012: Agent Dispatch as a DSL Primitive, Agents as Graph Nodes
- Status: Proposed
- Date: 2026-06-26
Context
ADR-0010 drew the durability boundary: slow Agent work runs as durable effect
handling outside the ReactiveBus, via WorkItem nodes claimed by a worker
host. That decision is sound and unchanged. This ADR refines a question
ADR-0010 left open and that Phase 5 answered pragmatically but sub-optimally:
where does the dispatch decision live — the choice of when to dispatch,
which Agent, with what context, and where the result routes.
Phase 5 implemented the first reflex arc with a Python peripheral,
ExtractionScheduler (src/runex/worker/signal.py). It scans
extraction_requested Thoughts and calls the blessed
signal_workitem helper (src/runex/worker/loop.py) once per subject. The
reasoning at the time was correct about one thing and wrong about another:
- Correct: constructing a valid
WorkItem(idempotency key, attempts, lease, availability, initial state — eleven-odd fields) must stay in the one blessed Python helpersignal_workitem. Re-encoding those fields in the DSL would duplicate the contract and invite drift. This is the source of truth for WorkItem construction and it belongs in Python. - Wrong: the dispatch decision (“this subject wants extraction, route it to handler X”) was also frozen in Python, hardcoded as a polling scan. That decision is exactly the kind of policy the system exists to evolve.
The deciding constraint is the project’s core value proposition, restated by
the user during v0.2 acceptance: the brain must be able to evolve — Agents
must eventually be able to reprogram the brain’s own structure. Phase 6
already built the evolution pipeline (draft → verify → approve → deploy) for
BusinessRule/ActionDefinition. That pipeline can only evolve .scm data.
It cannot evolve Python. Therefore:
Any logic we want an Agent to be able to evolve must be
.scmdata (machine / action / guard), not Python. Dispatch and routing policy is precisely such logic.
This reframes the long-running “kernel vs. handler” debate. Any Agent invocation is really two separable concerns:
- The dispatch decision — when, which Agent, what context, where the result goes. Fast. Pure policy. Wants to be evolvable.
- The call itself — subprocess to a CLI / hit an LLM. Slow, blocking, needs lease/retry/recovery. Must run off the bus (ADR-0010).
The two camps only ever disagreed about concern (1). On concern (2) a non-blocking “Signal-Then-Work kernel” and an off-bus handler converge — both refuse to make the slow call on the synchronous bus. So the whole evolution question turns on where concern (1) lives.
Decision
Agent dispatch is a DSL effect primitive, and Agents/Sessions are ordinary graph nodes.
Concretely:
-
Add an effect-only primitive
(signal-work …)(and an Agent-shaped convenience form(signal-agent …)) to the engine’s effect environment, alongsideset-field,create-node,call-kernel, etc. (registered atsrc/runex/ontology/engine.py:700; guard-blocked like every other writing primitive atengine.py:719). The primitive is a thin shell over the existingsignal_workitemhelper — it does not re-encode the WorkItem contract; it calls into the one SoT. -
Dispatch / routing / difficulty-evaluation policy is authored in reactive
.scmactions and guards. A reflex becomes, e.g.:;; the whole dispatch decision, in .scm, evolvable by Phase 6 (signal-agent "claude-code" (goal "实现登录") (subject (self))) -
Agent,AgentRun, andSessionare modeled as Supertags with MachineDefinition lifecycles, exactly likeWorkItem/Proposal/Approval. AnAgentnode carries its dispatch metadata (CLI command, allowlisted tools, output schema). A general dispatcher leaf (one kernel) reads the targetAgentnode and subprocesses to the named CLI. No Agent-specific Python orchestration. -
The Python peripheral remains only as plumbing the runtime cannot absorb: the worker host, lease/claim, recovery loop. The
ExtractionSchedulerpolling peripheral is superseded — its dispatch decision moves into a reactive action; the off-bus execution stays where ADR-0010 put it.
This keeps three invariants simultaneously true:
- Evolvable: dispatch/routing/evaluation policy is 100%
.scm, so Phase 6 can let an Agent draft, verify, approve, and deploy changes to it. ✅ - No drift: WorkItem construction stays in
signal_workitem; the primitive is its薄壳 (thin shell). ✅ - Durable:
signal-workis a fast graph write on the synchronous bus (just create the node); the slow Agent call runs off-bus under a worker’s lease/retry/recover. This is textbook Signal-Then-Work, and being event-driven it is strictly better than the old polling scan. ✅
Evolution-Optimality
| Option | Dispatch lives in | Agent call runs | Evolvable |
|---|---|---|---|
Blocking call-agent kernel | DSL | on the bus (freezes) | ❌ rejected (ADR-0010) |
| Python scheduler (Phase 5 status quo) | Python | off-bus handler | ⚠️ sub-optimal — dispatch can’t be evolved |
signal-work DSL primitive + generic dispatcher | DSL | off-bus | ✅ optimal |
The blocking kernel was already rejected by ADR-0010 (it freezes the bus). The Python scheduler is durable and correct but locks the dispatch decision out of the evolution pipeline. The DSL primitive is the only option that satisfies both durability (ADR-0010) and evolvability (Phase 6).
The Non-Negotiable Guardrail
An Agent rewriting the brain (schema / machine / guard / action) must go through Phase 6’s
draft → verify → approve → deploygate. It may never load directly into production.
This is the same guardrail ADR-0010 used to reject “let the Agent orchestrate the whole system.” Direct Agent mutation of guards/machines would destroy determinism, auditability, and recoverability — “brain evolution” degrades into “whatever the model hallucinated today.” A gated, evolving brain is replayable and rollback-able at every step; an ungated one is an undebuggable black box. The gate is the difference between a maturing system and a demo. Phase 6 already built this gate; it is the only channel for Agent self-evolution.
The LLM is never the global orchestrator. The brain is graph + reactive rules
- the Phase 6 gate. The LLM acts only as a bounded proposer at the fuzzy seams: difficulty evaluation, triage, drafting. Evaluation is fuzzy (give it to an Agent); routing is deterministic (give it to a guard).
Extreme Scenarios, Mapped
These were raised as stress tests (multi-Agent OpenClaw-style collaboration).
Each composes from the same three pieces — signal-work/signal-agent
primitive, Agent/AgentRun/Session nodes, existing reactive cascade — with no
new mechanism:
-
Dispatch → collect → hand to a second Agent for QA.
WorkItem-A(worker-agent)succeeds → writes aresultnode → reactive action(signal-agent "qa" (subject result))→ QA Agent emits a Proposal/verdict. Pure cascade. -
Action evaluates difficulty first, then routes by it.
(signal-agent "triage" task)→ triage Agent writes a难度field → the field-set triggers a routing action whose guard(field-eq? "难度" "high")fires(signal-agent "senior-agent" task). Fuzzy eval to the Agent, deterministic route to the guard. -
Per-Agent sessions; runex probes completion via file tools.
AgentRun/SessionSupertag with adispatched → running → donemachine. A scheduled action (like the existingrecover_workitem) fires(signal-work "probe-session" run)→ a probe handler uses the file capability to inspect artifacts / query CLI status → writes back completion → the machine transitions. Probing is an existing tool handler. -
Obsidian: human writes & converses while Agents write back understanding; human approves. This is the Phase 5 reflex arc running both directions: human writes a Thought/Task in Obsidian → ingest → reactive dispatch to an Agent → Agent handler writes its understanding back as a Thought/Note node → projected to Obsidian → human approves (Phase 4). Already the core loop.
-
Schema/machine/guard injected and evolved; eventually by Agents themselves. Extend the Phase 6
BusinessRulepipeline to schema / machine / guard drafts under the same gate.verify_draftalready parses supertag and machine forms — this is a natural extension, not a rewrite.
All five reduce to: one new primitive (signal-work/signal-agent), a few new
Supertags (Agent/AgentRun/Session), one generic dispatcher leaf, and
extending Phase 6 to schema evolution.
Layering
Unchanged from ADR-0010 and its placement-rule supplement:
- Policy (which Agent, when, route by difficulty) →
.scmdata, evolvable. - The single I/O atom (subprocess to a CLI, hit an LLM, probe a file) → one kernel each.
- Plumbing (worker host, lease, recovery loop) → thin peripheral, no policy.
signal-work sits at the policy/plumbing seam: the decision to dispatch is
policy (.scm); the act of constructing the WorkItem is the signal_workitem
SoT the primitive shells over; the slow execution is the off-bus worker.
Alternatives Considered
Call the Agent synchronously from a blocking kernel
Rejected (already rejected by ADR-0010). Network/subprocess latency freezes all reactive work and couples transaction completion to an external service.
Keep dispatch in a Python scheduler (Phase 5 status quo)
Rejected as the final design. Durable and correct, but it locks the dispatch
decision out of the Phase 6 evolution pipeline — the one thing the system most
needs to be able to grow. Pragmatic for the first reflex arc; sub-optimal as
the permanent shape. The ExtractionScheduler is superseded by a reactive
signal-work action.
Let the LLM/Agent orchestrate the whole mesh
Rejected (consistent with ADR-0010). It makes ordering, approval, and recovery implicit in model behavior and forfeits determinism. runex owns orchestration via graph + reactive rules + the Phase 6 gate; the Agent is a bounded proposer.
Re-encode the WorkItem contract directly in the DSL
Rejected. It would duplicate the eleven-field construction owned by
signal_workitem and drift from it. The primitive is a thin shell over that
single SoT, not a reimplementation.
Let Agents load evolved schema/machine/guard directly
Rejected. See the guardrail. All brain mutation flows through the Phase 6
draft → verify → approve → deploy gate, without exception.
Consequences
Positive
- dispatch/routing/evaluation policy becomes Phase-6-evolvable
.scm - WorkItem construction stays in one drift-free SoT
- event-driven dispatch replaces polling — more correct and cheaper
- Agents/Sessions become first-class, queryable, machine-governed graph nodes
- multi-Agent collaboration composes from existing primitives, no new runtime
- the shared evolving graph is the moat: many Agents, one brain that grows
Negative / Tradeoffs
- adds a primitive and an Agent/AgentRun/Session bundle to maintain
- the generic dispatcher leaf needs an allowlist and authorization policy per Agent (CLI command injection surface)
- extending Phase 6 to schema/machine/guard evolution widens the verify gate’s responsibility and must not weaken it
Implementation Note
Suggested landing as a dedicated phase (Phase 9: agent-mesh — Phase 7/8 are the
already-planned post-v0.2 capabilities in tasks/, so this takes the next free
number):
signal-work/signal-agenteffect primitive (shell oversignal_workitem).Agent/AgentRun/SessionSupertag + machine bundle.- a real dispatcher provider (subprocess to claude / openclaw CLIs).
- extend Phase 6
draft → verify → approve → deployto schema/machine/guard.
This ADR records the decision; it does not change code.