Skip to content

Rebuild the eval engine on inspect_ai, keeping real Claude Code as the agent - #19

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

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

Conversation

@rominf

@rominf rominf commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds two eval engines built on inspect_ai, selectable with --engine, alongside the existing one. Nothing about the default path changes: --engine legacy stays the default, the new dependencies are optional extras, and a repo that has not migrated installs nothing new.

Three engines are now available. All of them drive the agent a skill is actually written for, so what differs is where it runs — not what it is, and not how cases are written or graded:

--engine Agent Runs in Platforms Legs
legacy the claude CLI, driven directly the host all routing, behavioral
claude-code the real CLI, via inspect_swe a container Linux only routing, behavioral
claude-code-no-sandbox the real CLI, under inspect_ai the host all routing, behavioral

Why. The existing engine hand-rolls machinery that a framework already provides: subprocess stream pumping, process-group termination, brace-scanning verdict extraction, and hand-rolled activation detection. Moving to inspect_ai buys a real sandbox, structured scorers, a judge that can read the artifacts a case produced (including images), .eval transcripts, retries, and time and cost limits.

A note on the isolation claim, which this PR no longer makes. An earlier revision argued that isolation was the substantive gain, on the evidence that one skill's cases scored 0/7 under the host-driven engines and 7/7 under a sandboxed one. That evidence does not support the claim, and I would rather say so here than have a reviewer find it.

Checking the trial runner's logs: its sandbox probe falls through to local when no container runtime answers, and it has been doing so since 2026-09-15. Exactly one Linux run was ever genuinely containerized, and the engine it graded was the harness-independent one this PR removes. Every claude-code result in the table below was produced on the host filesystem, by a leg that reported itself as sandboxed.

So the 0/7 → 7/7 split is real and its cause is not established. It is not the container, because there wasn't one. It is not a clean working directory either — both claude-code and claude-code-no-sandbox give each sample a fresh temporary directory, and they land on opposite sides of the split. One untested possibility is that inspect_swe overwrites $HOME/.claude/settings.json to set up the agent, which on a shared runner would also clear any user-level skills; that would make the isolation a side effect of a destructive write rather than a property worth shipping.

What this PR claims instead is narrower and checkable: the engines are the same agent in different places, the reports say which, and the guards refuse the configurations that silently lied about it — including the one that produced this table. --engine claude-code now refuses a sandbox that shares the host's filesystem, which is what surfaced all of the above.

Non-obvious decisions

  • The dataset contract is unchanged. evals.json and the CLI surface are untouched, so a product repo migrates by changing one flag.

  • machine.yml gains an optional sandbox: key naming a compose file beside it. Absent means the default container with no network — right for a skill that only reads and writes files. Name one to opt into egress or to bind a device in. examples/skill-with-a-device/evals/ is the worked pair: a machine.yml asking for GPU runners, and the compose file that binds the devices in and grants egress. The two are not substitutes — labels: decides which machine the job lands on, sandbox: decides whether the container on it can see the hardware that machine has.

  • Routing now runs on all three. It previously had one leg, and the other two were refused rather than allowed to fall through to legacy and report a container that never started. Routing is where isolation matters most — a stray user-level skill changes every case's decision rather than one case's grade — so claude-code is the leg to prefer there: its guest has no ~/.claude to contribute. claude-code-no-sandbox cannot read the CLI's session-init event, so it cannot notice or report that contamination, and therefore refuses to run a routing leg without ANTHROPIC_API_KEY, which is what lets it redirect the config dir. On stopping at the decision, claude-code is ahead of legacy: its calls cross inspect's bridge, so the one revealing the decision is declined before it runs, rather than observed after the fact and then raced to a process kill. The tool-call and inspection budgets ride the same path. claude-code-no-sandbox can do neither — its CLI's output is buffered until exit — so it runs to a message cap, bounded by --case-timeout and the CLI's own --max-budget-usd, and costs more per case. Each report records which caps actually applied rather than which were passed.

  • There is no harness-independent engine. An earlier revision of this PR added one — a framework-native agent, no claude CLI involved. It is gone. It carried a smaller tool set than the real harness, so a skill referring to a tool it did not have failed for a reason belonging to the agent rather than the skill, and nothing in the report distinguished the two. It never scored above the sandboxed real-CLI leg on any skill, and its routing numbers measured a different activation mechanism than the harness uses, so they were never interchangeable with the rest.

  • Windows runs unsandboxed, by construction. inspect_swe installs a Linux proxy binary in the guest and cannot run on a Windows guest at all, so claude-code-no-sandbox drives the CLI from the host instead. That is what the legacy engine already does on Windows, so it is not a regression — but it is not an improvement either, and reports now state sandbox_isolated rather than leaving a reader to assume.

  • inspect_swe is an optional extra, not load-bearing. claude-code-no-sandbox reaches the same real agent through inspect_ai alone, so only the claude-code cross-check depends on it.

  • The package gets bigger, not smaller. 7,124 lines against 4,938 on main. Retiring the legacy path later removes 798 measured lines and plausibly ~1,200 with the imports, constants and tests that exist only for it — so roughly a quarter to a third more code than today, in exchange for the machinery above. This PR removes none of it.

