Skip to content

Add an inspect_ai eval engine behind --engine - #19

Draft
rominf wants to merge 33 commits into
amd:mainfrom
rominf:inspect-engine
Draft

rominf wants to merge 33 commits into
amd:mainfrom
rominf:inspect-engine

Conversation

@rominf

@rominf rominf commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

A second eval engine, selected with --engine inspect and off by default. The dataset format, the CLI, and the reports are unchanged; only the thing driving the agent differs.

The legacy engine shells out to the claude CLI with permissions bypassed on the runner, and hand-rolls the machinery around it: workspace staging, an LLM judge with bespoke verdict parsing, and ~850 lines of streaming and SIGKILL logic to end routing runs early. This hands that work to inspect_ai.

Its agent is harness-independent rather than the claude CLI, so a skill is graded on whether its instructions work rather than on how one product reads them. That is the stronger claim, and the one a product repo can adopt. It also removes what blocked Windows: with no CLI in the sandbox there is no agent bridge, and that bridge is Linux-only.

Both engines produce the same outcome objects, so summarize, render_markdown and the report writers are untouched.

Risk: low while it stays opt-in. The default is legacy and that path is unchanged.

Evidence

Routing over a whole catalogue (8 skills installed together, 68 cases):

legacy inspect
Accuracy 56/68 68/68
Wall time 477s 49.5s
Model calls 349 (5.1/case) 68 (1.0/case)
Cost $4.95 ~$1 estimated (the provider reports none)

All 12 disagreements favour this engine and none go the other way — 8 legacy misses, 3 legacy over-triggers, 1 wrong skill, 1 error. Noise would be symmetric.

These are single runs. Repeating the routing leg on a five-skill room gave 46/46, then 45/46, then 46/46 — so this engine has roughly 2% run-to-run variance of its own. Well inside legacy's, but not zero, and worth knowing before anyone treats a single number as a baseline.

The more interesting number is the noise floor: the legacy engine disagrees with itself on 7 of 68 cases (~10%) across two identical runs. That is a property of the status quo rather than of this change, and it is larger than most real regressions would be.

One caveat stated plainly: "legacy misses 8 activations" is probably not "the product harness routes worse". Legacy kills each case after 4 non-bookkeeping tool calls and infers activation from the stream, so an agent that plans before activating is recorded as a miss. Some of that gap is the legacy measurement being lossy. Still an argument for this engine, but a different claim.

tools/benchmark_engines.py produces all of the above, and refuses to present the token columns as comparable, because they are not.

Non-obvious decisions

  • The tools are ours. inspect's own assume a POSIX guest (bash(), text_editor() and friends), which the Windows legs do not have.
  • The judge sees artifacts, not prose. It reads what the agent produced, attaching images so "did it draw a cat" is answerable, and reports whether a requirement is satisfied — so a "must not" is never scored by negating a verdict.
  • Routing is one model call. The decision is a tool call visible in the first reply and nothing is executed, so there is no agent loop to bound and no sandbox to start. This is a measurement choice as much as a cost one: no submit tool or agent prompt sits between the descriptions and the decision.
  • machine.yml gains sandbox:, naming a compose file for a skill that needs network egress or a device.
  • SKILLSCOPE_SANDBOX selects the provider (docker, podman, local) without discarding what the skill declared.

