Everything on this page is about the difference between what a session
remembers and what it is working on. Memory is slow, confirmed and
shared between sessions. Working state is fast, unconfirmed and thrown away at the
end: the file you just attached, the plan you agreed on, the list of things still to
do. The design problem is that working state has to survive compaction without
becoming memory, and without breaking the pairing invariant that compaction protects
(ADR-0025).
flowchart TD
typed["what the human typed
the only learning source"] --> msg["the user message"]
at["@path in that line"] --> body["the file's text,
attached=True"]
body --> msg
msg --> tr["the transcript: compaction works here"]
todo["the todo tool"] --> ws["working state on the session"]
plan["/plan and plan.md"] --> ws
ws --> req["one block just above the current turn"]
tr --> req
req --> prov["what goes to the provider"]
Stage 1. What you bring in about 10 minutes
1@path
context/attach.py
Look for: TOKEN ACCEPTS Attached attach
Fifty lines, and almost all the thinking is in what they refuse. A word starting
with @ in a typed line is a file to attach. The line itself is never
rewritten — that's the point. What the human typed stays exactly as typed, because
that string is the one thing allowed to create an active fact (MEM-9). The file's
text leaves this module as a separate piece that can only ever become a
TextBlock(attached=True). The boundary is a type, not a filter.
Read _refuse before you read attach. Each of these
stops the turn with a plain sentence naming the path and saying what
@path accepts — none of them raises an exception:
- the path doesn't exist
- the path is a directory, not a file
- the path is a credential file
- the path is outside the working directory
- the file isn't text
Notice which case is missing: .edgar/config.toml attaches
like any other file, because the permission engine only asks about a control file
when a tool tries to write it. Adding a rule here that the engine doesn't have would
mean this module inventing its own policy — exactly what edgar tries never to do.
One reuse worth noticing, in the last line of attach: an oversized
attachment goes through the same spill as oversized tool output, so it
keeps its head and tail and leaves a blob path the model can read. One
mechanism, two callers.
Take with you: a feature that only adds context is
still a security boundary. The interesting code is the refusals.
2The pinned block
context/working.py
Look for: Working render place
enter leave
Working state is the plan and the todo list: fast, unconfirmed, thrown away at
the end of the session. Memory is the opposite of all three of those things, which is
why they live in different files. The whole design sits in where Working
lives: on the Session, never in session.transcript.
Compaction only ever rebuilds the transcript, so it can't touch this — the pairing
invariant (CTX-4) holds here by construction, not by careful handling. An earlier
version put a pinned message inside the transcript and taught every compaction stage
to step around it. That kind of "teach every stage to remember" is exactly where bugs
like this come from.
render builds one message. place puts it just above the
current turn, below the cache breakpoint, because it changes while the cached prefix
must not. It's the same bytes on every request until the working state itself
changes — which is why a sixty-turn session with a dozen compactions still sends an
identical list every time nothing changed.
Then read enter and leave — together, they are plan mode
in full:
enter — sets the session's current mode to read-only
and remembers what it was.
leave — puts that remembered mode back.
There is no second permission engine and no plan-mode branch inside
decide(): a write attempted in plan mode is denied by the ordinary
read-only rules, nothing special-cased. And because leave can only
restore a mode the session already had, and only a typed /go ever calls
it, nothing automated can widen policy through this file (invariant 2).
Take with you: the cheapest way to survive a
transformation is to live outside what it transforms.
Stage 2. What the model is working on about 20 minutes
3The todo tool
tools/builtin/todo.py
Look for: ITEM TodoTool run
Forty lines, one call, no verbs. There's no add, no complete, no reorder: the
model sends the whole list exactly as it should read right now, and that replaces
whatever was there before. A diff against a list the model can't see is how todo
tools usually drift out of step with the conversation. A whole list can't drift —
there's nothing to compare it against.
- The tool's category is
read, because it never touches a file
the user owns. That keeps it out of the verify gate, and keeps it callable even
in plan mode, where writing the list is the work.
- The update leaves through the event bus as
TodoUpdated, which
the status bar draws and the session log records. --resume brings
the list back with no separate save path — the event itself is the record.
Take with you: a tool with one verb has no state
machine to get wrong.
4Looking at it
cli/inspect.py
Look for: rows BREAKPOINT context_show route_explain agents_command
edgar context show prints the prompt a session would actually send:
one row per section, its token count, and the cache breakpoint drawn where it falls.
It calls no provider. And it doesn't re-implement the assembly either, which is the
part worth copying — it calls the same build and system_text
a real turn calls, so a command that lied about the prompt would have to lie by
assembling a whole different one.
The counting has a small honest trick in it. The prefix's sections are joined into
one system message, so a section's count is just the difference between two running
totals of that same join. The rows add up to the message, and the message adds up to
the prompt, whatever separator sits between sections. The test checks exactly that:
the printed rows sum to the builder's own total.
The other two commands are the same idea, pointed elsewhere:
edgar sessions compact ID — calls the loop's own
compact(force=True) on a session replayed from disk.
compact() appends one line per stage to that record, so history is
added to, never rewritten, and --resume replays straight into the
compacted view.
edgar config show --resolved — prints every effective key next to
the layer it came from, with a credential shown as ***. A key is
never in the config in the first place — only the name of the variable holding
it.
M22 added two more of the same shape:
edgar route explain [PROMPT] — calls
routing.select_model(), the same pure function a turn calls, once per
role, and prints its Selection.reason. Then it walks the rules,
saying matched or skipped and why. It contacts nothing and needs no key. It also
doesn't flatter itself: the main turn passes a bare RoutingContext(),
so a rule keyed on mode, tags or schedule
can never match for the main role — and the command shows exactly that, instead of
inventing a richer context no real turn would ever build.
edgar agents list|validate — runs agents/discovery.py's
own search, folding in an extension's agents only when the project is trusted,
exactly like a real session does. validate reports each problem with
the line the offending key sits on, so the fix is one jump away.
Take with you: an inspection command should read the
code it inspects, never a second copy of it.
Sizes
Lines of code: blank lines and comments do not count, docstrings do.
just loc prints the current totals.
| File | Lines of code | What it is |
context/attach.py | ~48 | @path: find, refuse, read, spill |
context/working.py | ~81 | the plan, the todo list, plan mode |
tools/builtin/todo.py | ~41 | the model's one way to write the list |
cli/inspect.py | ~163 | context show, sessions compact, config show, route explain, agents |
Source: just loc, which counts with
tests/support/budget.py. Rounded; the tour's test fails if a figure drifts
more than 25 lines of code from the code.