Structured context memory
The usual way to give a model its history is one flat list of messages. That has a failure mode: read one large file and it pushes everything else toward the edge of the window, including the system prompt and the task the agent was given. The agent then forgets what it was doing, and nothing chose that outcome.
Leviath splits the window into named regions instead. Each one has its own size limit and its own rule for what to throw away first, so a big file read can only ever crowd out the region it landed in.
flowchart LR
subgraph FLAT["One flat list"]
direction TB
F1["task<br/>(oldest, first out)"]
F2["early turns"]
F3["a large file read"]
F4["recent turns"]
end
FLAT -->|"window fills"| LOST["The task falls off the end"]
subgraph REG["Named regions"]
direction TB
R1["task, pinned"]
R2["codebase, compacting"]
R3["conversation, sliding"]
end
REG -->|"window fills"| KEPT["Only the region that filled sheds;<br/>the task is untouched"]
What that looks like
A typical coding agent might divide its window like this:
| Region | Share | Kind | Holds | When it fills |
|---|---|---|---|---|
task |
12% | pinned |
The task and the ground rules | Nothing. Pinned regions are never dropped |
codebase |
20% | compacting |
Files the agent has read | Older content is summarized, not lost |
conversation |
33% | sliding_window |
The back-and-forth | Oldest turns drop off |
history |
15% | compact_history |
Summaries carried from earlier stages | Rolls forward, compacted |
| headroom | 20% | Left free for the reply |
The point is the last column. In a flat message list, all five of those compete for the same space
and the loser is whatever happens to be oldest. Here, a file dump can fill codebase completely and
task is still exactly where it was.
[context.regions]
task = { kind = "pinned", budget = "12%", seed = "task_input" }
codebase = { kind = "compacting", budget = "20%" }
conversation = { kind = "sliding_window", budget = "33%", max_items = 20 }
history = { kind = "compact_history", budget = "15%", source_region = "codebase" }The nine region kinds
| Kind | Behavior |
|---|---|
temporary |
The default when kind is omitted; recent entries, trimmed first under budget pressure. |
pinned |
Never evicted (architecture, the task). |
sliding_window |
Keeps the most recent entries; the conversation lives here. |
compacting |
Summarizes instead of evicting: file reads and tool results. |
compact_history |
Carries summaries from earlier stages forward, so a later stage skips the raw content. source_region names what it summarizes. |
clearable |
Wiped in one shot when space is needed (scratch). |
hashmap |
Keyed entries (alias hash_map); a write to a key replaces it. |
checklist |
A task list whose entries carry state. Written through todo_add / todo_done / todo_note, never evicted, and rendered open-items-first. |
custom |
Behavior defined by a Rhai script (see Rhai regions). |
An unrecognized kind is a hard parse error, not a silently ignored region. So is an
unrecognized strategy: strategy = "per-item" with a hyphen is refused, rather than leaving the
region to evict one entry at a time as if the line had not been written.
Per-kind keys
Most kinds take extra keys that only make sense for them:
[context.regions.conversation]
kind = "sliding_window"
max_items = 20 # default 10
strategy = "per_item" # per_item (default) | bulk | compact
overflow = 10 # with strategy = "bulk": how many to drop at once
compact_count = 10 # with strategy = "compact": how many to fold into a summary
[context.regions.codebase]
kind = "compacting"
budget = "20%"
compact_at = "80%" # compact once this full, see below
threshold_tokens = 30000 # a hard token ceiling, applied as well as compact_at
[context.regions.history]
kind = "compact_history"
source_region = "codebase" # which region's summaries roll forward
[context.regions.findings]
kind = "hashmap"
max_entries = 50 # a write to an existing key replaces it
[context.regions.brain]
kind = "custom"
script = "context_hooks/brain.rhai" # relative to the agent directory
persistent = false # true behaves pinned-like: never evictedTracking work with a checklist
A pinned region plus context_append gives persistence, which is the easy half. What it does not
give is state: "compute the fee table" and "compute the fee table done" are two different
strings, so nothing can count what is left and no gate can ask.
[context.regions]
todos = { kind = "checklist", budget = "3%" }The agent writes to it through tools rather than free text, so the state cannot drift from what the model believes it wrote:
| Tool | Effect |
|---|---|
todo_add(region, item) |
Adds an open item, returns its id |
todo_done(region, id) |
Ticks it off |
todo_note(region, id, note) |
Records a note without closing it |
It renders as one stable block with open items first. Sitting in the system section is what keeps it in front of the model every turn, as instruction rather than history.
An id is never reused, so a todo_done cannot land on a different item than the one it names. An id
that matches nothing is an error the model can read rather than a silent no-op.
The gate is the part that makes any of this enforceable:
[stages.implement.transitions.review]
gate = { require_no_open_items = "todos",
message = "Finish or explicitly drop the open items first." }The nudge names the items that are still open. It shares the same max_attempts budget as every
other gate, so it cannot wedge a run. A gate naming a region no stage declares, or a region that is
not a checklist, is refused by lev validate. At runtime such a gate could only ever count zero
and pass on the first attempt, which looks exactly like a stage that finished its work.
Keys every region accepts
| Key | Default | Meaning |
|---|---|---|
budget |
unset | A share of the model's context window, written as "35%" |
max_tokens |
5000 |
A token ceiling. See below for how it interacts with budget |
min_tokens |
unset | A floor for a percentage budget, so the region stays usable on a small model |
seed |
unset | What the region starts with. See below |
required |
false |
The stage re-runs rather than moving on while this region is empty |
summarizable |
true |
Set false to keep an edge transform = "compact" from paraphrasing this region. See transforms |
admission |
"evict" |
What happens when a write does not fit. "reject" refuses it instead of dropping something. See letting the agent decide what to forget |
required_message |
generated | What the model is told when a required region is empty. Supports {region} |
Resolved budget is the phrase used for the number a region actually gets, once the percentage
has been worked out against the model in front of it. A budget = "20%" region on a 200k-token
model resolves to 40,000 tokens. compact_at = "80%" then means 80% of that, so 32,000.
max_tokens behaves differently depending on whether budget is set. On its own it is a plain
ceiling. Alongside budget, it caps the resolved percentage, so the region gets whichever is
smaller.
A malformed budget or compact_at string is a hard error at load, so lev validate catches it
instead of a run failing later.
Seeding a region
seed fills a region before the first inference:
[context.regions]
task = { kind = "pinned", seed = "task_input" }
standards = { kind = "pinned", seed = "input" }
readme = { kind = "pinned", seed = { files = ["README.md"] } }
layout = { kind = "temporary", seed = { glob = "src/**/*.rs" } }
rules = { kind = "pinned", seed = { literal = "Never edit generated files." } }
env = { kind = "pinned", seed = { command = "git log --oneline -20" } }
computed = { kind = "temporary", seed = { rhai = "seeds/plan.rhai" } }
inherited = { kind = "pinned", seed = { caller = "brief" } }| Form | Fills from |
|---|---|
"task_input" |
The caller's task key, which is what lev run --task sets |
"input" |
A caller key named after this region, so --<region> on the CLI reaches it |
"<any-other-string>" |
The caller key of that name |
{ files = [...] } |
The contents of those files |
{ glob = "..." } |
Every file matching the pattern |
{ literal = "..." } |
Fixed text |
{ command = "..." } |
The stdout of a shell command |
{ rhai = "..." } |
The return value of a Rhai script |
{ caller = "..." } |
A named value passed by a parent agent |
A region literally named task gets seed = "task_input" implicitly, so older blueprints keep
working.
A seed that matches none of the forms above is ignored and the region starts empty. lev validate
reports that as region-seed-not-understood, which is worth reading before wondering why a region
came out blank. The table keys are exactly the ones in the left column, so { caller_input = "..." }
is a typo for { caller = "..." } and seeds nothing. A blueprint that seeds no region from the
task refuses a task outright rather than running without it.
Warning
A command seed runs at spawn, before the first inference and therefore before any tool-approval
prompt. Because there is nobody to ask in the moment, it must also be covered by
[safe_commands], or it does not run at all.
lev validate prints every command seed in a blueprint, lev run --no-seed-commands refuses
them for one run, and [security] allow_seed_commands = false refuses them machine-wide. Seeds
run once: a daemon restart does not replay them.
Seed paths stay in the working directory
files, glob and rhai seeds resolve against the run's working directory and may not leave it.
A path that does is refused at spawn, before anything is read.
The rule is the one read_file follows, for the same reason: the blueprint chose this path, not
you. Seeded contents land in a region the model reads on its first turn, so a path that escaped
would put whatever it named in front of the model without anything having asked you.
To read outside on purpose, declare it under [read_paths] and grant it in your config. That is
already the mechanism for "this agent is meant to read there and I agreed", and seeding answers to
it rather than having a second one of its own. A glob is checked per match, since ../*.toml cannot
be judged before it is expanded.
Scripts are stricter and have no [read_paths] escape: a stage hook, a custom-region script and an
output validator must all live inside the blueprint's own directory. A script is code the agent
ships, and there is no such thing as loading your logic from somewhere else on purpose.
Where a stage's own instructions live
A stage's system_prompt is pinned context, which is why it reads as instruction rather than
history. It goes into a region like everything else. By default that region is whichever pinned
region you declared first. That costs three things: its tokens are charged to that region's name in
the stage ledger, you cannot size or scope it, and it lands wherever
that region sits in the cacheable prefix.
Name a region for it and all three go away:
[context.regions]
stage_instructions = { kind = "pinned", budget = "3%" }The runtime writes the entering stage's prompt there, replacing the previous stage's. It is always assembled after every other pinned block, however you declared it, so the content in front of it stays byte-identical when the stage changes. That content is what a provider's prompt cache matches on. Instructions sitting in front of the shared prefix rewrite its head on every transition, which invalidates everything behind them.
Measured on a two-stage agent whose prompts are about 63 tokens each:
| Region | Without the declaration | With it |
|---|---|---|
task |
65 | 2 |
stage_instructions |
not present | 63 |
The 65 is the whole problem in one number: two tokens of task and sixty-three of somebody else's
instructions, under a heading that says task.
A blueprint that declares no such region keeps the old behaviour exactly, so this costs nothing to
ignore. The region is never hidden by a stage that omits it from its own [context.regions]: it
holds the instructions of the stage being entered, so hiding it would drop that stage's prompt.
Eviction is deterministic
When a region crosses its threshold, the runtime acts by the region's kind, never by pushing out whichever message is oldest across the whole window:
flowchart TD
W["New entry routed to a region"] --> C{"Region over<br/>threshold?"}
C -->|no| K["Keep"]
C -->|yes| T{"Region kind?"}
T -->|pinned| K2["Keep, never evicted"]
T -->|sliding_window| D["Drop oldest entries"]
T -->|compacting / compact_history| S["Summarize into a compact form"]
T -->|clearable / temporary| CL["Trimmed or cleared under budget pressure"]
Letting the agent decide what to forget
Everything above is reactive: a region crosses a threshold and the runtime makes room. That is the right default, and it has a blind spot. The runtime knows sizes; only the agent knows when it is done with something. A gather stage that fetches a spec, pulls out the three paragraphs that matter and writes them to a curated region has no further use for the raw text - but the raw text sits there until pressure happens to push it out, or, with a generous budget, until the run ends.
An agent can release an entry the moment it is spent:
context_delete { region: "sources", key: "rfc-9110" }
context_delete { region: "sources", index: 2 }
context_delete { region: "sources", oldest: 3 }Name the entry by key if it was written with one, by index as shown in context_list, or ask
for the oldest few. Releasing returns the tokens immediately.
Giving an entry a key when you write it is what makes the first form possible:
context_append { region: "sources", key: "rfc-9110", content: "<the raw spec>" }Making the agent choose
By default a full region evicts, and the agent is never told. For a region holding material the agent curated, that is the wrong trade: whichever write arrives when the region is full silently decides what was least important.
admission = "reject" hands that decision back:
[context.regions]
sources = { kind = "temporary", budget = "30%", admission = "reject" }Now a write that does not fit fails, and the agent is told the region is full and to release
something first. Nothing already in the region is lost to a write the agent did not know would
displace it. A region set this way is also exempt from the window-level eviction cascade - otherwise
reject would only change which code did the silent dropping.
This turns memory management into an explicit decision: you must choose what to forget before you can read more. It is a better failure mode than a silent omission the agent never learns about, and it is a genuinely different memory discipline from mechanical eviction - worth reaching for when the region holds findings rather than transcript.
Routing tool output
Tool output is routed to a region, so exploration lands in a persistent codebase region rather than scratch:
[stages.analyze.tool_routing]
default_region = "scratch"
max_result_tokens = 4000 # ceiling for any tool without one of its own
[stages.analyze.tool_routing.overrides]
read_file = "codebase"
# A stage that both greps and reads files needs two numbers, not one: a cap
# sized for the file read lets every grep through untouched, and one sized for
# the grep truncates every file.
[stages.analyze.tool_routing.max_result_tokens_per_tool]
read_file = 20000Both tables are keyed by tool name, and an alias matches the tool it aliases. Writing bash covers
the shell the model actually calls.
A stage may only route into a region it can see. Routing a result into a region the stage left out
of its own [context.regions] writes it where that stage cannot read it back, so lev validate
refuses the blueprint and says which region to add. The four the runtime always carries are always
valid targets: conversation, tool_results, final_output and stage_instructions.
An override entry can also carry both answers at once, which is usually what you mean when a tool needs its own region and its own ceiling:
[stages.analyze.tool_routing.overrides]
read_file = { region = "codebase", max_result_tokens = 20000 }
grep = "scratch" # just route itEither key on its own is fine: { region = "codebase" } routes without capping, and
{ max_result_tokens = 500 } caps without moving the result out of default_region. A value that
is neither a region name nor one of these tables is an error rather than a line that is quietly
skipped.
read_file also has a hard byte cap of its own, independent of any of this, and says so in the
result when it applies. Without one, a large file went into its region whole and was either
truncated or dropped as [result omitted] depending on how full the region already was. That is a
cliff rather than a limit.
Budgets travel across models
This is why budgets are written as percentages. A region sized at 20% of the window is 20% whether the model has 32k or 200k tokens, so the same blueprint keeps its shape when you switch models. Fixed token counts would need rewriting every time.
Note
Percentages are ceilings, and they may add up to more than 100%. That is deliberate: regions
rarely fill at the same time, so reserving exact shares would waste most of the window. Use
max_tokens and threshold_tokens when you need a limit that really is hard.