Mission: an agent and its builders build jq from its manual, scored on jq's own tests

A planner agent builds jq 1.6 in Python, the package jqpy/, from the jq manual alone. The work takes hours, so the planner works in milestones. Code, not the agent, scores each milestone. From milestone 2, the planner gives features to builder subagents. They run in parallel in its sandbox. After each milestone, a hook runs jq's own test suite on the build: 307 cases kept outside the agents' workspace. The hook puts the score on the first line of the planner's answer. A judge reads that answer and decides whether to run another milestone. To steer the running milestone, you type a line.

The mission's loop: drive.py starts one turn per milestone; the planner and up to four builders write jqpy/ in one sandbox; the Score hook runs jq's held-out tests on the answer; the judge decides between another milestone and the end

Read it left to right: each milestone is one turn of the planner, scored before the judge sees it.

In our run, two milestones took 26 minutes and 6.9M tokens, and the build passed 129, then 219, of jq's 307 tests.

Demonstrates: subagents in the planner's sandbox, a skill from a directory, a post_agent_turn hook that runs a command in the sandbox and replaces the answer, turns with idempotency keys and a timeout, live events, steering, a judge with structured output, and agents serve, where the mission outlives its driver.

Run: from the example's folder, with Docker running and MISTRAL_API_KEY set: uv run python drive.py jq-1 2

The planner and its builders

The planner and its builders

The planner owns the core: the lexer, the parser and the interpreter. Each builder owns one file under jqpy/builtins/, which the planner names when it starts it, so two builders never edit the same file. Both load the jq-manual skill: the jq 1.6 manual, its 198 examples as dev_tests.py, and those layout rules.

agent = agents.Agent(
    model="mistral-large-4",
    name="jq-mission",
    instructions=PLANNER,
    environment=(
        agents.MistralSandbox(default_exec_timeout_seconds=300)  # dev_tests.py takes minutes; the default is 30 s
        if os.environ.get("MISSION_SANDBOX") == "mistral"  # served, an agent with subagents needs it
        else agents.LocalDockerSandbox()
    ),
    skills=[agents.LocalSkillBundle.from_directory(HERE / "skills" / "jq-manual")],
    subagents=[
        agents.Subagent(
            name="builder",
            description="Implements one jq feature in the one jqpy file you name, from the manual sections you give.",
            instructions=BUILDER,
            hooks=[Tokens()],  # no Score: the score is the planner's, once its builders are done
            builtins=agents.Builtins(subagents=False),
        )
    ],
    subagent_policy=agents.SubagentPolicy(concurrent_child_turn_limit=4),
    hooks=[Score(), Tokens()],
)

The builders run in the planner's sandbox and write to the same workspace. At most four run at once, and Builtins(subagents=False) keeps a builder from starting builders of its own. The Tokens hook adds up the tokens of the planner's and the builders' model calls.

On your machine, the build runs in a Docker sandbox. With MISSION_SANDBOX=mistral, it runs in a MistralSandbox, which needs the cloud_user workspace role, shown as Mistral Cloud User in the admin console.

A score from code, not from the model

A score from code, not from the model

The agents check their own work on the manual's examples. The score that counts comes from elsewhere: jq 1.6's own test suite, held_out/jq.test, which stays in the example's folder. After each planner turn, the Score hook copies it and score.py into the sandbox, outside the workspace, runs the score, and puts its line before the planner's report:

class Score(agents.Hook):
    """After each planner turn, run jq's held-out tests on the build and put the score first in the answer."""

    async def post_agent_turn(self, ctx, answer):
        score = await ctx.sandbox.write_file("score.py", SCORE)
        tests = await ctx.sandbox.write_file("jq.test", HELD_OUT)
        run = await ctx.sandbox.execute(f"python3 {score} {tests}", timeout_seconds=300)
        lines = run.stdout.strip().splitlines()
        line = lines[-1] if run.exit_code == 0 and lines else f"score failed: {run.stderr[-300:]}"
        return agents.hook.PostAgentTurnHookAccept(
            acceptance=agents.hook.ReplaceAssistantContent(
                content=[agents.TextContentBlock(text=f"{line}\n\n{answer.text}")]
            )
        )

