Skip to content

feat(session): session server v2 — opt-in trajectory-tree serving; v1 preserved byte-exact#1771

Open
guapisolo wants to merge 16 commits into
mainfrom
feat/session-rollback-mode
Open

feat(session): session server v2 — opt-in trajectory-tree serving; v1 preserved byte-exact#1771
guapisolo wants to merge 16 commits into
mainfrom
feat/session-rollback-mode

Conversation

@guapisolo

@guapisolo guapisolo commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Motivation

The session server needs multi-lineage trajectory support (retries, compaction with carried assistants, subagent forests). Earlier revisions of this branch replaced the linear implementation in place; that made a brand-new system look like an evolution of the old one and put the default production path at risk. This revision restructures the work so that:

  • v1 (linear trajectory) is preserved byte-exactly and stays the default. git diff against the pre-branch base is empty for every v1 module and test file.
  • The tree implementation lands as an opt-in session server v2 (--use-session-server v2), fully contained in miles/rollout/session/v2/.

Commit series (atop the stacked picks)

  1. test(session): nine byte-exact HTTP pins for the v1 retry/rollback surface, verified against the untouched tree — the default path can never move silently. Includes a characterization pin for v1's few-shot prompt-assistant anchoring quirk (out of v1's append-only + one-step-retry envelope; such flows belong to v2).
  2. fix(tito): preserve-think kwargs become family constants on every template surface ("never cut think"), plus supports_midpath_assistant_rerender marking DeepSeek-V3.2's structural exception. Spike-verified byte-identical on pure tool loops.
  3. feat(session): v2 trajectory tree — node = end of one model generation, full root→node token snapshots, pure deepest-prefix attach search, append-only commits under the session lock (MAX_NODES=1024 backstop).
  4. feat(session): session server v2 — always-branch serving (mismatches grow siblings/new roots; retry semantics moves to the samples-op picker), --session-strict-append-only single-chain guard, compaction splice with zero-inheritance-root fallback, 409 on extending truncated generations, per-leaf assembly with default pick (temporal-supersession trim, roots never trimmed, non-retry-shaped trees 422) and default merge (exactly-once completion masking, rewards keyed by response id), both replaceable via --session-sample-picker-path / --session-merge-function-path. Version dispatch is a ~10-line seam in sessions.py; the codec is parameterized so the v1 wire stays byte-identical while v2 adds reward/metadata.
  5. feat(rollout): driver-side seam — the agent's semantic layer (per-branch rewards) travels through collect_samples under v2 and comes back applied by the server-side merge; the v1 driver path (metadata overlays, {"max_seq_len": ...} collect body) is byte-equal to before, pinned.
  6. test(session): tree-merge legality invariants — an independent oracle over randomized forests (seeded fuzz banks for retry shapes, truncation, guardrail-422 shapes) plus targeted edges (chained retries, equal-length twins, fork-below-fork ownership, mid-path length turns).

Testing

  • v1: restored suites pass unmodified; A-list byte-exactness proven by empty git diff against base; new v1 pins green against the pristine tree.
  • v2: HTTP matrix (branching, strict, truncation/compaction), session-state unit matrix (49), samples-op suite (16, incl. hook lanes), tree model (18), invariants fuzz (59). Router + session + generate_utils: 326 passed.
  • chat-template suite: 1463 passed (1 pre-existing failure on upstream picks, unrelated).

Follow-ups

  • GPU e2e for v2 branching flows (session_verify_runner branching-harness variant) on 4×H200 CI.
  • v2 twins of the race-condition / pretokenized e2e suites (v1-only today).

🤖 Generated with Claude Code

https://claude.ai/code/session_01NmYsBAGK6NDt1LzjA7VSqH

guapisolo and others added 10 commits July 22, 2026 22:36
TITOTokenizer.render_messages was a one-line delegate to the module-level
apply_chat_template that only binds self.tokenizer and
**self.chat_template_kwargs; having two names for the same rendering
operation split the vocabulary between the method and the function it
wraps. Rename the method (body and signature unchanged) so the instance
method matches both the module function and the HF-native
tokenizer.apply_chat_template convention, and update all call sites.

Inside tito_tokenizer.py the module function is now referenced as
template.apply_chat_template so the same-named delegation does not read
as recursion and cannot be confused with the method or the HF method.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Streaming clients hitting POST /sessions/{id}/v1/chat/completions got a
deterministic 400 today: the session server always injects
return_meta_info=True, and sglang rejects that combination with
stream=true. TITO also needs the complete non-streaming response
(choices[0].message + meta_info.output_token_logprobs), so real
streaming passthrough cannot work either.

Instead, pop stream/stream_options in Phase 1 so the backend call and
the SessionRecord stay non-streaming, then honor the client's intent at
response time: synthesize a single chat.completion.chunk carrying the
whole message as one delta (role/content/reasoning_content/tool_calls
with index; all tool_calls ride in one chunk because some clients
mis-assemble fragmented arguments), followed by data: [DONE]. Errors
keep their real status codes as JSON since they all occur before the
SSE body is built. Adapted from NVIDIA-NeMo/ProRL-Agent-Server and
THUDM/slime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Black-box agent harnesses mostly consume chat completions as SSE, so the
session-server e2e tests should exercise the fake-streaming path by
default instead of leaving it fast-test-only.

Add miles/utils/test_utils/openai_stream_client.py, a black-box SSE
client that rebuilds the non-streaming response shape from OpenAI delta
chunks (content/reasoning_content concatenation, tool_calls merged by
index with the streaming-only index key stripped). session_verify_agent
(test_session_server_multi_role, 7 models) and session_tool_agent now
default to stream=true, with a request_kwargs stream=False opt-out.

The rebuilt assistant message must survive the next turn's TITO prefix
check (message_matches compares role/content/reasoning_content/
tool_calls); two new fast tests pin that down: a multi-turn streaming
conversation extends the session without rollback, and rebuilt
tool_calls dict-equal the record's stored assistant tool_calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Streaming clients rebuild the assistant message from the fake-stream
chunk and, per OpenAI delta semantics, drop the streaming-only tool_call
`index` key. SGLang's non-streaming wire always carries it
(ToolCall.index, int or null), so the stored assistant message never
matched its replay: every multi-role e2e tool_result request died with
HTTP 400 "rollback failed: no assistant message found in the first 2
matched messages" and stage-c-4-gpu-h200 (qwen3 / deepseekv4 /
nemotron3) timed out with zero progress (run 29881124917).

message_matches' contract is template-relevant comparison, so normalize
tool_calls before comparing: drop `index` and null-valued keys from each
call on both sides. No chat template reads them, and Jinja renders an
absent key and a null-valued key identically.

MockSGLangServer now serializes `index` like real SGLang (the tool's
position in the tools list, via ToolCallItem.tool_index) — its previous
shape is what let the fast suite miss this. Fixtures and exact-shape
expectations follow the wire shape, and the new streamed tool-call ->
tool_result continuation test reproduces the CI failure without the
template.py fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…keys

Reviewers reasonably ask why the stateful server does not instead repair
the replayed message by filling the missing tool_call index back from
stored state. Record the rationale at the decision site: matching only
answers "same history?" (stored checkpoints stay the token/record
authority, nothing downstream reads replayed keys), enrichment would
presuppose the per-call correspondence the comparison is establishing,
and it cannot handle clients that renumber index instead of dropping it.
Also spell out that index is an sglang extension absent from the OpenAI
non-streaming message shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
compute_samples_from_openai_records, its per-record helper, and truncate_samples_by_total_tokens move from the client-side generate_utils module to miles/rollout/session/samples/merge.py, unchanged: the follow-up commit makes the session server assemble samples where the records live, and server-owned logic must not live in a driver-named module (core.py importing generate_utils.sample_utils.merge_samples set the dependency-direction precedent). Their tests move alongside to tests/fast/rollout/session/test_samples.py.

Also deletes tests/e2e/sglang/utils/logprob_verify_generate.py: already unreferenced, and built on the records-collection path the follow-up commit retires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds never leave it

POST /sessions/{session_id}/samples (registered before the catch-all proxy, which would otherwise forward it to the inference backend): the owning instance runs compute -> truncate -> merge synchronously on its event loop (the lock-free get_session invariant) and replies with one safetensors payload — one named tensor per computed array field, with the scalar JSON riding as the rank-one uint8 tensor _samples_meta (safetensors.numpy.load exposes no header metadata and __metadata__ is reserved by the format), so malformed payloads fail inside safetensors' validated parser instead of hand-rolled framing checks. Only the ten COMPUTED_FIELDS cross the wire on blank templates; the driver overlays them onto deepcopies of its input sample, and an import-time guard forces every new Sample field to be classified computed-or-template. The codec lives in samples/codec.py as a black box (encode_samples_reply / decode_samples_reply / SamplesReply), depending only on Sample, NumPy, and safetensors (>=0.8.0, now an explicit dependency); deterministic assembly failures return 422 with the assertion text; empty replies carry a no_records/all_truncated discriminator preserving today's ABORTED semantics.

The old records path (GET the full dump, GiB-scale under R3 with per-turn full-prefix arrays, parsed on the driver's single interpreter) is retired from the training path. collect_samples posts once via the new http_utils.post_bytes_no_retry (post() force-decodes json/text and blind-retries; a 5xx here means the owning instance died with the records, a 422 is deterministic), raises on non-2xx with the body text, raises on timeout instead of silently ABORTing the sample (a measured data-loss bug in the records path), and attempts the session DELETE on every path so failed sessions cannot accumulate. agentic_tool_call.generate shrinks to a shell: agent call -> collect_samples -> empty_reason mapping -> metadata application in today's order.

Tests assert golden Sample field values derived from the records fixture (per-turn, merged, truncated), the wire-codec round-trip, drift-guard and malformed-payload contracts, the 422/404/empty_reason/route-order contracts, and the client's behavior deltas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…from the samples codec

The import-time guard demanded a classification edit for every new Sample
field, though most new fields are driver-side and irrelevant to the wire
(main's multi-lora adapter/reward_spec broke import of the whole session
package the day it merged, killing pytest collection at conftest). The
guard also could not force correct classification: dumping a name into
TEMPLATE_FIELDS silenced it while shipping the very stale-value bug it
existed to catch.

The wire contract is now the allowlist alone: only COMPUTED_FIELDS cross
the samples wire; every other Sample field never crosses and the driver
overlay keeps its local deepcopy's value verbatim. The load-bearing
checks stay: every computed field needs exactly one wire representation,
and _assert_overlay_template_defaults still guards overlay equivalence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ple semantics

The flag made every turn a separate full-context sample (skipping the TITO
merge), which is wrong: sample boundaries are a property of the trajectory,
not a CLI switch, and per-turn siblings share one group_index so they pollute
group baselines. The session server now always folds linear turns into one
merged sample (a new sample only starts at a genuine context discontinuity,
i.e. future compaction/subagent support), the samples-op body shrinks to
{"max_seq_len"}, and agentic_tool_call.generate always returns list[Sample] —
including the aborted path, so a batch never mixes scalar and list shapes.

Consequences: multi_turn.generate always TITO-accumulates one scalar sample;
custom RMs on the agentic path are now called in batch form with
list[Sample]; check_reward_nonzero_std flattens nested groups; agentic +
--group-rm/--partial-rollout/--recompute-logprobs-via-prefill is documented
as unsupported.
…ample]]

A dynamic-filter group mixes scalar and list elements (agentic generate
returns list[Sample] per call), which is exactly why _flatten_samples
exists; the old list[Sample] annotation misdocumented that contract.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

guapisolo and others added 6 commits July 23, 2026 18:36
Additive files only, verified green against the current tree untouched:
nine pins locking the default session server's retry semantics at the
HTTP layer — pure-drop and divergent retries roll back one assistant and
regenerate; deep and no-anchor rollbacks 400 with exact error texts and
no state mutation; failed first turn keeps empty-session accept-anything;
exact-history resend is a degenerate extension; the disallowed-role 400
keeps its rollback side effect; collect after retry yields one sample;
and the few-shot prompt-assistant anchoring quirk is pinned as a
characterization (such shapes are outside v1's append-only + one-step
retry envelope — flows that need them belong to session server v2).

These pins exist so the upcoming opt-in v2 tree server can never
silently change the default path. The dir-level conftest re-exports
test_sessions' router_env fixture so additive modules can share it
without a shadowed import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…plate surface

Two structural facts about mid-path assistant re-rendering, promoted to
the registry so every template surface agrees ("never cut think"):

- The preserve kwargs (clear_thinking / truncate_history_thinking /
  drop_thinking / preserve_thinking) become family constants on all
  {tool} rows, not just the plain-chat rows; spike-verified byte-identical
  on pure tool loops. Prompt assistants carrying reasoning (few-shot,
  compaction) now render with their think blocks preserved — that is the
  fix, not a side effect.

- supports_midpath_assistant_rerender (True on the base class) marks the
  one structural exception: DeepSeek-V3.2's sglang encoder hardcodes the
  think gate with no escape kwarg, so re-rendering an assistant that
  precedes a later user turn cannot be canonical there and consumers must
  fail loud instead of silently cutting reasoning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First piece of session server v2 (opt-in tree serving; MULTI_LINEAGE_DESIGN
v5), new package only — nothing imports it yet and v1 stays byte-identical.

A session is a forest of TrajectoryNodes; a node is the end of ONE model
generation (delta_messages = env msgs + client-carried foreign assistants +
the sampled assistant; token_ids = full root->node snapshot so every leaf is
its own assembled sequence; completion_span in snapshot coordinates; seq =
per-session logical commit order, THE ordering key; response_id = the
agent-branch <-> leaf join key). find_attach_point is a pure deepest-prefix
search (full-delta consumption gate, twin ties to latest seq); create_node
is append-only under the owning session lock with a MAX_NODES=1024 runaway
backstop. Unit matrix copied verbatim from the v5 line (18 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…server v2

The v5 multi-lineage design lands as an OPT-IN second implementation
instead of replacing v1: --use-session-server grows an optional value
(bare flag or "v1" = the untouched linear server, "v2" = tree serving),
and the whole tree stack lives in miles/rollout/session/v2/. The v1
modules stay byte-identical to their pre-branch state.

v2 package (carried from the v5 line, imports adjusted only):
- session_state: always-branch serving over the forest — deepest attach
  point, suffix becomes the branch delta, non-extensions grow siblings or
  new roots; retry semantics moves entirely to the samples-op picker.
  Branch suffixes may carry client assistants (compaction); snapshot
  splice with zero-inheritance-root fallback on canonical-prefix
  divergence. Truncated-path extension is 409 (TruncatedGenerationError,
  defined here — v1 never raises it). --session-strict-append-only is the
  single-chain guard: branch shapes fail loud with attach diagnostics.
- assembly: per-leaf fold (compute -> truncate -> fold), default pick
  (temporal-supersession retry trim; roots never trimmed; non-retry-shaped
  trees 422) and default merge (exactly-once completion masking over the
  surviving set, rewards keyed by response id, fold < agent < server
  metadata layering); both replaceable via --session-sample-picker-path /
  --session-merge-function-path (sync-only, loaded in-process).
- core: v2 twin of SessionCore reusing v1's HTTP plumbing unchanged.

Shared seams, each inert for v1 by construction:
- sessions.py dispatches registry/core on the flag and forwards the
  collect body's "metadata" (agent semantic layer) only under v2.
- codec: encode/decode parameterized by a fields tuple; v1 default keeps
  the wire byte-identical, v2 adds reward + per-sample metadata
  (COMPUTED_FIELDS_V2) with conditional overlay semantics on decode.
- arguments: flag upgrade (nargs="?"), unknown values rejected, and the
  three v2 flags require --use-session-server v2.

Tests: v2 HTTP matrix (tree pins incl. deep/root divergence branching,
strict guard, truncation + compaction), session_state unit matrix, and
the samples-op suite (golden, multi-leaf trim/masking/rewards, hook
lanes) — all against a v2-flagged server; the restored v1 suites and the
byte-exact A-list (git diff against the pre-branch base is empty) pin
that the default path did not move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nal overlay

The driver half of session server v2, gated on
args.use_session_server == "v2" so the v1 path is byte-equal to before:

- OpenAIEndpointTracer captures the samples-wire allowlist at create()
  (v1 default for direct constructions); collect_samples gains
  agent_metadata and puts it on the body as "metadata" ONLY when set —
  the v1 collect body stays exactly {"max_seq_len": ...} (pinned).
- agentic_tool_call: under v2 the agent's metadata (incl. per-branch
  rewards keyed by response id) travels through collect_samples and comes
  back applied by the server-side merge, so the driver-side overlays
  (agent_metadata onto every sample, session_metadata onto the last) are
  v1-only branches now.
- session_verify_runner re-serializes an explicit version string instead
  of silently downgrading it to the bare flag.

Pins: the untouched v1 payload/behavior tests keep passing as-is; new v2
pins cover the metadata channel, reward/metadata overlay decode, and
create()'s version-to-fields selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…z + edge matrix

The v2 legality suite, carried from the v5 line (v2 import paths + the
extended wire tuple are the only changes): an independent oracle re-derives
the picker rules and earliest-surviving-leaf ownership from the design and
checks every assembled sample for token/lockstep legality, exactly-once
training over owned completion spans, precise trim sets (guardrail 422s
must name the violator), and reward/order placement. Three seeded fuzz
banks (retry-shaped forests, max_seq_len truncation with ownership
re-derived over the surviving material, shorter-replacement shapes driving
the guardrail lane) plus targeted edges: chained retries, equal-length
twins, fork-below-fork ownership nesting, mid-path length-turn fold stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@guapisolo
guapisolo force-pushed the feat/session-rollback-mode branch from 96f91e7 to 58bc6d5 Compare July 23, 2026 19:42
@guapisolo guapisolo changed the title feat(session): always-branch trajectory tree — serving policy moves to pluggable sample assembly feat(session): session server v2 — opt-in trajectory-tree serving; v1 preserved byte-exact Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant