A skill in edgar is a Markdown file that says how to do a kind of
job. When a future session starts, every skill's one-line description goes into the
prompt, and the model can open the one it needs. So a skill is not a note. It is an
instruction the agent will follow, possibly for months, possibly without anyone
rereading it.
That is what makes writing them automatically dangerous. The run that produces the
skill is a run that read files, ran commands and fetched pages. If any of that text
could reach the writer, then anything written on a web page could end up as a standing
instruction to your agent. That is not a hypothetical: it is the whole point of a
prompt injection.
Five refusals stand between a run and a skill, and this page is a walk through
them:
- Arithmetic decides when. Four plain checks, no model asked
whether to ask a model.
- The writer sees an outline, not the run. There is no field
anywhere that can carry a tool's output.
- The body has a fixed shape. Four headings, or it is refused.
- One folder, machine-owned. Nothing edgar writes ever lands
beside a file a person wrote.
- A human reads it first, unless you switched that off on
purpose, and then edgar says so every single session.
flowchart TD
fin["a turn ends"] --> arith["triggers(): four checks"]
arith -->|"none fired"| quiet["nothing happens
no call, no cost"]
arith -->|"one fired"| out["Outline.of(turn)
names, counts, typed text"]
out --> ask["one model call
no tools at all"]
ask --> wall["write_learned()"]
wall -->|"wrong shape, or a
human's name"| no["refused, in a sentence"]
wall -->|"propose (default)"| draft[".edgar/proposals/
a human decides"]
wall -->|"auto"| folder[".edgar/skills/learned/"]
Stage 1. When to even try about 8 minutes
1Four checks, and three of them want a passing test
learning/synthesis.py
Look for: Turn triggers _repeats
The first question is when to write a skill at all. Asking a model "was that run
worth remembering?" after every turn would cost money on every turn, most of them to
hear "no". So the answer is a comparison, the same way the controller decides
whether to call itself (The controller, stop 1).
triggers() returns the names of whichever of these fired:
- long — the run used a lot of tools and the declared
check passed.
- recovered — something failed, the agent got past it,
and the declared check passed. The most valuable kind: the skill is
literally "here is the wall, here is how you get over it".
- correction — a human typed
/steer mid-run. This
one does not wait for a passing check, because "no, do it like this" is a
statement about the procedure whatever happens next.
- repeated — this shape of work has shown up before, across
sessions, and no skill was loaded for it already.
def triggers(turn, config, store):
verified = (turn.verification == "passed")
fired = []
if verified and len(turn.tools) >= config.min_tool_calls: fired += ["long"]
if verified and turn.errors: fired += ["recovered"]
if turn.corrections: fired += ["correction"]
if verified and not turn.skills and repeats >= config.min_repeats:
fired += ["repeated"]
return fired
Notice verified. It does not mean the model said it was done. It
means edgar ran the project's own declared check — its test command — and the check
exited zero. "Nothing is done until it's verified" is the harness's promise, and
here it is doing real work: three of the four triggers simply cannot fire on a run
that did not prove itself.
The last trigger is the one that needs memory: "have I done this shape of job
before?" cannot be answered from one turn. _repeats() asks the
experience store, which is the only read in an otherwise pure function.
Take with you: when a feature costs money every
time it runs, look for the version that costs nothing when the answer is no.
Stage 2. What the writer is allowed to see about 12 minutes
2An outline with no room for the dangerous part
learning/synthesis.py
Look for: Outline render _error BRIEF
Here is the trick worth stealing. You could write a filter: take the whole run,
strip out anything that looks like tool output, send the rest. Filters lose. There
is always one more shape the filter did not know about.
Instead, look at what Turn and Outline are
able to hold:
- the line the human typed;
- corrections the human typed;
- tool names, in the order they were called;
ErrorRecords — four fields the harness computed itself: which
tool, what kind of failure, the exit code, the program name;
- the verification result:
passed, failed or
unverified.
There is no field for a tool's output. No field for an error message. No field
for a fetched page. render() cannot leak what Turn cannot
hold — the type is the filter, and it cannot be out of date. Adding a field to that
dataclass is a decision that needs an ADR, not a patch.
class Turn: # the ONLY seven things synthesis may know
prompt # a human typed it
corrections # a human typed these too
tools # names only: "shell", "edit" — never the arguments
errors # ErrorRecord: tool, kind, exit_code, program. No message.
verification # passed | failed | unverified
agent, skills # which loop it was, and what it had loaded already
Outline.of(turn).render() ->
what tripped: long, recovered
what was asked: bump the httpx pin
tools called, in order: shell, edit, shell
failures the harness recorded: shell nonzero_exit exit 1 (pytest)
declared check: passed
task shape: main:shell>edit
Two details are worth slowing down for.
- Tool arguments are left out on purpose, even though the
requirement would have allowed them. An argument is text the model wrote
after reading the previous tool's output — so "put the file you just read
into the next command" is a way to smuggle that output through. Tool names come
from a fixed list. Arguments come from anywhere.
_error() distrusts its own data. The program name
inside an ErrorRecord was chosen by the model, so it is stripped down
to letters, digits, dots, dashes and underscores before it is printed. The tool
pipeline already strips it once. Doing it twice costs one line and survives the
day one of those two paths changes.
BRIEF is what the model is told to do with all this. Every rule in
it — the four headings, the name format, "write the general procedure, not today's
file names" — is also enforced in code at the next stop, because a prompt is advice
and a function is a wall.
Take with you: if untrusted text must not reach
somewhere, do not filter it out. Build the road so it never had a lane.
Stage 3. Where it may write about 12 minutes
3The one function that creates a skill file
learning/synthesis.py skills/discovery.py
Look for: write_learned render_skill Provenance body_shape BODY_SECTIONS LEARNED archive
Every path that could ever produce a skill file goes through
write_learned(). It refuses far more often than it writes, and each
refusal comes back as a plain sentence rather than an exception.
def write_learned(root, home, name, body, session, trigger):
# 1. the shape, or nothing
if body_shape(body) is wrong: return "refused NAME: missing section(s): ..."
# 2. a skill a human wrote keeps its name, in every mode
if discover(...)[name] is hand-authored:
return "refused NAME: a hand-authored skill ..."
# 3. and the path is built here, from a name already checked
write(root / ".edgar/skills/learned" / name / "SKILL.md")
flowchart LR
body["the model's body"] --> shape{"four headings,
in order?"}
shape -->|"no"| stop1["refused"]
shape -->|"yes"| clash{"a human already
owns that name?"}
clash -->|"yes"| stop2["refused"]
clash -->|"no"| put[".edgar/skills/learned/NAME/SKILL.md"]
Step 1, the shape. BODY_SECTIONS names four
headings — When to use, Procedure, Pitfalls,
Verification — and body_shape() insists on all four, in that
order. A piece of advice with no "when to use" is a landmine: it goes into every
future prompt and nobody knows when it applies. A procedure with no "verification"
cannot be checked by the next run. The rule is only applied to learned skills; a
skill a person wrote is prose they chose the shape of.
That constant lives in skills/discovery.py, not here, for two
reasons: edgar skills validate is a v1 command that has to keep working
with the whole learning package deleted, and one definition means the writer and the
checker can never disagree about what a skill looks like.
Step 2, the name. The question "is there already a skill called
that, and did a human write it?" is put to discover() — the same index
a real session reads. Not a directory listing, not a guess. If a person owns the
name, the answer is no, in auto mode as much as any other.
Step 3, the path. LEARNED is
.edgar/skills/learned and the path is assembled from it plus a name the
parser already restricted to letters, digits and dashes. It cannot climb out of the
folder, because there is nothing in it to climb with.
render_skill() then stamps the file with its
Provenance: learned: true, which session wrote it, which
trigger fired, when. That is what lets edgar skills list --learned show
you every machine-written skill, and edgar skills forget NAME take one
back out. forget calls archive(), which moves the
folder into learned/.archive/ rather than deleting it — a machine wrote
the file, and you may still want to read what it thought.
Take with you: one function that can create the
thing, and every rule spelled out inside it. When there are two, they drift, and the
looser one becomes the real policy.
Stage 4. Who says yes about 10 minutes
4propose by default, and a disclaimer if you change that
controller/gate.py cli/setup.py
Look for: Gate AUTO_SYNTHESIS
One setting, skills.synthesis, with three values:
off — nothing is written and no call is made. The
_synthesis() step returns on its first line.
propose — the default. The skill is written to
.edgar/proposals/ for you to read, edit and move yourself. Nothing
the agent can read has entered the agent's instructions.
auto — written straight into learned/, and only
after a passing verification.
Even in auto, the correction trigger can only ever
propose. Think about why: a correction is the one trigger that does not require a
passing check, so pairing it with an unreviewed write would be the one hole in
"verified or nothing".
auto writes, and only when ALL of:
synthesis == "auto"
the declared check passed on this turn
the trigger was not "correction"
the body and the name survived write_learned()
otherwise -> a proposal file, and a line telling you it is there
And when you do choose auto, edgar tells you so at the start of
every session — that is AUTO_SYNTHESIS in
cli/setup.py. Not once at install time, not buried in
doctor. Every session, because the risk it names is a slow one: a
mistake, or an instruction injected into a task, that reaches a learned skill stays
there until somebody removes it. edgar doctor prints the current mode
too, and says so plainly when the controller is switched off and the whole thing is
inert.
The gate itself fires the call the same way it fires its own: as a background
task, so the turn never waits, and wrapped so that a synthesiser that explodes
leaves a logged rejection rather than a broken session.
Take with you: the safe setting should be the
default, and the unsafe one should keep saying its own name out loud.
5Skills get better by being watched, not edited
learning/observations.py learning/distill.py
Look for: observe notes outline PATCH_BRIEF HISTORY command
A skill that gets followed and then goes wrong is the cheapest teacher there is.
So when a turn loaded a skill and ended badly — a failed check, a failure, a
correction — one line is appended about it.
observe(root, name, errors, corrections, verification):
note = ""
if verification == "failed": note += "the declared check failed"
if errors: note += "failures: shell nonzero_exit, edit not_found"
if corrections: note += "corrected: " + redacted typed lines
if not note: return 0 # a turn that worked is not worth a line
append "- " + note to .edgar/skills/learned/.history/NAME.md
return how many lines there are now
Same closed vocabulary as the outline: computed error fields, the check's verdict,
and lines a human typed. There is no branch in there that could reach a tool's
output, because nothing passes it one.
Once enough lines have piled up, outline() hands the model the
skill's current body plus those lines and PATCH_BRIEF asks for a better
body under the same name. You can also ask for it by hand with edgar skills
distill NAME, which is what distill.py's command()
is — and it deliberately routes its answer back through the controller's own
apply(), rather than writing the file itself. "Propose or write, and
under which conditions" is one decision, and duplicating it is how the two copies
start to disagree.
Where those lines live is the decision to argue with. The
requirement says "that skill's HISTORY.md" — which, for a skill a person
wrote, would mean edgar writing a file inside a folder you own. It does not. Every
observation goes to one machine-owned folder,
.edgar/skills/learned/.history/, one file per skill name, whatever the
skill's origin. Your skill is still watched and still gets patches proposed. What
changes is that nothing edgar writes ever lands beside a file you wrote.
Take with you: when a spec tells you to write into
somebody else's folder, the spec is usually describing the feature, not the
filesystem. Keep the feature; move the file.
How it is proved
Two tests carry most of the weight of this page, and both are worth opening.
tests/unit/test_synthesis_gate.py drives a whole fake run through
the gate and asserts the outline that comes out word for word — including that the
model call is made with no tools, and that a failing check produces
no call and no file.
tests/property/test_synthesis_boundary.py generates the run
and the answer: hostile text on every road a real session has, and answers
that are paths, names a person already owns, bodies of the wrong shape, actions
nobody asked for. In auto — the loosest mode there is — it snapshots
every file a person wrote before the session and asserts every byte is identical
after. An example test cannot make that claim. The interesting answer is the one
nobody thought to script.
Sizes
| File | Lines of code | What it is |
learning/synthesis.py | ~149 | Triggers, the outline, and the one function that writes a skill |
learning/observations.py | ~58 | One redacted line per bad turn, per skill |
learning/distill.py | ~54 | edgar skills distill NAME, by hand |
skills/discovery.py | ~123 | The index, the body shape, and archiving a learned skill |
controller/gate.py | ~232 | The post-turn gate, which fires synthesis as well as itself |