Non-goals

Retiring the legacy engine, and flipping the default. Both are follow-ups, deliberately: this PR is net-additive so it can be reverted by not passing a flag.

Test plan

Unit tests run in CI on Linux and Windows, alongside a key-free pass over the sandbox layer itself — a case seeds a file and expects that same file, with a stub solver standing in for the agent, so what passes or fails is whether the container started, the fixture staged, the guest listed and the listing matched. No model is called, so it runs on a pull request from a fork, which is where a cross-platform sandbox bug shows up.

The evidence below comes from running the whole catalogue of a downstream skills repository on self-hosted runners, through an enterprise Anthropic-compatible gateway, at one commit.

Routing, measured per case against a noise floor. Routing now runs on all three engines, so it can be compared rather than asserted. Whole room, one commit, one session, no degraded cases:

agreement
legacy vs claude-code-no-sandbox 60/67 (89.5%)
legacy vs itself, second pass 64/67 (95.5%)

Five of the seven engine flips are not explained by run-to-run variance. Aggregate accuracy, meanwhile, is 0.892 against 0.894 — near-identical, and it conceals every one of those flips. That is the reason this comparison is per case: the headline number agrees while the cases underneath it do not.

Cost on the same room: legacy $3.79 over 261 model calls, claude-code-no-sandbox $14.62 over 869 — 3.9×, which is the price of not stopping at the decision. claude-code stops earlier than legacy does; the host leg cannot, because its CLI's output is buffered until exit.

Both legs reported zero provider-degraded cases, which is what makes the comparison readable at all — a gateway outage during either pass would otherwise have shown up as an engine difference.

Behavioral, checks passed per skill (Linux). Read with the note above: the claude-code column was produced on the host filesystem, not in a container, so this is not a sandboxed-versus-unsandboxed comparison.

Skill legacy claude-code claude-code-no-sandbox
lemonade-router-builder 21/21 21/21 21/21
local-ai-app-integration 9/9 9/9 9/9
local-ai-use 7/7 timed out 7/7
magpie-kernel-evaluator 12/12 11/12 12/12
serving-llms-on-epyc 10/10 9/10 10/10
tracelens-analysis-orchestrator 0/7 7/7 0/7

claude-code-no-sandbox matches legacy on every skill. Both drive the same binary, so that agreement is the evidence that wrapping the CLI in the framework changes nothing about what it concludes — and it is the one conclusion in this table that does not depend on the sandbox question.

The tracelens row is the open one. Three engines ran on the same host and one of them scored 7/7 where the others scored 0/7, so something separated them; the note above sets out why it was not a container and not a working directory. Until that is explained the row is a real observation with no established cause, and it is visible at all only because more than one engine ran — which remains the argument for keeping more than one.

Windows. Real Claude Code runs there under inspect_ai via claude-code-no-sandbox: lemonade-router-builder 21/21 and local-ai-use 7/7, matching Linux. A second run of the same engine on the same machine scored lemonade-router-builder 15/21 with no change in between, so Windows run-to-run variance is not characterised — treat individual Windows numbers as provisional.

Known gaps

  • Two GPU skills have produced no result under any engine, including legacy. Their sandbox is verified to expose the devices and resolve DNS, and a case still overran an hour without reporting. Pre-existing, and not a regression from this PR.
  • Windows has no isolation and no measured variance floor (above).