score.py reads the tests, then deletes them and itself, so the next milestone never finds them in the sandbox. It runs each case in a new Python process that gets the program and its input, never the expected output. The system lets that process start no other process, so the build cannot run a jq binary itself. It prints one line, such as jq.test 219/307.

One turn per milestone

One turn per milestone

drive.py starts each milestone as a turn, with an idempotency key made of the mission's name and the milestone's number. It waits 40 minutes at most and prints each tool call of the planner as it happens. A milestone still running then gets a steer: interrupt your builders, then report. An interrupt of the planner's turn would stop the planner only, and its builders would keep editing files. drive.py waits 10 more minutes, which includes the score's run, then interrupts the milestone. The next one starts from the files it wrote, and a builder still running then reports to it:

turn = await session.start_turn(milestone(n), idempotency_key=f"{name}:m{n}")  # a rerun gets the same turn
if isinstance(turn, agents.Busy):
    sys.exit(f"session {session.id} runs turn {turn.active_turn_id}, not a milestone: interrupt it first")
if served:
    SAVED.mkdir(exist_ok=True)
    saved_file.write_text(json.dumps({**saved, "session_id": session.id, "running": n}))
keyboard.turn = turn
record = await turn.result(after=saved.get("cursor"), timeout=MILESTONE_SECONDS, on_event=show)
if isinstance(record, agents.Running):  # time is up: an interrupt would stop the planner, not its builders
    await turn.steer(TIME_UP)
    record = await turn.result(after=record, timeout=10 * 60, on_event=show)  # the score included
if isinstance(record, agents.Running):
    await turn.interrupt()  # a builder still running reports to the next milestone
    record = await turn.result()

The judge is a second agent, on mistral-medium-latest, with output_type=Verdict: a done flag and one sentence of why. It says done when the score passes 90% or the report shows the build is stuck:

async def judged(report: str) -> bool:
    """Ask the judge whether the mission is done."""
    match await judge.run(report):
        case agents.Completed(output=Verdict(done=True, why=why)):
            print(f"done: {why}")
            return True
        case agents.Completed(output=Verdict(why=why)):
            print(f"{DIM}go on: {why}{RESET}")
        case other:
            print(f"{DIM}the judge gave no verdict ({type(other).__name__}): go on{RESET}")
    return False
Steer the running milestone

Steer the running milestone

Type a line and press Enter: drive.py adds it to the running milestone with turn.steer(). Between two milestones there is no turn to steer, and drive.py prints not sent, as it does when turn.steer() returns TurnConflict because the milestone has just ended:

async def steer(self, line: str) -> None:
    # steer() adds the line to this turn only; it never starts a turn between two milestones
    try:
        steered = self.turn is not None and isinstance(await self.turn.steer(line), agents.Turn)
    except agents.AgentsError as error:  # the session ended, or the connection did
        print(f"  {DIM}↳ not sent: {error}{RESET}")
        return
    if steered:
        print(f"  {DIM}↳ steered: {line}{RESET}")
    else:
        print(f"  {DIM}↳ not sent: no milestone is running{RESET}")
Served: the mission outlives the terminal

Served: the mission outlives the terminal

With agents serve, or when you deploy with Studio (self-hosted or Mistral-managed), the planner and its builders run in the served agent. drive.py --served starts the turns, waits, and asks the judge. Kill the driver and run it again: the same idempotency keys return the same session and the same running milestone, and the driver attaches to it. It keeps the session's id and the last milestone's cursor in .missions/.

From a second terminal, watch.py follows the same session, and steers whichever milestone runs:

