Agent Runner v2 — Constitutional Runtime over ACP

Replaces `scripts/agent_runner.py` and the agentic loop in `infra/console/server.py` with a single Rust daemon implementing OpenClaw's Agent Client Protocol (ACP) wire interface, extended with HAAK's…

#Purpose

Replaces scripts/agent_runner.py and the agentic loop in infra/console/server.py with a single Rust daemon implementing OpenClaw's Agent Client Protocol (ACP) wire interface, extended with HAAK's constitutional governance layer and designed for the Merkle-CRDT ledger specified in [[33-constitutional-ledger]].

OpenClaw agents connect by changing one config line. All agents — native or OpenClaw — pass through the constitutional layer. The ledger schema carries proof and envelope fields from day one (null in v0), leaving no migration debt for Filix v1.0 cryptographic enforcement.

#Layer Stack

OpenClaw Agent        HAAK Native Agent        CLI
      │  ACP/WebSocket       │  ACP/WebSocket    │
      └─────────────┬────────┘                  │
                    ▼                            │
           ┌─────────────────┐                  │
           │  Gateway Server │◄─────────────────┘
           │  Axum, :18789   │
           │  ACP wire compat│
           └────────┬────────┘
                    │
           ┌────────▼────────┐
           │  SessionManager │  serial queues · TTL eviction
           │                 │  identity reconciliation
           │                 │  session store (SQLite)
           └────────┬────────┘
                    │
           ┌────────▼────────┐
           │  Constitutional │  mandate injection · policy gates
           │  Runtime<R>     │  mailbox · board · roster heartbeat
           │                 │  ledger append (BLAKE3 + parents)
           └────────┬────────┘
                    │
        ┌───────────┴───────────┐
        │                       │
┌───────▼─────────┐   ┌─────────▼───────────┐
│AnthropicBackend │   │ ClaudeCodeBackend    │
│reqwest + SSE    │   │ subprocess           │
│reasoning blocks │   │ stream-json parse    │
└─────────────────┘   └─────────────────────┘

#AgentRuntime Trait

ACP-compatible interface. Any backend implementing this trait is governable by ConstitutionalRuntime and manageable by SessionManager.

#[async_trait]
pub trait AgentRuntime: Send + Sync {
    async fn ensure_session(&self, input: SessionInput) -> Result<Handle>;
    fn run_turn(
        &self,
        handle: &Handle,
        input: TurnInput,
    ) -> BoxStream<'static, Result<RuntimeEvent>>;
    async fn cancel(&self, handle: &Handle) -> Result<()>;
    async fn close(&self, handle: &Handle, reason: CloseReason) -> Result<()>;
    async fn status(&self, handle: &Handle) -> Result<SessionStatus>;
}

#RuntimeEvent

ACP wire-compatible core. HAAK extensions are additive — unknown type values are ignored by OpenClaw clients. seq is mandatory on all events from day one (prerequisite for stream signing in v1.0).

pub enum RuntimeEvent {
    // ACP-compatible — same JSON field names as OpenClaw
    TextDelta       { seq: u64, text: String },
    ToolCall        { seq: u64, id: String, name: String, input: Value },
    ToolCallUpdate  { seq: u64, id: String, input_delta: String },
    ToolResult      { seq: u64, id: String, content: String, is_error: bool },
    UsageUpdate     { seq: u64, input_tokens: u32, output_tokens: u32 },
    Done            { seq: u64, stop_reason: StopReason },
    Error           { seq: u64, code: String, message: String },

    // HAAK extensions
    ReasoningDelta  { seq: u64, text: String },
    PolicyGate      { seq: u64, entry: LedgerEntry },
    MailboxInjected { seq: u64, entry: LedgerEntry },
    LedgerAppend    { seq: u64, entry: LedgerEntry },
}

#Ledger Entry

Every turn, tool call, and policy verdict produces a ledger entry. Schema matches [[33-constitutional-ledger]] exactly. proof and envelope are null in v0; their fields are present and typed from day one.

pub struct LedgerEntry {
    pub cid:       Cid,              // BLAKE3 over JCS-canonical form (RFC 8785)
    pub entity_id: String,           // agent session key
    pub target:    String,           // session id or tool id
    pub quality:   EntryQuality,     // Turn | ToolCall | PolicyVerdict | MailboxInject | SessionLifecycle
    pub timestamp: DateTime<Utc>,
    pub source:    String,           // session key of producing agent
    pub actor:     String,           // agent roster id
    pub parents:   Vec<Cid>,         // prev turn CID, tool call CID, etc.
    pub tags:      Vec<String>,
    pub payload:   Value,            // JCS canonical; skill trace format for Turn entries
    pub proof:     Option<Proof>,    // null (v0) → attestation (v1.0) → zk-stark (v2.0)
    pub envelope:  Option<Envelope>, // null (v0/v1.0) → KDF-encrypted (v1.5)
}