Risk

Low for existing users — the default engine, the dataset format and the CLI surface are unchanged, and the new dependencies are optional extras. The risk is carried entirely by repos that opt in with --engine.

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.
It graded skills with a react agent carrying five tools where the real
harness has many more, so a skill referring to a tool it did not have
failed the case for a reason that belonged to the agent rather than the
skill -- and nothing in the report told the two apart. Every remaining
engine drives the agent a skill is actually written for; what differs
between them is where it runs.

Its behavioral numbers never exceeded the sandboxed real-CLI leg on any
skill, so nothing it measured is lost. Its routing numbers were a
different matter: it offered each skill as a tool definition and took one
turn, which is not how the harness finds a skill, so those numbers were
never interchangeable with the rest. Routing is now legacy-only and says
so, until a leg exists that drives the real CLI.

Removing it costs one piece of coverage, so that is replaced rather than
dropped. The old CI job proved the sandbox layer -- container starts,
fixtures stage, guest lists, expectations match -- by grading a fixture
with a mock model, and neither replacement engine can run key-free on a
fork's pull request. tools/sandbox_smoketest.py exercises the same layer
with a stub solver instead: the case seeds a file and expects that same
file, so nothing an agent does can pass it or break it, and the assertions
move from YAML into reviewable Python that also runs locally.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The old name said which binary it drove, which stopped distinguishing
anything once the engine that drove something else was removed. Every
engine drives the claude CLI now; what differs is where it runs.

The new name says that, and says it against its sibling: claude-code runs
the same agent in a container, this one runs it on the host. A reader
choosing between them no longer has to know that "cli" was the one without
isolation -- which is the property that actually decides which to use, and
the one a reader is most likely to get wrong in the direction that
overstates what a run proved.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Git tracks this as a rename, so the diff is the identifiers rather than
the file. `--engine claude-code-no-sandbox` now traces to
`engine/no_sandbox.py` and the solver it exports, instead of stopping at a
module named after the old flag value.

The guard's message and the sandbox docstring already said which engine
they belonged to; this makes the file agree with them.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Four, found by reviewing the removal rather than by running it.

The benchmark's routing mode could no longer complete. Routing has one
engine, so every pair is either the same engine twice or a candidate that
will be refused -- and the baseline leg runs first, twice under --noise, so
the refusal arrived after a full routing run had been paid for, reported as
a missing file rather than as the engine that was never going to run. It
now refuses up front, before spending anything.

The sandbox smoke test asked the harness which provider it had chosen and
then checked that answer against itself, which cannot fail. A run that
quietly got no container -- a stray SKILLSCOPE_SANDBOX, a change to the
default, an inverted platform check -- passed every remaining assertion
against the host filesystem and stayed green under the name "Sandbox
machinery". The expectation now comes from the caller, per platform, the
way the job it replaced did.

The refusal's own test asserted a word that appears elsewhere in the
message, so deleting the clause it was guarding still passed. It asserts
the clause now; confirmed by deleting the clause and watching it fail.

The rest is prose that outlived what it described: an install hint
defaulting to an engine argparse rejects, a module explaining its reason
for existing in terms of the deleted one, a comment pointing at a prompt
that no longer exists, and the machine.yml schema text skill authors read.

Also ignore inspect's .eval transcripts. One was staged by accident while
fixing the above, which is argument enough.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
`inspect_swe` prepares the guest for the CLI by writing
$HOME/.claude/settings.json outright, discarding whatever was there. In a
container that file belongs to nobody and the write is setup working as
intended. Under SKILLSCOPE_SANDBOX=local, $HOME is the developer's own,
and the same write destroys their real configuration -- permissions,
model, gateway environment -- with no backup and no warning.

`require()` already refused Windows and a missing extra. It did not refuse
this, so the combination was reachable by anyone who had set the variable
for the other engine, which is the engine that requires it.

Found by doing it. The recovered file was four weeks stale; there was no
newer copy to recover.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Routing ran on `legacy` alone, which made the sandbox the one thing it
could not have -- and routing is where isolation matters most. A stray
user-level skill does not spoil one case's grade; it is offered for every
prompt, so it changes every decision at once and the run still reports a
clean accuracy.

