Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
175 commits
Select commit Hold shift + click to select a range
8957ede
feat(scenarios): declare writer workers and drive them through each b…
Sep 1, 2026
d5cf40c
fix(scenarios): name the writer worker the same on both backends
Sep 1, 2026
0061399
fix(scenarios): let the writer workers be the only fan-out, and stop …
KarthikAvinashFI Sep 1, 2026
d03de45
merge: bring feat/hosted-bundle-v2-production into the scenario branch
KarthikAvinashFI Sep 2, 2026
4d3c246
feat(scenarios): declare the scenario axes as data, per modality
KarthikAvinashFI Sep 2, 2026
ff16578
feat(scenarios): derive a coverage grid from the contract and sample …
KarthikAvinashFI Sep 2, 2026
23ef2af
feat(scenarios): expand a proved scenario across the callers it stays…
KarthikAvinashFI Sep 2, 2026
8f44b0e
feat(scenarios): plan the stage from the grid, give it full tool acce…
KarthikAvinashFI Sep 2, 2026
d30d072
test(scenarios): cover the grid, planning and suite-editing tools
KarthikAvinashFI Sep 2, 2026
c124252
fix(scenarios): weight the suite so it is not half adversarial, and h…
KarthikAvinashFI Sep 2, 2026
d99032d
fix(harness): keep the operator reachable from an ungated stage
KarthikAvinashFI Sep 2, 2026
1c236e3
feat(harness): give every backend the same host tools, reading and ru…
KarthikAvinashFI Sep 2, 2026
55c0a9f
test(harness): pin the shell tool's boundaries
KarthikAvinashFI Sep 2, 2026
03908b9
fix(scenarios): refuse a scenario naming a condition its world does n…
KarthikAvinashFI Sep 2, 2026
087df25
fix(scenarios): take the shortest path to a cell, and reject placehol…
KarthikAvinashFI Sep 2, 2026
31d855c
test(scenarios): pass is not seeding, so the objection stands
KarthikAvinashFI Sep 2, 2026
190f8aa
fix(scenarios): accept a condition asserted in the starting data, and…
KarthikAvinashFI Sep 2, 2026
278197d
fix(harness): resolve python in the shell to the interpreter the harn…
KarthikAvinashFI Sep 2, 2026
d4a71b2
fix(scenarios): scope the agent's rules to the cell, and stop delegat…
KarthikAvinashFI Sep 2, 2026
8cb9165
feat(harness): record what a stage spent its turns on, and say so whe…
KarthikAvinashFI Sep 2, 2026
17caf41
feat(scenarios): record what each tool refuses until, and plan cells …
KarthikAvinashFI Sep 2, 2026
c59f278
refactor(scenarios): let the model choose what to test, and keep the …
KarthikAvinashFI Sep 2, 2026
7889632
docs(harness): state the principle rather than one agent's measurements
KarthikAvinashFI Sep 2, 2026
1ae413b
fix(scenarios): keep the universal axes free of any one modality's vo…
KarthikAvinashFI Sep 2, 2026
78930da
fix(scenarios): let a fan-out save, by sharing one list between the s…
KarthikAvinashFI Sep 2, 2026
d2e3ca9
fix(scenarios): withhold drop_scenario from writers, as saving alread…
KarthikAvinashFI Sep 2, 2026
79160e6
test(scenarios): pin that a writer's accepted scenario is what the st…
KarthikAvinashFI Sep 2, 2026
7e93491
fix(scenarios): deal caller names out per writer, since a writer cann…
KarthikAvinashFI Sep 2, 2026
9e0c3b5
fix(scenarios): hold the world for the length of a proof, and refuse …
KarthikAvinashFI Sep 2, 2026
4886cd3
feat(scenarios): plan a suite as one line per scenario before any of …
KarthikAvinashFI Sep 2, 2026
b832330
fix(scenarios): deal accents and locations out with the names, which …
KarthikAvinashFI Sep 2, 2026
8d53059
feat(scenarios): expose the blueprint to the stage as record_blueprin…
KarthikAvinashFI Sep 2, 2026
a00d5f8
feat(scenarios): plan a large suite before writing it, as its own skill
KarthikAvinashFI Sep 2, 2026
dfbf3c3
feat(scenarios): brief writers from the plan, dealt so no writer owns…
KarthikAvinashFI Sep 2, 2026
88a018a
refactor(scenarios): one skill tree with plan and write sub-skills, a…
KarthikAvinashFI Sep 2, 2026
6497fc1
fix(world): share one store engine per image, with a database per world
KarthikAvinashFI Sep 2, 2026
d927a02
feat(scenarios): report the shape of a suite too large to read
KarthikAvinashFI Sep 2, 2026
1fde3e3
feat(scenarios): make thinking a knob, set separately for the planner…
KarthikAvinashFI Sep 2, 2026
fad1439
fix(scenarios): wait for writers, which were launching in the backgro…
KarthikAvinashFI Sep 2, 2026
1174c5b
fix(scenarios): hold the world for reading it too, since restore rewr…
KarthikAvinashFI Sep 2, 2026
3d745fb
perf(scenarios): send one preamble and only the method the stage is u…
KarthikAvinashFI Sep 2, 2026
f180e22
docs(scenarios): name the failure where a situation becomes a unit test
KarthikAvinashFI Sep 2, 2026
c804f9b
refactor(scenarios): plan in angles and counts, not one written-out s…
KarthikAvinashFI Sep 2, 2026
cf57c95
refactor(scenarios): make the plan a canvas the loop dispatches from …
KarthikAvinashFI Sep 2, 2026
54a4ab0
fix(scenarios): reclaim stale claims on load, not on every deal
KarthikAvinashFI Sep 2, 2026
3a06a98
feat(scenarios): let writers open buckets the plan never saw, and nam…
KarthikAvinashFI Sep 2, 2026
68423dd
fix(scenarios): route discoveries through the writer's report, which …
KarthikAvinashFI Sep 2, 2026
be16cf3
fix(scenarios): refuse a plan that enumerates, and make a count say w…
KarthikAvinashFI Sep 2, 2026
418cc42
fix(world): release store engines when a run is killed, not only when…
KarthikAvinashFI Sep 2, 2026
d788424
docs(scenarios): size a bucket by the state it crosses, with the arit…
KarthikAvinashFI Sep 2, 2026
43028d3
feat(scenarios): derive a bucket's size from state axes, and report w…
KarthikAvinashFI Sep 2, 2026
e5f9b98
feat(scenarios): state coverage against cells, rules, gated tools and…
KarthikAvinashFI Sep 2, 2026
b28f384
feat(scenarios): catch buckets that are one test twice when they shar…
KarthikAvinashFI Sep 2, 2026
4a55b28
fix(scenarios): embeddings only run when a run asks for them, since t…
KarthikAvinashFI Sep 2, 2026
227fe72
refactor(scenarios): split what the agent should do from what makes i…
KarthikAvinashFI Sep 2, 2026
9935630
feat(scenarios): build a plan up a theme at a time instead of in one …
KarthikAvinashFI Sep 2, 2026
20b4c56
test(scenarios): allow the replace argument name in the skill check
KarthikAvinashFI Sep 2, 2026
d9bc132
refactor(scenarios): rename facet and live to why_hard and varies_by
KarthikAvinashFI Sep 2, 2026
f2e374d
fix(scenarios): count progress from named scenarios checked against disk
KarthikAvinashFI Sep 2, 2026
597a3b2
fix(scenarios): tell the writer what must differ, not only how many t…
KarthikAvinashFI Sep 2, 2026
8de2fa4
fix(scenarios): refuse a count larger than its own axes can tell apart
KarthikAvinashFI Sep 2, 2026
7493ebd
refactor(scenarios): teach the planning method with examples from fou…
KarthikAvinashFI Sep 2, 2026
c860899
refactor(scenarios): rewrite the planning skill as a role, generic to…
KarthikAvinashFI Sep 2, 2026
162545f
refactor(scenarios): make the writing skill's vocabulary neutral acro…
KarthikAvinashFI Sep 2, 2026
e9cb215
docs(scenarios): say plainly that a prose-only plan is refused, not n…
KarthikAvinashFI Sep 2, 2026
424eddd
fix(scenarios): refuse an axis whose levels name rows instead of desc…
KarthikAvinashFI Sep 2, 2026
3951f2a
refactor(scenarios): rewrite the planning skill as numbered steps any…
KarthikAvinashFI Sep 2, 2026
01f6106
refactor(scenarios): call the list buckets, since that is what it holds
KarthikAvinashFI Sep 2, 2026
9a939f8
refactor(scenarios): make an angle a readable case, and drop the fiel…
KarthikAvinashFI Sep 2, 2026
af2e83c
docs(scenarios): list every refusal in the skill, in the words the co…
KarthikAvinashFI Sep 2, 2026
eec1fa5
fix(scenarios): refuse a large plan that leaves most of the grid unto…
KarthikAvinashFI Sep 2, 2026
50518a3
fix(scenarios): let the planner probe the agent, since exploring is i…
KarthikAvinashFI Sep 2, 2026
b0869dd
fix(world): restore in one transaction, so an interrupted run cannot …
KarthikAvinashFI Sep 2, 2026
9dff493
fix(scenarios): journal each writer's work, so a long fan-out survive…
KarthikAvinashFI Sep 2, 2026
42f3ab2
fix(scenarios): let a slice writer follow the run's thinking setting …
KarthikAvinashFI Sep 2, 2026
c20192e
fix(scenarios): resolve a session's relative paths from the run direc…
KarthikAvinashFI Sep 2, 2026
2a1a052
feat(scenarios): refuse a large plan that names none of the precondit…
KarthikAvinashFI Sep 2, 2026
ee044e0
fix(scenarios): journal at the accept itself, since the fan-out hook …
KarthikAvinashFI Sep 2, 2026
cc4a689
fix(scenarios): credit scenario names to one bucket through a ledger,…
KarthikAvinashFI Sep 2, 2026
4c40f6d
fix(scenarios): read the canvas fields that exist in plan-only mode
KarthikAvinashFI Sep 2, 2026
a8a3f09
docs(scenarios): gate the write skill by role and strip the domain vo…
KarthikAvinashFI Sep 2, 2026
bb07e9f
test(scenarios): prove the journal on the path writers actually take
KarthikAvinashFI Sep 2, 2026
8a2a696
feat(scenarios): run several slice writers at once, since a claim tak…
KarthikAvinashFI Sep 2, 2026
4231eef
docs(scenarios): start at eight writers and keep claiming while work …
KarthikAvinashFI Sep 2, 2026
9358c53
fix(scenarios): deal locations across slices, since instruction alone…
KarthikAvinashFI Sep 2, 2026
dbb94e9
fix(scenarios): persist the credit ledger, or a restart forgets which…
KarthikAvinashFI Sep 2, 2026
7b64e52
test(scenarios): pin that the stage's count overrides the target a mo…
KarthikAvinashFI Sep 2, 2026
d7582e5
fix(scenarios): fold against journalled scenarios too, since a delega…
KarthikAvinashFI Sep 2, 2026
e350c3f
feat(scenarios): withhold writing from a stage that has writers, so t…
KarthikAvinashFI Sep 2, 2026
c6c78b2
fix(harness): wait out a provider ceiling instead of ending the run o…
KarthikAvinashFI Sep 2, 2026
e2ebc5f
fix(scenarios): keep writers to the cells they were given, since they…
KarthikAvinashFI Sep 2, 2026
d0f3d62
fix(scenarios): size a writer's turns from the slice it can be handed…
KarthikAvinashFI Sep 2, 2026
ff51639
fix(scenarios): tell a continued run how many are outstanding, not th…
KarthikAvinashFI Sep 2, 2026
6bee245
fix(harness): withhold the SDK's own sub-agent tool from a stage that…
KarthikAvinashFI Sep 2, 2026
c4bddbf
revert(harness): a declared worker is invoked through the SDK's Agent…
KarthikAvinashFI Sep 2, 2026
4833067
feat(harness): let a stage name its own backend and model, since stag…
KarthikAvinashFI Sep 2, 2026
740999b
fix(harness): name a stage's workers in its prompt, so it dispatches …
KarthikAvinashFI Sep 2, 2026
2b35d18
fix(harness): check a backend against the model its caller will drive…
KarthikAvinashFI Sep 2, 2026
bfc7bcf
docs(semantic): give the technical reason the switch exists, nothing …
KarthikAvinashFI Sep 2, 2026
1adb062
docs(semantic): same technical reason in the test as in the module
KarthikAvinashFI Sep 2, 2026
5a8c1f0
fix(noise): match a caller's environment on its words, so an unmapped…
KarthikAvinashFI Sep 2, 2026
aeea095
fix(hosted): let the per-stage backend names reach a sandbox, or the …
KarthikAvinashFI Sep 2, 2026
76be847
feat(voice): carry call direction from the agent to the person who an…
KarthikAvinashFI Sep 2, 2026
c52aab4
fix(platform): send the agent's own system prompt when provisioning, …
KarthikAvinashFI Sep 2, 2026
9974606
fix(hosted): let a run opt into background noise, or a sandbox can ne…
KarthikAvinashFI Sep 2, 2026
b260b77
feat(voice): say which ambient clip started, so silence is not mistak…
KarthikAvinashFI Sep 2, 2026
bc72ce2
docs(understand): list call direction among the things to establish a…
KarthikAvinashFI Sep 2, 2026
d5a84bd
fix(hosted): send the run's modality, so a voice call is not recorded…
KarthikAvinashFI Sep 2, 2026
643943b
fix(understand): require the field a record is identified by, or its …
KarthikAvinashFI Sep 2, 2026
bda49bd
fix(understand): say the exact shape a recorded schema takes, identif…
KarthikAvinashFI Sep 2, 2026
47ba7d3
fix(gemini): use an API key when one is set, so a blocked token endpo…
KarthikAvinashFI Sep 2, 2026
8ce46b5
feat(voice): record the ambience source with the call, so silence is …
KarthikAvinashFI Sep 3, 2026
ba22432
fix(harness): surface the engine failure reason on a caseless simulat…
KarthikAvinashFI Sep 3, 2026
a83283b
fix(harness): carry the contract call direction into scenarios and pr…
KarthikAvinashFI Sep 3, 2026
34396e2
fix(harness): keep sqlite column defaults when compiling the world to…
KarthikAvinashFI Sep 3, 2026
005cd37
fix(harness): retry a retryable caseless report instead of failing ev…
KarthikAvinashFI Sep 3, 2026
0038777
fix(harness): tell every stage which way the call goes via the contra…
KarthikAvinashFI Sep 3, 2026
64b8f2c
fix(harness): cast sqlite boolean defaults so postgres accepts the co…
KarthikAvinashFI Sep 3, 2026
3eee73f
fix(harness): give a voice call a budget that fits a real conversation
KarthikAvinashFI Sep 3, 2026
510428c
feat(harness): let a hosted job set how many writers run at once
KarthikAvinashFI Sep 3, 2026
affc8fa
fix(harness): let a hosted run set its writer fan-out, suite batch an…
KarthikAvinashFI Sep 3, 2026
d8b349d
fix(harness): let a hosted run turn on parallel scenario writing
KarthikAvinashFI Sep 3, 2026
7cba9b6
fix(harness): always tell a delegating stage its writers run at the s…
KarthikAvinashFI Sep 3, 2026
f36675e
refactor(harness): make the writer ceiling one constant and drop the …
KarthikAvinashFI Sep 3, 2026
2bd46ce
fix(harness): retry a transient gateway error instead of failing the …
KarthikAvinashFI Sep 3, 2026
a3bf74f
fix(harness): back off exponentially on a gateway error and aim write…
KarthikAvinashFI Sep 3, 2026
b952069
feat(harness): make the scenarios skill about the craft and enforce t…
KarthikAvinashFI Sep 3, 2026
d9970a3
feat(harness): teach the skill to check order, values and refusals ra…
KarthikAvinashFI Sep 3, 2026
69b8a9a
feat(harness): show the occurrence check that an ordering rule must n…
KarthikAvinashFI Sep 3, 2026
0790acb
fix(harness): retry the vertex token fetch so a refused proxy tunnel …
KarthikAvinashFI Sep 3, 2026
48fb835
fix(scenarios): give a suite one owner, so a later save cannot delete…
KarthikAvinashFI Sep 3, 2026
7c504a9
feat(scenarios): gate a suite on what it tests, not on how many calle…
KarthikAvinashFI Sep 3, 2026
55de041
feat(scenarios): say when a bucket's scenarios do not differ along th…
KarthikAvinashFI Sep 3, 2026
8066eb2
fix(scenarios): deal speaking style and harder callers, and let a wri…
KarthikAvinashFI Sep 3, 2026
85105d0
docs(scenarios): name a scenario for what it tests, reach the consequ…
KarthikAvinashFI Sep 3, 2026
d0d14f1
refactor(scenarios): read setup code in one place and import suite st…
KarthikAvinashFI Sep 3, 2026
d18d5ae
Merge remote-tracking branch 'origin/feat/hosted-bundle-v2-production…
KarthikAvinashFI Sep 3, 2026
6178bfd
refactor(scenarios): move suite storage into scenariogen/store
KarthikAvinashFI Sep 3, 2026
bb14d9b
refactor(scenarios): move the scenario model into scenariogen/model a…
KarthikAvinashFI Sep 3, 2026
88af039
refactor(scenarios): move planning, the grid and the canvas into scen…
KarthikAvinashFI Sep 3, 2026
54689b3
refactor(scenarios): move the writing stage, its tools and the gates …
KarthikAvinashFI Sep 3, 2026
6e1e0fd
refactor(scenarios): point every importer at scenariogen, so each mod…
KarthikAvinashFI Sep 3, 2026
9d174ea
refactor(scenarios): collect the read-only quality checks under scena…
KarthikAvinashFI Sep 3, 2026
320aedb
feat(scenarios): move the scenario skills into the package and load a…
KarthikAvinashFI Sep 3, 2026
3a28041
refactor(scenarios): drop dead code and name the tool builders for wh…
KarthikAvinashFI Sep 3, 2026
1d66349
fix(scenarios): repair a short suite only, since removing scenarios c…
KarthikAvinashFI Sep 3, 2026
c5b3cbf
feat(scenarios): make a scenario carry a hazard, a withheld fact, a f…
KarthikAvinashFI Sep 3, 2026
15474f0
feat(scenarios): a bucket must name what goes wrong in each scenario …
KarthikAvinashFI Sep 3, 2026
07110a0
feat(scenarios): discover writer skills from files, and keep every sk…
KarthikAvinashFI Sep 3, 2026
26d540a
feat(scenarios): require a scenario to build the world it needs, and …
KarthikAvinashFI Sep 3, 2026
71c1397
fix(scenarios): refuse a scenario that plants nothing, at the moment …
KarthikAvinashFI Sep 3, 2026
43c2a29
fix(scenarios): let a writer actually set what its scenario plants
KarthikAvinashFI Sep 3, 2026
e84b775
fix(scenarios): persist a bucket's hazards, so a reloaded plan still …
KarthikAvinashFI Sep 3, 2026
40b090f
fix(scenarios): refuse a scenario that builds none of the state it tu…
KarthikAvinashFI Sep 3, 2026
716fc2c
fix(scenarios): stop a refusal check from failing the agent that corr…
KarthikAvinashFI Sep 3, 2026
a73928c
fix(scenarios): require an invariant, and refuse an instruction that …
KarthikAvinashFI Sep 3, 2026
f7ddbe2
fix(scenarios): refuse a folder name that says who called instead of …
KarthikAvinashFI Sep 3, 2026
e88a4a4
fix(scenarios): refuse a refusal check that fails the agent for not d…
KarthikAvinashFI Sep 3, 2026
7eabaff
fix(scenarios): scope an end-state check to the forbidden route when …
KarthikAvinashFI Sep 3, 2026
e4a99dd
fix(scenarios): refuse a scenario whose hazard nothing would grade
KarthikAvinashFI Sep 3, 2026
dcbf070
fix(scenarios): deal the harder callers first, since a rotation from …
KarthikAvinashFI Sep 3, 2026
ca82473
fix(scenarios): grade a hazard against what the checks inspect, not w…
KarthikAvinashFI Sep 3, 2026
dc8990c
fix(scenarios): assign each writer its callers, since offering them r…
KarthikAvinashFI Sep 3, 2026
3a031be
fix(scenarios): refuse a scenario graded by one shared check in one o…
KarthikAvinashFI Sep 4, 2026
19d20ca
fix(scenarios): refuse a scenario graded by exactly the checks a sibl…
KarthikAvinashFI Sep 4, 2026
14a9039
fix(harness): send the authored caller when provisioning, since the p…
KarthikAvinashFI Sep 4, 2026
b4ef7c1
fix(harness): write every declared check into its scenario folder, si…
KarthikAvinashFI Sep 4, 2026
b86b9fb
fix(scenarios): state the whole bar in the writing skill, and stop re…
KarthikAvinashFI Sep 4, 2026
52bb4dc
fix(scenarios): tell the writer which way the call goes, and to give …
KarthikAvinashFI Sep 4, 2026
53aa917
fix(scenarios): give the simulated person their own facts and a goal …
KarthikAvinashFI Sep 4, 2026
8ece211
fix(scenarios): teach the standard before the checklist, since a chec…
KarthikAvinashFI Sep 4, 2026
afa6548
Merge remote-tracking branch 'origin/feat/hosted-bundle-v2-production…
KarthikAvinashFI Sep 4, 2026
0361178
fix(scenarios): keep validating persona values against the platform v…
KarthikAvinashFI Sep 4, 2026
c98543f
fix(scenarios): refuse raw SQL in setup, since a strict world API tau…
KarthikAvinashFI Sep 4, 2026
74eef42
fix(harness): bound a call by the turns its scenario needs, since onl…
KarthikAvinashFI Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions harness-ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from fi.alk.harness.chat import Conversation # noqa: E402
from fi.alk.harness.config import chosen_model, credentials_hint # noqa: E402
from fi.alk.harness.run import run_suite # noqa: E402
from fi.alk.harness.scenarios import load as load_scenarios # noqa: E402
from fi.alk.harness.scenariogen.store.suite import load_scenarios # noqa: E402
from fi.alk.harness.understand import load as load_contract # noqa: E402

