The RSI loop kernel — one durable loop over the existing seams¶
run_guarded runs one session; RSIController turns one observation into
accept / reject / defer / rollback / stop; durable_runs keeps a leased event
log. LoopKernel is the small state machine that puts them in one order and
survives a crash.
from agentdescent import LoopConfig, LoopKernel, LoopObservation
from agentdescent.durable_runs import SqliteRunStore
store = SqliteRunStore("runs.sqlite3")
kernel = LoopKernel(
store,
LoopConfig("tenant-a", "ws-1", max_generations=4, target=0.9),
)
run_id = kernel.submit()
def step(ctx):
# generate + evaluate one candidate; PRM / planner / verifier all fit here
return LoopObservation(reward=0.7, cost=1.0, artifact_id="skill-1")
# apply is called once per accepted generation; make it idempotent for
# (run_id, generation), exactly like step.
def apply_artifact(context, observation):
materialise(observation.artifact_id)
report = kernel.run(run_id, "worker-1", step, apply=apply_artifact)
report.best_reward, report.stopped_reason, report.generations
What one generation is¶
step(context) -> LoopObservation is the caller's session: generate a
candidate, score it, and return the reward, cost, risk, artifact id and any
evidence. The kernel never runs a strategy itself.
LoopContext carries the generation, deterministic seed, previous
budget_scale, previous best and the reconstructed history. A caller that
wants PRM ranking, MCTS or an ensemble chooses it inside step (or delegates
the choice to ComputeController).
What the kernel owns¶
- Order.
step -> RSIController -> apply/rollback -> record. - Durability. Every completed generation is a
stepevent carrying the observation and the decision that was actually taken. A restart replays those decisions throughRSIController.restore(); it never re-calls a gate. - Completion. The final generation is a
stepevent too, so the run's last observation is not hidden inside thecompletedpayload. - Audit. Optional
DecisionLogrecordskind="loop"rows with a deterministic per-generation id; optionalRSITelemetryemits one span per generation. - Control. Target, cost budget, patience, risk limit, gates and an optional
cancel(). A missingLoopConfigvalue or a step returning the wrong type is a recorded failure, never a silent zero.
At-least-once, not exactly-once¶
durable_runs is at-least-once. step and apply must therefore be
idempotent for (run_id, generation): a crash after the external effect but
before the event replays that generation. The kernel records a generation only
after both return. The event key is generation:{n}, and the decision log's
record id is {run_id}:generation:{n}, so the replay is a no-op for the log.
If retryable failures exhaust the step budget, run() can return with
status == "running"; call run() again until report.terminal.
Reporting and recovery¶
report = kernel.report(run_id) # replay + reconstruct, no gates
snapshot = kernel.snapshot(run_id) # durable_runs snapshot
kernel.cancel(run_id, reason="operator stop")
LoopGeneration exposes the action, reason, reward, gain, cost, artifact,
stop/applied flags and span id for each generation. LoopReport.to_dict() is
JSON-serialisable for a console or a daily report.
The kernel is not a distributed scheduler: one SQLite lease, one worker at a time. The event schema is what a Postgres/Temporal adapter keeps.