Podman needs three things beyond the binary, each found by the previous one failing on a real runner: the [podman] extra (the provider is registered by a package, not the binary), podman-compose plus INSPECT_PODMAN_COMPOSE (bare podman compose delegates to Docker's plugin, which then needs the daemon podman was chosen to avoid), and a search registry (podman will not guess Docker Hub for an unqualified image name). All three are in docs/usage.md. Worth it where a runner's user cannot reach the Docker socket, which is the case this came from.

Test plan

  • 268 unit tests, Linux and Windows

  • On self-hosted runners, against a live catalogue, through an enterprise gateway: routing 46/46, and behavioral 3/3 cases with 21/21 expectations met, isolated in a podman sandbox. That is the whole stack — tools, scorers and the LLM judge — on the infrastructure that would host it.

  • New CI job drives the engine on mockllm — free, no key, works from a fork — and is green on both: Ubuntu reports sandbox: docker, Windows reports sandbox: local. Its load-bearing assertion is a seeded fixture file: it exists because the case staged it, not because the agent acted, so files_exist passing proves staging, working directory, listing and matching all hold. That is the exact chain that was broken in a container earlier in this branch.

    Two consequences worth naming. The Docker sandbox is now exercised, not just podman. And the Windows leg runs the cross-platform tools on Windows, including the PowerShell listing path, which nothing else covers.

Not verified — why this is a draft

  • Behavioral has run against one real skill of eight. It passed completely, but every new skill so far has found a new bug, so treat coverage as thin rather than settled. The CI fixture is deliberately trivial and proves the machinery, not the grading.
  • No judged expectation has been graded in CI. mockllm satisfies nothing, so the LLM judge is only ever exercised by hand.
  • No downstream trial — nothing has pointed a consuming repo at this branch.
  • --engine claude-code has never executed. Wired, guarded, unrun.
  • Flipping the default, and the ~1,200 lines of legacy that could then be deleted, belong in a later change once the above is closed.

Bugs this found in itself

Every one came from running against a real container or a real runner, and none would have been caught by the unit suite: a failed sandbox listing reading as an idle agent; the container working directory defaulting to /; artifact paths resolved against the wrong root; the judge never seeing the agent's final message; transcript truncation dropping the very action a check turns on; and a credentials guard firing on runs that reach no provider.

The new CI job then found two more on its first run: tests that asserted a POSIX answer on any host, and the CI model pin coercing mockllm to a real model — which turned the one check that needs no key into one that does.

The legacy engine shells out to the claude CLI with permissions bypassed
on the runner, and hand-rolls workspace staging, LLM judging and routing
termination around it. This adds a second engine that hands that work to
inspect_ai, selected with `--engine inspect` and off by default until a
benchmark shows what it changes.

Its agent is harness-independent rather than the claude CLI, so a skill
is graded on whether its instructions work rather than on one harness's
reading of them. That also removes what blocked Windows: with no CLI in
the sandbox there is no agent bridge, and the bridge is Linux-only.

Both engines produce the same outcome objects, so `summarize`,
`render_markdown` and the report writers are untouched.

Three pieces are not thin wrappers. The tools are ours because inspect's
assume a POSIX guest, which the Windows legs do not have. The judge sees
artifacts rather than the agent's prose, attaches images so "did it draw
a cat" is answerable, and reports whether a requirement is satisfied so
a "must not" is never scored by negating a verdict. Routing is a single
model call with no agent loop: the decision is a tool call, visible in
the first turn, so there is no turn to pay for and kill.

machine.yml gains a `sandbox` key naming a compose file, so a skill that
must reach the network to pull a model can say so.
Neither engine reported its own cost. The legacy engine discards the
`total_cost_usd` the CLI returns with every result, and inspect keeps
usage in its own log where skillscope's report never looks. That was
fine with one engine and nothing to compare it against.

Both now record into `usage`, which the report meta carries, and
`tools/benchmark_engines.py` runs the same dataset through both and
reports per-case agreement plus what each run spent.

Agreement is compared case by case rather than in aggregate: two runs
can post identical accuracy while individual cases flip in both
directions and cancel out. `--noise` runs the legacy engine twice so
flips can be read against how much it already disagrees with itself.

The spend columns are not equally trustworthy and the report says so
rather than tabulating them as though they were. Legacy token counts
are a floor -- assistant events omit the system prompt and cached
input, and a routing case is killed before its totals arrive -- so cost
is its reliable figure. inspect has a cost only when the provider
supplies pricing, which a gateway does not, so wall time is often the
only column comparable on both sides.
The inspect engine grades a skill with a harness-independent agent, which
tests whether the skill's instructions work rather than how one product
reads them. That choice raises an obvious question, so `--engine
claude-code` answers it: run the real harness in the sandbox and see
whether it agrees.

It slots into the existing flag rather than adding a command, so the
benchmark tool can diff its report against either other engine with no
new machinery. Reporting only -- harness runs are nondeterministic and
the harness is not what we grade, so a divergence is a question about
the skill rather than a build failure. Linux only, because inspect_swe
shells `bash -c` to find the CLI and the proxy it installs in the guest
is a Linux binary.

Also fixes how the inspect engines count model calls. They recorded one
per sample while the legacy engine records one per assistant response:
identical for routing, where a case is a single turn, but a large
undercount for behavioral, where the agent loops. The benchmark puts the
two columns side by side and names that one comparable, so it had to
mean the same thing on both sides. Counted from assistant messages now.
The two legacy commands read the same `claude` stream but recorded from
different events: routing counted assistant replies, behavioral counted
only the result event, which is one per case. A behavioral run of three
cases reported three model calls while costing $0.72 -- far more than
three small calls -- and the benchmark puts that column beside the other
engine's and calls it comparable.

Both now go through one recorder in `usage`. The same three cases report
fourteen calls against the new engine's twelve, which is the like-for-
like reading: two agents looping to similar depth.
`--engine` changes what drives the agent while leaving the dataset, the
CLI and the reports identical, so it belongs in usage rather than being
discoverable only from --help. Says what each engine needs, why the
inspect one is cheaper, why the claude-code leg never gates, and where
the sandbox does and does not exist.
…config

Which provider to use is a property of the runner -- docker, podman on a
host that has that instead, local where there is no container -- and
whoever runs the job knows it while a skill does not. What the sandbox
has to provide is the opposite: the skill declares a compose file when it
needs network egress or a device.

`SKILLSCOPE_SANDBOX` already selected the provider but returned it bare,
discarding any compose file the skill had declared. A skill that needs
the network would then run without it and fail for a reason nothing in
the report explains. The two are now resolved independently.

podman works with no registration: `inspect-podman` publishes an
`inspect_ai` entry point and inspect resolves the bare name, so
installing the extra is the whole setup.
A behavioral report showed the same numbers whether the agent had been
contained or had worked directly in the harness's filesystem with
permissions bypassed, which invites the reader to assume the former.
Both engines now record what held the run, and the markdown says it
outright -- the legacy engine reports `host`, and the Windows legs, which
have no sandbox available at all, no longer look like the Linux ones.

Routing under the inspect engine reports `none` rather than a provider:
it offers the skill tool and never calls it, so nothing is executed and
there is nothing to isolate. That is not the same claim as unprotected.

Also stops the gateway-versus-federation guard firing on runs that reach
no provider. `--model mockllm/model` is the wiring check that costs
nothing, and refusing it because the shell holds both Anthropic variables
broke it on exactly the machines most likely to have an OAuth token.
A graded run on the inspect engine starts a sandbox and installs a skill
before it first reaches a provider, so a bad key surfaced as a task that
failed after all that work -- a 401 buried in a sample error, eighteen
seconds and a container in.

One tiny call up front turns that into a message on the first line. The
legacy engine already had this; the inspect one skipped it on the grounds
that the CLI-based probe tested the wrong thing, which was true and left
nothing in its place.

mockllm reaches no provider, so it is skipped rather than charged for a
round trip that proves nothing.
`list_paths` returned an empty list when the listing command failed, so a
sandbox that could not be listed was indistinguishable from one the agent
had left empty. That is not a cosmetic difference: `files_exist` fails,
and the judge builds its evidence from the same listing, so every judged
expectation fails too with "no files were produced". Nine checks blame
the skill for what the harness did.

It now raises. The scorer reports that it could not list the sandbox, and
the judge declines to rule rather than being handed an empty workspace as
fact -- a judge told "no files" will confidently conclude the agent did
nothing.
A container sandbox starts at `/`. So `write_file("router.json")` landed
at `/router.json`, `find . -type f` walked the whole image and died on
`/proc`, and the judge -- had the listing survived -- would have read
twenty arbitrary system files as the case's artifacts. An agent left to
guess reasonably tried `/app`, then `~`, and scattered its output.

Everything a case does now happens in `/workspace`: fixtures are seeded
there, the file tools resolve against it, shell commands run in it, and
it is what gets listed. The agent is told so, because where its output
lands should not be something the dataset has to predict.

`local` keeps the harness's own working directory -- it already has a
sensible one, and creating `/workspace` on someone's machine would not be.

inspect_swe solves the same problem the same way; its agent cwd falls
back to the home directory when the sandbox default is `/`.
Two reasons a case could not pass in a container.

The judge read artifacts by the path `list_paths` reported, which is
relative to the case's working directory, while `read_file` resolves
against the sandbox's own -- `/`. Every artifact came back unreadable and
the judge concluded, reasonably, that nothing had been produced. It now
resolves them the same way the tools do.

And it never saw the agent's final message, so an expectation about what
the agent *told* the user -- "output the curl commands they need" -- was
unanswerable however well the agent had done. The legacy judge was given
both halves and this one was not.

They stay labelled apart, because the distinction is what stops an agent
writing "I won't call the cloud API" from settling an expectation that it
avoided doing so: actions are judged from tool calls and artifacts, and
only what the agent told the user is judged from its message.
The judge's transcript was cut at the end, so the agent's most recent
tool calls were the first to go -- and those are usually the ones an
expectation is about. An agent writes a file, validates it, and reports
the result; losing the validation makes the run look like it claimed
something it never did, which is precisely how the last failing check
read: "only the agent's unsupported claim".

Tool calls are now kept whole, since each is short and the list of them
is the record of what happened. Only results are capped, individually,
because they are what grows without bound. If the whole still overflows,
the middle goes rather than the end.
This repository is public and the fixture named a real internal CI
service account alongside the gateway's header, which together describe
how model access is fronted. The test is about parsing `Key: value`
lines; the values were never the point.
Every bug in this branch was found by running against a real container,
and none of them would have been caught by the unit suite. `mockllm`
reaches no provider, so the machinery around the model can be driven for
free, with no key, on a pull request from a fork -- on Ubuntu, where
Docker is the default sandbox, and on Windows, where there is none and
the cross-platform tools are the only thing that works.

The assertions are about the harness rather than the skill, because the
mock satisfies nothing. It checks that no case errored, that the sandbox
that ran is the one the platform should have chosen, and that no check
failed because the sandbox could not be listed -- which otherwise reports
identically to an agent that did nothing.

The load-bearing one is the seeded fixture. The file exists because the
case staged it, not because the agent acted, so `files_exist` passing
proves the whole path: staged into the working directory, listed there,
and matched against what the case asked for. That is exactly the chain
that was broken in a container an hour ago.
Two failures on the first run of the new job, both worth having.

The sandbox-selection tests asserted `docker` on any host, which is only
true on a POSIX one -- on the Windows runners the answer is `local`,
because inspect's sandbox layer assumes a POSIX guest. They pin the
platform now and assert both answers, which is what they should have
said in the first place.

The engine job pointed at its fixture with `working-directory`, but a
`--skills-dir` glob that was passed is resolved against the repo root,
and `find_root` takes that from the nearest `.git`. Running from a
subdirectory of a checkout still globbed the checkout, so the fixture was
never found. `SKILLSCOPE_REPO` is the documented way to say which repo is
under test, and is what the action already uses.

The local reproduction missed this because the throwaway fixture was not
inside a git repository, so the root happened to be the fixture.
The CI pin coerces any non-opus model to opus so paid runs stay
comparable between runs. `mockllm` reaches no provider, grades nothing
and costs nothing, so pinning it turned the free wiring check into a run
that needed a key -- in the one environment where not needing a key is
the entire point. The new engine job failed on exactly that.
`mockllm` never calls the submit tool, so the agent looped to the full
120-message budget and every turn was a real sandbox round trip. The CI
grade step took 43 seconds to learn what it knows in three turns, and the
same thing made a mock run over a real catalogue take 157 seconds for
three cases.

A model that reaches no provider now gets six turns. The job's grade step
drops to about two seconds and the assertion it rests on -- the seeded
fixture being found -- is unchanged.

Also caches the pip download. inspect-ai pulls in the order of eighty
packages, which was the other twenty seconds.
A trial on a self-hosted runner installed the podman binary, selected
`SKILLSCOPE_SANDBOX=podman`, and failed with a ValueError from inspect's
registry naming neither the variable that chose the provider nor the
package that supplies it. The binary being present proves nothing:
inspect resolves a third-party provider through an entry point, so the
Python package has to be installed too, and that is the `[podman]` extra
rather than `[inspect]`.

Checked once before any container starts, with the install line in the
message. The resolver is injectable so this is testable without the
inspect extra, which the unit suite runs without on purpose.
A `react` agent delivers its answer through the submit tool, which lands
in `output.completion`. The judge read the last assistant message
instead, and that is often the preamble -- so an expectation about what
the agent told the user could be graded against "the commands are below"
rather than the commands.

On a real skill this showed up as the judge reporting that the final
message "only describes having printed registration/test commands but
contains no actual curl commands", which is exactly what it was shown.

Falls back to the last assistant message for agents with no submit tool.
Three things, each found by the previous one failing on a real runner:
the provider package, podman's own compose rather than the shim that
delegates to Docker's, and a search registry because podman will not
guess one for an unqualified image name.

Worth the setup where the runner's user cannot reach the Docker socket,
which is the case this came from.
rominf added a commit to amd/skills that referenced this pull request Sep 14, 2026
The default sandbox has no network, which I had been treating as a reason
those skills cannot be containerised. It is not: it is a default, and
`sandbox:` in machine.yml exists so a skill can say otherwise. That key
has been unit-tested and never used for real, so this is its first
outing.

`local-ai-app-integration` installs a runtime and fetches models. Its
compose file is inspect's own default minus the line that removes the
network, with the image fully qualified because podman will not guess a
registry.

Note this cannot merge ahead of amd/skillscope#19: releases before it
reject `sandbox:` as an unknown key outright.
It resolved against the skill root, which is what gets published -- so
eval infrastructure would ship with the skill. A path written in a file
is also most usefully relative to that file. Both point at evals/, beside
the machine.yml that names it.

Found by writing the first real one.
A malformed sandbox declaration raised out of the whole behavioral
command, so one skill's broken setup discarded results for skills already
graded and paid for. On a real run that threw away three passing cases to
report that a fourth skill named a compose file that was not there.

It reports failed outcomes for that skill and carries on, which is the
same rule the structural gate already follows: a neighbour's mistake says
nothing about whether this run can proceed.
In an agent loop the user sees every assistant turn, so an expectation
about what the agent told them is satisfied by any of those. Reading only
the final turn credited the agent with its closing summary and called the
work missing -- on a real skill the judge reported that the agent "only
claims curl commands were delivered as text" when it had printed them a
turn earlier.

The submitted answer still comes last and is marked, because it is the
answer where the rest is working. The split the prompt relies on is
unchanged: actions are judged from tool calls and artifacts, and only
what the agent told the user is judged from what it said.
`inspect_swe` runs Claude Code inside the sandbox and reaches the model
through a bridge whose proxy is a Linux binary, so it cannot run on
Windows at all. This drives the same CLI on the host, the way the legacy
engine does, and maps what it did into inspect's messages -- so the
scorers, the artifact-reading judge and the .eval transcript all work
unchanged.

The point is fidelity. A skill is written for this harness, and grading a
different agent measures something customers will not experience. With
this the real harness runs on both platforms and only the isolation
differs: a container on Linux via --engine claude-code, the host on
Windows via --engine claude-cli, with the report saying which.

Unsandboxed by construction, which is what the legacy engine already
does, so it is not a regression. Refuses a container provider outright
rather than running the CLI in one filesystem and scoring another.

`build_task` gains a solver seam: what drives the agent swaps while
staging, scoring, judging and reporting stay put.
Legacy against inspect asks whether a different agent reaches the same
verdicts. Legacy against claude-cli asks something narrower and sharper:
both drive the same CLI, so agreement means the framework around the
agent is faithful, and a flip is a defect in the crossing rather than a
property of a different agent. That is the measurement that decides
whether the legacy path is redundant or merely superseded.

The engine list now lives in the CLI and is imported, so a new engine is
offered in both places at once rather than in whichever was remembered.

On the fixture the two agree on every case at 9 model calls and ~27s
each, which is what the same binary run twice should look like.
Every files_exist check on the claude-code leg of the first trial run
failed with "sandbox holds: nothing" -- nine of nine, across every skill
that asserts files. The agent was not doing nothing: a container starts
at /, so it worked there while the scorers read /workspace.

The other engines say this in a prompt, because their agent takes
instructions. This one takes a cwd. The directory is created before the
solver runs, since this leg's agent brings its own tools and so never
touches ours, which is what would otherwise have made it.

Read as a comparison, the old numbers said real Claude Code performs
worse than a harness-independent agent on these skills. They said
nothing of the kind.
Every engine but `legacy` runs on inspect_ai, so any of them can hit the
missing-extra path -- but the hint said `--engine inspect` regardless. A
Windows CI job that had passed `--engine claude-cli` was told to go look
at a flag it had never used.

The remedy is the same extra either way, so this only costs a reader
their bearings. That is enough.
The guard around the inspect dispatch was a literal tuple listing only
inspect and claude-code. `claude-cli` was added to the chain inside it
but never to the tuple, so it fell through to the legacy engine: every
run that asked for it silently got something else, while the report
recorded `engine: claude-cli` throughout. The preflight, which does key
off INSPECT_ENGINES, meanwhile demanded an extra the run never used --
which is how a Windows job failed on a flag it had not passed.

The absent .eval transcripts were the symptom that gave it away: legacy
does not write them.

This invalidates every claude-cli measurement taken so far, including
the benchmark that reported legacy and claude-cli agreeing 4/4 with cost
within 3%. They agreed because they were the same engine; the figure
matched the noise floor exactly, which should have been the tell.

Tests now assert which runner a flag reaches, and that the guard and the
choices list cannot drift apart again.
npm installs `claude` as a .cmd shim. The legacy engine starts it with
the synchronous subprocess.run, which copes, so this never surfaced
while --engine claude-cli was silently running legacy. Under inspect's
asyncio subprocess the path is exec'd directly and Windows answers
WinError 2 -- which reads as "the CLI is not installed" when it plainly
is, and which the report surfaced as the task failing rather than the
launch failing.
The react agent installs the skill through inspect's skill() tool, which
arrives as part of its toolset. A solver that replaces the react agent
replaces that too -- so claude-cli ran the real CLI against an empty
workspace and the agent answered from the prompt alone. It scored 4/21
on a skill every other engine passed 21/21, and finished faster doing
it, which was the only visible sign.

The seam is now a factory taking the skill's directory, because staging
is the driver's job: legacy copies the tree into .claude/skills, and
inspect_swe does the same through its own skills= argument. This does
what both already do.

Third invalid claude-cli measurement in a row. The first two were the
dispatch falling through to legacy and the CLI failing to launch on
Windows; this one produced plausible-looking numbers rather than an
error, which is why it needed the comparison to catch.
The probe ran `bash -lc exit 0` and read `success` to decide between
bash and PowerShell. But a guest without bash does not answer "that
failed" -- there is nothing to exec, so it raises before any result
exists. On a Windows host under the `local` sandbox that surfaced as
WinError 2 and took the whole task down, reporting 0/0 expectations,
which reads as the harness being broken rather than the probe learning
exactly what it asked.

Also drops the cmd.exe wrapper added last commit. It was reasoned from
npm shipping `claude` as a .cmd shim that Windows cannot exec directly.
The runner says otherwise: `which` resolves to claude.CMD and async exec
starts it with rc=0, as do sync Popen and cmd.exe. The wrapper fixed
nothing and added a shell layer between us and the agent's arguments.
inspect keeps a sqlite buffer so `inspect view` can watch a run in
progress. It lives under the user data directory, named after the task,
and on this runner that directory is the service account's profile --
long enough that a task named after a longer skill crosses MAX_PATH.
sqlite then answers "unable to open database file" and the whole task
dies. Measured rather than inferred this time: 241 characters writes,
260 does not, on the same machine in the same run.

Nothing watches a CI run live and the .eval log is written either way,
so the buffer is cost without benefit exactly where it breaks.

LOCALAPPDATA does not help -- platformdirs asks Windows, not the
environment, so the short path I set last commit was ignored. That
commit's reasoning was right and its remedy did nothing.
The `--timeout` deadline ends the process with os._exit, which takes the
report and the transcript with it, so an overrun says only that it
overran. inspect has its own per-sample limit and we were handing it the
whole budget -- so the two fired together and the hard kill won the
race.

Keep two minutes back. inspect stops the sample, scores what exists and
writes the log; the deadline stays as the backstop for a genuinely hung
process, which is what it is for.

Eight Instinct runs have now overrun. Not one of them said whether the
agent was working, waiting, or stuck, and two of the three diagnoses
that followed were wrong because there was nothing to read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant