A Tour of the Harness · v3 · M14

Skill synthesis

An agent that writes its own instructions for next time. The interesting part is not the writing — it is the list of things the writer is not allowed to read, and the list of places it is not allowed to write.

Built M14
Kept in sync by a test

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:

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.

Sizes

FileLines of codeWhat it is
learning/synthesis.py~149Triggers, the outline, and the one function that writes a skill
learning/observations.py~58One redacted line per bad turn, per skill
learning/distill.py~54edgar skills distill NAME, by hand
skills/discovery.py~123The index, the body shape, and archiving a learned skill
controller/gate.py~232The post-turn gate, which fires synthesis as well as itself