async def main(name: str) -> None:
    session_id = json.loads((SAVED / f"{name}.json").read_text())["session_id"]
    session = agents.Agent.load_from(agent_name=agent.name).session(session_id)
    print(f"watching mission {name} · session {session_id}")
    keyboard = Keyboard()
    reading = asyncio.create_task(keyboard.read()) if sys.stdin.isatty() else None
    async for event in session.iter_events():
        show(event)
        match event:
            case agents.TurnStarted(turn_id=turn_id) if turn_id is not None:
                keyboard.turn = session.turn(turn_id)  # the milestone that runs now
            case agents.TurnEnded(state=state):
                keyboard.turn = None
                if state.text:
                    print(state.text.splitlines()[0])  # the milestone's score
    if reading is not None:
        reading.cancel()

For agents serve, the key's user needs a workspace role with Workflows access, such as Developer or Workspace Contributor; a workspace administrator assigns it. Served, an agent with subagents needs a MistralSandbox: set MISSION_SANDBOX=mistral in the shell of agents serve, or add ENV MISSION_SANDBOX=mistral to the Dockerfile that agents deploy writes. The key's user, or the deployment's service account, then needs the cloud_user role.

The mission's code

The mission's code

FileWhat it holds
main.pyThe planner, the builder subagent, the Score and Tokens hooks, the judge. agents serve loads agent
drive.pyOne turn per milestone, the live view, the judge, steering from the keyboard
watch.pyA second terminal on a served mission: its live events, and steering
score.pyRuns jq's held-out tests on the build, in the sandbox
skills/jq-manual/The skill: the jq 1.6 manual, its 198 examples as dev_tests.py, the layout rules
held_out/jq.testjq 1.6's test suite, 307 cases scored
test_mission.pyA test with a scripted model: one milestone, scored in a Docker sandbox

jq's manual and tests are from jq 1.6, under the MIT license (skills/jq-manual/COPYING).

Run it

Run it

You need Docker. From the example's folder:

uv sync
export MISTRAL_API_KEY=...
uv run python drive.py jq-1 2        # at most 2 milestones; without a number, at most 6

To run the build in a MistralSandbox, set MISSION_SANDBOX=mistral.

Served, in three terminals:

MISSION_SANDBOX=mistral uv run agents serve     # terminal 1
uv run python drive.py jq-1 6 --served          # terminal 2: kill it, run it again, it attaches
uv run python watch.py jq-1                     # terminal 3: the live events; type a line to steer

The test needs no key: uv run pytest -o asyncio_mode=auto test_mission.py.

Output

Output

Two milestones in a MistralSandbox (MISSION_SANDBOX=mistral), on your machine. Long runs of tool calls are folded, and long report lines end in …:

Output
read jq-manual
bash ls -la /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/ && ls -la ~ && pwd
bash cat /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/dev_tests.py && grep -n
… 50 more tool calls
edit
bash cd /workspace && PYTHONPATH=/workspace python3 /tmp/smoke.py 2>/dev/null | tail -8 && pyth
bash cd /workspace && python3 /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/de
m1 jq.test 129/307 15 min · 2.12M tokens
Milestone 1 is complete — all milestone-1-scope examples pass, and the 124 remaining failures are all later-milestone builtins (length/keys/sort/regex/dates/paths/streaming) or update-assignment operators, which are out…
Milestone 1 report:
• Built: the jqpy core from the manual alone — jqpy/lexer.py (tokens, keywords, string interpolation scanning), jqpy/parser.py (full precedence chain: | < as < , < // < or < and < comparisons < + -…
• dev_tests.py: `manual 74/198` — every milestone-1 section (identity/paths/slices/iterators, comma/pipe/parens, []/{}/.., arithmetic, comparisons, if, and/or/not, //, try-catch/?, interpolation, variabl…
• Next: Milestone 2 — spawn up to 4 parallel builder agents, each owning one feature module under jqpy/builtins/ (e.g. math/type predicates, object/array functions, string functions, regex/PCRE) with its manual se…
go on: The score (129/307 ≈ 42%) is below 90% and the build is not stuck, as the report explicitly states progress toward Milestone 2.
read jq-manual
bash cd /workspace && python3 /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/de
bash cd /workspace && python3 /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/de
… 6 more tool calls
spawn collections_b
spawn strings_b
spawn generators_b
spawn types_math_b
wait
wait
wait
wait
… 16 more tool calls
edit
bash cd /workspace && PYTHONPATH=/workspace python3 /tmp/assign_test.py 2>&1 | head -3 && pytho
bash cd /workspace && for s in "Identity" "Identifier-Index" "Slice" "Iterator" "Comma" "Pipe"
m2 jq.test 219/307 11 min · 4.80M tokens
Milestone 2 report:
• Built: four builder modules in parallel, wired into jqpy/builtins/__init__.py — collections.py (flatten/sort/sort_by/group_by/min/max/unique/combinations/reverse/transpose/bsearch/contains/inside, 27→29 exampl…
• dev_tests.py: `manual 156/198` (up from 74 at the end of milestone 1); all milestone-1 and milestone-2 sections pass with zero regressions — the 42 remaining failures are all in not-yet-built features.
• Next: milestone 3 — spawn builders for the remaining biggest clusters: regex/PCRE (test/match/capture, 8 examples), objects (keys/has/in/to_entries/from_entries/with_entries, 9), paths (getpath…
go on: The score on jq's own held-out tests is 219/307, which is approximately 71.3%, below the 90% threshold.
tokens: 6,683,541 in, 236,604 out

The two milestones took 26 minutes. In milestone 2, the four builders wrote their modules at the same time, and the score rose from 129 to 219. Of the 6.9M tokens, 97% are input: each model call sends the conversation so far, and the planner reads long sections of the manual.

The served run below shows milestone 1, with long report lines cut at [...]. The driver was killed 4 minutes into the milestone and run again 20 seconds later. It attaches to the running milestone and replays its events from the start:

Output
mission jq-served · session 01a1a939-5173-7664-971e-aaa90f10eec3
m1 still running, attaching
read jq-manual
bash ls -la /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/ && ls -la . && pwd
bash grep -n "title:" /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/manual.yml
… 33 more tool calls
bash cd /workspace && for f in "Identity" "Index" "Slice" "Iterator" "Pipe" "Parenthesis" "cons
bash cd /workspace && python3 /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/de
bash cd /workspace && grep -rn "subprocess\|os.system\|popen" jqpy/ ; python3 -c "
m1 jq.test 130/307 9 min
manual 72/198 — dev_tests.py full run (and 67/67 on every example in this milestone's scope: python3 dev_tests.py sections Identity/Index/Slice/Iterator/Comma/Pipe/Parenthesis, array/object construction, recursive des…
[...]
go on: The score on jq's own held-out tests is 130/307, which is approximately 42.3%, below the 90% threshold.

Meanwhile, in watch.py, a line typed during milestone 1 asked for the dev_tests.py count on the first line of the report. The report above opens with it:

Output
watching mission jq-served · session 01a1a939-5173-7664-971e-aaa90f10eec3
read jq-manual
bash ls -la /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/ && ls -la . && pwd
bash grep -n "title:" /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/manual.yml
… 8 more tool calls
bash sed -n '2382,2810p' /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/manual.
bash sed -n '2759,2810p' /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/manual.
bash sed -n '2634,2706p' /tmp/vibe-harness-files/skills-74fd9300/jq-manual-e5d56928f19a/manual.
↳ steered: Put the dev_tests.py count on the first line of your report.
bash mkdir -p /workspace/jqpy/builtins && ls /workspace
write_file /workspace/jqpy/interp.py
write_file /workspace/jqpy/lexer.py
… 21 more tool calls
bash cd /workspace && grep -rn "subprocess\|os.system\|popen" jqpy/ ; python3 -c "
jq.test 130/307

Served, the model calls run in the served agent, not in the driver, so the driver prints no token count.

Adapt it

Adapt it

  • Another spec and its tests: swap the manual and the held-out suite for your own: a library ported to another language, a parser built from its grammar, a client built from its API reference. Keep the suite out of the agents' workspace, and let the hook score each milestone.
  • Another stop rule: change the judge's instructions, for example to stop when a milestone raises the score by less than 5 tests.
  • Longer or wider milestones: raise MILESTONE_SECONDS, or concurrent_child_turn_limit for more builders at once.