Answer-state checkpoints, and finishing an interrupted run into an answer - #203
Merged
Conversation
A run that spent `budget.wall_ms` ended through `early_result`, which hands the host a fatal `error` alongside whatever partial answer existed. Hosts treat `result.error` as fatal and discard the run, so every iteration of real work was thrown away over a *time* verdict — one that says nothing about whether that work was any good. Deadline exhaustion now takes the same terminal handoff as an exhausted iteration budget: it sets `terminal_handoff` and breaks, reaching the extract pass that turns partial work into a best-effort answer with ledger-derived citations. Token exhaustion deliberately stays fatal, since there is no budget left to pay for the extract call it would trigger. The extract call is reserved with `deadline_exempt=True`. `call_root` reserves `through_deadline=True`, which refuses once `remaining.wall_ms` reaches 0 — so without the exemption the fallback would be unreachable in exactly the case it exists to serve. Token budget still binds, and the caller keeps such calls to exactly one. Terminal REPL finalization is skipped for deadline handoffs the same way it is for `IterationLimitExceeded`: re-entering the sandbox after the deadline cannot help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cozy tells users a best-effort answer stopped because it "ran out of time" whenever the recovered cause is BudgetExhausted. That is only honest while the wall clock is the sole budget resource able to reach a recoverable terminal handoff — token exhaustion stays fatal, and semantic budgets surface as PolicyError. Assert both halves so a future change that lets another resource recover here fails this test, rather than Cozy silently telling users the wrong thing about why their answer is incomplete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`draft_chars` is Python's len() — a count of code points. The relay's forwarding filter compared it against JavaScript's String.length, which counts UTF-16 code units. Any non-BMP character in a draft made the two disagree, so isRlmEvent rejected the frame and relay.ts dropped it before it ever reached the host descriptor. Silently, and permanently: the draft keeps the character, so every later checkpoint of that run was dropped too, and the adapter's high-water mark had already advanced, so the evidence riding those frames was never republished. A watchdog SIGKILL then rendered the bare work-log card with zero salvage — the exact outcome checkpoints exist to prevent. Cozy is a messaging app. Emoji in a draft is the common case, not an edge case. Four green suites missed it because every fixture was ASCII, so the regression tests here use emoji, astral CJK, math alphanumerics, a ZWJ sequence, and a regional-indicator flag, plus the negative case so a genuinely wrong draft_chars is still rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The engine decides whether a terminal extract call is worth making from what it owns: a retained draft, or a step that ran to completion. Neither exists when generated code calls a host accessor and then raises before printing — no draft, no successful step, no stdout — so the engine concludes there is nothing to extract while the host is holding every row the answer needed. Observed on a real 314k-message corpus: a run retrieved 84 message GUIDs across four ledger records and returned a bare BudgetExhausted, having never attempted extraction at all. The engine cannot fix this alone without learning what a host data layer is, which is exactly the boundary this design exists to keep. So it asks instead. `RLMConfig.extractable_work_probe` is a host-supplied predicate, consulted only alongside the engine's own test and never replacing it. A probe that raises is treated as "no work" — a terminal recovery path must not be able to fail the run it is recovering. Known follow-up, deliberately not in this commit: unlocking the extract call is necessary but not sufficient. `_trajectory_summary` builds the extract prompt from draft + per-iteration code + stdout, so a run whose data exists only in the host ledger hands the extract pass failing code and nothing else. Measured: extraction now runs and returns InsufficientEvidence rather than being skipped. Closing that needs a second, symmetric hook letting the host contribute opaque context to the extract prompt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unlocking the terminal extract call was necessary but not sufficient. The extract prompt is built from the draft plus per-iteration code and stdout — everything the engine can see — but a REPL only surfaces what generated code chose to print. Code that fetched rows and then raised prints nothing, so the extract pass was handed failing code and nothing else and answered InsufficientEvidence while the rows sat in the host's ledger. Measured on a real 314k-message corpus: four ledger records and 84 message GUIDs retrieved, extraction attempted, extraction declined. `RLMConfig.extract_context_provider` returns pre-rendered text the engine bounds and concatenates without parsing — the same opacity contract as the checkpoint payload. Bounded at 8k chars because the extract call is deliberately uncached (`cache_anchors=None`), so every character is paid at full prefill and an unbounded host could spend the recovery call's whole input budget describing what it found. A provider that raises contributes nothing rather than ending the run it is recovering. Deliberately narrow: observations are supplied only when the HOST is what made extraction possible. When the engine can already see the work — a retained draft, or a step that ran to completion — the prompt stays byte-identical to what it was before this hook existed. Runs that extract successfully today keep their answers, their spend, and their benchmark scores; the behavior change is confined to runs that produce nothing. This is ReAct's shape rather than a new invention: dspy.ReAct puts every tool's return value straight into the trajectory, so extraction always sees the data. dspy.RLM, being REPL-based, has exactly the gap fixed here — its `variables_info` is built once from the inputs and never reflects what execution retrieved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A run that stops short used to hand the host a fatal error and throw away everything it had done. Three related changes make an interrupted run finish into an answer, plus one bug fix found while testing them.
Answer state no longer dies with the process
Hosts embedding the Pyodide substrate can be SIGKILLed mid-execution — Pyodide cannot be preempted, so a wedged run runs no finalizer and no signal handler. Until now every answer-critical value (the draft, and whatever host state backs it) existed only in process memory until
project_run_responseat the very end.Trace ABI v7 adds a
checkpointevent carrying the draft plus an opaque host payload, emitted after each executed step whose draft moved. A kill becomes a render rather than a recovery.CONFIGURABLE, notDURABLE— it carries content, so it must be retention-gated.checkpoint_seqis strictly increasing perrun_id, enforced inTraceRecorder(the only per-run authority); the stateless half stays invalidate_event_body.payloadis validated asMapping | Noneand never inspected or schema-checked, matching how the relay already ferries adaptermeta.payload: null; an unrepresentable payload drops that one checkpoint. A checkpoint can never fail a run.Wall-clock exhaustion routes to the extract fallback
Spending
budget.wall_msended throughearly_result, which hands back a fatalerroralongside whatever partial answer existed — and hosts discard the run. Every iteration of real work was thrown away over a time verdict, which says nothing about whether that work was good.Deadline exhaustion now takes the same terminal handoff as an exhausted iteration budget. Token exhaustion deliberately stays fatal: there is no budget left to pay for the extract call it would trigger.
The extract call is reserved with
deadline_exempt=True—call_rootreservesthrough_deadline=True, which refuses onceremaining.wall_mshits 0, so without the exemption the fallback would be unreachable in exactly the case it exists to serve.The host can now say what the engine cannot see
Two symmetric hooks, both opaque, both keeping the engine ignorant of what a host data layer is:
extractable_work_probe— the engine decides whether extraction is worth attempting from a retained draft or a completed step. Neither exists when generated code calls an accessor and then raises before printing. Measured on a real 314k-message corpus: a run retrieved 84 GUIDs across four ledger records and returned a bareBudgetExhausted, never attempting extraction.extract_context_provider— unlocking the call was necessary but not sufficient. The extract prompt is built from draft + code + stdout, and a REPL only surfaces what the code chose to print, so extraction was handed failing code and answeredInsufficientEvidencewhile the rows sat in the host's ledger.extract_context_provideris deliberately narrow: observations are supplied only when the host is what made extraction possible. When the engine can already see the work, the prompt is byte-identical to before this PR — same answers, same spend, same benchmark scores. The behavior change is confined to runs that produce nothing today.Bounded at 8k chars because the extract call is deliberately uncached (
cache_anchors=None), so every character is paid at full prefill.This is
dspy.ReAct's shape rather than a new invention — it puts every tool's return value straight into the trajectory, so extraction always sees the data.dspy.RLM, being REPL-based, has exactly this gap: itsvariables_infois built once from the inputs and never reflects what execution retrieved.Bug fix: emoji silently killed every checkpoint
events.tscompared Python's code-pointdraft_charsagainst JavaScript's UTF-16String.length. Any non-BMP character made them disagree, soisRlmEventrejected the frame and the relay dropped it before it reached the host — silently, and for every later checkpoint too, since the draft keeps the character.Found by adversarial review, not by the suite: every fixture was ASCII. Regression tests now use emoji, astral CJK, math alphanumerics, a ZWJ sequence, and a regional-indicator flag, plus the negative case.
Testing
1261 passed, 3 skipped, ruff clean,deno test pyodide/60 passed.Mutation-verified rather than assumed: removing the draft/payload gate, the provider guard, the emission guard, the repaired-code emission point, the
draft_charscheck, the strict-monotonic check, the host_context wiring, the 8k bound, or the narrow gate each fails a distinct test. The UTF-16 fix is verified against a real WASM-emitted frame.Notes for review
ScaffoldManifest.from_dict, not hand-edited.checkpointevent — adding one renumbers seqs across a run. Say the word if the cross-language corpus should carry one.execute_stepdoes not emit a checkpoint. That follows the spec's explicit enumeration, but that path can also mutateanswer['content']— a one-line addition if wanted.droste==0.21.1and its host side is ready but blocked on a release carrying this.🤖 Generated with Claude Code