app = FastAPI(title="harness")
Expand Down Expand Up @@ -469,8 +469,8 @@ async def scenarios():
shown as unknown.
"""
from fi.alk.harness.environment import load_catalogue
from fi.alk.harness.folder import folder_for
from fi.alk.harness.prove import prove
from fi.alk.harness.scenariogen.store.folder import folder_for
from fi.alk.harness.scenariogen.write.prove import prove

out = current.path if current else None
if not out:
Expand Down Expand Up @@ -518,7 +518,7 @@ async def scenario_file(name: str, path: str):
Resolved and then checked to be inside that scenario's own folder: the path comes from a
query string, and a page is not a trustworthy source of one.
"""
from fi.alk.harness.folder import folder_for
from fi.alk.harness.scenariogen.store.folder import folder_for

out = current.path if current else None
if not out:
Expand Down
2 changes: 1 addition & 1 deletion src/fi/alk/harness/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Then `world/snapshot.py` writes `world.sqlite`, `handlers/*.py`, `world.py` and

## 4. Scenarios — the conversations worth having

`scenarios.py`, `skills/write-scenarios/SKILL.md`, tools in `scenario_tools.py`
`scenarios.py`, `skills/scenarios/write/SKILL.md`, tools in `scenario_tools.py`

A scenario is a **change on the base environment**, and it owns a folder (`folder.py`):

