by Martin Monperrus Tags:

The Concept

Read any coding-agent transcript a week later and you get a list of commands. ls, cat process_orders.py, python -c "…", pytest -q, git commit. What you never get is the thing you actually need: what the agent thought it was doing. The command is the residue of a decision; the decision itself is nowhere in the log. Reasoning tokens don’t fix this — they are verbose, they are not aligned with the tool calls, and increasingly they are not even shared.

The Epistemic Agent` moves the justification into the tool call itself. The shell tool has three mandatory arguments instead of one:

The epistemic vocabulary

The epistemic agent has seven types of tool calls:

The crucial rule is that the type is chosen by the agent’s epistemic situation, not by the command. pytest -q is test-hypothesis when a particular failure is expected, and verify when finished work is being confirmed. The same string, two different intentions. No static analysis of a transcript can recover that distinction — only the caller knows, and here it has to say.

The file tools (read_file, write_file, str_replace_edit) take a mandatory reason but no type: their act type is a property of the tool, not of the situation, so asking for it would only invite lying. The result is a journal with no gaps — every single tool call in a session carries a justification.

Interpretability at the action boundary

Interpretability research mostly looks inward: probing activations, attributing circuits, reading chains of thought. For an agent that acts on a real filesystem, there is a second place where interpretability is decided, and it is much closer to what we care about — the boundary where the model stops thinking and touches the world. Every consequence an agent has passes through a tool call. If that boundary carries no intent, the system is opaque exactly where it matters most, no matter how legible its internals are.

The reason field is interpretability at that boundary. It is not a commentary track alongside the action; it is a precondition of the action existing at all. The following properties follow, and none of them hold for reasoning traces:

There is a behavioural effect too, and it is the reason this is not merely logging. A closed vocabulary forces a decision before the side effect: an agent that cannot name what kind of act it is about to perform generally has not decided what it is doing. Asking for the name makes the indecision visible — to a supervisor, and sometimes to the model itself.

And because the declaration is structured, it can be checked against the command it accompanies. A collect-info call whose command contains >, rm, git commit, pip install or mv is self-contradictory: the agent declared an observation and wrote an effect. The type says the call is non-mutating, the command says otherwise, and the harness can refuse it — or demand the honest type — without understanding the task at all. The same check runs in the other direction: a revert that touches a file no earlier change-state mentioned is not a revert. Consistency checking like this is impossible on a bare command log, because there is no claim to contradict.

Missing or ill-typed metadata means the command never reaches the shell. It comes back as a tool error the model has to fix. The gate is in-process with guarantees, not a soft prompt request:

REJECTED | None            | `type` is mandatory. Re-issue the call with one of: …
REJECTED | explore         | `type` must be one of collect-info, test-hypothesis,
                             change-state, verify, setup, revert, cleanup — got 'explore'
REJECTED | collect-info    | `reason` is too terse (10 chars, need >= 20).
                             Explain the intent, not the syntax.
REJECTED | test-hypothesis | requires the `reason` to state the belief under test
                             and what would refute it
RAN      | collect-info    | {"stdout":"ok\n","stderr":"","returncode":0}

End-to-end execution

Two tasks from agent-benchmark, run end-to-end with a frontier model behind agentknit, oracle-checked:

buggy-script-fix (fix four logic bugs in a summary script) — 8 calls, oracle OK: All checks passed. The typing work is visible in the trace:

▶ bash(type='collect-info', command='pwd && ls -la && find . -maxdepth 2 -type f | sort',
       reason='Build an initial map of the repository so I can locate the
               order-processing script, inputs, and any tests before changing behavior.')
▶ read_file(path='orders.json',
       reason='Understand the input schema and representative values so fixes in
               the processor preserve the expected data interpretation.')
▶ str_replace_edit(path='process_orders.py', …)
▶ bash(type='change-state', command='python process_orders.py',
       reason='Generate the deliverable summary file now that the computation logic
               has been corrected and the script should produce the intended output.')
▶ bash(type='verify', command='python check.py',
       reason='Produce independent evidence that the fixed processor now matches the
               expected summary; any reported mismatch would refute that belief.')

Look at call 4. python process_orders.py is typed change-state, not verify — because in this task running the script is the deliverable: it writes summary.json.

dead-code-removal (find dead functions across a package, remove them, keep the tests green, report them) — 15 calls, all justified, oracle OK. The shape came out 67% collect-info, 27% change-state, 7% verify: eight reads and an AST scan before the first edit, which is exactly the profile you want on a reachability task, and now something you can read off a table rather than infer from a wall of commands.

What the epistemic metadata is good for

It catches wrong beliefs before the oracle does. In an earlier run of the same task with a weaker model — one that failed the oracle — the agent wrote, in a test-hypothesis reason: “unique_customers should be 4 (Alice, Carol, Dave, Frank)”. The docstring said distinct customer names across ALL orders, i.e. 7. The wrong belief is right there in the journal, one call before the run ended and long before check.py said expected 7, got 4. A supervisor reading reasons — not commands — could have flagged it. Commands alone say nothing.

It makes policy expressible. mutating is derived from the type, so “never let a non-human-approved change-state or revert through” is a one-line predicate over structured data. Compare with pattern-matching shell strings, which is undecidable in practice.

It makes the closed vocabulary a forcing function. A model that cannot name what kind of act it is performing usually has not decided. The test-hypothesis gate — reason must articulate a belief and what would refute it — is the sharpest one: the frontier model mostly avoids the type rather than fake a hypothesis, which is the honest outcome. Fewer things get called “tests”, and verify absorbs what was never a test to begin with.

Who this is for?

The obvious target users are teams in highly regulated environments — medical devices, avionics, automotive, banking, nuclear, public infrastructure — where the blocker to adopting coding agents is not capability but accountability. In those settings a change is not done when the tests pass; it is done when someone can demonstrate, to an auditor who was not there, why each step was taken, who authorised the mutating ones, and what was tried and abandoned. Standards like IEC 62304, DO-178C or ISO 26262 do not ask for a diff, they ask for traceability from intent to artefact. “The LLM decided to run this command” satisfies nothing.

The journal is the shape that evidence has to take. Every mutating act declares itself as mutating (so the reviewable set is a filter, not a judgement call), every act carries a contemporaneous rationale (written before the effect, not reconstructed afterwards for the auditor), and the revert entries preserve exactly the dead ends that a rework record is supposed to contain. It is append-only JSONL, so it diffs, archives, and attaches to a change request like any other controlled document. The same argument applies to two adjacent groups: security teams, who want a machine-checkable policy boundary rather than a regex over shell strings, and research on agent behaviour, which currently has to infer intent from command logs where none was ever recorded.

The session documents itself

What all this buys is that the trace and its rationale are the same artifact. Documentation normally lives one step away from the thing it describes — a commit message written after the fact, a design note that drifts, a summary the agent composes at the end from a context window that has already been compacted. Every one of those is a retelling, and retellings are optimistic: the dead ends fall out, the order gets tidied, the belief that turned out to be wrong is quietly replaced by the one that worked.

Here there is nothing to retell. The justification was a precondition of the call, so the document is not written about the session — it is the session, projected. That has a practical consequence: the report cannot be stale or selective, because no step could have happened without contributing its own line to it.

Each accepted call is appended to .epistemic/journal.jsonl:

{"seq": 5, "t": 21.4, "act": "change-state", "mutating": true,
 "command": "python process_orders.py",
 "reason": "Generate the deliverable summary file now that the computation
            logic has been corrected and the script should produce the
            intended output.",
 "ok": true, "returncode": 0, "outcome": "rc=0 — (no output)"}

…and the journal is re-rendered into RATIONALE.md after every call, not at the end. The report opens with the shape of the session, which turns out to be a surprisingly informative fingerprint:

_8 tool call(s), of which 8 shell act(s) with a declared intent._

| act type       | calls | share |
|----------------|------:|------:|
| `collect-info` |     5 |   62% |
| `change-state` |     2 |   25% |
| `verify`       |     1 |   12% |

Then a numbered narrative — reason, command, outcome, one entry per call — and a final Dead ends section listing every revert, which is the part a post-hoc reader most wants and never gets.

Limitations

A stated reason is a claim about an intention, not evidence of one. This is declared interpretability, in the same sense that a type annotation is a declared type — cheap, checkable against behaviour, occasionally a lie. What it buys is that the lie is now written down next to the action it accompanied, which is strictly better than a command with no story at all.

The reasons are self-reported, and a model can write a plausible reason for a bad command; this buys legibility, not truth. The gates are shallow by design, they are not enough to stop determined confabulation.

And it costs tokens on every single call.

Code

See https://github.com/ASSERT-KTH/cacc/blob/main/epistemic_agent.py