Skill trace payload format (for Turn entries), as specified in [[strategy/19-filix-v1-plan]]:

{
  "skill_name": "agent_runner",
  "inputs_hash": "<blake3 of canonical turn input>",
  "outputs_hash": "<blake3 of canonical response>",
  "timestamp": "<iso-8601-utc>",
  "actor": "<roster id>"
}

#Session Store

SQLite. Designed so that the Filix v1.0 transition (session store becomes a materialized view over the ledger) requires no schema migration — prev_cid and proof fields are present from v0.

CREATE TABLE sessions (
    id              TEXT PRIMARY KEY,  -- blake3(agent_id || session_key || created_at)
    agent_id        TEXT NOT NULL,
    session_key     TEXT NOT NULL UNIQUE,
    backend         TEXT NOT NULL,     -- "anthropic" | "claude_code"
    model           TEXT,
    mode            TEXT NOT NULL,     -- "persistent" | "domain" | "oneshot"
    state           TEXT NOT NULL,     -- "idle" | "running" | "cancelled" | "closed"
    pubkey          TEXT,              -- null (v0); ed25519 pubkey (v1.0)
    last_activity   TEXT NOT NULL,
    created_at      TEXT NOT NULL
);

CREATE TABLE turns (
    id              TEXT PRIMARY KEY,  -- cid of ledger entry
    session_id      TEXT NOT NULL REFERENCES sessions(id),
    seq             INTEGER NOT NULL,
    prev_cid        TEXT,              -- null for first turn; hash-chained thereafter
    input_hash      TEXT NOT NULL,     -- blake3 of canonical turn input
    output_hash     TEXT,              -- blake3 of canonical response; null until complete
    stop_reason     TEXT,
    usage           TEXT,              -- json: {input_tokens, output_tokens}
    started_at      TEXT NOT NULL,
    completed_at    TEXT,
    proof           TEXT               -- null (v0); json attestation (v1.0)
);

CREATE TABLE history (
    id              TEXT PRIMARY KEY,  -- cid of this message entry
    session_id      TEXT NOT NULL REFERENCES sessions(id),
    turn_id         TEXT REFERENCES turns(id),
    seq             INTEGER NOT NULL,
    role            TEXT NOT NULL,
    content         TEXT NOT NULL,     -- JCS canonical json
    created_at      TEXT NOT NULL
);

CREATE TABLE ledger (
    cid             TEXT PRIMARY KEY,
    quality         TEXT NOT NULL,
    entity_id       TEXT NOT NULL,
    target          TEXT,
    actor           TEXT NOT NULL,
    parents         TEXT NOT NULL,     -- json array of cids
    tags            TEXT NOT NULL,     -- json array
    payload         TEXT NOT NULL,     -- JCS canonical
    proof           TEXT,              -- null (v0)
    envelope        TEXT,              -- null (v0/v1.0)
    timestamp       TEXT NOT NULL
);

Session IDs are BLAKE3-derived, not random UUIDs. This makes them re-derivable from agent identity — prerequisite for keypair-anchored session identity in v1.0.

#SessionManager

Matches OpenClaw's AcpSessionManager. Per-session serial queue: no concurrent turns within one session. TTL eviction applies only to domain mode sessions; persistent sessions (standing roles) are never evicted.

Three session modes:

ModeTTL evictionUse
persistentneverStanding roles (Naga, Veda, Reed)
domainconfigurable (default 30 min)Project-scoped agents
oneshotafter each turnConsole web sessions

On startup, SessionManager reads data/agent-roster.jsonl for standing: true agents and calls ensure_session for each, preloading them before the gateway accepts connections.

#ConstitutionalRuntime<R>

A decorator implementing AgentRuntime by wrapping any other AgentRuntime. The session manager sees only another AgentRuntime — the constitutional layer is transparent to it and to the inner backend.

Per-turn sequence:

  1. Session open — append SessionLifecycle ledger entry; register/heartbeat roster
  2. Pre-turn — read unread mailbox entries; append MailboxInject ledger entry for each; prepend to history as [mailbox from X: ...] user turn
  3. System prompt — assemble: constitutional preamble + mandate text + board excerpt + constitution_hash: blake3(constitution.md) in prompt footer (non-enforcing in v0; read by policy engine in v1.0)
  4. Tool gating — pass tool list through policy.gate(tools); emit PolicyGate ledger entry per tool with verdict and constitution_hash; proof: null in v0
  5. Run inner — stream RuntimeEvents from inner.run_turn()
  6. On ToolCall — re-evaluate policy with known input; append ToolCall ledger entry; if Blocked, short-circuit: emit PolicyGate event, inject tool_result error, continue
  7. On ToolResult — append ToolResult ledger entry, parents: [toolcallcid]
  8. On Done — compute outputhash (BLAKE3 of accumulated response); complete Turn ledger entry with prevcid = previous turn's CID; board post if relevant

