Projection-only context tooling, separated from synaxi-predict.
This repository is the runtime + benchmark harness used to evaluate context projection: shrinking a coding agent's ever-growing transcript down to a small, constant-space context that a local model can actually run, without losing the agent's working memory.
It can drive two execution paths against the same projection engine:
- In-process agent loop (
benchmark.py) — the harness owns the loop and executes tools itself, against a sandbox observed through a FUSE filesystem. - Claude Code via a MITM proxy (
_proxy_server.py) — Claude Code owns the loop; a local proxy projects the coding worker subagent's requests before they reach the upstream model (local Ollama or real Claude). Interactive chat and UI requests pass through untouched — see Two agents.
pip install -e .
# Wrap the Claude Code TUI (uses your existing Claude subscription/auth).
# Launches the synaxi-chat orchestrator, which delegates coding to the
# projected synaxi-worker subagent.
synaxi-projection wrap claudeThat's it — this starts the projection proxy, routes Claude Code through it,
and launches claude as usual. Use it like normal Claude Code; only the
worker subagent's requests get projected. When you're done, exit the session
and optionally clean up:
synaxi-projection status
synaxi-projection doctor
synaxi-projection unwrap claudeSee Install and Run below for more configuration options.
On a 31-task coding suite (10×T2, 12×T3, 9×T4), driven through the same
claude -p → proxy → model path so projection is the only variable:
| metric | gemma4 + projection (local) | Haiku baseline (growing) | Haiku + projection (cache-aware) |
|---|---|---|---|
| pass rate | 20/31 (65%) | 30/31 (97%) | 31/31 (100%) |
| raw context processed | 2.38M | 19.0M | 7.44M |
| effective billed input | 2.38M | 2.90M | 1.89M |
| cache-hit ratio | 0% (no cache) | 97% | 92% |
Against a caching-enabled frontier model, projection beats the traditional
growing transcript on both raw context (2.6× less) and real billed cost
(1.53× cheaper) — while matching quality (31/31 vs 30/31). It also makes an
8B local model (gemma4) viable at all: 65% on the same suite where the raw
transcript would overrun its context and loop.
Getting the cost win required making projection cache-aware: because
projection rebuilds the message array each turn, the cache breakpoints must sit
on the byte-stable segments (system, tools, append-only world) rather than the
volatile tail. Doing so cut billed input 1.8× (3.42M → 1.89M) with no other
change. Full write-up, caveats, and reproduction steps in
docs/RESULTS.md; raw runs in
data/results/.
A coding agent's transcript grows without bound: a ~10 KB system prompt, ~60
tool schemas, and every prior tool_use / tool_result turn. A frontier model
tolerates this; a 7B local model drowns in it.
The naive fix — "just keep the last turn" — is worse than useless. If projection collapses history to a single tool pair and drops command output, the agent never sees the result of its last action and loops forever:
turn 2: ls -> "test_event_emitter.py" -> model decides to read it
turn 3: ls (again) -> "test_event_emitter.py" -> model decides to read it
turn 4: ls (again) -> ... (the read result was dropped)
Correct projection keeps the full progression of durable observations, each
re-emitted as a valid, correctly-paired tool_use / tool_result exchange,
so the model always sees what actually happened and moves forward.
All projection logic lives in synaxi_projection/projection.py — the single
source of truth shared by the benchmark loop, the proxy, and proxy.py.
The context is rebuilt from three ingredients every turn:
The WorldCache is a token-weighted store of everything the agent has durably
observed, keyed by identity:
| Observation | Key | Value |
|---|---|---|
| File read | path |
file contents |
| File written/edited | path |
new contents |
| Command run | cmd:<command> |
stdout + stderr (ls, pytest) |
generate_context() then replays each entry as a real message pair, not as
a prose summary:
user: <goal>
assistant: tool_use Bash(ls -F) (synthetic id syn_…)
user: tool_result "test_event_emitter.py"
assistant: tool_use Read(test_event_emitter.py)
user: tool_result "import pytest …"
assistant: tool_use Bash(python3 -m pytest)
user: tool_result "exit=1 … ModuleNotFoundError"
assistant: tool_use <the real most-recent action> (verbatim, Claude's id)
user: tool_result <the real most-recent result>
+ <system-reminder> (the agent's distilled operational memory)
Why native pairs instead of a <current_files> blob? Tool-calling models are
trained on the assistant(tool_use) → user(tool_result) shape. Re-emitting
observations in that exact shape keeps the model on-distribution, and because
every synthesized tool_use is immediately followed by its tool_result,
the projected payload stays a valid Anthropic Messages request for Ollama's
native endpoint.
The most recent completed tool pair is appended verbatim (keeping the upstream agent's real tool id/name) as the live stimulus the model must react to; the rest of history is the deduped world above it.
The world is bounded by a token budget. On each turn WorldCache.tick()
evicts the highest-cost entries first:
score = tokens(entry) * turns_since_last_used
Large, stale entries (an old file you haven't touched in 10 turns) are evicted
before small, recent ones (the ls output you keep relying on).
The budget is sized to the upstream model. The in-process benchmark loop, whose
model is a small local one, uses a tight token_budget = 8000. The proxy,
which fronts a large-context model (native Claude's 200 K window), defaults to a
much larger DEFAULT_WORLD_TOKEN_BUDGET (80 000, override with
SYNAXI_WORLD_TOKEN_BUDGET) — because a budget smaller than the working set
thrashes: a source file bigger than the budget is evicted the turn after it is
read and then re-read every turn, burning tokens and tripping rate limits.
Sizing the budget above the working set keeps every observed file resident.
Repeated tool failures are distilled into a short block and delivered as a
trusted <system-reminder> in the live tail, e.g.:
python: command not found→ "Usepython3(notpython) in this sandbox."- reading a directory as a file → "use
run_command(ls/find) for discovery." - the same path failing twice → "list files first, then read exact paths."
This is how the agent learns within a run without re-deriving the same lesson
every turn. It rides in a <system-reminder> — the same convention Claude Code
uses for its own in-band dynamic context — and is explicitly framed as the
agent's own distilled memory ("not user input"). That matters when the proxy
fronts a full model: an imperative appended after a tool_result reads exactly
like a prompt-injection attempt (a real worker flagged and refused it), whereas
a <system-reminder> is trusted context the model applies to itself.
- Claude Code's ~10 KB system prompt is replaced by
PROJECTION_SYSTEM, a lean, action-oriented loop contract (one tool per turn; read before edit; validate with tests; don't repeat a failing action; stop when validated). - The ~60 incoming tool schemas are stripped to
DEFAULT_KEEP_TOOLS(Read,Write,Edit,Bash, plus harness equivalents). Discovery (ls/grep/find) is funnelled throughBashso its output is cached and replayed. The model's response therefore uses tool names the upstream agent can actually execute.
generate_context(..., tools_dialect=…) selects the tool vocabulary so the same
engine serves both paths:
| Path | dialect | read tool | command tool |
|---|---|---|---|
In-process loop (benchmark) |
harness |
read_file(path) |
run_command |
| Proxy in front of Claude Code | claude |
Read(file_path) |
Bash(command) |
When the proxy fronts real Claude (native Anthropic API), projection also
places Anthropic cache_control breakpoints so its stable prefix is cached and
re-read at ~0.1× instead of re-processed every turn. The subtlety: projection
rebuilds the message array each turn, so a breakpoint on the volatile last
message caches almost nothing. project_payload(..., cache_prefix=True) instead
marks the byte-stable segments:
- the system contract block (identity + loop rules never change),
- the tool list (fixed at 4 tools),
- the last durable world observation (the world is append-only, so everything above the newest entry is identical to last turn).
Volatile <system-reminder> blocks (operational memory + runtime reminders)
are relocated to the live tail so they never bust the cached prefix. This reproduces Claude Code's own
incremental-caching shape and, in our suite, cut effective billed input 1.8×
(see docs/RESULTS.md). The Ollama path ignores
cache_control, so caching is auto-disabled there — the lean string system
prompt is used unchanged.
Projection only helps an agent that accumulates a long transcript.
Interactive chat turns, /init, autocomplete, and MCP sidecar calls don't need
it — projecting them would strip context the UI expects. So wrap claude
installs two Claude Code subagents and projects only one of them:
| Agent | Tools | Proxy treatment |
|---|---|---|
synaxi-chat |
Agent(synaxi-worker), AskUserQuestion |
passthrough — a code-free orchestrator that grills the user one question at a time (each with a recommended answer, offered as structured AskUserQuestion choices when the decision is discrete) into a self-contained, BDD-shaped brief, then delegates it to the worker |
synaxi-worker |
Read Write Edit Bash Grep Glob |
projected — the autonomous coder whose ever-growing context is what projection shrinks to constant space |
AskUserQuestiondepends on the conversation UI, so Claude Code blocks it for spawned subagents.synaxi-chatruns as the main session agent (--agent synaxi-chat), so it owns the UI and may use it; the headless worker never can.
A custom subagent's markdown body is its API system prompt, verbatim (a
documented Claude Code behaviour). So synaxi-worker's prompt carries a fixed
sentinel — WORKER_SENTINEL = "SYNAXI-PROJECTION-WORKER" — and the proxy
projects a request iff its system text contains it:
is_worker = is_worker_payload(payload) # substring match on system text
do_project = (not DISABLE_PROJECTION) and (PROJECT_ALL or is_worker)This is a deterministic stamp we control, not a heuristic that guesses from
prompt shape — so it can't drift as Claude Code's own prompts change between
versions. Every logged request records is_worker / projected in its
projection_meta, so the gate is auditable after the fact.
SYNAXI_PROJECT_ALL=1restores the old "project every request" behaviour (used to A/B a small local model with no custom agent).SYNAXI_DISABLE_PROJECTION=1forwards everything untouched (pure MITM logger).
A projected context is a lossy, reconstructed view — right for a headless
coder that only needs its durable observations, wrong for a conversation where
the user's exact phrasing matters. Splitting the roles lets each half get the
treatment it needs: the chat agent keeps full fidelity for talking to you; the
worker runs on constant-space projected context so a small or local model can
carry a long task without drowning or looping. wrap installs both agents into
the Claude Code agents dir for the session and removes them on exit (see
What wrapping does).
Projection decides how observations are compressed back into context. FUSE is the observation layer — the ground truth of what actually happened on the filesystem. The two are complementary.
FileSystemTracker (in benchmark.py) mounts the task sandbox through a
passthrough FUSE filesystem (fusepy + macFUSE). Every tool then executes
against the mount instead of the raw directory:
agent tool ──▶ FUSE mount (passthrough) ──▶ real sandbox dir
│
└── journals every read()/write()/open()/create()/…
_FusePassthroughOps records each operation as ("r"|"w", path, ts). Around a
tool call the tracker brackets the journal:
begin_tool()→ marks the current journal index (and snapshots mtimes+hash)end_tool()→collect_since(idx)returns the exact read set and write set for that single tool call
This gives per-tool, ground-truth I/O attribution — which files this command actually touched — without parsing the model's prose.
- In-process loop: after each tool runs,
_sync_world()walks the sandbox for anything whose mtime changed and refreshes theWorldCachewith the real on-disk contents. So if apytestrun rewrites a file as a side effect, the next projected context reflects what's actually on disk, not what the model thinks it wrote. FUSE additionally supplies the precise read/write sets stored alongside each turn for analysis. - Claude Code via proxy: Claude Code executes its own tools inside the FUSE
mount, so the run's real read/write set and file integrity are still captured.
The projection world, however, is rebuilt from the conversation flowing
through the proxy (that is all the proxy can see) —
build_world_from_messages()pairs everytool_usewith itstool_resultand folds them into a freshWorldCacheon each request.
FileSystemTracker(root, mode=…, require_fuse=…):
| mode | behavior |
|---|---|
auto |
try FUSE, fall back to snapshot if unavailable (default) |
fuse |
require a working FUSE mount (raises if it can't mount) |
snapshot |
no FUSE; diff by mtime + SHA-256 tree hash only |
The mount is verified at startup with read and read-write probe files, and
rejected early if it is read-only or non-propagating. --require-fuse forces
real FUSE and fails loudly rather than silently degrading to snapshot.
Setup: install macFUSE (system-wide), then
pip install fusepy. Without both, the tracker degrades tosnapshotmode.
┌──────────────────────────────────────┐
│ synaxi_projection/projection.py │
│ • WorldCache (token-weighted LRU) │
│ • ProjectionControlState (memory) │
│ • generate_context() / project_payload()
└──────────────────────────────────────┘
▲ ▲
harness dialect │ │ claude dialect
┌────────────────────────┘ └────────────────────────┐
│ │
┌───────────────────────────┐ ┌──────────────────────────────┐
│ benchmark.py (in-process) │ │ _proxy_server.py (MITM) │
│ run_task() agent loop │ │ project_payload() per request │
│ executes tools itself │ │ │
│ FileSystemTracker (FUSE) │ │ Claude Code ──▶ proxy ──▶ Ollama
│ observes the sandbox │ │ (native Anthropic API, v0.14+)│
└───────────────────────────┘ └──────────────────────────────┘
Ollama speaks the Anthropic Messages API natively (v0.14.0+), so the proxy does no format translation — it only projects, rewrites the model name, forces non-streaming for logging, injects auth, and forwards.
| File | Responsibility |
|---|---|
projection.py |
Canonical projection engine (world, memory, context, worker sentinel gate). |
benchmark.py |
In-process agent loop, FileSystemTracker (FUSE), scoring. |
_proxy_server.py |
MITM proxy: gates + projects each request, forwards, logs JSONL. |
proxy.py |
Start/stop the proxy; apply_projection() entrypoint. |
wrapper.py / cli.py |
synaxi-projection wrap claude … wrapper; installs/removes agents. |
agents/*.md |
Bundled subagents: synaxi-chat (orchestrator) + synaxi-worker (projected). |
sandbox.py |
TaskSandbox temp working dirs + pytest/script runners. |
Benchmarking & analysis (scripts/)
| File | Responsibility |
|---|---|
run_suite.py |
Full 31-task suite runner (projection / baseline / local). |
analyze_results.py |
Honest token-cost + pass-rate analysis of any run(s). |
suite_status.py |
Live progress monitor for an in-flight suite. |
pip install -e .
# optional, for real FUSE observation (plus system macFUSE):
pip install fusepyBenchmark, in-process loop (local Ollama, FUSE-observed):
synaxi-projection-benchmark \
--provider ollama --model gemma4:latest \
--projection --fs-tracker fuse --require-fuseBenchmark through Claude Code + the projection proxy:
# start the proxy pointed at Ollama
python -m synaxi_projection.proxy --upstream http://127.0.0.1:11434 --model gemma4:latest &
# run benchmark tasks through `claude -p` via the proxy, with FUSE tracking
synaxi-projection-benchmark --claude-code --model gemma4:latest --fs-tracker fuseWrap the Claude Code TUI:
# Against real Claude (uses your Claude subscription; `claude` auths itself).
# Launches the synaxi-chat orchestrator, which delegates coding to the
# projected synaxi-worker subagent.
synaxi-projection wrap claude
# Against a local Ollama model instead.
synaxi-projection wrap claude --upstream http://127.0.0.1:11434 --model gemma4:latest
synaxi-projection status
synaxi-projection doctor
synaxi-projection unwrap claudeWhat wrapping does:
- Starts the projection proxy on
http://127.0.0.1:<port>. - Writes
ANTHROPIC_BASE_URLinto.claude/settings.local.jsonso every Claude Code session (including daemon-spawned workers) routes through it. - Installs the bundled
synaxi-chatandsynaxi-workersubagents into the Claude Code agents dir (honouringCLAUDE_CONFIG_DIR), saving any same-named agents first. - Launches
claude --agent synaxi-chatwith the env var set. Only the worker's requests get projected (via the sentinel gate above). - On exit — even after a crash — restores settings, removes the installed agents (or restores the saved originals), and stops the proxy.
Flags: --agent <name> launches a different session agent (default
synaxi-chat); --no-agent launches plain Claude Code with no agent; both are
only honoured when the agent definition exists on disk. Any flag the wrapper
doesn't recognise (e.g. --dangerously-skip-permissions) is forwarded verbatim
to the claude binary.
Ensure
~/.local/binis earlier inPATHthan the original Claude install.
scripts/run_suite.py runs the whole 31-task suite through the claude -p →
proxy → model path and writes one JSONL record per task to data/runs/.
# Projection through REAL Claude (uses your Claude subscription).
# Do NOT set ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN — `claude -p` auths itself;
# native auth is auto-enabled when --upstream targets api.anthropic.com.
python scripts/run_suite.py --mode claude-code \
--upstream https://api.anthropic.com --model haiku --fs-tracker snapshot
# Baseline A/B: same path, projection DISABLED (full growing transcript).
python scripts/run_suite.py --mode claude-code --no-projection \
--upstream https://api.anthropic.com --model haiku --fs-tracker snapshot
# Local model through the proxy → Ollama.
python scripts/run_suite.py --mode claude-code \
--upstream http://127.0.0.1:11434 --model gemma4:latest --fs-tracker snapshotAnalyze / compare runs — computes the honest token cost (fresh input + cache write + cache read) that the naive counters hide:
# summarize every canonical run + regenerate the machine-readable rollup
python scripts/analyze_results.py data/results/*.jsonl --json data/results/summary.json
# per-task head-to-head between two runs
python scripts/analyze_results.py \
data/results/haiku_projection.jsonl data/results/haiku_baseline.jsonl --compareSee docs/RESULTS.md for the published numbers.
- The proxy appends every projected request + upstream response to
~/.synaxi-projection/conversations/session_<ts>.jsonl, includingprojection_meta(original vs projected system length, tool count, message count) so you can see exactly how much was compressed. - The benchmark writes a full per-run artifact to
data/conversation_history/(and…/claude_code/for the proxy path) with every turn, token counts, tool calls, and the FUSE tracker mode used.
- ✅ Projection / context-control behavior
- ✅ Harness execution + conversation history logging
- ✅ FUSE / snapshot filesystem observation
- ❌ Token optimization / compression techniques from the main Synaxi product