Skip to content

tito: per-turn token record persistence + abridge composition (production capture) - #158

Merged
Meirtz merged 5 commits into
masterfrom
feat/tito-record
Jul 23, 2026
Merged

tito: per-turn token record persistence + abridge composition (production capture)#158
Meirtz merged 5 commits into
masterfrom
feat/tito-record

Conversation

@Meirtz

@Meirtz Meirtz commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Makes the TITO gateway's token capture production-ready end to end: a byte-fidelity verdict on the span capture path (P0), durable per-turn token records + session lifecycle on the gateway (P1), and the abridge serve-mode composition that puts an Anthropic-speaking agent in front of the gateway with joinable message-level capture (P2). A follow-up adversarial review (18 confirmed findings, 5 blockers) is fully addressed in the last two commits — see "Review response" below.

P0 — byte-fidelity verdict

A downstream consumer's captured-span fixture showed every gen_ai.prompt.*.content cut at exactly 120 chars, mid-word. Verdict from source: agentix does not truncate anywhere on the capture path. populate_*_span stores full strings, Span.set_attribute keeps raw values, JsonlProcessor writes Span.export() verbatim, the SIO trace bridge forwards full attrs, and the OTel exporter sets no value-length limit (the OTel Python SDK default is unlimited). The fixture was hand-trimmed. Two deterministic tests pin the property (>2000-char prompt survives populate → processor → JSONL round-trip byte-for-byte, and the OTel export leg untruncated).

The record contract — tito.record.v1 (normative)

The gateway is the producing, normative side of this contract; the schema document lives in plugins/tito/README.md and downstream consumers adapt to it. One flat JSON line per committed turn, flushed per line, in <record_dir>/<session_id>.jsonl:

{
  "schema_version": "tito.record.v1",
  "session_id": "<gateway session id>",
  "thread_id": "<x-thread-id echo>",        // OPTIONAL: omitted when header absent
  "request_id": "<x-request-id echo | null>",
  "turn_index": 0,                           // monotonic; failed lines leave a detectable gap
  "ts": 1750000000.0,
  "model": "<chat request model | null>",
  "backend_kind": "sglang" | "vllm",
  "sampling": { "temperature": 0.6, ... },   // whitelist lifted from the request body; {} = backend defaults
  "tokenizer": { "checkpoint": "...", "tokenizer_sha256": "<64 hex>", "chat_template_sha256": "<64 hex>" },
  "prompt_token_ids": [ ... ],
  "prompt_segments": [ {"start": 0, "end": 42, "source": "prefix"}, ... ],
  "completion_token_ids": [ ... ],
  "completion_logprobs": [ ... ],            // finite floats, 1:1 with completion ids, same forward pass
  "assistant_message": { ... },
  "finish_reason": "stop" | "tool_calls" | ... | null,
  "prefix_stable": true,
  "render_skew": null | {"equal": false, "first_divergence": 17}
}

Key semantics:

  • tokenizer.tokenizer_sha256 — SHA-256 over the tokenizer definition bytes: fast tokenizers hash the complete serialized tokenizer.json (backend_tokenizer.to_str()); slow tokenizers the sorted-JSON vocab. Equal value ⇔ identical tokenization. chat_template_sha256 hashes the template actually in effect.
  • prompt_segments.source — gateway-native, exhaustive vocabulary: render (from-scratch render: first turn or prefix-window-outrun fallback), prefix (reused accumulated checkpoint), system/user/tool (per appended role), generation_prompt. Segments tile the prompt exactly — loss-mask material with no downstream re-tokenization.
  • prefix_stable — computed by the sink against the last line it actually wrote (not the in-memory checkpoint): true iff this prompt extends the previous recorded line's prompt+completion. An applied rollback whose turn never produced a line (upstream error/timeout/409) surfaces as false on the next recorded turn. false ⇒ do not splice into one linear token stream.
  • Strictness — lines are strict JSON (allow_nan=False); non-int token ids / non-finite logprobs are rejected at the sink (logged + turn_index gap), never coerced. A closing tito.session.v1 line (reason: deleted/ttl_evicted/capacity_evicted/shutdown) finalizes each file.