Expand Down
3 changes: 2 additions & 1 deletion src/fi/alk/harness/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
)
from .contract import AgentContract, Runtime, RuntimeInterface, ToolSpec, validate_contract
from .job import ExecutionMode, HarnessJob, HarnessStage
from .scenario import Scenario, validate_scenario
from .scenariogen.model.scenario import Scenario
from .scenariogen.quality.checks import validate_scenario
from .session import Stage, Turn
from .sources import (
AgentSource,
Expand Down
2 changes: 1 addition & 1 deletion src/fi/alk/harness/authoring_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .cli import _auto
from .job import HarnessJob, ProviderExecutionMode
from .provider_import import inspect_provider_target
from .scenarios import load as load_written
from .scenariogen.store.suite import load_scenarios as load_written
from .understand import PROVIDER_IMPORT_PROFILE_PATH_ENV


Expand Down
10 changes: 8 additions & 2 deletions src/fi/alk/harness/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Say,
SessionOpened,
SessionSpec,
WorkerSpec,
StageDone,
ToolReturned,
ToolServer,
Expand All @@ -45,6 +46,7 @@
"Say",
"SessionOpened",
"SessionSpec",
"WorkerSpec",
"StageDone",
"ToolReturned",
"ToolServer",
Expand Down Expand Up @@ -94,11 +96,15 @@ def backend_names() -> list[str]:
return sorted(_LOADERS)


def resolve(name: str | None = None) -> HarnessBackend:
def resolve(name: str | None = None, model: str | None = None) -> HarnessBackend:
"""The backend a run will use: the one named, or ALK_HARNESS, or the default.

An unknown name is a loud error naming what exists. Falling back silently would run a whole
suite on the wrong harness, which is only discovered from the bill.

``model`` is the model this caller will actually use, which is not always the run's. A stage
that names its own backend names its own model with it, and checking the pair against the
run's global model instead rejected the very combination the setting exists to express.
"""
asked = (name or os.environ.get("ALK_HARNESS") or DEFAULT_BACKEND).strip().lower()
asked = _ALIASES.get(asked, asked)
Expand All @@ -113,7 +119,7 @@ def resolve(name: str | None = None) -> HarnessBackend:
# A named model that this backend cannot reach is a configuration mistake, and it is only
# visible here. Left to run, the provider rejects the model mid-stage and the failure reads
# as the harness having nothing to say rather than as the wrong pairing.
wanted = os.environ.get("ALK_HARNESS_MODEL", "").strip()
wanted = (model or os.environ.get("ALK_HARNESS_MODEL", "")).strip()
if wanted and not backend.can_drive(wanted):
raise ValueError(
f"harness backend {backend.name!r} cannot drive model {wanted!r}; "
Expand Down
67 changes: 66 additions & 1 deletion src/fi/alk/harness/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,25 @@

from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable, Protocol, runtime_checkable

ToolHandler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]

# How many workers a backend may run at once. Both SDKs cap this themselves (one at twenty by
# default), so this is the harness's own ceiling, set below theirs and shared so that the turn
# budget reserved for a fan-out matches what can actually be in flight.
# The one ceiling on fan-out: how many workers may run at the same time. It protects the
# machine, and it is a constant rather than an environment variable because a setting
# nobody remembers makes every run behave differently for reasons nothing on screen
# explains. The scenarios stage imports this as its own ceiling and tells the model.
# The hard ceiling on writers running at once, enforced by claim_slice rather than asked
# for: a prompt is guidance and this is a safety limit on the machine. Ten is the number
# a stage is told to aim at; the extra two are headroom so a brief overshoot is not a
# refusal in the middle of a suite.
MOST_WORKERS_AT_ONCE = 12


def qualified(server: str, tool_name: str) -> str:
"""The fully qualified name a session grants and a model calls.
Expand Down Expand Up @@ -91,8 +105,44 @@ def tool_server(
# a host CLI implements them from files.py. Anything else asked for as a builtin is refused at
# session build time rather than silently dropped.
FILE_TOOLS = ("Read", "Glob", "Grep")

# Everything a backend without a host CLI can offer under the names Claude Code uses, so a
# stage's skill text means the same thing whichever loop is running it. Reading tells you what
# an agent is meant to do and running tells you what it does; the scenarios worth writing come
# from the gap, so a stage trusted with one is trusted with both. Writing is absent on purpose:
# a stage able to edit the agent under test could make its scenarios pass by changing the agent.
HOST_TOOLS = (*FILE_TOOLS, "Bash")
ASK_TOOL = "AskUserQuestion"
KNOWN_BUILTINS = (*FILE_TOOLS, ASK_TOOL)
DELEGATE_TOOL = "Delegate"
KNOWN_BUILTINS = (*FILE_TOOLS, ASK_TOOL, DELEGATE_TOOL)


@dataclass
class WorkerSpec:
"""A worker the model may run to do part of its stage, in its own session.

Both backends we ship can start a second model session and hand its answer back as a tool
result, and both decide at call time how many to run; only the vocabulary differs. This is
that capability said once, so a stage declares a worker and every backend honours it.

``instructions`` is the worker's own system prompt. ``servers`` and ``builtins`` are its
tools, named exactly as ``SessionSpec`` names them, and default to the parent's when left
empty. ``max_turns`` bounds one worker; ``model`` overrides the parent's for it.

A worker never inherits the parent's conversation. Everything it needs comes from
``instructions`` plus the brief the model writes when it calls, which is what keeps a large
fan-out from copying the whole stage history N times.
"""

description: str
instructions: str
servers: dict[str, ToolServer] = field(default_factory=dict)
builtins: tuple[str, ...] = ()
max_turns: int = 40
model: str = ""
# How hard this worker may think. Empty leaves the model's own default alone. Held here
# rather than taken from the stage because a worker's job is not the stage's job.
effort: str = ""


@dataclass
Expand Down Expand Up @@ -122,6 +172,21 @@ class SessionSpec:
# stage (understand, interactive) passes its gate in fully built; backends without a
# permission callback concept ignore it, which is safe because their gating is structural.
permission_override: Any = None
# Workers this session may run, by name. Declaring any of these is what lets the model
# divide its own work; a stage that declares none behaves exactly as before.
workers: dict[str, WorkerSpec] = field(default_factory=dict)

def worker_turns(self) -> int:
"""Turns to reserve beyond the parent's own, so a fan-out cannot run the budget dry.

One backend bills every worker's turns to the session that started them, so a budget
sized for the parent alone stops a fan-out partway through and loses the work. Reserving
the worst case here keeps that a backend detail rather than a stage's problem.
"""
if not self.workers:
return 0
widest = max(worker.max_turns for worker in self.workers.values())
return widest * MOST_WORKERS_AT_ONCE

def granted(self) -> list[str]:
"""Every tool name this session may call, qualified the way the model calls it."""
Expand Down
109 changes: 103 additions & 6 deletions src/fi/alk/harness/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from typing import Any, AsyncIterator

from claude_agent_sdk import (
AgentDefinition,
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
Expand All @@ -27,6 +28,7 @@
)

from .base import (
MOST_WORKERS_AT_ONCE,
Call,
ModelReply,
Say,
Expand All @@ -40,6 +42,26 @@

DEFAULT_MODEL = "claude-sonnet-4-6"

# What this SDK calls the tool that runs a worker. The harness calls the capability
# ``Delegate``; only this line knows the vendor's name for it.
_DELEGATION_TOOL = "Agent"


def _ask_only(ask: Any) -> Any:
"""Allow everything, but route the one tool that has a person on the other end.

An ungated stage is trusted with its tools; it is not therefore talking to nobody.
"""

async def gate(tool_name: str, payload: dict[str, Any], context: Any) -> Any:
from claude_agent_sdk.types import PermissionResultAllow

if tool_name == "AskUserQuestion":
return await ask(tool_name, payload, context)
return PermissionResultAllow(updated_input=payload)

return gate


def _sdk_server(server: ToolServer) -> Any:
"""A ToolServer as the in-process MCP server the SDK routes calls to."""
Expand Down Expand Up @@ -164,20 +186,95 @@ def create(self, spec: SessionSpec) -> ClaudeSession:
for tool_spec in server.tools
),
]
servers = {
server_name: _sdk_server(server)
for server_name, server in spec.servers.items()
}
agents: dict[str, Any] = {}
for name, worker in spec.workers.items():
worker_servers = worker.servers or spec.servers
for server_name, server in worker_servers.items():
servers.setdefault(server_name, _sdk_server(server))
agents[name] = AgentDefinition(
description=worker.description,
prompt=worker.instructions,
tools=[
*(worker.builtins or spec.builtins),
*(
qualified(server_name, tool_spec.name)
for server_name, server in worker_servers.items()
for tool_spec in server.tools
),
],
mcpServers=list(worker_servers),
model=worker.model or "inherit",
maxTurns=worker.max_turns,
# Blocking, so the call returns the worker's result. Left unset these launch in
# the background and the caller is told it will be notified later: a stage that
# dealt fifty scenarios across five writers then ended at its next turn, killing
# all five, and reported success having saved one. The parent has nothing useful
# to do while a slice is written, and it must not finish before the work does.
background=False,
# Only when asked for: the field has its own default and passing a blank
# through would override it with nothing.
**({"effort": worker.effort} if worker.effort else {}),
)
if agents:
# The delegation tool is named for the model, and it has to be granted here or the
# gate below refuses the very call these workers exist to receive.
allowed = [*allowed, _DELEGATION_TOOL]
env = dict(provider_env(spec.model))
if agents:
env.setdefault(
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS", str(MOST_WORKERS_AT_ONCE)
)
# One level only. A worker that delegates again multiplies the fan-out by a factor
# nothing in the stage accounted for, and the depth is free to raise later.
env.setdefault("CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH", "1")
# A declared worker is reached through the SDK's own sub-agent tool, which asks *which*
# kind to run. Nothing otherwise tells the model that ours exists, and left to guess it
# dispatches the generic kind: that one launches detached, answers "launched
# successfully, you will be notified", and returns nothing, so a stage dealt eight slices,
# dispatched eight agents holding none of its tools, declared success and exited having
# written nothing. On the other backend a worker is a plain tool in the list, which is why
# this only ever bit here. Naming them costs a line and removes the guess.
said = spec.system_prompt
if spec.workers:
said += (
"\n\n## Your workers\n\nYou have these sub-agents: "
+ ", ".join(sorted(spec.workers))
+ ". Dispatch one by naming it exactly as the kind of sub-agent to run. They "
"return what they produced when they finish. Do not dispatch a general-purpose "
"sub-agent instead: that kind runs detached, holds none of your tools, and its "
"work is lost when you finish your turn."
)
options = ClaudeAgentOptions(
system_prompt=spec.system_prompt,
system_prompt=said,
allowed_tools=allowed,
mcp_servers={
server_name: _sdk_server(server)
for server_name, server in spec.servers.items()
},
mcp_servers=servers,
setting_sources=[],
max_turns=spec.max_turns,
model=spec.model,
env=provider_env(spec.model),
env=env,
)
if agents:
options.agents = agents
if spec.cwd is not None:
options.cwd = spec.cwd
if not spec.gated:
# An ungated stage is given the host's whole tool list on purpose, and an unattended
# run cannot answer a prompt, so approval has to be settled here rather than left to
# the default. The boundary for such a stage is the sandbox it runs in, not the tool
# list: it is reading an agent's own repository to write tests against it, and every
# artifact it produces still goes through the three gates before it is kept.
options.permission_mode = "bypassPermissions"
if spec.permission_override is not None:
options.can_use_tool = spec.permission_override
elif spec.ask is not None:
# Ungated does not mean unattended. In a conversation there is a person on the
# other side, and the stage asking them something is the point of keeping it
# open. Without this the question is approved silently and never reaches them.
options.can_use_tool = _ask_only(spec.ask)
if spec.gated:
# Not acceptEdits: that auto-approves Edit and Write before the permission callback
# is consulted, so a stage could rewrite an artifact by hand and skip the tool whose
Expand Down
Loading
Loading