Both new legs reach the same `claude` CLI through inspect_ai:
`claude-code` inside a container via inspect_swe, `claude-code-no-sandbox`
on the host. They surface the agent's tool calls in different shapes, so
the crossing happens in one direction only -- a bridged ToolCall is
re-wrapped into the stream-json shape `routing.detect_activation` already
reads. Two graders that must agree about what counts as an activation
would drift; converting data is cheap.

The host leg refuses to start without the credential that lets it redirect
CLAUDE_CONFIG_DIR. The legacy engine only warns, because it reads the CLI's
init event and can name a skill that gate-crashed the room. Neither leg
here gets that event, so the same contamination would be invisible -- and
invisible is the part that matters. Observed, not feared: a probe of this
leg put roughly forty of the host's user-level skills in the room and none
of the three that were staged.

Stopping at the decision is deliberately absent, and the module says why:
on the host leg the subprocess buffers until exit, so the decision is only
visible once it is paid for; on the sandboxed leg an approval policy could
do it, but approval runs before the bridge adopts the assistant message, so
terminating on the tool call that reveals the decision may discard the
observation it fired on. That failure is silent and looks exactly like an
agent correctly declining to route. It waits on a container run.

What contained a run is now passed to `_finish_routing` by the leg that
knows, as required keywords, rather than derived. Deriving it from the
engine's name is what reported a container for runs that never started one.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
`test_a_container_provider_is_allowed` called `verify.require()` and
expected None. It passed locally and failed on every unit job: that
function checks the sandbox, then imports inspect_swe, and the unit suite
runs without the extras on purpose.

The test meant something narrower than it asserted -- that a container is
not refused *for being a container* -- so it asserts that instead, and
tolerates the run stopping on a missing wheel, which is a different answer
to a different question.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
They patched `sandbox.is_windows`, which `verify.require()` never consults:
it reads `sys.platform` itself, and reads it before the sandbox check. So
on a Linux runner these measured the sandbox guard and on a Windows one
they measured the platform guard, under the same names -- passing locally
and failing on every Windows job.

Both levers are pinned now, so the class measures the same thing wherever
it runs. Checked by running the whole suite with every platform-branching
module told it is on win32.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The docs, the benchmark's refusal, and its docstring all still described
routing as legacy-only. They were written when that was true and were left
behind by the legs that made it false.

The replacement says more than "all three work". Which leg a routing run
uses matters more than it does for behavioral, because a stray user-level
skill changes every decision at once rather than one case's grade -- so the
docs now state what each leg does about that, and that the two
inspect-backed legs cost more per case because neither stops at the moment
a skill activates.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
It did not reach them at all. The only bound on a sample was the whole
command's remaining budget, so one hung prompt could spend the run -- the
exact thing the flag exists to prevent, as `deadline`'s own docstring says.
The report meanwhile recorded `case_timeout` as though it had applied,
which is the same "a cap that is reported and not enforced" defect this
branch has now fixed twice elsewhere.

inspect's `Task(time_limit=)` is per sample, which is per case, so the flag
maps onto it directly. Clipped to what `--timeout` has left, for the reason
the behavioral cap keeps a reserve: the command deadline ends the process
outright and takes the report with it, so a per-case cap that outlives it
turns a timeout into silence.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Legacy kills a routing case the moment a skill activates, counts tool
calls, and passes the CLI's own spend cap. The new legs did none of that:
a case ran to a message cap whatever happened, so a decision made on turn
one was paid for to turn thirty.

The sandboxed leg now stops earlier than legacy manages. An approver sees
each tool call the bridged CLI *proposes*, so the case ends before the work
happens, rather than after it has run and the process is raced to a kill.
The tool-call and inspection budgets ride the same path, reusing legacy's
own BOOKKEEPING_TOOLS and inspection test so the counts mean the same
thing.

The skill's name goes into the approver's reason, and the outcome mapper
reads the transcript first and that reason second. Approval runs before the
bridge adopts the assistant message, so terminating on the call that
revealed the decision can discard it -- and a suppressed activation is
indistinguishable from an agent that correctly declined to route. Two
sources, one of which cannot go missing, rather than one that probably
survives.

The host leg gets the CLI's own --max-budget-usd, probed first the way
legacy probes it. It still cannot stop at the decision or count calls: its
stdout is buffered until exit, so the decision is only visible once it has
been paid for. Recovering that means driving the subprocess directly, which
is what legacy already does.