P1 — gateway persistence + lifecycle

--record-dir (env TITO_RECORD_DIR); no record dir → in-memory behavior unchanged. vLLM logprobs are retained through TurnHarvest (previously validated-then-discarded); both backends record sampled-token logprobs from the same forward pass. Lifecycle: harvest-then-DELETE remains the documented flow; optional --session-ttl-seconds / --max-sessions evict idle/LRU sessions — always flushing + finalizing the record file first, never touching an in-flight session, dropping the pool pin like DELETE. The idle clock starts when a turn ends, so a generation slower than the TTL never lets the agent's next request evict its own session. Interleaved turns on one session are an explicit 409 (ConcurrentSessionUpdateError) instead of a silently unrecorded response.

P2 — abridge composition + recorder alignment

  • agentix-bridge-serve --tito-url (mutually exclusive with --upstream-base-url): Anthropic shell over the token-recording gateway. Because real Anthropic agents multiplex conversations over one key (Claude Code helper calls, Task subagents, reruns) while a gateway session is one linear history, each caller session demuxes by conversation: key = canonicalized (system, first user message), one AnthropicToOpenAI(SessionForward) — one gateway session — per conversation. Documented boundaries: deep compaction keeping the opening rides the gateway's rollback/from-scratch paths (past-window rewrites are its documented 400); byte-identical openings collide; caller-LRU eviction mid-rollout splits gateway-side capture (size --max-sessions above the concurrent key count). --tito-delete-on-evict (default off) reaps gateway sessions when a caller session closes.
  • --record-dir (env ABRIDGE_RECORD_DIR, either mode): per-session Recorder rows carry session_id, request_id, and (tito mode) gateway_session_id — the gateway's own session id, restoring the session-level join; request_id is bound on a context var and reaches the upstream as x-request-id, so message rows join token records per call. Recorder is log-and-serve (capture failure never fails the agent's call) and never resurrects a closed file.
  • Translation-gap status: tool_choice passes through (autoauto, anyrequired, nonenone, {type:tool,name}→named function; four xfails green; disable_parallel_tool_use stays dropped, matching the reference translator). Assistant thinking history is forwarded as reasoning_content (vLLM/sglang dialect; the Anthropic crypto signature dropped) — including thinking-only assistant turns; the fixture xfail stays, re-documented as a deliberate dialect divergence from the LiteLLM golden (thinking_blocks), with positive contract tests. Request-level thinking budget, images, long tool names, metadata.user_id, top_k, stop_sequences remain documented xfails.
  • Translator version pin: TRANSLATION_SPEC_SHA = one SHA-256 over the transforms module and both client modules that shape the upstream body (assistant replay, forced stream=False, model override, upstream_params), reported on GET /_health.

Review response (18 confirmed findings)

