Skip to content

abridge/tito: TITO gateway — Anthropic agent → session sidecar → TITO training data#122

Closed
FatPigeorz wants to merge 11 commits into
masterfrom
abridge/gateway-tito
Closed

abridge/tito: TITO gateway — Anthropic agent → session sidecar → TITO training data#122
FatPigeorz wants to merge 11 commits into
masterfrom
abridge/gateway-tito

Conversation

@FatPigeorz

@FatPigeorz FatPigeorz commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Builds out the TITO gateway path — routing an Anthropic-style agent through a session-scoped sidecar and converting the trace into TITO (token-in/token-out) training data for Claude Code — plus the runtime/abridge reliability work underneath it.

What's included

  • abridge: SessionForward + Sidecar/Forward primitives — route /v1/... traffic into a session-scoped sidecar, with API-review consistency and boundary fixes.
  • abridge: Convert ∘ Session — compose conversion over a session so an Anthropic agent run becomes TITO training data.
  • tito: native TITO engine — plugin-ized as agentix.tito, dropping the vendored Miles dependency.
  • tito-gateway: BackendPool — multi-backend sticky routing (M3) + /healthz.
  • provider: uv SandboxProvider — runs the runtime from a uv venv, no Docker/Nix required.
  • runtime + abridge reliability refactor — checkpoint + architecture-review fixes.
  • Vendored cc_convert / tito sidecar sources and an expanded conversion fixture suite (requests/responses/streams).

Test plan

  • uv run pytest across the workspace
  • pyright clean (zero errors)
  • e2e reconnect / protocol suites pass

🤖 Generated with Claude Code

FatPigeorz and others added 10 commits June 28, 2026 16:10
Bring the two upstream repos in-tree as standalone, vendored sidecar
projects under sidecars/ — NOT uv workspace members, NOT part of the
agentix package. abridge forwards to them over localhost HTTP, so all
protocol/ML logic lives here and abridge core stays shape-blind.

- cc_convert/  Anthropic<->OpenAI translator (Rust core + axum binary + PyO3)
- tito/        TITO pretokenize + session-recording gateway (FastAPI, Miles)

Vendored as-is; refactor (cc_convert binary -> in-process code, tito
records -> /trace bridge) follows. Upstream licenses/notices preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…w fixes

Checkpoint of the in-progress single-transport reliability work together with
the fixes from the multi-agent architecture review. Verified at this state:
pyright clean + 296 core/abridge tests pass.

Architecture-review fixes folded in:
- H1  result-emit task is retained (set + done-callback) so a GC'd task can't
      drop a successful call's terminal state.
- H2  the worker's own agentix.* logs are routed off the /log capture pipe
      (propagate=False) to kill the broken-pipe feedback loop; user stdlib
      logging stays captured (ray-style, no agentix import needed).
- H3  session() always deletes the container even if aclose() raises.
- H4  upstream HTTP status is carried through the abridge tunnel (RemoteSioError
      .status_code) instead of collapsing to 502.
- M1  _ensure_sio disconnects a stale client before rebuilding (no duplicate
      /rpc socket on a reconnect gap).
- M2  abridge request_timeout is threaded into ns.request (was capped at 300s).
- M4 + doc drift: PROTOCOL.md gains resume/ack + correct test path; codec
      docstrings drop ext-types; dead AGENTIX_LOG_BUFFER refs removed; REFACTOR
      log wording fixed.
- L cleanups: drop dead make_sio ASGIApp + boot_error frame plumbing; typed
      CallCancelled terminal state (no bare CancelledError escaping Ok|Failed);
      call:error KeyError guarded; worker cancel enqueued sync; worker drain
      join bounded; proxy session teardown no longer masks the body error;
      sidecar _proc cleared / unreachable raise documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ /healthz

Architecture-review M3: the gateway now routes inference across N backends
(sglang/vLLM replicas) via BackendPool — sticky by session_id for prefix-cache
locality, report_down on a transport error, forget on session DELETE. Done
entirely in non-vendored gateway code (a MilesSessionServer subclass overriding
do_proxy + a forget middleware), so VENDORED_MILES_AUDIT stays valid — no
vendored file is touched.

- config: add backend_urls / routing_policy (validated).
- gateway: build the pool (single discovered backend by default, or the
  explicit backend_urls list); add a /healthz alias so abridge's default
  Sidecar health probe works (L10).
- tests: pool routing/wiring (test_pool_routing.py) + the pool unit tests.

13 tito tests + pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Docker/Nix)

A lightweight SandboxProvider at plugins/providers/uv that materializes a venv
with uv and launches the runtime server as a local subprocess — for dev / eval
/ CI where container isolation isn't needed.

- create(): `uv venv` + `uv pip install -e <project>` (or reuse_venv=<path>),
  then `<venv>/bin/python -m uvicorn agentix.runtime.server.app:app`; raw-socket
  health wait; delete()/get()/aclose().
- entry-point agentix.provider -> uv = agentix.provider.uv:UvProvider, so the
  string registry resolves "uv" on an editable workspace install.
- The worker imports user code only from site-packages (PYTHONPATH stripped),
  so the rollout module is pip-installed into the venv, like the Docker bundle.

Verified on the cluster: 4 tests + pyright green; drove a full agentic RL
rollout (UvProvider -> runtime -> TITO gateway -> sglang) end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecar

The TITO gateway records a trajectory only under
/sessions/{id}/v1/chat/completions, with the id assigned by the gateway at
POST /sessions. A plain Forward posts straight to {target}{path}, so it can't
reach that route. SessionForward closes the gap: it lazily creates the session
(or eagerly via open()), remembers the assigned id, and rewrites every inbound
path to {create_path}/{id}{path} — so an in-sandbox black-box agent keeps
calling an unmodified /v1/chat/completions and the whole rollout lands in one
session. Read .session_id afterward to harvest the trajectory
(GET /sessions/{id}); the session is not deleted on aclose().

- Forward gains a tiny _url_for(path) seam (no behavior change) that
  SessionForward overrides; session creation reuses Forward's httpx pool, the
  x-session-id / x-request-id stamping, and the error handling.
- Generic, not TITO-specific (create_path / session_id_field configurable);
  the vendored Miles session server is untouched, so the M3 audit stays valid.

Tests: 6 new (mock httpx + a live stdlib sidecar mounting /sessions and
/sessions/{id}/v1/chat/completions, proving the real path rewrite). Full abridge
suite 49 passed; pyright clean on the changed source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the multi-lens API design review of the abridge surface:

- `.session_id` is now unavailable until the gateway session exists — reading it
  before `open()`/the first request raises instead of returning Forward's
  throwaway uuid, so a premature harvest fails loudly rather than hitting a
  fabricated id.
- Fix the class docstring example: it referenced a non-existent `openai_env_for`
  helper; show the real harvest pattern via `.session_id` and the new
  `delete_session()`, and make the agent-wiring line runnable.
- Document the one-instance-per-rollout reuse contract (all of an instance's
  calls accumulate into one gateway session; use a fresh instance per rollout).
- Add `delete_session()` to reap the server-side session after harvesting (no-op
  if none; kept separate from `aclose()`/`Proxy.stop` so the default preserves
  the trajectory; resets so the next call opens a fresh session).

Tests: +2 (premature `.session_id` read raises; `delete_session` reaps + resets).
Full abridge suite 51 passed; pyright clean on the changed source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Acting on the multi-lens API design review (pre-existing / consistency findings):

- README five-minute example used `upstream_model=`, which the
  AnthropicFromOpenAIClient constructor doesn't accept (only `model=`) — fixed so
  the first runnable block doesn't TypeError.
- Surface the load-bearing ordering constraint on Proxy.start: registering the
  /abridge namespace must precede the runtime client's connect. start() now
  catches the low-level RuntimeError and re-raises in this surface's vocabulary
  ("open the proxy before any other sandbox.remote()/health()"), and the docstring
  states it.
- Give OpenAIClient an environ(handle) mirroring the Anthropic clients, with the
  OpenAI SDK's required /v1 suffix baked into OPENAI_BASE_URL so callers can't drop
  it. All three bundled clients now wire uniformly via env=client.environ(handle).
- Promote the dynamic-route seam to a typed, exported DynamicRoutes Protocol;
  _collect_handlers gates on isinstance(client, DynamicRoutes) instead of a
  duck-typed getattr, and Client documents both registration paths.
- Distinguish "couldn't reach the sidecar" (now AbridgeError 503) from a relayed
  upstream 502, so agent retry logic can tell sidecar-down from bad-gateway.
- The tunnel returns 400 for a body that is present but not a JSON object
  (array/string/unparseable) instead of silently coercing it to {}.
- Drop @runtime_checkable from the empty Client marker Protocol (isinstance was
  always-true, a false validation signal); construction-time validation stays the
  single source of truth.

Tests: +2 (non-object body -> 400; OpenAIClient.environ bakes in /v1); network
error assertion updated to 503. Full abridge suite 53 passed; pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ode training)

Make protocol-conversion and session-routing two ORTHOGONAL, composable
capabilities instead of one fused class:

- Forward.handler(path) exposes a Forward/SessionForward as a plain downstream
  Handler — the composition seam (an OpenAI body in, a ClientResponse out).
- AnthropicToOpenAI(downstream, model=...) is the Convert capability and nothing
  else: it translates Anthropic /v1/messages ⇄ OpenAI chat-completions and hands
  the OpenAI body to `downstream`. Transport-blind. Compose:
    tito  = SessionForward(gateway_url, paths=["/v1/chat/completions"])  # protocol-blind
    proxy = Proxy(AnthropicToOpenAI(tito.handler(), model="qwen3-4b"))
  SessionForward never sees Anthropic; AnthropicToOpenAI never sees a session.

Fidelity for strict session backends (the hard part): a session recorder like the
TITO gateway matches each turn's resent assistant message byte-for-byte (role,
content, reasoning_content, tool_calls), but the Anthropic round-trip is lossy — it
drops reasoning_content (Qwen3 emits "\n\n" even with thinking off) and the
per-tool-call `index` sglang stamps. So AnthropicToOpenAI REMEMBERS the exact
assistant message the downstream returned, keyed by the tool-call ids that survive
the round-trip, and replays it verbatim on later turns — making history identical
from the backend's POV and immune to whatever private fields it keeps. Works for
real Claude Code (tool_use ids are stable) and keeps the converter's own concern
(translation fidelity), not the session layer's.

No OpenAI SDK dependency (raw httpx via the downstream Forward).

Verified on GPU (B30Z): a Claude-Code-shaped Anthropic agent with reasoning ON,
driving Qwen3-4B through AnthropicToOpenAI ∘ SessionForward → TITO gateway → sglang:
3 real tool calls (982), 2 records, 1432-token trajectory, 0 hard mismatch, reward
1.0. Tests: +4 (handler() seam; convert over a fake downstream; convert ∘
SessionForward end-to-end; verbatim assistant replay). Full abridge suite 57 passed;
pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Miles)

Reimplement the token-in-token-out alignment engine natively under
agentix/tito/engine and move the gateway out of sidecars/tito into the
plugins/ workspace as the agentix-tito plugin (import agentix.tito).

Engine (agentix/tito/engine), all model-agnostic except a small per-model delta:
- pretokenize: TITOTokenizer + Qwen3TITOTokenizer — the only Qwen3 difference is
  a fixed jinja template plus re-inserting the `\n` after `<|im_end|>` the model
  omits when it stops
- compare:   special-token-segment mismatch audit
- trajectory: LinearTrajectory + SessionRegistry (single-step rollback,
  from-scratch-vs-accumulated mismatch report)
- session_app: FastAPI session routes (3-phase pretokenize -> proxy -> checkpoint)
- messages / render / processing / errors

No vendored training-framework code and no sglang dependency: the engine
tokenizes with transformers + tokenizers + jinja2, isolated to this plugin so
agentix core / abridge never pull them. Verified CPU-only with a tiny in-memory
tokenizer — pyright clean, 38 tests pass, no model download or GPU.

Also:
- wire plugins/tito into the root pyright include + extraPaths
- drop the inherited standalone-repo scaffolding (docs/, plan.md, vendored-Miles
  audit, upstream pin, standalone CI, zh README) and rewrite the README + the
  sidecars/ index for the native plugin
- rename lingering miles-isms: config.as_session_args, router_timeout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@FatPigeorz

Copy link
Copy Markdown
Collaborator Author

@Meirtz could you help me?

- uv.lock: regenerate so `uv lock --check` passes (was stale vs
  pyproject changes — transformers/tokenizers/uv/etc).
- ruff: sort imports, PEP585/604 annotations, StrEnum for
  MismatchType/TITOTokenizerType, wrap over-long lines, export
  SessionForward in agentix.bridge.__all__.
- ruff config: exclude vendored `sidecars/` (own upstream repos/CI,
  mirrors the pyright exclude).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Meirtz

Meirtz commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Review — verified against the PR head (throwaway worktree; abridge suite 57/57 pass)

This is a big, capable PR that delivers work the abridge ARCHITECTURE marks as planned (the TITO gateway path), and the core simplification (single SIO transport, to_thread for sync fns, bounded shutdown drains, FrameTooLarge recovery) is a genuine improvement. But it mixes a core-runtime rewrite, a vendored Rust crate, and three new components in one 255-file unit with zero prior review, and the review surfaced several confirmed correctness bugs plus contract-hygiene issues. Findings below all survived an adversarial refutation pass; I'm noting the verdict where it shifted.