The decision rule is a pure function so it is tested in CI. The unit suite
runs without the inspect extra on purpose, and a rule reachable only
through an approver would have had no coverage where it matters.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The docs claimed neither inspect-backed leg stops at the decision. That was
true when written and stopped being true an hour later: `claude-code`
declines the call that reveals the decision before it runs, which is earlier
than `legacy` manages.

Replaced with the per-leg difference, since that is what decides what a
routing run costs -- and with the note that the report records which caps
applied rather than which were passed, because a cap that is reported and
not enforced has now been the same defect three times on this branch.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
rominf added a commit to amd/skills that referenced this pull request Sep 23, 2026
The candidate engine has run against one skill in a container on a
laptop, and against fixtures in hosted CI. It has never run here: these
runners, this catalogue, this gateway. Routing first, because it starts
no sandbox and so cannot be blocked by whatever runtime the runner has --
and because one model call per case makes it the cheap half.

Scoped to its own branch and reporting rather than gating. It is not part
of this repository's CI and deleting the branch removes it.
rominf added a commit to amd/skills that referenced this pull request Sep 23, 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.
A gateway 504 lands in a report as a lower score with nothing in the
verdict saying why, so a reader cannot tell it from the skill failing. On
one catalogue these have been observed on roughly a third of runs, which
makes an unmarked provider error the most likely reason two runs of the
same engine disagree -- and therefore the first thing to rule out before a
difference between engines means anything at all.

Three parts. The reason is kept: a result event puts it in `result` or in
`subtype` depending on how it failed, and collapsing both into "result
event reported an error" discarded the one thing that made the failure
readable. Observed doing exactly that to a 504. Then it is classified, with
deliberately generous matching -- a false positive makes a reader look
twice at a run that was fine, a false negative lets an outage score as a
routing miss, and those costs are not symmetric. Then it is counted, in the
totals of both commands and on both engines, so the two cannot describe an
outage differently during one.

The report says so above the verdict table rather than below it. A reader
who has taken in the score has already formed a view, and a note underneath
does not undo it.

What is not guessed at: an error with no stated reason is neither marked
nor excused, and `error_max_turns` is the agent running out of road rather
than the gateway. Marking that degraded would excuse a real failure.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Only the legacy engine runs evals/hooks.py. No inspect-backed engine builds
the ctx those hooks take, so on those engines the file is simply not read --
and not read leaves no trace. The case is graded as though its setup
happened, and the failure turns up later as a skill that mysteriously does
not work on this runner.

The catalogue this was written against ships one: serving-llms-on-instinct
uses setup to clear stale vLLM containers and teardown to remove them.
Skipping that on a shared GPU runner leaks containers holding GPU memory
into whatever runs next -- the same contamination the sandboxed engines
exist to prevent, arriving through the door left open by removing the
engine that closed it.

So a behavioral run whose skills ship hooks the chosen engine cannot
execute now stops, naming every skill affected, before the model probe --
finding out the credentials are fine is no use when the run could not have
honoured the setup anyway.

Routing is exempt: it installs the skills, asks which one fires, and
executes nothing.

This is a gate on retiring the legacy engine, not a workaround. Either the
inspect path grows hook support or the remaining hooks go; until one of
those happens, the refusal is what keeps the choice visible.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
On the real runner the host leg errored two cases the legacy engine graded
`correct_trigger` and `true_negative`, both with "the sample produced no
agent messages". The runs had happened. The driver appended a closing
message only when the CLI had something to say, so a run that finished
without a tool call and without a closing sentence left an empty message
list -- and the routing mapper, whose job is to tell "reached for no skill"
from "never ran", could not tell them apart from nothing.

An agent that declines to route is the case a routing eval most needs to
grade, so losing exactly those is the worst shape the bug could take.

The distinction still exists, in the right place: a stream carrying no
result event at all is a CLI that did not reach the end, and still produces
nothing. Only a finished run with an empty answer is now recorded, using
the placeholder `state.output` has always used for the same case -- the
message list was the half that disagreed with it.

