Skip to Content
文档📐 ADR 决策记录ADR-0012: Agent Dispatch as a DSL Primitive, Agents as Graph Nodes

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 helper signal_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 .scm data (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:

  1. The dispatch decision — when, which Agent, what context, where the result goes. Fast. Pure policy. Wants to be evolvable.
  2. 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:

  1. Add an effect-only primitive (signal-work …) (and an Agent-shaped convenience form (signal-agent …)) to the engine’s effect environment, alongside set-field, create-node, call-kernel, etc. (registered at src/runex/ontology/engine.py:700; guard-blocked like every other writing primitive at engine.py:719). The primitive is a thin shell over the existing signal_workitem helper — it does not re-encode the WorkItem contract; it calls into the one SoT.

  2. Dispatch / routing / difficulty-evaluation policy is authored in reactive .scm actions and guards. A reflex becomes, e.g.:

    ;; the whole dispatch decision, in .scm, evolvable by Phase 6 (signal-agent "claude-code" (goal "实现登录") (subject (self)))
  3. Agent, AgentRun, and Session are modeled as Supertags with MachineDefinition lifecycles, exactly like WorkItem/Proposal/Approval. An Agent node carries its dispatch metadata (CLI command, allowlisted tools, output schema). A general dispatcher leaf (one kernel) reads the target Agent node and subprocesses to the named CLI. No Agent-specific Python orchestration.

  4. The Python peripheral remains only as plumbing the runtime cannot absorb: the worker host, lease/claim, recovery loop. The ExtractionScheduler polling 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-work is 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

OptionDispatch lives inAgent call runsEvolvable
Blocking call-agent kernelDSLon the bus (freezes)❌ rejected (ADR-0010)
Python scheduler (Phase 5 status quo)Pythonoff-bus handler⚠️ sub-optimal — dispatch can’t be evolved
signal-work DSL primitive + generic dispatcherDSLoff-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 → deploy gate. 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:

  1. Dispatch → collect → hand to a second Agent for QA. WorkItem-A(worker-agent) succeeds → writes a result node → reactive action (signal-agent "qa" (subject result)) → QA Agent emits a Proposal/verdict. Pure cascade.

  2. 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.

  3. Per-Agent sessions; runex probes completion via file tools. AgentRun/Session Supertag with a dispatched → running → done machine. A scheduled action (like the existing recover_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.

  4. 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.

  5. Schema/machine/guard injected and evolved; eventually by Agents themselves. Extend the Phase 6 BusinessRule pipeline to schema / machine / guard drafts under the same gate. verify_draft already 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) → .scm data, 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):

  1. signal-work / signal-agent effect primitive (shell over signal_workitem).
  2. Agent / AgentRun / Session Supertag + machine bundle.
  3. a real dispatcher provider (subprocess to claude / openclaw CLIs).
  4. extend Phase 6 draft → verify → approve → deploy to schema/machine/guard.

This ADR records the decision; it does not change code.

Last updated on