🔴 Core runtime — confirmed correctness bugs

  • stderr capture pipe can deadlock the worker (rated up to blocker on verify). When an async remote fn writes >64 KiB to stdout/stderr without the capture task draining, the pipe buffer fills and the worker blocks. Concrete repro: a remote target that prints a few hundred KB and returns — the write blocks before the return is emitted. Needs a continuously-draining reader (or non-blocking fds).
  • Codec ext-type removal silently drops /trace and plugin frames carrying numpy/pydantic values. With _encode_ext/_decode_ext gone, any span attr or plugin payload holding an ndarray or BaseModel raises worker-side (frame dropped) — no coercion fallback. This also collides head-on with Harden sandbox return boundary by replacing host-side pickle deserialization #118 (see below).
  • resume race can return a terminal ResultUnavailable for a call that in fact just completed successfully, losing the result across a reconnect.
  • Call submission fire-and-forget turns a lost call emit into a spurious errorrefuted on verify (not a real finding; disregard).

🔴 New components — confirmed

  • BackendPool never recovers a down backend: report_up has no production caller, so a backend marked down stays down for the process lifetime.
  • UvProvider never drains the runtime server's stdout pipe after the health check → same pipe-buffer deadlock class as above.
  • Gateway chat route doesn't force stream=false: a stream:true request yields an unhandled 500. Sessions/sticky pins are also only freed by explicit DELETE (unbounded growth for abandoned rollouts).

🟡 Contract hygiene (arch-alignment — all confirmed, not refuted)

  • Deletes root ARCHITECTURE.md and ROADMAP.md — the normative "preserve that surface" docs — with no replacement, and README.md still links to both (dangling). If they've moved, repoint; otherwise these shouldn't vanish silently.
  • /log rewritten from the documented structured LogRecord bridge (level/name-preserving, reliable) to raw stdout/stderr capture. This changes one of the documented "Three Built-In Systems": replay now flattens to INFO (sandbox errors hidden under default host config) and chunked splitting can corrupt multi-byte UTF-8 at 64 KiB boundaries. If this is the intended new contract, CLAUDE.md's "Three Built-In Systems" section needs to change with it.
  • abridge ARCHITECTURE.md/ROADMAP.md left stale — tito still drawn as "planned" though the PR ships it.
  • Vendored cc_convert gateway vs the abridge ROADMAP gate ("gateway binary adapters must be a separate change and bring a pinned source/release artifact + a required compatibility test"). The crate lands with no Rust CI and no consumer exercising its abridge integration. (Verify downgraded this from blocker to major — the crate is dual-licensed MIT OR Apache-2.0, LICENSE files present, compatible — but the ROADMAP-gate violation stands. Its vendored .github/workflows/release.yml also can't run here and points its Trusted Publisher at the upstream project.)

🟡 Tests / CI — what green CI does not prove

  • The vendored Rust crate (~9k lines incl. its own suites) is run by no CI job.
  • UvProvider (246 lines) is not type-checked — pyright include/extraPaths were updated for tito but not for plugins/providers/uv, so it escapes the zero-error gate.
  • The tito gateway HTTP surface is never exercised (every test disables the session routes or fakes the server), and the codec rewrite has no dedicated test.

🟡 abridge layer — minor only (this part is solid)

SessionForward stickiness is sound (double-checked lock, correct x-session-id). Minors: delete_session() mutates state without the session lock; _ensure_session rejects a 201 Created; the public session_id setter is silently overridden by lazy creation; Convert-over-Session leaks the forwarder's httpx pool unless closed manually.


Recommendation: the direction is good and most of this is well-built, but I'd ask to split it — the core /log+codec rewrite is a contract change that deserves its own PR and its own decision (it also conflicts with #118, which moves RPC returns to msgpack; the two can't both merge without reconciling the codec). Land the tito gateway + uv provider + vendored crate separately, behind the ROADMAP's pinned-artifact + compat-test gate, with UvProvider added to the pyright scope. Happy to help sequence this, @FatPigeorz — the stderr-deadlock and BackendPool-recovery bugs are the two I'd fix first regardless of how it's split.

@Meirtz

Meirtz commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Hi @FatPigeorz — thanks again for all the work in this PR. Following the 2026-07-01 review, we're landing it as a stack of independent PRs rather than as one 255-file merge. Each stage is ported onto a fresh branch off master, goes through CI + review on its own, and this PR closes once the stack lands. Your commits are credited via Co-authored-by on each ported slice.

Please don't push further work onto abridge/gateway-tito — it won't be merged directly, and new commits there will drift from the stack. New work is welcome as follow-ups on the per-stage branches below (or fresh branches off master).

The stage map and file ownership:

Stage Scope Status
A abridge SessionForward + AnthropicToOpenAI + DynamicRoutes (plugins/abridge/, small agentix/sio.py status-code hunk) #133 — up now
B plugins/tito gateway (self-contained workspace member; fixes for report_up dead code, stream:true 500, unbounded session pins; real HTTP-surface tests) next
C plugins/providers/uv UvProvider (+ stdout-drain deadlock fix) queued
D core runtime reliability: stderr >64KiB pipe deadlock, resume race, bounded shutdown drain (no wire-contract changes) queued
E codec/log contract: restricted-Unpickler allowlist (#116; pickle returns stay — #118 closed), /log rewrite decision after D
F sidecars/cc_convert deferred — no in-tree consumer yet; blocked on the abridge ROADMAP gate (pinned artifact + compat test + Rust CI). Recommend consuming a pinned release rather than vendoring

Decisions already settled during review (to save you re-litigating): serialization keeps pickle with a restricted-unpickler allowlist (#116); the msgpack-returns direction was evaluated and closed (#118); anthropic_from_openai.py keeps master's version (it carries #124's aclose work — stage A's AnthropicToOpenAI composes alongside it rather than replacing it).

If you'd like to own any of stages B–E, comment here and we'll coordinate — otherwise I'll keep porting them in order.

Meirtz added a commit that referenced this pull request Jul 2, 2026
…A) (#133)

Port the abridge SessionForward/AnthropicToOpenAI work from
origin/abridge/gateway-tito onto master as the first slice of the #122
split:

- SessionForward: forward to session-scoped sidecars (create-on-first-
  request, path rewrite to /sessions/{id}{path}, delete_session reap).
- AnthropicToOpenAI: Anthropic Messages agent over any OpenAI-compatible
  downstream Handler — protocol translation only, transport-blind, with
  verbatim assistant replay so session recorders byte-match history.
- Forward.handler(): the composition seam — a bound Handler a wrapper
  can sit on without knowing the transport.
- proxy: typed DynamicRoutes protocol for construction-time routes;
  tunnel 400s on non-object JSON bodies instead of coercing to {}.
- sio: RemoteSioError carries the error envelope's status_code, so the
  tunnel replies with the handler's real status (429/400/503) instead
  of collapsing every remote failure to 502. (The source branch had
  this producer half; the port initially took only the consumer.)

Review fixes on top of the port (from the pre-PR adversarial review):

- _ensure_session accepts any 2xx create response, not just 200.
- session_id setter now attaches: assigning an id marks the session
  ready so the first request doesn't create a fresh session and
  silently overwrite the assigned id.
- Forward.handler() returns a closeable handler and AnthropicToOpenAI
  delegates aclose() to it, so Proxy.stop reaps the forwarder's httpx
  pool through the converter composition.
- Proxy.session() detects a body failure by catching it, not by probing
  sys.exc_info() — teardown errors are no longer swallowed for callers
  that open the session inside an unrelated except block.
- delete_session() no longer resurrects a closed pool for one DELETE;
  it uses a one-shot client, so the documented stop → harvest → reap
  flow leaks nothing.
- Assistant replay memory keys on (id, name, canonical args) per tool
  call, not ids alone — servers that reuse tool-call ids across turns
  (TGI's literal "0") no longer corrupt the replayed history.

Co-authored-by: FatPigeorz <wjhhhhhha0@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Meirtz added a commit that referenced this pull request Jul 2, 2026
…134)

Port plugins/tito from origin/abridge/gateway-tito onto master as the
second slice of the #122 split: a self-contained workspace member
providing the TITO gateway — a FastAPI session server that keeps a
token-aligned trajectory per session and proxies chat completions
across a pool of OpenAI-compatible sglang replicas, with a native
engine (incremental pretokenization, append-only message validation,
single-step rollback, mismatch reporting). No vendored Miles; imports
nothing from agentix core beyond the namespace.

Planned fixes on top of the port:

- BackendPool: a downed replica now recovers — report_up is wired into
  the proxy success path, and a down mark expires after down_cooldown
  seconds (half-open) so a replica that is never picked isn't
  blacklisted forever.
- BackendPool: sticky session pins are LRU-bounded (max_pins) — the
  recommended rollout flow never DELETEs its session (the trajectory
  must survive for harvest), so the pin map grew without bound.
- chat route: force stream=false upstream (drop stream_options) — the
  TITO flow needs the full JSON completion; a stream:true agent
  previously got a 500 from json-parsing the SSE bytes.

Fixes from the pre-PR adversarial review:

- chat route: accept tool-call-only assistant turns with content:null
  (sglang/vLLM emit them routinely) — previously the first tool call
  of an agentic rollout 502'd.
- trust_remote_code is now an explicit opt-in (config field +
  --trust-remote-code), never a hardcoded True: loading a tokenizer
  executes Python shipped inside the checkpoint repo.
- trajectory: keep only the two reachable checkpoints (single-step
  rollback) instead of every full prefix+completion token list —
  O(turns^2) dead memory per session.
- chat route: malformed request JSON is a 400; structurally malformed
  200 upstream bodies are a clean 502 (not an unhandled 500).
- proxy responses pass the upstream body through verbatim — no JSON
  re-encode (which 500'd on NaN/Infinity and perturbed byte-sensitive
  bodies).
- prefix-mismatch diagnostic no longer raises a bare ValueError when
  the new sequence is shorter than the stored checkpoint.
- CLI: --backend-url is repeatable (multi-replica pool) and
  --routing-policy is exposed — the pool was Python-API-only.
- packaging: requires-python >=3.11 (StrEnum), drop unused
  setproctitle dep, sglang-specific docs corrected.

- concurrency (from the concurrency review pass): a retry chain that
  outruns the trimmed checkpoint window now re-renders from scratch
  instead of merging onto an empty prefix (which silently dropped the
  whole stored history and recorded a corrupt trajectory); rollback is
  planned+validated before any mutation, so a rejected (400) request
  no longer commits truncation side effects; the phase-3 interference
  guard uses a monotonic version counter (num_assistant was ABA-prone);
  DELETE sets `closing` inside the lock (a cancelled DELETE no longer
  wedges the session); the request body is read before taking the
  session lock (a dribbling upload no longer blocks DELETE); the
  forget-on-delete middleware matches only the exact session resource.

Tests: real HTTP-surface coverage (httpx ASGITransport over the actual
FastAPI app: session flow, forced non-streaming, tool-call null
content, failover + cooldown recovery, error shapes, verbatim
passthrough) on top of the ported unit tests. plugins/tito wired into
the uv workspace + pyright include/extraPaths.

Co-authored-by: FatPigeorz <wjhhhhhha0@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Meirtz added a commit that referenced this pull request Jul 2, 2026
Cherry-pick of efedacf from origin/abridge/gateway-tito (+ its ruff
import-order hunk from 90f970a): a lightweight SandboxProvider at
plugins/providers/uv that materializes a venv with uv and launches the
runtime server as a local subprocess — for dev / eval / CI where
container isolation isn't needed. Registered as entry point 'uv';
wired into pyright include/extraPaths (workspace membership via the
existing plugins/providers/* glob).

Fixes on top of the port (planned + adversarial review):

- stdout drain: the runtime's merged stdout/stderr is drained for the
  sandbox's lifetime into a bounded tail (8x64KiB). Previously nothing
  read the pipe after health — asyncio flow control pauses at ~192KiB
  buffered, so a server that logged past that blocked mid-write and
  wedged every in-flight rollout. The tail also feeds the
  exited-before-health diagnostic (was a one-shot read).
- that diagnostic path uses asyncio.wait, not wait_for — wait_for
  cancels the drain on timeout and delete()'s re-await then surfaced a
  bare CancelledError instead of the RuntimeError diagnostic (repro: a
  grandchild holding the pipe open past the 2s window).
- ports are reserved in-process until the sandbox dies (same
  _inflight_ports guard as DockerProvider) — the subprocess binds the
  number seconds after allocation, so concurrent creates could collide:
  worst case two rollouts silently sharing one runtime.
- a failed venv materialization removes its mkdtemp root (a retry loop
  leaked one partial venv per attempt).
- the default uv binary resolves via uv.find_uv_bin() — the packaged
  wheel's binary is not on PATH under systemd/cron/absolute-path
  launches; a bare FileNotFoundError also now carries install guidance.
- README/docstring examples close the provider (aclose is the only
  thing that removes a materialized venv).

Tests: flood server proving the drain (pre-fix it wedges mid-write),
grandchild-held pipe diagnostic, port-collision guard, temp-root
cleanup, packaged-uv resolution off-PATH.

Co-authored-by: FatPigeorz <wjhhhhhha0@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Meirtz added a commit that referenced this pull request Jul 2, 2026
…ion fixes (PR #122 stage D) (#136)

Bucket-A slice of the origin/abridge/gateway-tito reliability work
(commit b81f2fc), ported surgically onto master with NO wire-contract
removals (the HTTP /call fast path, the /log structured bridge, and the
codec ext types all stay; those belong to later stages of the #122
split).

Server (runtime/server/sio.py):
- resume is now definitive: a cached result replays, a running call is
  left alone, and an evicted/unknown call_id gets
  call:error type=ResultUnavailable — never silence that hangs the
  host's remote() forever.
- the completed-task transition is atomic w.r.t. resume: the result is
  stored into pending_results synchronously inside the done-callback
  (same loop turn as the calls.pop), so a resume can never observe a
  completing call_id in neither map and misreport a live result as
  unavailable. (This window existed on the source branch; ported
  as-fixed.)
- result-emit tasks are strongly referenced (emit_tasks set) — a bare
  create_task in a done-callback can be GC'd before it runs, silently
  dropping a completed call's result. Applied to both the SIO path and
  the retained HTTP fast path's timeout branch.

Client (runtime/client/client.py):
- _ensure_sio tears down a disconnected stale handle before building a
  fresh one — overwriting it leaked the aiohttp session + reconnect
  task and could leave a second live /rpc socket.
- a malformed call:error with no error payload resolves to a typed
  MalformedError instead of a bare KeyError escaping remote().
- server-side cancellation now raises CallCancelled (RemoteCallError
  subclass), not a bare asyncio.CancelledError that reads as local
  task cancellation.
- new try_remote() -> Result[R] (Ok | Failed) for harnesses that
  branch on outcomes at scale; also on Sandbox.

Provider (provider/base.py):
- session() deletes the sandbox even when sandbox.aclose() raises —
  the container and its reserved port must never leak.

Worker (runtime/server/worker/process.py):
- shutdown drain is bounded (2s): a wedged outbound pipe would hang
  the queue join forever.
- cancel enqueues its Cancelled frame synchronously instead of via an
  untracked create_task that GC could drop.

Adversarial-review fixes on top of the port:
- the stale-handle teardown and close() use socketio's shutdown(), not
  disconnect() — disconnect() is a library no-op on a dropped transport
  and never stops the background reconnect task, so the leak/double-
  socket the teardown targets persisted.
- submit_http_call attaches its cache+emit callback at submission (not
  in the timeout branch), so the calls-pop/pending-store transition is
  in the same completion-callback batch on the HTTP path too; the
  HTTP-answered path self-acks its cached copy.
- the HTTP fast path is opportunistic: transport/status failures fall
  back to SIO instead of leaking raw httpx errors through remote() and
  try_remote()'s Result contract.
- docs/concepts/remote-calls.mdx + try_remote docstring updated for
  CallCancelled; client.py __all__ and test_public_exports extended.

Docs: PROTOCOL.md documents resume/ack, the no-silent-loss invariant,
and the CallCancelled/WorkerExited client mapping.

NOT ported (deliberately): the single-transport migration (POST /call
removal), reconnection-knob removal, boot_error cleanup, /log rewrite,
codec ext-type removal, and every master test the branch's merge-base
lag would have deleted (test_extract, test_swe_env, docker retry,
client options — all phantom deletions).

Co-authored-by: FatPigeorz <wjhhhhhha0@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Meirtz added a commit that referenced this pull request Jul 3, 2026
* security: restricted unpickler on the sandbox->host return boundary (#116, PR #122 stage E)

Return values travel host-ward as pickle.dumps(result) and the host
decoded them with plain pickle.loads. pickle reconstructs objects by
invoking whatever callables a stream names, so decoding a value shaped
by a less-trusted sandbox workload (a cloned repo, a generated patch, a
benchmark task) is a trust boundary: the returned object can direct
reconstruction on the host (#116).

Keep pickle as the return codec (the msgpack-returns alternative was
evaluated and closed as #118) and decode host-side through a strict
allowlist (agentix/runtime/shared/safepickle.py). find_class permits
only:
  - a reviewed set of value types whose construction has no external
    effect (stdlib data: datetime/decimal/fractions/uuid/collections/
    pathlib; builtin containers + exceptions; numpy arrays), and
  - a small set of inert reconstruction helpers (copyreg, numpy
    _reconstruct),
and refuses everything else WITHOUT importing it. A denylist was
rejected as unsound: a stream can name a C-accelerator module
(_operator vs operator), a callable produced by one reconstruction step
is invoked by the next without passing find_class (so attribute-access
helpers must never be admitted), and many ordinary constructors have
side effects. A closed allowlist of value types closes all three.

First-party types (agentix.*) are trusted by default: the framework and
its plugins are the trusted computing base that builds the bundle and
runs the sandbox, and their return types (TunnelHandle, BashResult,
agent results) are inert. This keeps the framework's own paths
(Proxy.start, bash.run, agent adapters) working without setup. A
workload's own return types are refused by default; opt in with
safepickle.allow_module(prefix) / allow_callable(module, name), or set
AGENTIX_PICKLE_TRUST=1 to trust the sandbox fully. A refusal raises
agentix.RestrictedUnpickleError.

Only the sandbox->host direction is restricted (client._unpickle_value,
both the SIO and HTTP result paths). The sandbox-side decode of
host-supplied arguments/context stays plain pickle -- the trusted
host->sandbox direction.

Tests: non-allowlisted callables (subprocess.check_output/Popen,
os.system, eval) refused; attribute-access helpers (operator/_operator
attrgetter/itemgetter/methodcaller, getattr) refused; refusal does not
import the named module; object-dtype numpy arrays gate nested globals;
first-party return types (TunnelHandle, BashResult) and permitted value
types + builtin exceptions + workload-via-allow_module round-trip; trust
bypass; end-to-end refusal over a real remote() call. PROTOCOL.md
documents the boundary; RestrictedUnpickleError exported.

Closes #116.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: design exact-type restricted unpickling

* security: add exact pickle type opt-in

* test: cover exact pickle policy boundary

* security: restrict pickle globals to exact values

* test: cover exact first-party pickle values

* test: tighten safepickle compatibility assertions

* style: format restricted unpickler

* docs: add exact-type unpickling implementation plan

* test: cover warm pickle extension cache bypass

* security: reject pickle extension opcodes

* test: make restricted decode regressions harmless

* docs: list the six exact default return types in PROTOCOL

The unpickling paragraph still described the pre-exact policy: blanket
agentix.* prefix trust and the deleted allow_module()/allow_callable()
surfaces. Name the six individually registered first-party return types
and point extension at allow_type(Class), matching safepickle.

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

* style: format public export list

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

* chore: keep agentix-ray-build skill out of the stage E diff

The Ray-cluster build notes are unrelated to the restricted unpickler;
untrack them so the stage E diff stays on-topic. The file stays on disk
as an untracked local note.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
@Meirtz

Meirtz commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

The split of this PR has fully landed. Status of each piece, with an audit of what did and didn't make it to master:

Landed (stages A–E):

In several spots master ended up a superset of the branch: pool down-marking gained cooldown + report_up recovery, tito checkpoint rollback gained a from-scratch re-render fallback, AnthropicToOpenAI replay is keyed on (id, name, canonical-args) with an aclose() lifecycle, and the protocol test suite is strictly larger (exactly-once across mixed HTTP/SIO paths, resume semantics).

Deliberately not ported:

  • the /log rewrite (the structured LogRecord bridge with ack/replay stays; the line-stream rewrite was lossier),
  • the msgpack codec ext-type removal (numpy/pydantic round-trip on side channels stays),
  • the single-transport simplification (the POST /call fast path stays, hardened),
  • the ARCHITECTURE.md / ROADMAP.md deletions.

Stage F — vendored sidecars/cc_convert — deferred; the audit supports that call:

  • Zero call sites on this branch: the agentix.bridge.sidecars.cc_convert_sidecar(...) preset advertised in sidecars/README.md was never implemented, and the branch's own examples use the in-process Python translation (AnthropicFromOpenAIClient) — same as master.
  • The vendored release.yml configures PyPI Trusted Publishing against upstream yitianlian/cc_convert; that file must not live in this repo.
  • If the Rust converter's breadth is needed later (true incremental SSE, thinking blocks, images, tool_choice, vLLM/SGLang wire quirks, exact token counting), the right shape is depending on the published cc-convert wheel behind the pure-function seam _anthropic_transforms.py already defines — not vendoring the source tree.

Follow-up candidates the audit surfaced (intent from this PR that nothing on master delivers yet):

  1. Raw stderr capture — master bridges stdlib logging, but fd-2 writers (subprocess stderr, C extensions) never reach /log.
  2. A durable in-sandbox log artifact ($AGENTIX_LOG_DIR/sandbox.log) backing the lossy live stream.
  3. Host-side msgpack ext decoding of sandbox-emitted side-channel payloads sits outside the new restricted-decode boundary — worth revisiting under Host-Side Unsafe Deserialization of Sandbox Return Values #116's threat model.
  4. Port cc_convert's ~180-case conversion fixture corpus to test the Python transforms (currently ~9 tests).

With A–E merged this PR is superseded — closing it is your call, @FatPigeorz. Thanks for the groundwork; the bulk of it is now on master.

🤖 Generated with Claude Code

@Meirtz

Meirtz commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Closing: the split has fully landed on master — stages A–E via #133#137, all follow-ups (#138#141) resolved via #142#144 (see the audit comment above for the piece-by-piece mapping). The one deferred piece (experimental cc_convert sidecar preset) remains tracked as #121. Nothing in this branch is unmerged beyond that.

@Meirtz Meirtz closed this Jul 3, 2026
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.

2 participants