The rule is a pure function so it is tested without the inspect extra. The
first version of these tests needed it, and would have gone untested in CI,
which is exactly where this failure came from.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The first sandboxed routing run on a real container graded 24 of 67 cases
as "the agent never ran". They had run: the legacy engine saw those same
cases activate a skill. The mapper read `sample.messages` alone, and for a
bridged agent that record is not reliable -- inspect adopts one conversation
onto the sample following heuristics about which thread is the main one, and
a run ending inside a sub-agent can leave it holding the wrong thread or
none at all.

The transcript is strictly more complete: every bridged generation emits a
ModelEvent, sub-agents included. This was known before the leg was written
and the leg was written without it, which is the whole of the bug.

Both records now, in order and de-duplicated by identity, because the same
turn appears in both when both have it and the routing decision is the first
skill reached for -- counting one turn twice could move it.

The distinction the guard exists for survives: a sample with neither record
still reports as never having run, because that is an infrastructure failure
and grading it as a miss would invent a routing result.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Three faults in the approver, all found by the first sandboxed run that
worked, all pushing the same way: terminate a case before it chose.

The tally was captured when the approver was built, and an approver is
built once per task -- so a per-case budget was really a per-run one. It
crept up until it crossed the budget and then terminated any case that made
a counted call before reaching for a skill. Visible from outside as four
cases stopped at tallies of 10, 11, 12 and 13: consecutive, across
different prompts. It lives in inspect's store now, which is scoped to the
sample.

`_is_skills_inspection` takes (tool_input, skills) and was being handed
(tool name, JSON arguments). It therefore never recognised a survey of the
installed skills, which is why every budget message in that run read "0
inspection(s)".

And a survey was spending both budgets rather than only the inspection one.
Legacy makes the three categories exclusive and says why: ending a run
mid-survey scores deliberation as a missed trigger. That is precisely what
this did.

None of it changes a verdict that was reached honestly -- a case that chose
is still graded on what it chose. What it changes is how many cases got to
choose at all.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Four cases on the real runner were reported as "the sample produced no
agent messages, so the run never made a routing decision". They had hit the
message cap. What survives on the sample when that trips can be the prompt
and nothing else, so the check for an assistant turn found none and called
it an infrastructure failure.

It is the opposite of one. An agent that spent its whole budget without
reaching for a skill is the clearest missed trigger there is, and the
legacy engine grades its own equivalent -- `tool_budget` -- as a verdict
rather than an error. Reporting it as "never ran" both loses the finding
and blames the machine for the agent's behaviour.

A sample carrying a limit therefore counts as having run. A sample with
neither a limit nor an assistant turn still does not, because that is the
case the guard exists for.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The sandboxed leg reported ten tool calls for cases the approver had
terminated at five. Both numbers were right about different things: the
approver saw each call once, and the mapper saw each twice.

De-duplication keyed on object identity. The same turn arrives as two
different objects -- one adopted onto the sample, one carried by the
transcript event -- so identity never matched and every call was counted in
both. Only the sandboxed leg was affected: the host leg has no bridge, so
no transcript events to duplicate, which is why its distribution looked
sane while the sandboxed one ran to ten.

It keys on message id now. The column was the visible half; the effective
budget was the other, because a doubled sequence crosses a per-case cap at
half the calls it should.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The previous commit went out with two failing tests: the stand-in ToolCall
gained an id in the assertions but not in its constructor. I chained the
push onto a grep for failures, which matched, succeeded, and let the push
run anyway.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Two runs of one engine disagreed on six cases against a noise floor of one,
and the reason was not visible in either report: 26 of 67 cases had stopped
within a single call of a budget. Those are coin-flips. One more call either
way moves them across the line and takes the verdict with them, and in the
report they look exactly like cases decided on their merits.

The count now sits in the totals and above the verdict table, because a
reader who has taken in the score has already formed a view about what the
run measured.

Not corrected, reported. The budget is doing its job -- stopping a case that
is rummaging rather than choosing is the whole point. What was missing is
any way to tell a flip that means something from a threshold artefact, which
is the difference between two runs disagreeing and two runs being unreadable.

Caps a leg did not set are not thresholds: a leg that cannot enforce a
budget reports none, and this invents none for it.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The cap asks one question -- is this agent still choosing, or has it started
work -- and the answer depends on where it is standing. Measured over one
67-case room: legacy averages 0.58 tool calls before deciding and peaks at
4; the same agent in a container averages 2.46 and peaks at 10, because it
is orienting in a filesystem it has never seen. That is not worse routing.

