A Tour of the Harness · v2 · M19

Working state

What you attach, what the model is working on, and what edgar is about to send. Four stops, about half an hour.

Built M19, the first v2 code
Kept in sync by a test

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.

FileLines of codeWhat it is
context/attach.py~48@path: find, refuse, read, spill
context/working.py~81the plan, the todo list, plan mode
tools/builtin/todo.py~41the model's one way to write the list
cli/inspect.py~163context 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.