Generalise the harness and close the defects that made runs look verified - #70
Draft
KarthikAvinashFI wants to merge 45 commits into
Draft
Generalise the harness and close the defects that made runs look verified#70KarthikAvinashFI wants to merge 45 commits into
KarthikAvinashFI wants to merge 45 commits into
Conversation
KarthikAvinashFI
changed the base branch from
feat/pluggable-harness
to
feat/hosted-bundle-v2-production
August 31, 2026 05:59
…hoose the reference
… state the execution doctrine
…body under the limit
…e was assumed to be
…correct the containment claims
…culated was wrong
…nd say what was proved
… is what names it
… and refuse it at contract time
…ing the agent for it
KarthikAvinashFI
force-pushed
the
experiment/autonomous-harness
branch
from
August 31, 2026 17:08
af4b58b to
87856e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Why
The harness could only test agents it had already been fitted to. Two separate problems sat behind
that, and this branch takes both.
It enumerated the agents it knew. The run stage branched on a fixed set of connectors, and the
build stage worked through two tools (
write_env_file,run_env_command) that only ran containercommands. An agent reached some other way, or needing anything a
dockercommand could not do,could not be tested without a code change here first. Supporting a new kind of agent meant a
release of this repo.
Several stages reported a state they had not established. The worst of them:
proberecordedevery tool that lives in the submitted agent's own runtime as passing, with the detail "executes
inside the submitted agent runtime", without executing it. In the hosted lane that runtime does not
exist yet when
proberuns, so a world where nothing had been exercised scored perfectly and wenton to grade an agent against sub-goals it could not measure.
The concrete case this is for: an agent repository submitted by someone else, that nobody here has
read, which does not look like the two we built against.
2. What changed
The build stage gets a shell
src/fi/alk/harness/build.py:297builtins=("AskUserQuestion",)("AskUserQuestion", "Read", "Glob", "Grep", "Write", "Edit", "Bash")write_env_fileandrun_env_commandThe two world tools it replaces are removed from
world/tools.py, andworld/workspace.py, whichbacked them, is deleted.
The deny-by-default permission model is not removed.
UNWANTED,gate_hooksandpermission_gateall stay, and every other stage is bounded exactly as before. What changed is oneline: the hidden-tool list is now filtered against what the stage was granted, so a stage that asks
for
Bashkeeps it instead of being silently outranked by a blanket denial.backends/claude.py:188disallowed_toolsbecomes[name for name in UNWANTED if name not in set(allowed)]config.py:166UNWANTEDunchanged in content; its comment now states that a granted tool is keptgate_hooksandpermission_gateunchangedconfig.py:127read_only_sessiongains a docstring arguing why the stage that reads the customer's source keeps no shell, and anextra_builtinsescape per sourceRead,Glob,Grep,AskUserQuestionThe reasoning for keeping the gate, and for the grant meaning the same thing hosted and locally, is
in
config.py:215. A sandbox bounds the hosted lane, but the same stages run in-process on anoperator's machine where nothing bounds a shell, and a grant that means two different things stops
the local run being a rehearsal of the hosted one.
Transports become a declaration
New file
src/fi/alk/harness/transports.py(293 lines).A transport is resolved from a declaration (
transport.json) rather than from a branch in the runstage. Each transport carries its own
claimspredicate, so it recognises its own agent, and thebuild stage either names one this repo implements or writes a runner and declares where it lives.
TransportUnresolvedis raised before any world is leased rather than surfacing as a failedscenario twenty minutes in.
transports.pyTransport,Evidence,TransportUnresolved, declaration loading, runner import bymodule:Attributehosted_scheduler.py_register_builtin_transportsregisters the two this repo implements (LiveKit, chat), each with its ownclaims;_transport_requiresreads what a runner owesGates that prove rather than assume
world/probe.py:267ProbeReport.unprovenlist instead of being recorded as a passunprovenis kept out of the score in both directionsworld/probe.py:446verify_runtime_tools(world, contract) -> RuntimeToolVerdict. Calls the agent's own tools against the built world once the runtime is upworld/probe.py:426RuntimeToolVerdictwithchecked,broken,reason,tools, andok = checked and not brokenhosted_scheduler.py:1532_verify_worldruns that gate once per world, keyed by the runtime object rather than its pool index, and demotes a world that fails itRuntimeToolVerdictexists to keep three outcomes apart: tools were called and none was broken,tools were called and these were broken, and there was no way to call anything. The third used to
read as the first. The seam is checked before the tool list for the same reason: a world with no
forwardseam also cannot say which tools it holds, so asking what it declares answers "none" fora world that was never able to answer, which turned the gate into a silent no-op on the whole
hosted lane.
All of
_verify_world's outcomes log at WARNING, deliberately: the hosted guest emits WARNING andabove, so an INFO line there is indistinguishable from no line at all.
An unbuildable repository refuses, instead of crashing
build.py:90EnvironmentNotBuildable(RuntimeError)carriesproblemsas data, not only as a formatted messagerequire_buildablestill raises on the same condition, with the same message textbuild.py:108record_refusalwritesenvironment-refusal.json;refusal_atreads it back and distinguishes unreadable from absenthosted_entrypoint.pyThe stage that decides this and the process that reports it upward are different processes, and
what crosses that boundary is an exit status. A non-zero exit reads as "the guest crashed", which
was classified as retryable and spent a second sandbox re-deriving the same refusal.
Receipts
hosted_scheduler.pycall_evidence_faultsholds a runner to what its transport declared it owes;CallEvidenceMissinggets its own code and carries the outcome it rejectedevidence_missing/simulator rather than being relabelled as an unrenderable runnerhosted_scheduler.py"requires": []is treated as a declaration that the runner owes nothing; only an absent key inheritsWorld kinds
world/kinds.pyROW_STORESregisters postgres, postgresql, mysql, mariadb and clickhouse againstSqliteWorldSqliteWorlditself is unchanged; it readsworld.state(), so any row store is the same shape to look atworld/kinds.py:228Skills
Twenty files,
+1437 / -245. The organising change is that a stage skill states how the stageworks for any agent, and everything that differs between kinds of agent moves into
references/*.mdthat the model selects after it has read the contract.config.py:sub_skillsbuilds the catalogue from each reference's frontmatterdescription, nameand description only. The body stays on disk until the model asks for it, so a stage carries an
index of everything it could do at a fraction of the cost of carrying all of it, and adding support
for a new kind of agent is a file in a directory rather than a release.
_summarisereads thefrontmatter specifically, because falling back to the first line of prose summarises a skill as
---and a catalogue that describes nothing cannot be chosen from.skills/harness.md+48. Adds the division the rest depends on: the model decides and writes, the code executes, and no model is in the loop at call time. Also states that phases are checkpoints you may return to, and that memory is the files on disk rather than the conversationskills/build-environment/SKILL.md+80 / -11. New section on working out what kind of agent this is from evidence before choosing a reference, and when to ask instead of guessing. New credentials section: never print, echo, log or seed a credential value, because anything printed reaches the guest log, which is captured into artifacts that outlive the sandbox. The tool instructions change fromwrite_env_file/run_env_commandtoWrite,Edit,Bash,Read,Glob,Grepbuild-environment/references/_writing-a-runner.md,voice-livekit.md,voice-hosted-platform.md,voice-bland.md,voice-multi-actor.md,browser-and-computer-use.md,retrieval-and-assistants.md. Each opens with a selection check that restates the evidence justifying it, and says what it is not forbuild-environment/scripts/probe_voice_providers.py(101 lines) checks provider credentials and prints status codes only, never binding the response body, because a provider error can quote the credential it was sent.check_call_evidence.py(100 lines) checks a receipt against what the platform rendersskills/write-scenarios/SKILL.md+31 / -94. Cut to the stage method; the framework and the per-modality method move to referenceswrite-scenarios/references/_framework.md,_authoring-code.md,voice.md,chat.md,coding.md,cua.mdskills/understand-agent/SKILL.md+28 / -4skills/provision-environment/SKILL.mdbuild.open_stagehas always usedbuild-environment.ALK_HARNESS_PROVISIONstill selects the alternate opening line atbuild.py:320Contract, bundling, seed
contract.pyhttpis no longer silently aliased to thefi.alkenvelope, and there is now a way to declare neitherbundle_author_v2.pyagent.pysession.py:156_elidedshortens a long label from the middle, keeping the end that names the file_elidedis small and worth one line of why: every skill file under a stage shares a prefix wellpast 77 characters, so truncating from the end recorded three reads of
.../skills/write-scenar...and could not say whether the model had opened the skill body or one of its references. Whether the
reference catalogue is used at all is the question this branch most needs answered, and the display
format was the only thing preventing it being answered from a log we already had.
3. Behaviour callouts
the hosted lane a sandbox contains it. Run locally, nothing does. The skill tells the stage to
keep its work inside the run's own directories, which is an instruction, not an enforcement.
that previously completed with a perfect probe score and meaningless sub-goal results will now
either fail that gate or report
unproventools.ProbeReport.scorechanges meaning for any contract with runtime tools. Those tools used tocount as passes; they now count as neither.
guest_crashed/infrastructureclassification.Behaviour is unchanged; the assumption is just no longer silent.
4. Tests
Nine new files, 109 test functions, plus additions to four existing files.
tests/harness/test_runtime_tool_gate.pyforwardseam reportschecked=False, not a pass; a refusing tool counts as working; a hosted-shaped world with neitherforwardnorruntime_toolsdoes not read as cleantests/harness/test_written_runner_loading.pymodule:Attributeloads from the bundle and imports cleanly on its owntests/harness/test_runner_conventions.pytests/harness/test_environment_refusal.pytests/harness/test_chat_envelope.pyhttpto thefi.alkenvelopetests/harness/test_generated_entrypoint.pyagent.pytests/harness/test_sqlite_to_postgres_schema.pypkis not a boolean, andNOT NULLandDEFAULTsurvive translationtests/harness/test_permission_gate.pytests/harness/test_world_kind_selection.pytests/harness/test_suite_review.pyExisting files:
test_hosted_scheduler.py+119,test_harness.py+196 / -94,test_hosted_entrypoint.py+51 / -17,test_bundle_author_v2.py+5 / -3.5. Commands
Terminal output
<image: full suite result on this branch>
6. Steps to test on local
experiment/autonomous-harnessand runuv sync.uv run pytest tests -q. Record the pass and fail counts.feat/hosted-bundle-v2-productionand run the same command. Confirm the failure set isthe same, so nothing here is a regression against the base.
verified N runtime toolsorno runtime tools declaredline atWARNING before the first graded call.
the job reports a refusal naming each tool and what the repository must expose, and that it does
not consume a second sandbox attempt.
Video
<video: a hosted run reaching the runtime-tool gate, and a refusal on an unbuildable repository>
7. Scope in / scope out
In scope: the build stage's tool grant, transport resolution by declaration, the runtime-tool
gate and the probe reporting behind it, the refusal path, receipt requirements, row-store world
kinds, and the skills reorganisation.
Out of scope:
generated chat world stands up no tools endpoint to point it at, so no tool-based sub-goal can
pass however the agent behaves. The bundle now says so rather than grading the agent for it. The
fix is a design decision, not an implementation, so it is deliberately not attempted here.
identical hosted and locally so that a local run is a rehearsal of the hosted one; making them
differ would defeat that. Whether that trade is right is one of the things this PR is open for.
ALK_HARNESS_PROVISIONandbuild.py:320are left in place. The skill file the legacy pathonce paired with was already unreferenced; removing the flag as well would widen the diff without
changing behaviour.
SqliteWorldis not renamed. The name is now inaccurate, since it describes the shape of thestate rather than the engine, but renaming it touches the registry, the contract vocabulary and
the skills, and it changes nothing at runtime.
8. Design choices
makes the grant mean something: a stage's tool list is a statement about what that stage is, and
a blanket denial made that statement unenforceable in one direction while a blanket allow made it
unenforceable in the other. Filtering keeps both true.
PreToolUsehook rather than the permission callback alone. Anallowed_toolsentryauto-approves before
can_use_toolis consulted, so the callback never sees anything granted. Thehook is consulted on every call, which is what makes deny-by-default true rather than intended.
unprovenas a third state, kept out of the score. Folding it into either passes or failuresreintroduces the original defect in one direction or invents failures in the other.
reconcile replaces a demoted world at the same index, and that replacement is exactly when the
check matters most, because the world it replaced may have been demoted by this gate.
process are different processes, and an exit status cannot carry a per-tool remedy.
index of everything it could do rather than the contents, and a new kind of agent is a file rather
than a release.
9. What this does not claim
authoring stage that saw its tools needed a package from
requirements.txt, wrote that theruntime would have the full environment and that binding would happen there, and then failed a
gate that asked for proof of binding and found none. Two things here bear on it, and neither has
been run against it. The build stage can now install the package and execute the thing, rather
than only writing a file and running a container command, so the deferral is no longer the only
move available. And
verify_runtime_toolsmakes the proof itself explicit and separatelyreportable, so "nothing was proven" is distinguishable from "nothing was broken". What is
unchanged is that a run still stops when binding cannot be proved, and correctly so. Whether the
extra capability is what the stage was actually short of is the untested part.
development: four on a voice agent, all completed, two fully clean, with real audio throughout and
14 to 22 turn conversations carrying four recordings each; and four on a chat agent shipping no
Compose file, no Dockerfile and no data store, ending in a graded pass covering contract, invented
world, generated scenarios, live conversation, observed tool calls and graded sub-goals. Those
runs were made before this branch was rebased onto its current base, so they are a record of what
the approach did, not a verification of this exact tree.
the stage does something outside the run's directories on an operator's machine.