Held to a flat 4, it put 24 of 67 sandboxed cases within one call of the
threshold. A third of that run measured the budget rather than the agent,
and the leg scored lower for it -- 0.851 against 0.91 -- which then read as
a difference between engines. Two engines judged by one number while doing
different amounts of unavoidable work is unfair by construction, and
repeating the comparison only measures the bias more precisely.

Scaled rather than replaced, so a caller asking for a tighter budget still
gets one everywhere. The report states the cap that was enforced rather than
the one requested, because otherwise the near-limit count is measured
against a threshold that never applied.

Three is calibrated from that measurement and clears the observed peak with
room to spare. It is a measurement, not a constant; recalibrate it if the
room or the image changes.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The factor of three came from a mean of 2.46 tool calls per sandboxed
case, which summed two unrelated populations: cases that call the skill
tool immediately, and cases that never find a skill and rummage until
something stops them.

Separated, the first group -- the only one the budget can meaningfully
cut short -- decides at a mean of 0.31 calls, median zero, peak four.
Thirty-two of thirty-nine call the skill tool first with no preamble at
all. The cases sitting near the old threshold are all in the second
group, and budget cannot rescue them: they score no_activation at four
calls or at forty.

So the scaling bought no accuracy and spent real money doing it. Two
keeps headroom over the observed decision peak without funding the
agents that are lost.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
This leg answered the routing question on its first tool call and then
went on to do the entire job: downloading trace files, running the
analysis, writing reports, spawning subagents. Measured over one 67-case
room, 152 of its 220 tool calls happened after the decision it was being
asked for, and nothing read any of them.

The cause was structural. It awaited inspect's `subprocess`, which
returns only once the process has exited, so nothing it produced could
arrive in time to end a run. The sandboxed leg has an approver because
its calls cross inspect's bridge; this leg's CLI is a plain subprocess
with no such interception.

So apply the same rule from the other side, against the `stream-json`
the CLI prints as it goes, and kill the process group the moment it
fires -- which is what the legacy engine has always done, and this leg
is what replaces the legacy engine.

Opt-in, because the two callers want opposite things from one CLI: a
behavioral case is graded on what the skill produced and has to run to
the end. Left unset, the driver takes the old path unchanged.

The rule is handed over as a factory rather than a closure: the solver
is built once and run per sample, so a shared tally would carry one
case's spent budget into the next -- a bug already shipped once in the
approver.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
A bridged turn reaches the mapper twice -- adopted onto sample.messages
and carried by a transcript event -- and the two copies were being
counted as two turns.

De-duplicating by message id looked like the fix and was not. The bridge
builds a fresh message when it adopts the turn rather than moving it, so
each copy carries an independently generated id and the keys never
matched. The provider's tool call ids do survive the crossing, which
makes them the only stable name the turn has.

Measured on one 67-case room: 299 tool calls reported where the
transcript held 226, and case-by-case as bad as 23 against 12.

Verdicts were never affected -- an activation is found by presence, not
by count -- but every tool-call column was, and so was the headroom the
budget was calibrated against.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Both legs that build the CLI's command line pass --effort into it. The
sandboxed legs go through inspect_swe, whose `effort` argument they
never filled -- and its default is not "match everyone else", it is
documented as leaving the model's own default reasoning effort in place.

The behavioral leg was worse: `run` takes an effort, and dropped it on
the way to `build_task`, which had nowhere to put it.

So the trial compared a leg thinking as hard as it was told against one
thinking as hard as it liked, across the whole catalogue, and read the
difference as a fact about isolation. Any leg-to-leg gap measured before
this needs measuring again.

Pinned by tests that read the call sites, because the alternative is a
live sandboxed run and a silent revert here would look like a finding
about skills rather than about the harness.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The previous commit passes `effort` to claude_code(). 0.2.70 does not
take it, and the extra's floor was 0.2.70 -- so a runner that already
had it satisfied the requirement, never upgraded, and raised
`TypeError: Unexpected keyword argument(s): effort` inside a task
inspect had already started. The sandboxed leg produced no report at
all while the run reported success.

Raise the floor, and check the capability in `require()`, which exists
for exactly this: a legible refusal before the run rather than a
traceback partway through one.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
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.

2 participants