Blockers

  • prefix_stable vs unrecorded rollback (repro'd) — fixed: sink-owned stability baseline = last recorded line; reviewer repro regressed as test_unrecorded_rollback_turn_still_breaks_prefix_stability.
  • Flat vs nested record shape; missing policy/tokenizer_sha256; missing sampling; segment-source vocabulary — resolved by contract ruling: the producer is normative (schema section above / README); added sampling, the tokenizer block with a genuinely computed tokenizer_sha256, and thread_id passthrough; structure stays flat; segment sources stay gateway-native. Consumers adapt.

Should-fix

  • TTL counts generation as idle (repro'd) — fixed (idle clock starts at turn end); reviewer repro regressed as test_generation_time_does_not_count_as_idle_for_ttl.
  • Sink except OSError only + skipped index increment — fixed: except Exception, increment in finally (any failure leaves a detectable gap), strict-JSON validation at the sink.
  • One-key-one-gateway-session bricks multiplexing (both lenses) — fixed by the conversation demux (+ regression test).
  • No gateway-session lifecycle closure on caller eviction--tito-delete-on-evict added; the eviction-split residual is explicitly documented (README + serve docstring).
  • Spec sha covered one module — fixed: joint hash over the three body-shaping modules.
  • thread_id never emitted — fixed: x-thread-id passthrough into records.

Nits

  • Recorder _write unprotected — fixed: log-and-serve, aligned with the gateway sink policy.
  • Recorder reopens file after aclose — fixed: closed-guard drops late rows loudly.
  • Thinking-only assistant history dropped — fixed: passes the append guard with reasoning_content.
  • session_id_for docstring / join-key distortion in tito mode — fixed: gateway_session_id row field + docstring caveat.
  • NaN / numpy scalars via default=repr+allow_nan — fixed: allow_nan=False + sink rejection of non-int ids / non-finite logprobs.

Test coverage

P0 fidelity round-trips; tito contract over the real ASGI app (record shape incl. sampling/tokenizer/thread_id, logprobs 1:1, per-line crash-safety, prefix_stable true/false incl. the unrecorded-rollback break, interleave 409, TTL/capacity eviction flush + in-flight immunity + generation-time immunity, strict-JSON gap detection, shutdown finalize, vLLM render_skew/logprobs/finish_reason); a real-Qwen3-tokenizer golden (network-marked, cache-or-skip offline) proving incremental == from-scratch token-exactly with segment boundaries decoding to role markers; abridge composition e2e over a real-HTTP fake gateway (per-conversation demux, delete-on-evict reap, identity headers, SSE replay, row↔record joins incl. gateway_session_id), Recorder id/robustness suite, tool_choice fixtures green, /_health joint sha pin.

Gates (at 13c25f2)

  • uv run ruff check .All checks passed!
  • uv run pyright0 errors, 0 warnings, 0 informations
  • uv run pytest tests/ plugins/ -q757 passed, 1 skipped, 6 deselected, 18 xfailed
  • uv lock --check → clean

🤖 Generated with Claude Code

Meirtz and others added 5 commits July 24, 2026 05:25
Spike verdict: agentix's span-population and capture paths do NOT
truncate attribute values — populate_*_span stores full strings,
Span.set_attribute keeps raw values, JsonlProcessor writes
Span.export() verbatim, and the OTel exporter applies no value-length
limit (SDK default is unlimited). A downstream consumer's captured-span
fixture showing gen_ai.prompt.*.content cut at exactly 120 chars was
hand-trimmed fixture data, not a capture defect.

Two deterministic tests pin the property: a >2000-char prompt survives
populate_anthropic_span -> in-memory processor -> JsonlProcessor
round-trip byte-for-byte, and the same length survives the
OTelTraceProcessor export leg untruncated.

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

The gateway's token truth used to live only in an unbounded in-process
dict — no durability, no TTL, and an interleaved turn was silently
dropped from the trajectory with just a warning. This makes the capture
production-grade:

- --record-dir (env TITO_RECORD_DIR): one flushed tito.record.v1 JSON
  line per committed turn in <dir>/<session_id>.jsonl — session_id,
  turn_index, request_id (x-request-id echo), model, backend_kind,
  prompt/completion token ids, completion logprobs (1:1, retained from
  the same forward pass on BOTH backends — the vLLM path previously
  validated then discarded them), prompt_segments (render/prefix/
  per-role/generation_prompt spans: loss-mask material), assistant
  message, finish_reason, tokenizer fingerprint (checkpoint +
  chat_template_sha256), prefix_stable, render_skew (vLLM per-turn
  render-vs-accumulated probe), ts. A final tito.session.v1 line lands
  when the session closes. No record dir -> behavior unchanged.

- prefix_stable is computed against the last COMMITTED checkpoint: a
  retry rollback or history rewrite is served and recorded but flagged,
  so a trainer can split or reject instead of splicing a lie.

- Interleaved turns on one session are now an explicit 409
  (ConcurrentSessionUpdateError) — silent capture loss is unacceptable.

- Optional --session-ttl-seconds / --max-sessions eviction for
  long-running gateways: record files are flushed+finalized before a
  session becomes unreachable, in-flight sessions are never evicted,
  and eviction drops the pool's sticky pin like DELETE does. The
  documented flow stays harvest-then-DELETE.

- Engine refactor to expose segment boundaries without changing render
  behavior: TITOTokenizer.appended_segments + fix_prefix (Qwen3's
  newline fixup moved there); LinearTrajectory.prepare_prompt returns
  ids + segments + stability, with prepare_pretokenized kept as a thin
  compatible wrapper.

Tests: record shape + crash-safety (flush per line), prefix_stable
true/false, 409 interleave, TTL/capacity eviction flush + in-flight
immunity, shutdown finalize, vLLM logprobs/skew/finish_reason records,
and a real-Qwen3-tokenizer golden (network-marked, cache-or-skip
offline) proving incremental == from-scratch token-exactly across a
multi-turn tool-calling session with segment boundaries decoding to the
expected role markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the direct-mode server onto the token-recording session gateway and
make the message-level capture joinable against its token records:

- serve --tito-url (env TITO_URL, mutually exclusive with
  --upstream-base-url): each caller session composes
  AnthropicToOpenAI(SessionForward(tito_url).handler(), model=...) — the
  gateway sees the OpenAI chat body, owns render/generate and the token
  record, one caller key == one gateway session. Default mode unchanged.
- serve --record-dir (env ABRIDGE_RECORD_DIR), either mode: wrap each
  session's client in Recorder(<dir>/<session_id>.jsonl).
- Recorder rows gain request_id + optional session_id. The request id is
  minted at the capture layer and bound on a context var; Forward and
  AnthropicFromOpenAIClient reuse it for the upstream x-request-id, so a
  message row and the gateway's per-turn token record share one join key
  (and orphan rows from tunnel-504 agent retries become deduplicable).
  The record file now opens lazily — a route-enumeration probe leaves no
  empty file.
- Translation contract pin: TRANSLATION_SPEC_SHA (sha256 of the
  transform module source) exported and reported on GET /_health so
  downstream data contracts can pin the exact translation in effect.
- Transform gaps closed: Anthropic tool_choice now maps to OpenAI
  (auto->auto, any->required, none->none, {type:tool,name}->named
  function); the four tool_choice xfails flip green.
  disable_parallel_tool_use stays dropped, matching the reference
  translator. Assistant thinking history is now forwarded as
  reasoning_content (the vLLM/sglang dialect our downstreams read) —
  the 35_assistant_thinking_history xfail stays, re-documented as a
  deliberate dialect divergence from the LiteLLM golden
  (thinking_blocks + crypto signature), with a positive contract test.

Tests: composition e2e over a real-HTTP fake gateway (session-per-key,
forced non-streaming, model override, identity headers, SSE replay,
record-dir row/record join), Recorder id fields + transport alignment +
lazy open, /_health sha pin, mode flag validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ility baseline, lifecycle)

tito.record.v1 is now the normative, documented contract (the README
carries the schema document; downstream consumers adapt to it). The
structure stays flat; three data additions close the review's
contract-alignment findings:

- sampling: whitelisted sampling params lifted verbatim from the chat
  request (temperature/top_p/top_k/min_p/max_tokens/... ), {} = backend
  defaults.
- tokenizer block replaces tokenizer_fingerprint: {checkpoint,
  tokenizer_sha256, chat_template_sha256}. tokenizer_sha256 is really
  computed — SHA-256 over the tokenizer DEFINITION bytes (fast
  tokenizers: the complete serialized tokenizer.json via
  backend_tokenizer.to_str(); slow: sorted-JSON vocab); method is part
  of the documented contract.
- thread_id: passthrough of the x-thread-id request header, key omitted
  when absent.
- prompt_segments keep the gateway-native source vocabulary
  {render, prefix, system, user, tool, generation_prompt}, documented
  as exhaustive.

Token-exactness blocker (reviewer repro): prefix_stable was computed
against the in-memory checkpoint, so a rollback applied in phase 1
whose turn never produced a record line (upstream non-200/timeout/409)
vanished from the JSONL — the next successful line claimed stability
while its prompt did not extend the previous LINE. The sink now owns
prefix_stable, computing it against the last line it actually wrote
(per-session last prompt+completion), so unrecorded discontinuities
surface as false. PreparedPrompt.prefix_stable remains as advisory
engine metadata with the distinction documented.

Lifecycle (reviewer repro): the idle clock now starts when a turn ENDS
(last_used refreshed in the chat handler's finally) — previously a
single generation slower than the TTL let the agent's next request
sweep its own live session away mid-rollout.

Sink strictness: append failures catch Exception (not just OSError —
e.g. UnicodeEncodeError from a lone surrogate no longer 500s a
committed turn), and the turn_index increment moved to finally so ANY
failed line leaves a detectable index gap; lines are strict JSON
(allow_nan=False) and the sink rejects non-int token ids / non-finite
logprobs (log + gap) instead of coercing them into training data.

Tests: both reviewer repros regressed (unrecorded-rollback stability
break, TTL-vs-generation-time), NaN/strictness gap detection, sampling
whitelist + thread_id passthrough, tokenizer block hashes.

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

Real Anthropic agents multiplex several logical conversations over one
API key (helper calls, Task subagents, reruns), while a TITO gateway
session is one append-only linear history — binding a key to a single
gateway session bricked the key at the first unrelated history (400,
409 under concurrency). serve --tito-url now DEMUXES per caller
session: requests are keyed by the canonicalized (system, first user
message) pair and each distinct conversation lazily gets its own
AnthropicToOpenAI(SessionForward) — its own gateway session, its own
assistant-replay memory. Documented boundaries (loud, not silent):
deep compaction that keeps the opening rides the gateway's rollback /
from-scratch paths; byte-identical openings collide; a caller key
LRU-evicted mid-rollout continues in fresh gateway sessions (capture
splits there — size --max-sessions above the concurrent key count).
New --tito-delete-on-evict (default off) reaps a caller session's
gateway sessions when it closes; default keeps them for harvest.

Join-key fidelity: the transport now publishes the session id it
stamped upstream (context var), and Recorder rows gain
gateway_session_id — the gateway's OWN session id, i.e. the session_id
in its token records — restoring the session-level join that the
caller-key hash cannot provide (session_id_for docstring carries the
tito-mode caveat).

Recorder hardening: _write is log-and-serve (any capture failure is
logged, the agent's call still succeeds — matching the gateway sink's
policy), and a straggler dispatch after aclose() drops its row loudly
instead of resurrecting the closed file handle.

Translation contract: TRANSLATION_SPEC_SHA now hashes every module
that shapes the upstream body — the pure transforms plus both client
modules (assistant replay, forced stream=False, model override,
upstream_params) — so behavior drift outside the transforms file can
no longer hide behind an unmoved pin. Thinking-only assistant history
messages (legal Anthropic shape) are no longer dropped: their
reasoning rides reasoning_content through the append guard.

Tests: multiplexed-conversations regression (subagent/helper openings
get separate gateway sessions while each conversation's turns stay in
its own), delete-on-evict reap, gateway_session_id row join,
recorder log-and-serve + closed-drop, thinking-only preservation,
joint spec-sha pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Meirtz
Meirtz merged commit 2321acc into master Jul 23, 2026
5 checks passed
@Meirtz
Meirtz deleted the feat/tito-record branch July 23, 2026 23:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant