Skip to content

examples: add the spa/direct arm of the tau2-airline onboarding - #424

Open
aviweit wants to merge 5 commits into
skillberry-ai:mainfrom
aviweit:skillberry_benchmarks_tau2
Open

examples: add the spa/direct arm of the tau2-airline onboarding#424
aviweit wants to merge 5 commits into
skillberry-ai:mainfrom
aviweit:skillberry_benchmarks_tau2

Conversation

@aviweit

@aviweit aviweit commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

What

Exports the live cap-evolve project as examples/skillberry_benchmarks_tau2_airline/spa/
the intervention: spa arm of that example's PROMPT.md. The capability is the airline
agent's tool surface only, and it reaches the model as a Skillberry Store skill that the
proxy injects into the agent's LLM calls, so the benchmark never sees skill files.

The spa/ subfolder keeps the prompt's one arm per run rule visible: the direct arm is a
separate onboarding in its own project and lands as a sibling, never merged into this one.
The two arms' numbers are not like-for-like and should never be averaged.

Contents

  • capevolve.yaml + capevolve.smoke.yaml — the real and smoke run specs
  • adapters/adapter.py + gateway.pytasks/run_trials/score/trajectories/apply,
    and the OpenAI-compatible gateway wiring
  • seed_capability/ — the generated seed: an intentionally empty my_skill/SKILL.md,
    14 agent-visible tool scripts, and the frozen primitive_tools/functions.py
  • optimizer/INSTRUCTIONS.md (+ the prompt-only variant)
  • split_ids.json (all 50 ids) and split_ids_task9.json / smoke_split.json
    (train = val = test = ["9"])

Both specs set protected_paths: ["primitive_tools/*", "my_skill/SKILL.md"]. Under SPA,
SKILL.md is the prompt-enrichment channel, so freezing it alongside the frozen substrate is
what makes this a genuinely tools-only capability.

Export changes

Two paths were rewritten so the export is portable; every other file is byte-identical
to the live project:

Field Before After
optimizer_instructions_file absolute path optimizer/INSTRUCTIONS.md (project-relative — also resolve_instructions_file()'s default)
runner_repo_path ../../vendor/… ../../../vendor/… (spa/ is one level deeper)

Both specs were re-read through cap-evolve's own spec reader afterwards: intervention,
skill_name, protected_paths, split files, and instructions file all resolve, and
runner_repo_path lands on the pinned vendor/skillberry-benchmarks checkout.

Export the live project as examples/skillberry_benchmarks_tau2_airline/spa/ — the
`intervention: spa` arm of that example's PROMPT.md, where the capability is the airline
agent's TOOL SURFACE only and reaches the model as a Skillberry Store skill the proxy injects.

The `spa/` subfolder keeps the prompt's ONE-ARM-PER-RUN rule visible: the `direct` arm is a
separate onboarding in its own project and lands as a sibling, never merged into this one.

Contents: both run specs, the adapter + gateway, the generated seed capability (empty SKILL.md
+ 14 tool scripts + the frozen primitives module), the optimizer instructions, and the split
files. PROJECT.md is deliberately NOT exported: it is a project-scaffold artifact read only by
the dashboard for display, no example carries one, and its text referred to run directories
that exist on one machine.

Two paths were rewritten so the export is portable; every other file is byte-identical:
- optimizer_instructions_file: absolute -> project-relative `optimizer/INSTRUCTIONS.md`,
  which resolve_instructions_file() already treats as its default.
- runner_repo_path: ../../vendor/... -> ../../../vendor/..., since spa/ sits one level
  deeper than the live project dir.

No credentials: .env is not exported, and the gateway reads OPENAI_BASE_URL/_API_BASE/_API_KEY
from the environment. Run trajectories are output, not input, so they stay in their run dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Avi Weit <weit@il.ibm.com>
@aviweit aviweit self-assigned this Sep 2, 2026
@aviweit

aviweit commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Test: end-to-end optimization run on the SPA arm

Ran a full optimization through the intervention: spa arm of this example to verify the
delivery path works end to end, and to confirm the two properties that make the arm's
measurement meaningful. Numbers below are from that run's dashboard.

Configuration

Spec capevolve.yaml (the one added in this PR)
Intervention spa — candidate imported into the Skillberry Store, injected by the Skillberry Proxy-Agent
Capability [tools], actions: [edit]
protected_paths primitive_tools/*, my_skill/SKILL.md
Split train = val = test = ["9"] (no-holdout; the engine logs a splits_warning and the test number is a fit metric)
Trials 5 per evaluation
Target model gpt-oss-120b (tier mid)
Optimizer claude-code, hill-climb, gate paired (k_se 0.2)

Result

Stage val Gate
Baseline (seed) 0.800 ± 0.200
cand_0001 0.800 rejected, Δ = +0.000
cand_0002 0.800 rejected, Δ = +0.000

Best candidate: seed — no candidate was accepted, so the champion never moved off the
seed. Sealed test: 0.200 ± 0.200 (per-trial rewards [0, 0, 0, 0, 1]). 2 iterations,
15 metric calls, optimizer cost $7.13 / 108,873 tokens; runner cost $0 (internal gateway).

Both iterations logged a gate_warning: with a 1-task split there is a single paired sample,
so the paired SE is 0 by construction and the gate falls back to STRICT (accept any Δ > 0,
reject 0). Both rejections came from that fallback. Note that val and the sealed test scored
the same unmodified seed capability on the same single task and returned 0.800 and 0.200 —
a 1-task/5-trial shape cannot resolve a Δ smaller than its own ±0.2, so this run exercises the
pipeline rather than establishing a capability result.

What I verified

1. The store is refreshed with the candidate's skill before every evaluation. The adapter's
apply() runs ahead of each evaluation and calls reset_store_to_skill(), which is
delete_skillpurge_orphansupload_skill — a full replace from a clean slate, not a
merge. So no tool, snippet, or prose from the previous candidate can survive into the next
candidate's measurement; only the tagged frozen primitives are protected across the redeploy.
Confirmed for all three evaluations in the run (cand_0001, cand_0002, and the sealed
test FINAL).

Worth stating precisely: the refresh is per evaluation, not per iteration — which is the
stronger guarantee, since it also covers the baseline and the final sealed-test scoring.

2. The deployed skill's SKILL.md was empty — the capability really was tools-only.
my_skill/SKILL.md is 0 bytes in every candidate directory of the run (seed,
cand_0001, cand_0002), and protected_paths lists it alongside primitive_tools/*, so it
is outside the optimizer's edit surface. Under SPA, SKILL.md is the prompt-enrichment
channel, so an empty and frozen SKILL.md means the run carried no prose lever at all: the
only thing being optimized was the tool surface (my_skill/scripts/*.py), which is what this
arm is supposed to isolate.


Second test: 3-iteration run on the same arm

Repeated the test with a longer budget — cap-evolve run --max-iterations 3 --max-optimizer-usd 100
against the same capevolve.yaml. Same configuration as above (spa / tools-only / task-9
no-holdout split / 5 trials / gpt-oss-120b / hill-climb + paired gate), with a freshly
measured baseline rather than a reused one.

Result

Stage val Δ Gate
Baseline (seed) 0.800 ± 0.200
cand_0001 0.600 ± 0.245 −0.200 rejected
cand_0002 0.600 ± 0.245 −0.200 rejected
cand_0003 0.600 ± 0.245 −0.200 rejected

Best candidate: seed. Sealed test: 0.400 ± 0.245 (per-trial [0, 0, 0, 1, 1]),
test_delta +0.000. 3 iterations, 25 metric calls, stall 3/3. Optimizer cost $6.93 /
100,291 tokens; runner cost $0. Wall-clock: 2,067 s optimizer + 2,873 s runner.

All three rejections came from the STRICT fallback (paired SE is 0 at n=1).

What the candidates edited, all inside my_skill/scripts/:

  • cand_0001, cand_0002 — in-body eligibility guard in cancel_reservation.py
  • cand_0003 — computed cancellation_eligible / cancellation_assessment fields added to
    get_reservation_details.py, plus the cancel_reservation.py guard

Same two properties verified again

1. Store refreshed with the candidate's skill before every evaluation — 5 evaluations in
this run (seed, cand_0001, cand_0002, cand_0003, test FINAL), each preceded by
apply()reset_store_to_skill() (delete_skillpurge_orphansupload_skill).

2. Tools-only capabilitymy_skill/SKILL.md is 0 bytes in all four candidate
directories (seed, cand_0001, cand_0002, cand_0003). The frozen substrate held too:
primitive_tools/functions.py is byte-identical to the seed in every candidate, so both
protected_paths entries were respected.

@skillberry-bot

Copy link
Copy Markdown
Contributor

Automatic Labeling Failed

An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually.

@aviweit aviweit added benchmark-smoke-tau2 Run only the tau2 smoke benchmark on this PR and removed benchmark-smoke-tau2 Run only the tau2 smoke benchmark on this PR labels Sep 3, 2026
Signed-off-by: Avi Weit <weit@il.ibm.com>
@aviweit
aviweit force-pushed the skillberry_benchmarks_tau2 branch from 18ed80d to fc895d5 Compare September 3, 2026 13:56
@aviweit
aviweit marked this pull request as ready for review September 3, 2026 13:57
@aviweit aviweit changed the title examples: add the SPA arm of the tau2-airline onboarding examples: add the spa/direct arm of the tau2-airline onboarding Sep 3, 2026
The spa/ arm shipped a spec, a seed and an adapter but no way to get from a fresh
checkout to a runnable project, so onboarding it meant reading ../PROMPT.md and
hand-running the steps. The other examples each carry a setup.sh; this is the
executable transcript of the same intake phase for this arm.

Five steps: venv + core CLI (dashboard backend optional, --no-dashboard skips
it); the benchmark at its PINNED commit; the Skillberry stack; the project
wiring; the check gate.

Three things the SPA arm needs that the direct arm does not:

  * the benchmark is the skillberry build, PINNED, installed with the
    [skillberry] extra — that extra is what registers the airline_skillberry
    domain, and without it every rollout dies at env construction. Pinned rather
    than latest main because the recorded results belong to that commit.
  * the Skillberry stack (Store + Proxy-Agent) is PROVISIONED here and started
    by run.sh. The intervention skill names a run that provisions on the
    operator's behalf as the anti-pattern, so setup does not start services.
  * the seed is two halves — my_skill/ (edited) and primitive_tools/ (frozen,
    covered by protected_paths) — so both are copied.

It scaffolds into its own base, .capevolve-spa, not the shared .capevolve. The
two arms are separate onboardings; a shared project dir means one arm's seed and
spec overwrite the other's, which delivers candidates one way while the record
says the other. Run dirs land in that base too, so neither arm's runs mix.

The registered-domain check WARNS and never dies: tau2's registry API is not
ours to depend on, and `cap-evolve check` plus the first rollout are the
authoritative gates.

Verified from a clean slate (no venv, no vendor/, no project): exits 0, installs
the benchmark at a3a8326 with airline_skillberry registered, provisions store
0.2.1 + agent e359494, wires .capevolve-spa/project, and `cap-evolve check`
returns ok: true. No credential value is ever echoed — the preflight tests the
variables and greps .env with -q, printing only names.

Signed-off-by: Avi Weit <weit@il.ibm.com>
setup.sh provisions; this starts and runs. Splitting them is the intervention
skill's rule: a run that provisions on the operator's behalf is the anti-pattern,
because provisioning clones and installs, and a run should only ever start what
is already there.

Three services, in an order that matters:

  * the benchmark's environment service (:8004). POLLED, never slept on —
    importing tau2 pulls in litellm so the first start is ~10s, and a fixed sleep
    either wastes time or races the service. LITELLM_LOCAL_MODEL_COST_MAP=True
    skips litellm's doomed remote cost-map fetch, which otherwise stalls startup
    until timeout.
  * the Store (:8000) BEFORE the Proxy-Agent, because SPA binds ONE skill by name
    at start and needs the store to answer when it does.
  * SPA (:7000) bound to my_skill.

Both starts are idempotent — a healthy service is reported, not restarted.
Restarting SPA mid-evaluation would swap the skill under a running rollout, so
the stack is left running at exit and the stop command is printed instead.

The model split is a correctness rule, not a preference: the AGENT is the
ibm/skillberry-local sentinel that tau2 routes to the proxy, and the USER
SIMULATOR goes STRAIGHT to the gateway. Proxying the simulator would inject the
capability into the very thing measuring the agent. Concurrency defaults to 4,
not the direct arm's 125: every agent call funnels through one proxy and one
store, so a high setting just queues behind them and times out.

--smoke selects the smoke spec; SPEC= and RUN_TS= override. RUN_TS is only
defaulted when the caller did not pin it, so --smoke cannot write into the full
run's directory.

Verified live end to end (exit 0): all three services came up healthy in order
with SPA bound to my_skill and every pid owned by spa_env rather than adopted
from a squatter; cap-evolve resolved the spec from .capevolve-spa and echoed the
same topology; 3/3 rollouts scored with ZERO errored trials through the proxy;
seed and cand_0001 both 1.0; the gate correctly refused a zero delta; test
sealed once at 1.0 and the run finalized.

Signed-off-by: Avi Weit <weit@il.ibm.com>
# The pinned benchmark checkout, READ-ONLY: task definitions, the real tool
# implementations, and the reward checks.
# One level deeper than the live project (examples/<name>/spa/), hence ../../../.
runner_repo_path: ../../../vendor/skillberry-benchmarks

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runner_repo_path is resolved as Path(project) / value (core/cap_evolve/specfile.py::resolve_project_path), where project is the deployed project dir passed via --project — and cli.py itself assumes workdir = proj_abs.parent.parent, i.e. the project dir is always exactly two levels below the repo root (.capevolve/project or, here, .capevolve-spa/project).

spa/setup.sh sets BASE="$REPO/.capevolve-spa" and PROJECT="$BASE/project" — also two levels below $REPO. So ../../../vendor/skillberry-benchmarks (three ../) resolves to a directory one level above the repo root, not to $REPO/vendor/skillberry-benchmarks where setup.sh actually clones the benchmark:

>>> from pathlib import Path
>>> p = Path('/repo/.capevolve-spa/project')
>>> (p / '../../../vendor/skillberry-benchmarks').resolve()
PosixPath('/vendor/skillberry-benchmarks')          # what this spec produces (wrong)
>>> (p / '../../vendor/skillberry-benchmarks').resolve()
PosixPath('/repo/vendor/skillberry-benchmarks')     # what setup.sh actually clones (right)

direct/capevolve.yaml uses ../../vendor/skillberry-benchmarks (two ../) for the identical project-dir depth, and that one is correct.

The PR description justifies the extra ../ as "spa/ is one level deeper" — but that's true only of the example source layout under examples/…/; it isn't true of the deployed project dir at runtime ($BASE/project is the same depth for both arms). cap-evolve check won't catch this either — pipeline_selftest.py passes runner_repo_path through unresolved just to check template placeholders; it never verifies the resolved path exists. So this silently hands the optimizer a --bench-repo pointing at the wrong (likely nonexistent) directory, degrading its read-only benchmark context with no error.

Same issue in capevolve.smoke.yaml:52.

@OsherElhadad OsherElhadad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff, checked out the branch into a worktree, and traced the path-resolution logic against core/cap_evolve/cli.py / specfile.py. Summary:

Bug (see inline comment on spa/capevolve.yaml:51, also present in spa/capevolve.smoke.yaml:52): runner_repo_path: ../../../vendor/skillberry-benchmarks resolves to the wrong directory (one level above the repo root) given how spa/setup.sh actually lays out the project ($REPO/.capevolve-spa/project, same depth below $REPO as the direct arm's .capevolve/project). direct/capevolve.yaml's ../../vendor/skillberry-benchmarks is correct for the same depth. cap-evolve check won't catch this since pipeline_selftest.py never verifies the resolved path exists — it just checks template placeholders. Net effect: the optimizer silently gets no/wrong read-only benchmark context (task defs, real tool implementations, reward checks) during actual runs.

Structural asymmetry between the two arms: spa/ ships setup.sh + run.sh as the executable transcript of onboarding (per the file headers, these reproduce the intake/implement-and-check steps in one command). direct/ has no equivalent — no setup.sh/run.sh at all, so there's no reproducible way to stand up and run that arm short of manually re-deriving the onboarding steps from PROMPT.md. Worth adding for parity, or noting explicitly if it's intentionally out of scope for this PR.

Duplication: direct/adapters/adapter.py and spa/adapters/adapter.py share ~300 lines essentially verbatim — _STOP_LEAK_RE/_leaked_stop_continuation, _shown_metrics, _sim_to_rollout, score, and the whole gold-safe argument-level feedback stack (_iter_agent_tool_calls, _user_profile_facts, _localize_action, _localize_communicate, _derive_total_cost, _build_feedback). Given the PR's own stated goal of every-file-byte-identical-except-two-paths portability for examples, duplicating this much scoring/feedback logic across two "arms" of the same benchmark means any future fix to the feedback logic (e.g. the _localize_communicate heuristic, or the STOP-leak regex) has to be applied twice and will drift if someone updates only one. A shared module (even just within examples/skillberry_benchmarks_tau2_airline/) imported by both would remove the drift risk without hurting the "copy this example directory and it just works" portability goal.

Positives:

  • Good discipline throughout on gold-safety: score()/_build_feedback() never read or echo the ground-truth values, only key names — well-guarded, and the module docstrings say why.
  • The apply() guard-by-candidate-shape (checking for tools/tools.py vs my_skill/SKILL.md and naming the likely spec/seed mismatch in the error) is a nice fail-loud touch for what would otherwise be a confusing silent misconfiguration.
  • gateway.py (both arms) is careful never to put api_key into llm_args, backed by a real assert in its own __main__ self-check — good, since trajectories()/store: git would otherwise commit it.
  • The registry._domains monkeypatch in direct/adapters/adapter.py mirrors the existing pattern already used in examples/tau2_airline/adapters/adapter.py, so it's consistent with precedent in this repo (not a new global-state risk introduced by this PR), even though the base CapabilityAdapter docstring explicitly calls out apply()'s global-side-effect model as the reason materialize/live exists for concurrent-candidate evaluation. Not blocking, just flagging that this arm inherits that same single-global-slot constraint.

Not verified: I did not run an actual cap-evolve check/smoke run end-to-end — that requires cloning vendor/skillberry-benchmarks at the pinned commit, installing tau2-bench[skillberry], and live gateway credentials (OPENAI_BASE_URL/OPENAI_API_KEY), none of which are available in this review environment. The runner_repo_path bug above was verified by tracing the actual resolution code path and reproducing the path arithmetic locally, not by running the harness.

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.

3 participants