8.6 KiB
Runtime Engine: Lemniscate
Scope: the runtime loop for the agent (Nyx). It is a server, not a UI and not a borrowed coding harness.
Runtime decision
- Interactive harnesses (pi-agent-core, omp) are hosts: they wait for a human, end on session close, and do not run unattended.
- Nimmerworld implements its own loop (this engine) and runs indefinitely.
- Harnesses host nimmer-lib only. Clients never implement the agent brain; the runtime never borrows one.
Topology
Figure-eight with two lobes and two crossings:
Gate Summary In Action Result
| |
v Second Crossing v
+--------------+ +--------------+
| LEFT LOBE | | RIGHT LOBE |
| (intake) | | (effect) |
+--------------+ +--------------+
^ First Crossing ^
| |
Gate Feedback Action Call (tool/actuator)
NATS vocabulary (label map)
| Lemniscate label | NATS role | Transport | Delivery guarantee |
|---|---|---|---|
sensor pub <sensor.subject> |
publisher | core NATS | fire-and-forget: only subscribers open at publish time get a copy; late subscribers get nothing |
gate.summary, gate.decision |
subject (fan-in at L1, fan-out to gate layer) | core NATS | ephemeral, regenerated every pulse; nothing survives the cycle |
| L1 (LLM) | subscriber on gate.summary, publisher on gate.decision |
core NATS client | a swappable bus client, any language |
action.call.<target> |
stream (JetStream) | durable | at-least-once delivery, replayable |
actuator:<t> |
own JetStream consumer on its subject | durable | tracks its own position; a dead actuator stalls no one |
action.result |
stream (JetStream) | durable | causality: event_uuid -> cycle_uuid -> decision_uuid |
client:<name> |
own durable consumer on zone.state.> / action.result.> |
durable | replay-on-wake: reconnect and read what it missed |
Rules in NATS words: fan-out is the subject matcher, not code (wildcard
* = one token, > = one or more). Fan-in means many publishers onto
one subject; each consumer position is independent, consumers never
share state or corrupt each other. Idempotency is enforced by us, via
dedupe on decision_uuid/cycle_uuid, because JetStream guarantees
at-least-once, never exactly-once.
Subject naming (open): the engine above uses short subjects
(gate.summary, action.call.<target>). Our live bus streams use
nimmerverse.register.> / nimmerverse.cycle.>. Before any engine
traffic flows, pick ONE naming convention and map it here.
Bus transport (NATS / JetStream)
The engine is host-agnostic: it never speaks to a model or an actuator directly, everything crosses the bus as structured JSON.
sensor/camera -> pub <sensor.subject> (raw events; gates subscribe via catalog)
gate layer -> pub gate.summary (composer renders summary table, once per cycle)
L1 (LLM) -> sub gate.summary / pub gate.decision (core NATS, ephemeral, cycle-scoped)
L2 composer -> sub gate.decision
validate targets vs drives (table lookup, once, upstream of fan-out)
then pub action.call.<target> per validated action
actuator:<t> -> own JetStream consumer on action.call.<t>.>
client:<name> -> own durable consumer on zone.state.> / action.result.> (replay-on-wake)
Placement rules:
-
gate.summary/gate.decisionstay on core NATS: ephemeral, regenerated every pulse; never block the pulse loop on storage I/O. -
Consequence subjects (
action.call.>,action.result, causal records) are JetStream streams: at-least-once, survive restart, replayable viaevent_uuid -> cycle_uuid -> decision_uuid. -
Fan-out is the subject matcher, not code: the L2 composer publishes to
action.call.<target>from the catalog; each actuator consumes its own subject and tracks its own position; a dead actuator stalls no one. -
Validation happens once, in the composer, before the fan-out; consumers execute or nack, they never re-decide.
-
Idempotency rides the envelope: every action carries
decision_uuid+cycle_uuid; actuators dedupe on them so redelivery cannot double-fire. -
The LLM endpoint is a swappable bus client. vLLM today, harness tomorrow, zero engine changes.
-
Left lobe: intake. Gate summaries, evidence pulls, thresholds.
-
Right lobe: effect. Synthesized answer, action calls, client output.
-
L1 (first crossing): admission decision. Open/hold gates; shift thresholds; request evidence.
-
L2 (second crossing): synthesis. Produce answer and action-call list from the admitted content.
-
The crossings are the only synchronous points. All state resolves atomically there.
Loop semantics
- One traversal = one cycle. The lemniscate closes exactly once: L1 then L2.
- Between crossings, traversal is local; only the crossing touches shared state.
- The cycle is a transaction: no new input lands while it is open.
- Feedback: the L2 answer is the salience signal for the next gate summary. It is not a gate.
Data flow
L1 inputs:
- gate summary table: gate, impulses, threshold, last_opened, drives, last_decision_cycle, tier.
- request parameters (evidence pull, k frames, probe query).
L1 outputs:
- gate decision: decision_uuid, opened gate list, parameters, threshold deltas.
L2 inputs:
- composed content from the opened gates (evidence rows, frames, text).
- catalog of valid output gates from each opened gate's
drives.
L2 outputs:
- answer text.
- action-call list: action_uuid, target actuator/tool, parameters, tier.
- gesture accumulator update.
- salience signal for next summary.
Validation:
- output gate must be in the union of opened input gates'
drives. - invalid decision: cycle re-runs. Membership is a table lookup.
Gate summary envelope (what the model actually reads at L1):
{
"cycle_uuid": "c-042",
"gates": [
{"gate": "k8s_alert", "impulses": 4, "threshold": 10,
"last_opened": 12, "drives": ["k8s_action"], "tier": "T1",
"last_decision_cycle": "c-041"}
],
"request_params": {"k_frames": 3, "probe": null}
}
L1 replies exactly:
{
"decision_uuid": "d-117",
"opened": ["k8s_alert"],
"params": {"k_frames": 5},
"threshold_deltas": {"k8s_alert": -2}
}
Pulse clock
elapsed += dt
while elapsed >= interval:
crossing()
elapsed -= interval
- Interval = mean(L1_RTT) + mean(L2_RTT) + safety_margin.
- RTT is derived from v7 timestamps (impulse to decision), not from wall-clock probes.
- Sliding-mean estimator with hysteresis. A single slow cycle must not stretch the interval.
- Probe latencies are metadata on their gates; they never enter the cycle interval.
- dt >= 1/pulse_rate would fire every tick once pulse_rate > 62; the accumulator prevents that.
Map
| Lemniscate | Nimmerworld |
|---|---|
| Director | runtime |
| Zone / EmergentZone | bounded event episode |
| Crossing L1 | gate summary read; gate open/hold |
| Crossing L2 | composed input synthesized; answer + action call |
| Slot tokens | participants |
| Gesture accumulator | nimmer-lib trait algebra |
| update_all_zones | NATS event loop |
| Gate / Sensor | threshold channel upstream |
| UUID causality | event_uuid -> cycle_uuid -> decision_uuid |
| Intake tiers | summary poll + evidence pull |
| Policy layers | hard floor (operator) > presence policy (harness) > attention policy (runtime) |
| Gate tags | T0-T3 declared importance |
| Adaptive timing | round-trips regulate interval and animation duration |
| Register | declared sensors/gates/actuators/clients and drives links |
Engine corrections
_should_crossingmust accumulate time:elapsed += dt; while elapsed >= interval: crossing.dt >= 1/pulse_ratefires every tick once pulse_rate > 62 and makes the pulse rate meaningless.
- Loops A/B must carry distinct semantics:
- the crossing callback must know which loop it is on.
- symmetric loops degrade the figure-eight into round-robin.
- resolved by the L1/L2 split (intake vs effect).
Director.update_all_zonesmust callzone.execute_turn(dt), notzone.run_crossing():- otherwise the state machine never ticks.
- terminal reports must be collected, not dropped.
Interfaces (v1)
- Core NATS (ephemeral):
gate.summary,gate.decision. - JetStream (durable, at-least-once):
action.call.<target>,action.result, causal records. - Register/catalog: sensors, gates, actuators, clients,
drives. Catalog entry declares the class path; runtime imports an explicit registry and fails loud on a missing class. - Clients hold own durable consumers on
zone.state.>andaction.result.>(replay-on-wake).
Version: 0.12 | Updated: 2026-11-06