module

Smith::Context

Keeps the conversation transcript from growing past the provider's context window.

The invariant everything here is built around: every tool_use block must keep exactly one matching tool_result, and vice versa. Anthropic and OpenAI reject a request outright if that pairing is broken, so compaction may shorten content but must never orphan half a pair.

Constants

DEFAULT_COMPACT_AT = 0.80

Fractions of the budget: compaction acts at the trigger and compacts down to the target. Expressed as fractions rather than token counts so raising max_tokens for a wider-window model scales both.

80 rather than 85 because a single repo-wide grep is tens of thousands of tokens, and at 85 such a result lands over the budget before compaction was ever consulted. 50 rather than 60 because each compaction costs a full prompt-cache creation charge on the surviving prefix — halving how often it runs is worth more than the marginal fidelity.

DEFAULT_COMPACT_TO = 0.50
DOCUMENT_TOKENS = 5000
IMAGE_TOKENS = 1600

What an attachment is assumed to cost. Anthropic charges an image at roughly (width x height) / 750 tokens and caps it around 1600, which is the number taken here — smith does not parse image dimensions, and guessing low is the expensive mistake. A PDF is page-count-dependent and regularly worse, so it gets its own, larger figure.

MEDIA_MARKER = "removed to stay within the context window; mention it again to re-attach it"

Replaces an attachment once its turn is over. It names the file, so the model can ask for it again rather than being left with a hole it cannot see.

OLD_RESULT_BYTES = 512

What a stale tool result gets shortened to: enough to keep a call's identity, not its content. The old floor was 2 KB, which sounds modest until a long session holds three hundred of them — 150k tokens, more than the whole budget. At 512 bytes the same tail is ~37k, and as stubs ~6k.

RATIO_MAX = 2.0
RATIO_MIN = 1.0

Bounds on the calibration ratio. Below 1.0 is implausible for a bytes // 4 estimate; above 2.0 is a measurement error, not a context window.

RATIO_SMOOTHING = 0.3

How fast the calibration ratio follows what the provider reports. Low enough that a single outlier cannot put a session into permanent compaction.

RECENCY_WINDOW_TURNS = 3

How many real turns are left alone. One turn is a user message and everything the agent did about it, so three of them cover "read the file, edit it, run the tests" whole. A constant rather than config: an extra knob here buys nothing.

SUPERSEDABLE_TOOLS = ["read_file", "grep", "glob"] of ::String

Tools whose repeated call with identical arguments makes the earlier result redundant. bash is deliberately absent: running make test twice is not a duplicate, it is a before and an after, and collapsing the earlier one deletes exactly the comparison the model is making. The same goes for anything that observes changing state.

SUPERSEDED_MARKER = "superseded by a later identical call"
TRUNCATION_MARKER = "truncated by smith to stay within the context window"

Both the writer of a shortened result and whatever later has to recognise one need to agree on these, so they are named rather than inlined.

Class methods

compact(messages : Array(LLM::Message), budget : Budget, &summarize : Array(LLM::Message) -> String) : Result

Brings messages down to budget's target if it can, in escalating stages — and does nothing at all below the trigger.

The trigger and the target being different numbers is the whole design: compacting to the first point that fits means the next assistant turn pushes the history straight back over, and compaction runs again.

summarize receives the prefix being dropped and returns a replacement text. It is a callback rather than a provider so this whole module stays testable without a network.

Source
estimate_text_tokens(text : String) : Int32

The same heuristic applied to a plain string, so a breakdown of the context and the compaction decision cannot drift apart.

Source
estimate_tokens(messages : Array(LLM::Message)) : Int32

Rough heuristic, not an exact count. The point is to react before the limit, not to spend the last token of it — and Budget#ratio corrects the systematic part of the error against what the provider reports back.

signature is counted because Anthropic gets it back byte for byte (see LLM::Anthropic), which makes it part of the request whether or not anyone reads it.

Source
safe_cut_index(messages : Array(LLM::Message), target : Int32) : Int32

The lowest index of a User message such that everything from there on fits target. User messages only ever start a turn (Agent#send), so cutting there can never orphan a tool pair. Returns 0 when no such cut exists — the caller then leaves the history alone rather than breaking it.

Source

Nested types