Tool-Output Offload (Size-Tiered Tool-Output Policy)¶
On cached, tool-heavy, multi-turn sessions, a large tool result can be re-sent to the model on every react iteration and every subsequent turn. If it lands on the uncached side of the prompt-cache boundary, you pay full input price to re-prefill it each time — and the model attends over all of it on every forward pass. For a single large output (e.g. a big query result or a full export) this can dominate a turn's cost.
Tool-output offload collapses a genuinely large, settled tool result to a
small deterministic stub and lets the agent pull back exactly the slice it
needs on demand via the recall_tool_result tool. It is opt-in and
default-OFF in v8.6.0.
Status (v8.6.0): This ships the large-tier offload, default-OFF, behind a net-saving gate. Turn it on per-deployment, measure, then keep it. The default-ON flip and the medium-tier "advancing anchor" optimization are planned follow-ups.
The size-tiered policy¶
Tool results are handled by size, so you only pay the offload machinery where it actually saves money:
| Tier | Size | Handling |
|---|---|---|
| Hot | the keep_last_n most-recent results |
kept full, inline — the freshest data is never stubbed |
| Small | ≤ tool_result_compaction_size_chars (~8K) |
kept full, inline — too small to bother |
| Large | ≥ tool_result_large_offload_threshold_chars (~60K) and the gate fires |
offloaded to a stub + on-demand recall_tool_result |
A medium tier ("cache the bytes" via an advancing cache anchor) is designed but not yet shipped — see Status.
How to enable¶
from symfonic.agent import FrameworkConfig
config = FrameworkConfig(
tool_result_compaction_enabled=True, # the umbrella compaction switch
tool_result_offload_enabled=True, # turn ON large-tier offload (default False)
# optional tuning (defaults shown):
tool_result_large_offload_threshold_chars=60_000,
tool_result_offload_horizon_default=5, # expected conversation depth (turns)
tool_result_offload_recall_probability=0.5, # prior: how likely the model re-reads
tool_result_recall_mode="both", # "window" | "structural" | "both"
)
With tool_result_offload_enabled=False (the default), behavior is
byte-identical to v8.5.0 — the existing size-only compaction path runs
unchanged, and nothing is offloaded.
Config reference¶
| Field | Default | What it does |
|---|---|---|
tool_result_offload_enabled |
False |
Master switch for large-tier offload. |
tool_result_large_offload_threshold_chars |
60_000 |
Cheap pre-filter: only outputs at/above this size are even considered (the gate decides the rest). |
tool_result_offload_horizon_default |
5 |
Fixed prior for "remaining turns" — how long the savings accrue. Conservative; tune to your typical conversation depth. |
tool_result_offload_recall_probability |
0.5 |
Prior probability the model will re-read the output (priced as a recall round-trip in the gate). |
tool_result_recall_mode |
"both" |
Retrieval styles the recall tool exposes: substring window, structural (JSON-path), or both. |
tool_result_compaction_size_chars |
8000 |
Small↔Large floor (existing). |
keep_last_n |
1 |
How many most-recent results stay hot/full (existing). |
The net-saving gate (why it's safe)¶
Offload does not fire on size alone. After the pre-filter, a deterministic gate fires only when it is actually cheaper:
priced from the resolved model's rates. Concretely, for a ~321K-token result over a 5-turn horizon the gate computes a net saving (~+$8) and fires; for an 8K result it does not (the pre-filter blocks it, and even past it the recall round-trip would erase the saving). An unknown model (no pricing) never fires. This gate is what makes the feature safe to enable — it cannot trade a prefill saving for a more expensive recall loop.
Reading offloaded data: recall_tool_result¶
When a result is offloaded, the model sees a compact stub carrying a
mem_id (trm_*), a structural index (keys / item counts / line count), a
short head preview, and an instruction to call recall_tool_result. The full
body is never deleted — it stays in the ledger. The tool:
recall_tool_result(
mem_id, # from the stub
query="order-4821", # substring → returns ±window around each match
# or:
offset=0, max_chars=2000, # a byte range
# or:
json_path="items[3].price", # structural addressing (recall_mode "structural"/"both")
# no slice args → the whole body, capped
)
Retrieval is deterministic (no semantic search), so it never destabilizes the prompt cache. The structural index in the stub means the model can often answer without recalling at all (it sees row counts, columns, a preview).
When to enable it¶
Good fit: - Tool-heavy, multi-turn sessions with prompt caching on, where a large tool output (big query result, large document, full export) would otherwise ride uncached and be re-prefilled every turn.
Before enabling in production:
- Measure. It's default-OFF on purpose. Enable it, run your representative
workload, and confirm cost goes down without a quality regression (the model
must reliably recall_tool_result when it genuinely needs the full data).
- The keep_last_n hot window guarantees the freshest result is always
full, so the most-likely-needed data is never stubbed.
- Tune tool_result_offload_horizon_default to your typical conversation
depth — deeper conversations make offload more favorable.
Status & roadmap¶
- v8.6.0 (this release): large-tier offload, default-OFF, net-saving-gated; keyed recall (window + structural).
- Planned: decision-quality eval (offload on vs off) gating a default-ON
flip; the medium-tier advancing cache anchor ("cache the bytes" for
outputs the model re-reads in full) — see the design record at
.claude/docs/2026-06-17-design-size-tiered-tool-output-policy.md.
Related¶
- Lazy tooling — narrowing the tool manifest (a different cost lever; complements offload).
- Production adopter pattern.