#OpenClaw Compatibility

An OpenClaw user migrates by adding one backend entry to their config:

// ~/.openclaw/config.json
{
  "acp": {
    "backends": {
      "haak": { "url": "ws://localhost:18789" }
    }
  }
}

And pointing their agent's runtime at it:

{ "runtime": { "type": "acp", "acp": { "backend": "haak", "mode": "persistent" } } }

Wire protocol — our gateway implements OpenClaw's full RPC surface:

OpenClaw RPCHandler
session.initSessionManager::ensure_session
turn.run (streaming)SessionManager::run_turn
session.cancelSessionManager::cancel
session.closeSessionManager::close
session.statusSessionManager::status

Constitutional bind for OpenClaw agents. Unknown connecting agents receive the default mandate: sandboxed workspace, restricted tool allowlist (read/search/send_message only — no shell, no unconstrained file write, no direct network), board check-in on session open, all tool calls logged to ledger. A registered agent (known pubkey in roster) receives its specific mandate. The agent cannot negotiate or opt out — the constitutional layer is gateway-enforced.

#Crate Layout

New crate at infra/daemons/agent-runner/, sibling to infra/daemons/gateway/.

infra/daemons/agent-runner/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── lib.rs
    ├── runtime/
    │   ├── mod.rs           # AgentRuntime trait, RuntimeEvent, Handle, TurnInput, CloseReason
    │   ├── anthropic.rs     # AnthropicBackend — reqwest + SSE, reasoning blocks, tool loop
    │   └── claude_code.rs   # ClaudeCodeBackend — subprocess + stream-json parse
    ├── constitution/
    │   ├── mod.rs           # ConstitutionalRuntime<R>
    │   ├── manifest.rs      # AgentManifest — parse mandate markdown, assemble system prompt
    │   ├── policy.rs        # PolicyEngine — predicate eval, gate(), constitution_hash()
    │   ├── mailbox.rs       # MailboxClient — read/ack agent-mailbox.jsonl
    │   └── board.rs         # BoardClient — read/append board.md
    ├── ledger/
    │   ├── mod.rs           # LedgerEntry, LedgerWriter, Cid
    │   ├── hash.rs          # BLAKE3 + JCS canonical serialization (RFC 8785)
    │   └── proof.rs         # Proof enum: Null | Attestation | ZkStark
    ├── session/
    │   ├── mod.rs           # SessionManager — public API
    │   ├── queue.rs         # KeyedQueue — per-session tokio mpsc + worker tasks
    │   ├── store.rs         # SessionStore — SQLite, hash-chained turns
    │   └── eviction.rs      # TTL loop (domain mode only)
    └── server/
        ├── mod.rs           # Axum, WebSocket upgrade, :18789
        └── routes.rs        # RPC handlers, event serialization, ACP wire compat

ledger/ is a standalone module. In Filix v1.0, the session store becomes a materialized view over the ledger — that refactor is trivial if LedgerWriter has a clean interface. If ledger logic were entangled with the constitutional wrapper, it would be painful.

#Crypto Readiness

PhaseRequirementStatus
v0 (now)BLAKE3 CIDs, proof: null, envelope: nullin schema from day one
v1.0 (Aug 2026)Populate proof with attestation + agent signature; verify prev_cid chainproof field in schema; prev_cid in turns; PolicyEngine evaluates predicates
v1.5KDF audience keys, populate envelope, rotate on agent departureenvelope field in schema; tags on entries track audience
v2.0ZK-STARK proof generation and verificationproof accepts any Proof variant; verifier plugs in without touching event or session types

#First Build Target

Start with ledger/LedgerEntry, Cid, BLAKE3 + JCS canonical hash. Everything else builds on it: session store uses CIDs as primary keys, constitutional wrapper emits ledger entries, event stream carries them. Get the ledger module right first.

#Supersedes

Replaces the minimal spec in [[35-agent-runner]]. The Python implementation (scripts/agent_runner.py) and console agentic loop (infra/console/server.py) remain active until migration is complete.

  • [[33-constitutional-ledger]] — ledger entry schema, BLAKE3/JCS spec, crypto phase roadmap
  • [[36-agent-router]] — session routing and room assignment
  • filix-v1-plan — Filix v1.0 scope and design-for-crypto spec
  • ontology-governance-situation — governance proofs and authority model

Architecture 38 — Agent Runner v2 — Constitutional Runtime over ACP — 2026 — Zachary F. Mainen / HAAK