Date: 2026-08-13
Branch: scope/agent-a5-partial
Base / integration HEAD: 1299300
Core checkout: /mnt/TEMP/rustscript/agent-roadmap/rustscript at 06b37fd (pinned)
A5 in the agent roadmap is "script-owned serial agent loop with durable compaction". This commit series delivers the independently shippable parts of A5 as pure-RSS decision policies with executable tests, while the parts that depend on the A3 core blocker or on the user-excluded A4/A6 scopes stay out of this branch.
| Deliverable | Files | Commit |
|---|---|---|
| Serial loop policy skeleton | rss/agent/main.rss |
feat(agent): add serial loop policy skeleton |
| Durable compaction policy | rss/agent/compact.rss |
feat(agent): add durable compaction policy |
| Executable policy suites (pure + durable) | tests/agent_loop_tests.rs |
with the two commits above |
| Canonical context fixtures | tests/fixtures/agent/loop_context.json, tests/fixtures/agent/compaction_context.json |
with the two commits above |
| This scope-split document | plans/2026-08-13_a5-scope-split.md |
plan(agent): ... |
| Two stale SSE capability comments corrected | src/runtime/rss_runner.rs, rss/llm/openai_chat.rss |
docs(agent): ... |
- A3 core blocker (blocks): provider adapters' buffered response parsing,
streaming aggregation, and
http::client::sseexposure — seeplans/2026-08-13_a3-provider-core-blocker.md. The serial policy never calls a provider; a provider call that would succeed is returned as a typed blocked capability instead of fabricated success. - A4 (user-excluded): full test harness / approval machinery — no
rss/agent/harness.rss, no approval bridge, no registry/approval wiring. - A6 (user-excluded): parallel/subagent/task execution. The serial
policy rejects
parallel/taskconfiguration with typed errors and never invents parallel or subagent actions.
pub fn run(context: map) -> map is a state-machine step function: ONE typed
context map in, ONE discriminated decision map out, executed on synthetic
typed inputs (no provider transport, no storage, no tool runner).
Context:
turn: int completed turns so far (0 initially)
max_turns: int turns allowed before the run terminates
retry_count: int retries already consumed on the current turn
max_retries: int retries allowed per turn
phase: string "start" | "provider_result"
model: string model name carried by model.* event descriptors
provider: map canonical {ok, response, error} call result
(rss/llm/types.rss shape); ignored in phase "start"
config: map {base_retry_delay_ms, max_retry_delay_ms,
parallel, task}
Decisions (kind discriminator; every decision carries an events array):
blocked capability "provider.call" | "tool.dispatch" + typed reason
retry delay_ms (min(base * 2^retry_count, cap)), retry_count+1,
turn unchanged (a retry does not consume a turn)
next.turn turn + 1 (a turn without tool calls completed)
run.completed terminal decision after the last allowed turn
run.failed terminal decision carrying the typed ProviderError
{status, type, code, message, param, request_id} and reason
"non_retryable" | "max_retries_exceeded"
rejected typed rejection: parallel_not_supported, task_not_supported,
unknown_phase
Canonical event descriptors (script-visible events per src/events.rs):
model.started: {type: "model.started", turn, model}
model.completed: {type: "model.completed", turn, text, tool_calls}
run.failed / run.completed are SERVICE-OWNED event types; the policy only
describes the terminal decision and never emits them.
Retryability: 429, 408, 5xx, rate_limit_error, server_error are
retryable; everything else (including the typed invalid_request_error
family and malformed_payload) is non-retryable.
pub fn run(context: map) -> map plans a compaction from the message
history and returns the exact typed A2 storage command sequence
compaction.start -> message.compact -> compaction.commit; with
command: "fail" in the context it returns the typed
{op: "compaction.fail", payload: {id, error_message, completed_at_ms}}
command for any failure in the sequence.
Context:
session_id, run_id, compaction_id: string
generation: int CURRENT session generation; the plan targets
generation + 1 (A2 guards sessions.generation + 1)
messages: array ordered history, each entry
{ordinal, role, tool_call_id, content: array}
content parts follow the canonical shapes
({type: "text"|"tool_call"|"tool_result", ...})
config: map {max_context_messages, retained_tail, now_ms,
model, token_estimate}
Decisions:
compact.plan -> generation, source_start_ordinal, source_end_ordinal,
retained_tail_ordinal, summary_json, commands[3]
compact.skip -> reason "history_within_window" |
"history_within_retained_tail" | "invalid_config"
With n messages the naive boundary is n - retained_tail; the boundary
is then pushed forward to a fixpoint so that NO assistant tool-call
message inside the prefix is separated from its tool-result messages (tool
results always follow their assistant message, so a pushed boundary can
only pull tool messages into the prefix and the loop terminates). The
retained tail is every message AFTER the adjusted boundary.
The A2 storage layer (rss/storage/compactions.rss, messages.rss) is the
fixed contract; the policy conforms to it exactly:
compaction.startguards: session + run exist, run status iscompacting,sessions.generation + 1 = generation,source_start <= retained_tail_ordinal <= source_end, and messages exist at both range endpoints. The retained-tail marker is therefore recorded asretained_tail_ordinal = source_end_ordinal(the tail is everything AFTER the compacted range).message.compactis a guarded no-op until a committed compaction covers the range, so in the prescribed pre-commit position it is an idempotent sweep that only a hard failure can reject.compaction.commitis one atomic transaction: it flips the compaction tocommitted(guarded bysource_end_ordinal = end_ordinal, run stillcompacting, session generation unchanged), marks exactly[source_start_ordinal, source_end_ordinal]compacted, and advances the session generation. The harness treats a commit that matched no row (rows_affected == 0) as a failure and routes it tocompaction.fail.- Any failure at any step leaves every message untouched and the session generation unchanged: the original history is fully recoverable, and a failed compaction is durably recorded with its typed error message.
The execution tests drive the plan through the production A2 storage
service (rss/storage/main.rss via the existing AgentRunner + typed
command envelope harness) and assert the durable outcome: committed row,
prefix marked compacted, retained tail untouched, generation advanced
exactly once (happy path); failed row with the typed error, no message
compacted, generation unchanged (failure path).
The committed core blocker plan stands:
plans/2026-08-13_a3-provider-core-blocker.md. Unblock conditions (all in
the pinned core, out of this repository's scope):
TypeSchema::Callablefor every script prototype must match the declared parameter/return types regardless of call-site inference in non-root modules (statement-if calls, cross-module accessor arrays).- Annotated lets inside tail-position expression-if branches must resolve to their declared schema.
- Closures must be able to by-value-capture locals without forcing
Moveon the source (shared-accumulator pattern) to unblock the SSE callback aggregation. json::encodeaccepts only struct-shaped values (P3 marker-splice surface documented in the blocker plan).
Neither policy module touches the provider adapters, the core, or the restricted registry; the registry is not extended.
- A4 full harness and approval machinery: excluded by the user. The policies are exercised through the existing test harness pattern only (runner + typed contexts + typed storage commands); no approval flow, no harness module, no registry changes.
- A6 parallel/subagents: excluded by the user.
main.rssrejectsconfig.parallel/config.taskwith typed errors (parallel_not_supported,task_not_supported) and the decision protocol contains no parallel/subagent/task action; a dedicated test scans the decisions for such constructs.
| Criterion | Status |
|---|---|
| Serial loop: single context map input, discriminated decision output | GREEN (loop_* suites, 15 tests) |
| turn/max_turns accounting, turn increments across steps | GREEN (loop_full_serial_run_advances_turns_and_completes, loop_max_turns_terminates_run_completed) |
| Typed ProviderError retry/backoff decision (429/408/5xx/rate_limit/server_error vs 4xx/invalid_request) | GREEN (loop_retryable_error_retries_with_backoff, loop_backoff_doubles_then_caps, loop_nonretryable_error_fails_run, loop_max_retries_exceeded_fails_run) |
| Canonical model.started / model.completed event descriptors and run.failed terminal descriptor | GREEN (loop_canonical_event_shapes) |
| Provider call / tool dispatch as typed blocked capability, no fabricated success, no new builtins | GREEN (loop_start_phase..., loop_success_with_tool_calls..., loop_decisions_never_invent_parallel_or_subagent_actions) |
| No parallel/task actions | GREEN (typed rejections + decision scan) |
| Compaction: complete-prefix selection, tool-call/tool-result pairs never split, retained window | GREEN (compact_plan_*, 8 pure tests) |
| Compaction executes start -> message.compact -> commit via A2 typed storage; failure -> compaction.fail; no half-commit, history recoverable | GREEN (compaction_flow_commits_durably_and_retains_tail, compaction_failure_marks_failed_and_preserves_history) |
| No modification of provider adapters / core / registry to work around the A3 blocker | GREEN (no such files touched) |
| No A4 harness/approval, no A6 parallel/subagent scaffolding | GREEN (no such files created) |
| A3 provider parse/stream path | BLOCKED (core, see §4) |
| Full script-owned runner loop (A7/A8 wiring) | NOT IN SCOPE (blocked by A3; later scope) |
cd /mnt/TEMP/rustscript/agent-roadmap/a5
export CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-a5
cargo test --locked --test agent_loop_tests # 39 passed (post-fix)
cargo test --locked --test storage_tests # A2 regression
cargo test --locked --test runner_tests # A0/A1 regression
cargo test --locked --all-targets # full default scope
cargo test --locked --test core_repro_driver -- --ignored # A3 repros still failing as documented
cargo test --locked --test provider_tests -- --ignored # A3 streaming suites still blocked
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
git diff --checkAll temp/target/log artifacts live under /mnt/TEMP/rustscript/
(target-a5, agent-tests/).
fix(agent): ... on top of the two policy commits repairs the review
findings. The PRODUCTION ENTRY remains NOT wired: the policies are still
exercised through the test harness only (AgentRunner + typed contexts +
typed storage commands); no gateway/service code calls main.rss or
compact.rss yet. The A3 core blocker (provider adapters' buffered
response parsing, streaming aggregation, http::client::sse exposure) and
the A4/A6 user exclusions are unchanged and still block the full
script-owned runner loop (A7/A8 later scope).
compaction.start now implements the durable recovery contract in the A2
storage layer (rss/storage/compactions.rss; the schema is unchanged, no
migration):
- Idempotent resume: same
session_id + generationwith a PENDING row and the SAME payload (id, run, generation, range, summary, model, token estimate) returns the existing pending row — a crash between start and commit is safely resumed by the caller without any state change. - Typed conflicts: PENDING with a DIFFERENT payload →
ok:falsecompaction_pending_conflict; COMMITTED →ok:falsecompaction_already_committed; an id already used for a different session/generation →ok:falsecompaction_id_conflict(never a SQLite constraint error). FAILED rows are reset to pending by the guarded insert (retry path, same row keeps its audit identity). - Guard rejection is typed: when no row exists after the guarded
insert,
compaction.startreturnsok:falsecompaction_start_rejected— neverok:truewith an empty row set. - Restart recovery (already wired in
recovery.recover_active, now proven): the gateway production restart path (GatewayPersistence::open().load()) fails interrupted runs AND their pending compactions, so a session is never stuck; a fresh run incompacting+ a retry start + commit advances the session generation exactly once.
Tests (real SQLite through the storage service and through the gateway production path): idempotent resume + exactly-once commit, pending conflict, committed conflict, id conflict, every guard rejection typed (run status / generation / range / message endpoints), and the crash-window recovery flow (start → recovery → failed row → retry → commit, generation advances exactly once).
- Guard rejections are typed error envelopes (see P1); a caller can never claim a durable record that does not exist.
backoff_delay_ms(rss/agent/main.rss) ismin(max(base, 0) * 2^retry_count, max(cap, 0))with defined edge semantics: negative/zero inputs clamp to 0, base above cap clamps to the cap on entry, doubling SATURATES at the cap without overflow, and the loop terminates for any i64 inputs (huge retry counts included). Tests: base > cap, cap near i64::MAX, zero/negative inputs, retry_count 100k.
- Corrected the inaccurate single-map compiler-contract comment in
main.rss(provider_resultlegitimately takes the canonicalprovider+configpair; the two-map trigger is avoided because no map is passed onward). - Documented the full-compaction rule in
compact.rss: the fixpoint reaches the last message only when the last message is a tool result whose call sits in the prefix, so the retained tail is empty ONLY in that forced case; otherwise the tail keeps the configured window. Documented the O(n³) worst-case fixpoint bound (histories are bounded bymax_context_messages). - The tool-call cycle now CONSUMES the turn budget: a blocked
tool.dispatchcarriesturn + 1, so a runaway tool loop terminates atmax_turns(the completed model call's event keeps its own turn). Thetool_callsevent field is pinned to the exact count ofresponse.tool_callsentries (multi-call tested). - New coverage: multi-call pair preservation, missing tool result (call compacted as-is), boundary == n (documented empty-tail rule).
| Criterion | Status |
|---|---|
| Compaction start durable recovery (idempotent resume, typed conflicts, typed rejections) | GREEN (6 new suites, real SQLite + gateway production path) |
| Crash window: pending failed by restart recovery, retry start/commit, exactly-once generation | GREEN |
| Backoff edge semantics (clamp, saturate, zero/negative, huge inputs) | GREEN (3 new suites) |
| Tool-call cycle bounded by max_turns; tool_calls count pinned | GREEN (2 new suites + updated) |
| Full-compaction rule and O(n³) bound documented | GREEN (doc + boundary test) |
| Production entry (policy wired into gateway/service) | NOT WIRED (unchanged; A3/A4 blockers stand) |
The Wave 2 integration (A5+A7+A8+A9 on one branch) added three P3 hardening items to the durable compaction policy, all TDD (real SQLite and the gateway reopen path):
- failed → pending reset clears
completed_at_ms: the guarded insert'sON CONFLICT ... DO UPDATEnow setscompleted_at_ms = 0, so a retried compaction's later commit records only its own time (failed_retry_reset_clears_completed_at_ms). - Different compaction id on a failed row is a typed conflict: a retry
with a different id would silently replace the failed row's audit
identity;
compaction.startnow answerscompaction_failed_conflictand the caller must resume with the original id. Same-id retries (including the fresh-run recovery flow) are unchanged (failed_retry_with_different_id_is_a_typed_conflict). - Restart recovery fails EVERY pending compaction: after a restart any
pending row is an interrupted leftover, even when its run is already
terminal (the crash window between the run terminal commit and
compaction.fail); the recovery fail-sweep is unconditional instead of being limited to recovered runs (recovery_fails_pending_compaction_even_when_run_is_terminal,gateway_reopen_fails_orphan_pending_compaction_even_when_run_is_terminal).