feat(rollout_gateway): linear-history mode via generate-time prefix h… - #119
Conversation
| @@ -0,0 +1,237 @@ | |||
| # Linear-history mode for the rollout gateway (generate-time prefix healing) | |||
There was a problem hiding this comment.
I think committing the design doc is a good idea and maybe we should do this in future PRs too.
For the long-term design tho, I had codex compare this design with the session tree design from Miles TITO v2. Its recommended long term design is as follows:
Recommended Long-Term Architecture
The strongest long-term design is to store exact token checkpoints directly in the trajectory tree instead of combining:
LinearHealer + message tree + post-hoc token reconciler
Each generated assistant node should own:
MessageNode
├── canonical messages
├── exact prompt token IDs
├── exact generated token IDs
├── generated-token logprobs
├── loss-mask ownership
└── parent checkpoint
For each incoming request:
- Match it to the deepest compatible canonical message checkpoint.
- Reuse that node's exact token sequence.
- Incrementally tokenize only the appended message suffix.
- Apply a model-specific boundary merge.
- Generate from the assembled exact-token prompt.
- Attach the new exact-token checkpoint as a child.
This gives:
- A single chain for linear rollouts.
- Natural branches for sub-agents and genuine rewrites.
- Explicit rollback or deduplication for retries.
- No tokenization-artifact forks.
- No need for post-hoc REALIGN.
- One shared identity model for messages and tokens.
This architecture resembles the newer Miles tree-based TITO session design and fits the repository's existing MessageNode tree well.
It makes good sense to me. Wondering what do you think of this? Do you think there is a path towards this long-term design with the current PR? should we incorporate this long-term vision to this design doc too?
There was a problem hiding this comment.
Yeah, the tree version of the healer would be roughly what Miles calls v2. When we have non-linear harnesses ready to test, we can implement the tree version of the healer as well. It's not a lot of complexity, but we need a good harness to test on.
8298dce to
daf1f0c
Compare
| # that same stored prefix by only the genuinely-new (non-assistant, drift-free) | ||
| # messages, so r_ext starts with r_prev by construction (identical prefix list and | ||
| # tools) -- the client's re-serialized prior turns never enter the fed tokens. | ||
| r_prev = self.renderer.render(st.prev_messages, tools=st.prev_tools, add_generation_prompt=False) |
There was a problem hiding this comment.
To get the token delta, this blog proposes to render a dummy message header + new message, instead of rendering the original messages, to increase efficiency.
feat(rollout_gateway): linear-history mode via generate-time prefix healing
Summary
Adds an opt-in
history_mode="linear"to the rollout gateway that keeps a strictly linear, append-only agent history as one training sample per session instead of letting re-tokenization drift shatter it into many forked samples. It is implemented as a smallLinearHealer(rollout_gateway/linear.py) that heals drift at generation time; the existingTrajectoryManageris unchanged and stays permanently CLEAN. Default behavior (history_mode="tree") is untouched.Problem
The gateway owns tokenization, so each turn it re-renders the whole conversation the agent replays. Chat templates are deterministic, so system/user/tool messages re-render to identical ids — but a prior assistant message rarely does: its served tokens came from generation, while on the next turn it is re-rendered from a parsed message dict (
text+tool_calls) back through the template, yielding near-identical ids that differ in whitespace, tool-call JSON spacing, or reasoning re-keying.TrajectoryManagerdetects that drift and, to stay safe for harnesses that may branch or edit history, resolves it by FORK (split the rollout into a second sample) or REALIGN (drop a turn's signal).For a strictly linear, multi-turn tool-calling agent (the target use case here — a SWE agent whose history is append-only and where cosmetic drift is out-of-distribution-insignificant) that safety is pure overhead. On a real multi-turn SWE-agent run (Qwen3-Coder-30B,
fork_threshold_tokens=0) the large majority of sessions forked into several records each, even though every rollout was linear. Each fork re-emits the shared prefix asloss_mask=0context and splits the trajectory, so it is a large, avoidable training-efficiency loss.Approach
Because the gateway controls the tokens handed to the backend, it can splice the exact ids it already served for prior turns over the client's re-rendered version before generation, so the model always generates on the canonical, drift-free context:
where
served_prefixis the canonical ids through the last generated turn,close_Nis the template's between-message glue, anddelta_tailis the genuinely-new tokens (new observation messages + generation prompt).O_{N+1}is then sampled on the canonical context,TrajectoryManagersees an append-only sequence, appends the tail, and never forks — one session, oneTraceRecord, every generated turn trained on its true multi-turn context.Linearity is decided in message space, at the same altitude
TrajectoryManagermatches replayed history: dict equality (which is order-independent), not raw token identity. The storedprev_messages(whose last element is the gateway's own parsed assistant message) must be a prefix of the incomingmessagesunder==, and the tools schema must be unchanged. The genuinely-new messages are thennew = messages[len(prev_messages):], anddelta_tailis rendered from the gateway's own stored messages, so no hand-rolled delimiter logic is needed and the client's re-serialization of prior turns never enters the fed tokens:Both renders share the literal
prev_messagesprefix and the same tools, sor_extstarts withr_prevby construction — the prior turns are re-rendered from the gateway's canonical dicts, not the client's replay. Deciding linearity at the message level (rather than by a raw token-prefix check on the client's render) is what keeps the healer in agreement withTrajectoryManager: a harness that replays a prior tool call with its arguments re-keyed (the adapter emits the wire formsort_keys=Truewhile the manager keeps the model's emission order) or its JSON re-spaced produces a message that is still==to what the gateway stored — so the manager, matching by dict equality, keeps it linear — but it renders to different tokens under an order-sensitive template. A token-prefix check would wrongly flag that cosmetic re-serialization as non-linear and reset; matching at dict equality keeps it healed and reserves the non-linear path for a genuine edit/branch. This relies only on chat templates being prefix-consistent withadd_generation_prompt=False(rendering[m0..mk]is a prefix of[m0..mk, mk+1]), which holds for turn-delimited templates such as Qwen and Llama-3.What's in this PR
rollout_gateway/linear.py—LinearHealer, the per-sid healer. Tokenizer-free and torch-free; it only calls the injectedRenderer.healreturns the ids to feed the backend;commitadvances per-sid state after a turn is actually recorded.heal/commitare split becausecommitneeds the servedoutput_ids(which only exist after generation) and must fire only for turns that are truly recorded — a failed or retried generation must not advance the canonical prefix — keeping healer state in lockstep withTrajectoryManager.rollout_gateway/gateway.py—RolloutGateway.__init__gainshistory_modeandlinear_on_nonlinear. In linear mode it builds one sharedLinearHealerand injects it into every co-mounted adapter (like the sharedTrajectoryManager), so a session's canonical state is coherent regardless of which wire protocol its turns arrive on.fork_threshold_tokensis ignored in linear mode (forking is disabled by construction) and a warning is logged if both are set.rollout_gateway/adapters/common.py—BaseAdapterheals between render andbackend.generatein_run_turn, feeds the healed ids to bothgenerateand theTurnRecord, and callscommitbeforerecord_turn. Session teardown drops per-sid healer state.rollout_gateway/__init__.py— exportsLinearHealer.tests/rollout_gateway/test_linear_healer.py— new correctness suite (below).Config surface
history_mode:"tree"(default, current behavior) |"linear". Gateway-global, plumbed likefork_threshold_tokens; linear and tree sessions do not coexist within a run.linear_on_nonlinear:"reset"(default) |"error"|"passthrough"— behavior when a turn breaks the append-only assumption.When the assumption breaks
The message-level linearity check fails when the incoming history is not an append-only extension of what the gateway stored: a dropped/edited/branched prior message (a genuine dict change, not merely a re-serialization), context compaction, a changed tools schema, or an LLM-client retry that re-issues the same prompt and produces a second generation from the same prefix. It also falls back when the closer probe cannot isolate the between-message glue (a message-type-dependent template — the
close_unresolvedcounter). Behavior is controlled bylinear_on_nonlinear:"reset"(default) — drop per-sid state and re-anchor to the current render, treating the jump as the start of a fresh linear segment. The turns before and after each stay drift-free; only the single jump turn conditions on the client's render. Correct for benign, agent-controlled jumps; increments a counter so a run that resets constantly is visible."error"— raise and fail the rollout, for runs that want a hard guarantee the assumption holds."passthrough"— stop healing this session and route the rest through the standard tree/FORK path (today's behavior) for the remainder of the session.Validation
Unit tests (
tests/rollout_gateway/test_linear_healer.py) drive the realLinearHealer+TrajectoryManagerthrough the same heal → generate → commit pipelineBaseAdapter._run_turnuses, with a template-shaped fake renderer that reproduces assistant-only drift: renderer-driven drift forks without healing and collapses to one sample with it (loss mask covering every generated turn, served logprobs preserved); multi-turn drift stays one sample; the closer probe isolates the glue for both text and tool-call turns and does not double the closer when the served output already ends with the stop token; and the threelinear_on_nonlinearpaths behave as specified. The template-level assumptions (generation-prompt tokens equal the assistant open; prefix-consistency turn to turn; the closer probe returns<|im_end|>\nfor both text and tool-call turns and is a genuine suffix of a tool-callr_prev; served ids end with the stop token) were confirmed against the live Qwen3-Coder-30B tokenizer/template.Real-trace reconstruction. We reconstructed, at the token level, the per-turn prompt streams from real recorded multi-turn SWE-agent sessions (Qwen3-Coder-30B) and replayed them through the real
TrajectoryManagertwo ways: the drifted prompts as they actually happened, and the canonical served prefix + drift-free new-observation tail that healing feeds. The raw replay reproduced the recorded fork counts exactly (fidelity check). Healing collapsed the multi-record baseline sessions to a single record each while leaving the trained (loss_mask=1) token count identical — so the whole difference is redundant re-emitted context that healing removes. Training latency scales ~linearly with the sequence length forwarded, not the record count, so the total-token reduction is the latency-relevant figure, and it is smaller than the record reduction because early forks carry short prefixes while the redundancy is dominated by late, large-context forks. A small number of sessions did not collapse to a single record; the observed cases were benign harness-side history rewrites — an LLM-client retry re-issuing the same prompt, or a harness recovering a hallucinated tool name into a different tool with restructured arguments — which the message-level linearity check correctly treats as non-linear andon_nonlinear="reset"re-anchors into separate linear segments.Observability
LinearHealertracks per turn:healed_turnsandhealed_prefix_tokens(turns healed and canonical prefix tokens spliced);nonlinear(message-level linearity failures — a genuine history edit/branch or tools change, not cosmetic re-serialization);close_unresolved(turns where the closer probe couldn't isolate the glue and fell back). A highnonlinearrate means the "linear" assumption is wrong for that harness and tree mode is the better fit — what happened to those jumps is the (known)linear_on_nonlinearmode, so there is no separate reset counter.These are surfaced two ways.
LinearHealer.countersis the run-cumulative total across every session the shared healer has seen. Per session,finish_sessionpops that session's own counters (LinearHealer.pop_stats, a fixed schema with zeros for counters that never fired) and attaches them to every record's metadata underlinear_healer, riding the existingextra_metadata→TraceRecord.metadataseam — so a downstream consumer sees each rollout's healing stats without touching the healer. The per-session counters survivereset/passthroughjumps within a session (only per-sid healing state is cleared on a jump), so the final record still reports the fullnonlineartotal for that session.Non-goals
linear_on_nonlinear. Cosmetic re-serialization that keeps the dict==is handled — the linearity check matches at dict equality — but an actual content change (e.g. a harness that recovers a hallucinated tool name into a different tool with restructured arguments) is out of scope for healing.