tito: per-turn token record persistence + abridge composition (production capture) - #158
Merged
Conversation
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>
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.
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.*.contentcut at exactly 120 chars, mid-word. Verdict from source: agentix does not truncate anywhere on the capture path.populate_*_spanstores full strings,Span.set_attributekeeps raw values,JsonlProcessorwritesSpan.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.mdand 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 serializedtokenizer.json(backend_tokenizer.to_str()); slow tokenizers the sorted-JSON vocab. Equal value ⇔ identical tokenization.chat_template_sha256hashes 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'sprompt+completion. An applied rollback whose turn never produced a line (upstream error/timeout/409) surfaces asfalseon the next recorded turn.false⇒ do not splice into one linear token stream.allow_nan=False); non-int token ids / non-finite logprobs are rejected at the sink (logged +turn_indexgap), never coerced. A closingtito.session.v1line (reason:deleted/ttl_evicted/capacity_evicted/shutdown) finalizes each file.P1 — gateway persistence + lifecycle
--record-dir(envTITO_RECORD_DIR); no record dir → in-memory behavior unchanged. vLLM logprobs are retained throughTurnHarvest(previously validated-then-discarded); both backends record sampled-token logprobs from the same forward pass. Lifecycle: harvest-then-DELETEremains the documented flow; optional--session-ttl-seconds/--max-sessionsevict 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), oneAnthropicToOpenAI(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-sessionsabove the concurrent key count).--tito-delete-on-evict(default off) reaps gateway sessions when a caller session closes.--record-dir(envABRIDGE_RECORD_DIR, either mode): per-sessionRecorderrows carrysession_id,request_id, and (tito mode)gateway_session_id— the gateway's own session id, restoring the session-level join;request_idis bound on a context var and reaches the upstream asx-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.tool_choicepasses through (auto→auto,any→required,none→none,{type:tool,name}→named function; four xfails green;disable_parallel_tool_usestays dropped, matching the reference translator). Assistant thinking history is forwarded asreasoning_content(vLLM/sglang dialect; the Anthropic cryptosignaturedropped) — 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-levelthinkingbudget, images, long tool names,metadata.user_id,top_k,stop_sequencesremain documented xfails.TRANSLATION_SPEC_SHA= one SHA-256 over the transforms module and both client modules that shape the upstream body (assistant replay, forcedstream=False, model override,upstream_params), reported onGET /_health.Review response (18 confirmed findings)
Blockers
test_unrecorded_rollback_turn_still_breaks_prefix_stability.policy/tokenizer_sha256; missingsampling; segment-source vocabulary — resolved by contract ruling: the producer is normative (schema section above / README); addedsampling, thetokenizerblock with a genuinely computedtokenizer_sha256, andthread_idpassthrough; structure stays flat; segment sources stay gateway-native. Consumers adapt.Should-fix
test_generation_time_does_not_count_as_idle_for_ttl.except OSErroronly + skipped index increment — fixed:except Exception, increment infinally(any failure leaves a detectable gap), strict-JSON validation at the sink.--tito-delete-on-evictadded; the eviction-split residual is explicitly documented (README + serve docstring).x-thread-idpassthrough into records.Nits
_writeunprotected — fixed: log-and-serve, aligned with the gateway sink policy.aclose— fixed: closed-guard drops late rows loudly.reasoning_content.session_id_fordocstring / join-key distortion in tito mode — fixed:gateway_session_idrow field + docstring caveat.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,/_healthjoint sha pin.Gates (at 13c25f2)
uv run ruff check .→All checks passed!uv run pyright→0 errors, 0 warnings, 0 informationsuv run pytest tests/ plugins/ -q→757 passed, 1 skipped, 6 deselected, 18 xfaileduv lock --check→ clean🤖 Generated with Claude Code