Skip to content

examples(appworld): AppWorld benchmark with cap-evolve vs ALTK-Evolve, and optimizer isolation - #874

Merged
OsherElhadad merged 17 commits into
skillberry-ai:mainfrom
erezh16:appworld-example
Nov 5, 2026
Merged

OsherElhadad merged 17 commits into
skillberry-ai:mainfrom
erezh16:appworld-example

Conversation

@erezh16

@erezh16 erezh16 commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator

Adds examples/appworld: a ready-to-run AppWorld benchmark for cap-evolve. It includes an ALTK-Evolve arm on the same harness, so cap-evolve can be compared with another learning method under equal conditions. It also closes an isolation hole that let the optimizer read benchmark answers.

Value

  • A new benchmark for cap-evolve. AppWorld (everyday multi-app tasks, graded on the final app state) is widely used for agent learning. With this example, cap-evolve can be scored on it in one command per stage.
  • A fair comparison by construction. All arms use the same agent harness, model client and limits. Only AppWorld's official splits are used, and all arms are scored together in one interleaved test window. Feedback is gold-free, and candidate lints enforce AppWorld's "no task-specific hints" rule.
  • Optimizer isolation that works where Claude Code's sandbox can't start. A claude wrapper runs every optimizer call in a private mount namespace in which the answers simply do not exist. A planted-secret check proves this before a run.

Evidence (smoke test, 13–15 Oct 2026: gpt-oss-120b agent, Claude Sonnet 5.5 optimizer)

Detailed log: issue #804 (interim updates 1–6 and summary). Plan, findings (Appendix A) and results (Appendix B): docs/EVAL_SMOKE_TEST.md on the fork branch erezh-eval1. Plain-language summary: docs/EVAL_SMOKE_TEST_SUMMARY.html.

The example ran end to end. Acceptance checks A1–A8 passed:

  • the seed prompt is byte-identical to stock;
  • a released solution scores 1 and a no-op scores 0;
  • persisted records hold no gold values;
  • REPL isolation holds;
  • the key never reaches a log;
  • the longest of 10 dev episodes peaked at 12.2K prompt tokens.

Held-out results (test_normal, 168 tasks × 3 arms × 2 trials = 1,008 episodes, 0 infra errors):

arm task success scenario success Δ task success vs seed [95% CI]
seed (stock ReAct) 35.1% 13.4% —
cap-evolve hill-climb ⚠ 53.6% 34.8% +18.5 [+10.7, +26.2]
ALTK-Evolve (retrieval dose) 41.1% 23.2% +6.0 [−0.9, +13.1]

⚠ The first run is labelled isolation breach. In round 2, the optimizer's subagents read train/dev ground truth and ran AppWorld's evaluator; no test data was reached. That breach is why the isolation in this PR exists. A clean re-run with it is planned.

AppWorld gaps found and handled in the harness, for every arm:

  • AppWorld's REPL guard allows os, pathlib, io and os.environ. Agent code could list and read files, including ground truth, and read env vars. Those modules are now blocked, and the key is dropped from the environment.
  • max_output_tokens_per_task is never enforced at 42b5bcf, so the episode loop enforces it.
  • AppWorld's failure trace holds gold values. Only requirement texts and counters are kept, and native output dirs are deleted.
  • The PyPI release has no simplified ReAct agent, so the setup builds AppWorld from source at 42b5bcf (pydantic 2.12).

ALTK-Evolve integration:

  • The full playbook (1,715 guidelines, about 83K tokens) does not fit a 40K-context model. The dose lint marks it infeasible, so the retrieval dose is used.
  • The gateway rejects litellm's forced tool_choice, so a process-local shim routes the library to its own prompt-JSON path. The library itself is unmodified.

Isolation (check A9, plants 6 secrets and runs a real optimizer call that tries cat, Read, Grep, find /, a copy and a sandbox escape):

mechanism A9 in this dev container
sandbox/claude namespace wrapper pass: 0 secrets found; files written; lint ran
Claude Code's sandbox (bubblewrap + socat) cannot start: the container's /dev/null bind cannot be remounted. 0 secrets found, but no shell command can run
the wrapper, from this branch (settings injected by the wrapper) pass

Tests: python -m pytest examples/appworld/tests gives 15 passed (lints, injection, gold-free record, scoring, ALTK conversion), with no AppWorld install or model needed.

The framework-level gaps that this example works around are filed as separate issues.

🤖 Generated with Claude Code

@skillberry-bot skillberry-bot added enhancement New feature or request algorithm Optimization algorithms: GEPA / SkillOpt / hill-climb documentation Improvements or additions to documentation honesty Honesty/consistency of reported results (brand-critical) labels Oct 15, 2026
@skillberry-bot

Copy link
Copy Markdown
Contributor

🏷️ Automatic Labeling

I've analyzed this pull request and added the following labels:

  • enhancement - algorithm - documentation - enhancement - algorithm - documentation - honesty

These labels were selected based on the PR title, description, and changed files. If you believe any labels are incorrect or missing, feel free to adjust them manually.

erezh16 pushed a commit to erezh16/cap-evolve that referenced this pull request Oct 15, 2026
…namespace wrapper)

- examples/appworld: the evaluation code, same as PR skillberry-ai#874 (cleaned up:
  configurable paths and model key, settings templates, namespace wrapper
  for the optimizer, planted-secret check A9, offline tests).
- docs/EVAL_SMOKE_TEST_FIX.md: what to re-run and why (cap-evolve training
  from scratch with the answers out of reach from round 1; the final window
  for all three arms), with the isolation options tested on this host.
- docs/EVAL_SMOKE_TEST.md: findings F17 (Claude Code's sandbox cannot start
  in this container) and F18 (bwrap variant tested and dropped).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@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.

Code review findings

Reviewed the diff plus the checked-out branch (appworld-example @ a0996fb54) and AppWorld's actually-installed source. Most severe first.

1. Lint safety gate likely broken in the real launch path

examples/appworld/adapters/lints.py:32 reads env var APPWORLD_ROOT (hardcoded fallback /tmp/aw_eval/root_opt), while adapter.py's _appworld_root() (lines 48-49) reads a different env var, AW_APPWORLD_ROOT, falling back to AW_EVAL-derived root_opt. launch_cape_hc.sh — the actual search-launch script — sets neither. Only setup_project.sh sets APPWORLD_ROOT, and only for a one-off cap-evolve check call; final_window.py only sets AW_APPWORLD_ROOT.

On top of that, the optimizer is instructed to run lints.py itself inside its sandboxed mount-namespace (sandbox/claude:22), where root_opt is not mounted — only venv-aw, appworld/src, appworld/experiments, reference, lint_cache.json are allow-listed back in. The lint cache that would let lints.py run without touching root_opt is only ever built by write_cache() / --write-cache, which no script, README step, or CI step calls — only the unit test does.

Net effect: in the realistic launch path, the optimizer's required python lints.py . call hits a cold cache, tries to read root_opt/data/tasks/*/specs.json (invisible inside its sandbox), and raises FileNotFoundError instead of the {"ok": true} its instructions require before stopping — breaking the "lint before you stop" safety gate rather than just degrading it.

2. os-module sandbox bypass via submodule attributes

aw_agent.py:217 (harden_guard) removes os/pathlib/io/shutil/glob from AppWorld's ALLOWED_MODULE_NAMES, but AppWorld's installed safety_guard.py only inspects literal import statements (parse_imports) — it never walks attribute chains. Modules still allowed (e.g. uuid, contextlib) expose a live os submodule as a plain attribute (verified: uuid.os, contextlib.os both resolve to the real os module). Agent code can do import uuid; uuid.os.environ.get(...) and reach os directly, undermining the sandboxing this example's secrets story depends on.

3. Unredacted traceback on the exception path

aw_agent.py:356 — main()'s except branch writes traceback.format_exc()[-4000:] verbatim into the output record, unlike the normal path where evaluation_fields() carefully strips AppWorld's gold-bearing failure trace first. Any internal exception can leak task state/paths into out.json and downstream logs/optimizer feedback.

4. Lint read ordering skips the readiness wait

adapter.py:199 — run_trials() calls lints.lint_candidate(cdir) before _wait_readable(root) (line 206), while every other root_opt read in this file (including _split_ids and run_trials itself a few lines later) waits for readability first. Per the README, root_opt is locked while the optimizer runs; a cold-cache lint call during that window risks a transient read error or reading a half-written tree.

5. Secrets cleanup only covers one key, not all loaded

aw_agent.py:74 — load_secrets() applies os.environ.setdefault for every KEY=VALUE line in the secrets file, but cleanup (aw_agent.py:267, os.environ.pop(KEY_ENV, None)) and adapter.py's subprocess env filter (line 166) only strip the single configured key. A second credential in the secrets file would persist in the episode subprocess's env for the rest of the episode — exploitable via finding #2's os-bypass.

6. Sandbox bind-mount prefix-strip bug

sandbox/claude:54 — rel="${WORK#"$AW"/}" strips $AW/ (with trailing slash) from $WORK (no trailing slash). If the optimizer's cwd is exactly $AW_EVAL itself, the strip doesn't match and rel becomes $AW's own absolute path, producing a nonsense nested bind mount while leaving the real working directory unbound inside the sandboxed namespace.

7. Duplicated bootstrap-CI logic

scripts/analyze.py:50 — bootstrap() reimplements a percentile-bootstrap CI from scratch, duplicating core/cap_evolve/stats.py's existing bootstrap_ci() (the project's designated single source of truth for eval-statistics aggregation).

8. Hand-rolled sandbox reimplements the host's own isolation

examples/appworld/sandbox/claude (whole file, ~75 lines) reimplements mount-namespace/bind-mount isolation that Claude Code's own bubblewrap sandbox should provide, only needed because of a container limitation (/dev/null bind issue). This now requires its own permanent isolation audit (check_optimizer_isolation.sh) instead of fixing the actual gap.

9. Unverified upstream constructor compatibility (lower confidence)

aw_agent.py:252-265 — build_agent_class() passes 8 kwargs into the upstream appworld_agents.code.simplified.react_code_agent.SimplifiedReActCodeAgent constructor via **kwargs. That package isn't vendored and couldn't be installed in review (no registry access), so the signature match couldn't be verified. If upstream renamed/dropped a kwarg, episode construction fails with a generic opaque infra_error. Worth a smoke-test run before merge.


Lower-confidence items dropped after verification: minor duplicate progress-logger/token-counter/dotenv code across adapter.py/altk_common.py (cosmetic); a latent joblib cache secret-persistence path in cached_lm_call (unreachable today since use_cache=False); Adapter.tasks() hardcoding ROOT_OPT instead of AW_APPWORLD_ROOT (currently unreachable in the shipped call graph).

🤖 Generated with Claude Code

erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…h (review skillberry-ai#874, 1)

lints.py read APPWORLD_ROOT with a hardcoded /tmp/aw_eval fallback, while
adapter.py reads AW_APPWORLD_ROOT. Nothing built the lint cache, and the
optimizer was told to lint against root_opt, which its namespace hides. A cold
cache raised FileNotFoundError instead of printing {"ok": ...}.

- lints.py resolves the cache (AW_LINT_CACHE, default $AW_EVAL/lint_cache.json)
  and the build root (AW_LINT_ROOT, default $AW_EVAL/root_opt). It never follows
  the episode root, which is root_full in the final window.
- Missing cache and unreadable root raise LintDataUnavailable. The CLI prints
  "ok": false with "lint data unavailable" and exits 2.
- make_roots.py writes the cache from root_opt (mode 0644, sha256 in the
  manifest).
- setup_project.sh, launch_cape_hc.sh and check_optimizer_isolation.sh refuse to
  start without the cache. The optimizer instructions point at the cache, not
  root_opt.
- Tests: tests/test_lint_data_path.py. The shared fixture moves to
  tests/aw_testlib.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…s (review skillberry-ai#874, 4)

The lint ran before _wait_readable, unlike every other root read in the
adapter. Wait first, then lint. The _wait_readable docstring said root_opt is
locked while the optimizer runs; nothing locks it any more (the namespace wrapper
isolates the optimizer), so it now says what the wait is for. A comment says
Adapter.tasks() reads root_opt on purpose.

Test: tests/test_run_trials_order.py (fails before this change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…rops it (review skillberry-ai#874, 5)

load_secrets() set every KEY=VALUE line of the secrets file, but only the
configured key was removed afterwards. The adapter also gave the worker the
whole `cap-evolve run` environment minus that one key, including the
optimizer's credentials.

- load_secrets() sets only the agent key (AW_KEY_ENV) and the endpoint URL and
  returns what it set. drop_secrets() removes every secret name once the client
  is built.
- aw_agent.worker_env() builds the worker's environment from an allowlist
  (WORKER_ENV_NAMES plus LC_*/APPWORLD_*). The adapter uses it in _episode, so
  the final window is covered too.
- README: Setup step 4 and Harness describe this.

Tests: tests/test_worker_secrets.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
… (review skillberry-ai#874, 3)

main() wrote str(e) and traceback.format_exc() into the record. The message
reached the optimizer through Rollout.error and the score feedback, and a
crashed worker's stderr did too. An exception raised in world.evaluate() can
carry gold values.

- run_episode tracks its phase (setup / episode / evaluate). redact_error()
  reports the exception type, phase and frames (file:line in func, no source).
  The message is kept only outside evaluate, with the root, candidate, secrets
  file and key replaced, capped at 300 chars.
- The raw traceback is gone from the record. With AW_KEEP_OUTPUTS=1 it is
  written next to AppWorld's own outputs, which are kept only in that mode.
- adapter._episode reduces a crashed worker's stderr to its exception type.
- README Harness says so.

Tests: tests/test_error_redaction.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
… ones (review skillberry-ai#874, 2)

AppWorld's SafetyGuard (42b5bcf) checks literal imports and a list of function
paths only. Allowed modules hand out blocked ones as attributes, so
`import uuid; uuid.os.environ` got through. Dunder walks, frame access and
getattr(module, "o" + "s") did too.

- harden_guard() wraps SafetyGuard.is_syntax_safe (idempotent). After AppWorld's
  own check, code_guard_violations() refuses: .os/.sys/.io/... attributes;
  dunders other than __name__/__qualname__/__doc__/__module__/__init__; frame
  attributes; eval/exec/compile/globals/locals/vars/__import__/__builtins__;
  getattr with a blocked constant or on a module; and IPython !/% lines. The
  refusal reads like AppWorld's own "Usage of ... is not allowed". The seed
  prompt's examples, the setup snippet and getattr(apis, app_name) pass.
- Each episode records guard_blocked (refused steps) and guard_probe.
  final_window.py keeps them and analyze.py reports them per arm.
- README Harness says this is defence in depth, not a sandbox, and lists what
  remains.

Tests: tests/test_code_guard.py (bypasses, legitimate code, the seed prompt, and
harden_guard against a stand-in for AppWorld's module).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…workdir (review skillberry-ai#874, 6)

rel="${WORK#"$AW"/}" left an absolute path when the working dir was $AW_EVAL
itself, so the bind landed under $AW/$AW and the real cwd was unbound. AW was
also not resolved while WORK was (pwd -P), so a symlinked AW_EVAL failed the
guard.

- AW is resolved with readlink -f.
- A working dir equal to $AW is refused (exit 97): binding all of it read-write
  would bring root_opt back.
- Inside the namespace, an empty or absolute rel aborts the call.

Tests: tests/test_sandbox_shim.py runs the shim for real (unprivileged user and
mount namespaces, skipped where they are unavailable) with a stand-in claude.
The cwd == $AW_EVAL and symlinked-AW_EVAL cases fail before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…s fallback (review skillberry-ai#874, 8)

The review asked why the example hand-rolls mount-namespace isolation instead of
using Claude Code's built-in sandbox. The namespace path is still needed where
bubblewrap cannot start (containers whose /dev/null is bound from the host), so
it stays, but as the fallback.

- sandbox/claude is now a ~40-line dispatcher on AW_ISOLATION. In sandbox mode
  (the default) it passes the rendered optimizer-sandbox.json to the real CLI
  and requires failIfUnavailable. namespace mode runs sandbox/ns_exec.sh (the
  former shim, unchanged apart from taking the CLI path as $1). It fails closed
  without a settings file and finds the real claude on PATH past itself.
- Both settings templates also deny AppWorld's generate/ and the secrets file,
  as the namespace mode already hid them.
- check_optimizer_isolation.sh runs both modes through the same shim.
  launch_cape_hc.sh refuses to start without a passing check for its mode that
  is newer than the rendered settings and the shim (scripts/isolation_gate.py).
- README Isolation leads with the built-in sandbox.

Tests: tests/test_isolation_modes.py (dispatch, fail-closed, template parity,
launch gate). tests/test_sandbox_shim.py now drives namespace mode through the
dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…view skillberry-ai#874, 7)

analyze.py had its own percentile bootstrap for the scenario-clustered paired
CI of ΔTGC and ΔSGC. With equal-size scenarios (test_normal: 56 x 3 tasks),
both differences are the mean over scenarios of a per-scenario paired
difference. Resampling scenarios is then exactly bootstrap_ci over those 56
values.

- scenario_diffs() computes the per-scenario differences; paired_cis() asserts
  equal-size scenarios and calls bootstrap_ci (10,000 resamples, seed 0).
- bootstrap() and its RNG are gone. CI endpoints can move slightly against
  numbers from the old code (different random stream); re-run analyze.py on an
  existing results.jsonl to refresh them.

Tests: tests/test_analyze_bootstrap.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…d's agent (review skillberry-ai#874, 9)

The review could not check that the 10 keyword arguments passed to
SimplifiedReActCodeAgent match upstream. They do at 42b5bcf (agent.py
Agent.__init__ and react_code_agent.py). A future pin change would surface only
as an opaque infra_error.

- aw_agent.agent_kwargs() builds the arguments in one place, and
  unaccepted_kwargs() walks the MRO's __init__ signatures to name any argument
  nothing accepts. run_episode raises AgentConstructionError with those names
  before construction, and labels a construction TypeError.
- check_injection.py (A4) runs the check first (A4.0). README: re-run it after
  changing the AppWorld pin.

Tests: tests/test_agent_signature.py, against copies of the two upstream
signatures at 42b5bcf plus a drifted one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 added a commit to erezh16/cap-evolve that referenced this pull request Nov 4, 2026
…erry-ai#874, 2)

A second pass over finding 2 in AppWorld's real REPL found blocked modules still
reachable by four routes:
- private aliases in allowed modules: random._os, collections._sys,
  enum.bltns, fnmatch.posixpath (42 aliases in all)
- dynamic importers: pydoc.locate("os"), pydoc.resolve, unittest.mock.patch("os...")
- yaml tags that build any object: yaml.load("!!python/name:os.environ")
- attribute walks from a string: operator.attrgetter("_os.environ"),
  "{0._os.environ[K]}".format(m)

- code_guard_violations refuses every private (`_x`) attribute and getattr
  constant, the public aliases (posix, posixpath, ntpath, genericpath, bltns),
  attrgetter/methodcaller, and .format/.format_map strings with an
  attribute or index field (or a non-literal format string). Plain "{}" and
  "{name:d}" fields and f-strings still pass.
- pydoc, pydoc_data, unittest and yaml are removed from AppWorld's import
  allowlist. The seed prompt's code imports none of them.

Verified in the real REPL: all 42 aliases and every route above are refused,
and no planted secret leaks. A scripted agent episode (API docs, Spotify login,
playlist listing, json/datetime/re/format, complete_task) runs with 0 guard
blocks. check_injection.py (A4) passes.

Tests: tests/test_aw_agent.py (bypass and legitimate cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@erezh16

erezh16 commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All nine findings are fixed on this branch, one commit per finding (each with the tests that show it), plus a second pass on #2. Everything was checked against AppWorld at the pin (42b5bcf), installed from source.

# Finding Fix
1 Lint gate broken in the launch path d6799d4
2 os reachable through attributes f063a9a, 22ba65e
3 Unredacted traceback 22734b9
4 Lint before the readiness wait 1eac7e2
5 Only one secret removed 6074b94
6 Bind-mount prefix strip 3de79f1
7 Duplicated bootstrap CI a2bcce4
8 Hand-rolled sandbox 86b5fd9
9 Constructor compatibility 5e66af0

1. Lint gate. Confirmed. The step that built the lint cache was lost when this PR was cut. lints.py now reads its data only from the cache (AW_LINT_CACHE) or root_opt, never from the episode root, which is root_full in the final window. make_roots.py builds the cache. setup_project.sh, launch_cape_hc.sh and check_optimizer_isolation.sh refuse to start without it. The optimizer instructions point at the cache, not at root_opt. With no data, the CLI prints "ok": false ("lint data unavailable") and exits 2 instead of raising FileNotFoundError. A real isolation check (A9) through the sandbox now shows the optimizer's lint printing "ok": true.

2. Attribute bypass. Confirmed. harden_guard() now wraps SafetyGuard.is_syntax_safe with an AST check, but a second pass in the real REPL found more routes than uuid.os:

  • private aliases such as random._os, collections._sys and enum.bltns;
  • importers such as pydoc.locate("os") and unittest.mock.patch("os…");
  • yaml tags such as yaml.load("!!python/name:os.environ");
  • string walks such as operator.attrgetter("_os.environ") and "{0._os}".format(m).

All of these are refused now:

  • the check also refuses private (_x) and dunder attributes, frame access, getattr with a blocked constant or on a module, eval/exec/vars/globals, attrgetter/methodcaller, format fields that walk attributes or indexes, and IPython !/% lines;
  • pydoc, unittest and yaml are removed from the allowlist.

In the real REPL, all 42 aliases of a blocked module across AppWorld's allowed modules are refused, and no planted secret leaks. A scripted agent episode doing normal work (API docs, login, listing, json/datetime/re, complete_task) runs with 0 refusals, and the A4 check still passes.

A Python-level guard can't be complete, and the README now says so. The other controls are #5, the per-episode counters guard_blocked/guard_probe (reported by analyze.py), and keeping answers out of root_opt during search.

3. Traceback. Confirmed, though the exposure was narrower than stated. The traceback field was never read and out.json is deleted, but str(e) and the worker's stderr did reach the optimizer's feedback. Errors now report the exception type, the phase and the frames (file:line in func). The message is kept only outside evaluate, with paths and the key replaced and a 300-character cap. A crashed worker's stderr is reduced to its exception type.

4. Readiness wait. Fixed: run_trials now waits, then lints. After #1 the lint reads only the cache, so the order no longer matters at runtime. The stale "root_opt is locked" comments are gone.

5. Secrets. Confirmed, and the bigger leak was the adapter: it gave the worker the whole cap-evolve run environment, including the optimizer's credentials, minus one key. The worker's environment is now built from an allowlist (aw_agent.WORKER_ENV_NAMES). load_secrets() sets only the agent key and the endpoint URL, and drop_secrets() removes the key once the client is built.

6. Prefix strip. Fixed. A working dir equal to $AW_EVAL is refused, AW is resolved with readlink -f (a symlinked AW_EVAL used to fail every workdir), and an empty or absolute rel aborts. The tests run the shim in real user and mount namespaces.

7. Bootstrap. Fixed. With equal-size scenarios (56 × 3), the clustered paired ΔTGC and ΔSGC are means over scenarios of per-scenario differences, so analyze.py now calls cap_evolve.stats.bootstrap_ci on those differences and checks that the scenario sizes are equal.

8. Hand-rolled sandbox. Partly agreed. Claude Code's built-in sandbox is now the default (AW_ISOLATION=sandbox). The namespace code stays as a fallback in sandbox/ns_exec.sh for hosts where bubblewrap can't start (/dev/null bound from the host). sandbox/claude is now a short dispatcher. The isolation check stays either way, because it is the acceptance test for any mechanism, and launch_cape_hc.sh refuses to start without a passing check for its mode that is newer than the rendered settings. A real check passes in both modes here, with 0 of 6 planted secrets leaked.

9. Constructor compatibility. Checked against the source at 42b5bcf. The episode passes 10 kwargs (not 8), and Agent.__init__ or SimplifiedReActCodeAgent.__init__ accepts every one. check_injection.py (A4) now names any kwarg the upstream agent stops accepting, and the episode raises a labelled error instead of an opaque infra_error. A4 passes against the real install.

Items you dropped:

  • cached_lm_call: still unreachable, since use_cache=False.
  • Adapter.tasks() using ROOT_OPT: intentional (search tasks only), now commented.
  • The ALTK duplicate code: gone with the ALTK arm (below).

Other changes in this push:

  • The ALTK-Evolve arm is removed. It is a competing optimizer, not a benchmark, so the example now compares seed with cape-hc.
  • scripts/itest.sh adds a one-task integration test, modelled on tau2_airline/itest.sh (train task 82e2fac_1, 1 trial, 1 iteration). Its asserter adds two checks to tau2's: no infra-errored or lint-rejected rollout, and no gold-bearing record.
  • There is a CI leg in Integration tests (bench: appworld). ci_setup.sh provisions AppWorld in the runner cache, verified from both an empty and a warm cache.
  • The tests are laid out one file per module, as in examples/parsec/tests.

Not verified yet: a scored end-to-end run. RITS is unreachable from my machine, so the itest finished every step but scored nothing. Every episode timed out, and the asserter correctly failed it rather than passing a 0.0-vs-0.0 "no regression". The first appworld dispatch on the runner is that run.

🤖 Generated with Claude Code

devagent and others added 6 commits November 4, 2026 23:07
…olve

A cap-evolve adapter and harness for AppWorld's official simplified ReAct
code agent, an ALTK-Evolve arm on the same harness, and the scripts for an
honest comparison: official splits only, one interleaved final window,
gold-free feedback, candidate lints, and optimizer isolation.

- adapters/: adapter (one subprocess per episode), CapReActAgent (prompt,
  REPL helpers, API notes, memory slot; byte-identical to stock when empty),
  lints. REPL hardening and a per-task token cap applied to every arm.
- altk/: mine, consolidate, render and select ALTK-Evolve doses.
- sandbox/: a claude wrapper that runs every optimizer call in a private
  mount namespace with only an allowlist visible; settings templates for
  Claude Code's own sandbox where it can start.
- scripts/: data roots, splits, acceptance checks (incl. a planted-secret
  isolation check), launch, final window, analysis, isolation scan.
- tests/: 15 offline tests (no AppWorld install, no model call).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…al-window dir; reuse frozen artifacts

The optimizer instructions pointed at the project's copy of lints.py, which
lives under $AW_EVAL and is hidden by the namespace wrapper, so the optimizer
could not lint its own candidate. Point at the example's copy instead.
AW_FINAL selects the final-window dir so a re-run keeps an earlier window;
AW_REUSE_ARTIFACTS keeps the seed and altk artifacts byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The wrapper's allowlist exposed the whole AppWorld clone, and appworld/generate
holds the task generators: the solution and evaluation logic of every train/dev
scenario. An optimizer subagent read six of them. Expose only src/ and
experiments/ (all the venv's editable installs need), and plant an A9 secret
in the generator dir.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h (review skillberry-ai#874, 1)

lints.py read APPWORLD_ROOT with a hardcoded /tmp/aw_eval fallback, while
adapter.py reads AW_APPWORLD_ROOT. Nothing built the lint cache, and the
optimizer was told to lint against root_opt, which its namespace hides. A cold
cache raised FileNotFoundError instead of printing {"ok": ...}.

- lints.py resolves the cache (AW_LINT_CACHE, default $AW_EVAL/lint_cache.json)
  and the build root (AW_LINT_ROOT, default $AW_EVAL/root_opt). It never follows
  the episode root, which is root_full in the final window.
- Missing cache and unreadable root raise LintDataUnavailable. The CLI prints
  "ok": false with "lint data unavailable" and exits 2.
- make_roots.py writes the cache from root_opt (mode 0644, sha256 in the
  manifest).
- setup_project.sh, launch_cape_hc.sh and check_optimizer_isolation.sh refuse to
  start without the cache. The optimizer instructions point at the cache, not
  root_opt.
- Tests: tests/test_lint_data_path.py. The shared fixture moves to
  tests/aw_testlib.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (review skillberry-ai#874, 4)

The lint ran before _wait_readable, unlike every other root read in the
adapter. Wait first, then lint. The _wait_readable docstring said root_opt is
locked while the optimizer runs; nothing locks it any more (the namespace wrapper
isolates the optimizer), so it now says what the wait is for. A comment says
Adapter.tasks() reads root_opt on purpose.

Test: tests/test_run_trials_order.py (fails before this change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rops it (review skillberry-ai#874, 5)

load_secrets() set every KEY=VALUE line of the secrets file, but only the
configured key was removed afterwards. The adapter also gave the worker the
whole `cap-evolve run` environment minus that one key, including the
optimizer's credentials.

- load_secrets() sets only the agent key (AW_KEY_ENV) and the endpoint URL and
  returns what it set. drop_secrets() removes every secret name once the client
  is built.
- aw_agent.worker_env() builds the worker's environment from an allowlist
  (WORKER_ENV_NAMES plus LC_*/APPWORLD_*). The adapter uses it in _episode, so
  the final window is covered too.
- README: Setup step 4 and Harness describe this.

Tests: tests/test_worker_secrets.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
erezh16 and others added 11 commits November 4, 2026 23:07
… (review skillberry-ai#874, 3)

main() wrote str(e) and traceback.format_exc() into the record. The message
reached the optimizer through Rollout.error and the score feedback, and a
crashed worker's stderr did too. An exception raised in world.evaluate() can
carry gold values.

- run_episode tracks its phase (setup / episode / evaluate). redact_error()
  reports the exception type, phase and frames (file:line in func, no source).
  The message is kept only outside evaluate, with the root, candidate, secrets
  file and key replaced, capped at 300 chars.
- The raw traceback is gone from the record. With AW_KEEP_OUTPUTS=1 it is
  written next to AppWorld's own outputs, which are kept only in that mode.
- adapter._episode reduces a crashed worker's stderr to its exception type.
- README Harness says so.

Tests: tests/test_error_redaction.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ones (review skillberry-ai#874, 2)

AppWorld's SafetyGuard (42b5bcf) checks literal imports and a list of function
paths only. Allowed modules hand out blocked ones as attributes, so
`import uuid; uuid.os.environ` got through. Dunder walks, frame access and
getattr(module, "o" + "s") did too.

- harden_guard() wraps SafetyGuard.is_syntax_safe (idempotent). After AppWorld's
  own check, code_guard_violations() refuses: .os/.sys/.io/... attributes;
  dunders other than __name__/__qualname__/__doc__/__module__/__init__; frame
  attributes; eval/exec/compile/globals/locals/vars/__import__/__builtins__;
  getattr with a blocked constant or on a module; and IPython !/% lines. The
  refusal reads like AppWorld's own "Usage of ... is not allowed". The seed
  prompt's examples, the setup snippet and getattr(apis, app_name) pass.
- Each episode records guard_blocked (refused steps) and guard_probe.
  final_window.py keeps them and analyze.py reports them per arm.
- README Harness says this is defence in depth, not a sandbox, and lists what
  remains.

Tests: tests/test_code_guard.py (bypasses, legitimate code, the seed prompt, and
harden_guard against a stand-in for AppWorld's module).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…workdir (review skillberry-ai#874, 6)

rel="${WORK#"$AW"/}" left an absolute path when the working dir was $AW_EVAL
itself, so the bind landed under $AW/$AW and the real cwd was unbound. AW was
also not resolved while WORK was (pwd -P), so a symlinked AW_EVAL failed the
guard.

- AW is resolved with readlink -f.
- A working dir equal to $AW is refused (exit 97): binding all of it read-write
  would bring root_opt back.
- Inside the namespace, an empty or absolute rel aborts the call.

Tests: tests/test_sandbox_shim.py runs the shim for real (unprivileged user and
mount namespaces, skipped where they are unavailable) with a stand-in claude.
The cwd == $AW_EVAL and symlinked-AW_EVAL cases fail before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s fallback (review skillberry-ai#874, 8)

The review asked why the example hand-rolls mount-namespace isolation instead of
using Claude Code's built-in sandbox. The namespace path is still needed where
bubblewrap cannot start (containers whose /dev/null is bound from the host), so
it stays, but as the fallback.

- sandbox/claude is now a ~40-line dispatcher on AW_ISOLATION. In sandbox mode
  (the default) it passes the rendered optimizer-sandbox.json to the real CLI
  and requires failIfUnavailable. namespace mode runs sandbox/ns_exec.sh (the
  former shim, unchanged apart from taking the CLI path as $1). It fails closed
  without a settings file and finds the real claude on PATH past itself.
- Both settings templates also deny AppWorld's generate/ and the secrets file,
  as the namespace mode already hid them.
- check_optimizer_isolation.sh runs both modes through the same shim.
  launch_cape_hc.sh refuses to start without a passing check for its mode that
  is newer than the rendered settings and the shim (scripts/isolation_gate.py).
- README Isolation leads with the built-in sandbox.

Tests: tests/test_isolation_modes.py (dispatch, fail-closed, template parity,
launch gate). tests/test_sandbox_shim.py now drives namespace mode through the
dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view skillberry-ai#874, 7)

analyze.py had its own percentile bootstrap for the scenario-clustered paired
CI of ΔTGC and ΔSGC. With equal-size scenarios (test_normal: 56 x 3 tasks),
both differences are the mean over scenarios of a per-scenario paired
difference. Resampling scenarios is then exactly bootstrap_ci over those 56
values.

- scenario_diffs() computes the per-scenario differences; paired_cis() asserts
  equal-size scenarios and calls bootstrap_ci (10,000 resamples, seed 0).
- bootstrap() and its RNG are gone. CI endpoints can move slightly against
  numbers from the old code (different random stream); re-run analyze.py on an
  existing results.jsonl to refresh them.

Tests: tests/test_analyze_bootstrap.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d's agent (review skillberry-ai#874, 9)

The review could not check that the 10 keyword arguments passed to
SimplifiedReActCodeAgent match upstream. They do at 42b5bcf (agent.py
Agent.__init__ and react_code_agent.py). A future pin change would surface only
as an opaque infra_error.

- aw_agent.agent_kwargs() builds the arguments in one place, and
  unaccepted_kwargs() walks the MRO's __init__ signatures to name any argument
  nothing accepts. run_episode raises AgentConstructionError with those names
  before construction, and labels a construction TypeError.
- check_injection.py (A4) runs the check first (A4.0). README: re-run it after
  changing the AppWorld pin.

Tests: tests/test_agent_signature.py, against copies of the two upstream
signatures at 42b5bcf plus a drifted one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…verified

Tests: one file per module under test (test_adapter, test_aw_agent, test_lints,
test_sandbox, test_analyze, test_altk) instead of one file per review finding
plus a shared helper module. Each file sets up its own sys.path the way
examples/parsec/tests does, with core/ appended as a fallback, so
`python3 examples/appworld/tests/<file>.py` runs from a clean shell without an
installed cap-evolve. The same 65 test methods; classes are named Test<Thing>.

README Setup step 1 now has the install that a from-scratch smoke run used.
"-e core" named a directory AppWorld does not have. cap-evolve itself must be in
venv-aw, because launch_cape_hc.sh runs venv-aw/bin/cap-evolve. AppWorld 42b5bcf
leaves fastapi and starlette unpinned and breaks with fastapi >= 0.155
(Path(embed=)) and starlette < 0.48 (HTTP_422_UNPROCESSABLE_CONTENT). With
fastapi 0.135.0 and starlette >= 0.48, check_injection.py passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ALTK-Evolve is a competing optimizer, not a benchmark, so it does not belong in
a benchmark example. The example now compares two arms, the stock seed and
cape-hc.

- Deleted altk/ and its test, and seed_capability/memory/, which only the ALTK
  arm filled.
- The harness loses the memory block: CapReActAgent has no memory_block
  argument, the adapter no _memory_block, the lints no memory lint, and
  check_injection.py no A4.2 placement check. With no helpers and no notes the
  first request is still byte-identical to stock (A4.1).
- final_window.py freezes and runs seed and cape-hc only. analyze.py has one
  pair (cape-hc - seed). status.py and scan_isolation.py drop the altk entries;
  awpaths drops ALTK_PYTHON.
- The sandbox templates and the isolation check stop listing runs/altk.
- README: two arms, no ALTK setup step or section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/itest.sh is the example's end-to-end regression check, modelled on
examples/tau2_airline/{itest.sh, assert_itest.py, capevolve.itest.yaml}: one
train task (82e2fac_1, difficulty 1), 1 trial, 1 iteration, and the same budget
keys as tau2's itest spec. It runs the real launch path: setup_project.sh
(cap-evolve check), a fresh isolation check, then launch_cape_hc.sh, which now
takes AW_SPEC for a spec other than capevolve.yaml.

scripts/assert_itest.py makes tau2's three assertions (completed, an iteration
ran, final test >= baseline val) and adds two:
- episodes ran: no rollout is an infrastructure error or a lint reject. A broken
  key or model scores 0.0 like a failed task and would otherwise pass as
  non-regression.
- gold-free: no rollout names a gold file, and AppWorld's failure `trace` is
  absent from the evaluation fields. cap-evolve's own Rollout.trace (the agent's
  steps) is allowed.

`itest.sh --dry-run` covers everything up to the first model call. Run here
against a real AppWorld install, it passes. The full run was not done here (no
agent-model key on this host).

Tests: tests/test_itest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The appworld example's one-task regression (train task 82e2fac_1, 1 trial,
1 iteration) as a leg of the Integration tests workflow, next to tau2 and the
tau2_custom arms. Dispatch only: bench = appworld. It is not in benchmarks.yml.

- ci_setup.sh appworld does the example's README Setup steps 1-2 in the runner
  cache ($CAPEVOLVE_CI_CACHE/appworld, exported as AW_EVAL). It clones AppWorld
  at 42b5bcf, pulls the LFS bundles, applies the header patch, installs into its
  own Python 3.11 venv with the fastapi/starlette pins, downloads the data,
  builds root_opt and the lint cache, and writes the reference docs. Each step
  is skipped when its output is cached. It ends with the A4 injection check (no
  model call). git-lfs is required.
- run_suite.sh appworld copies the example's adapter, seed and rendered
  optimizer instructions, and writes the spec keys the example's capevolve.yaml
  sets. The agent reaches AGENT_MODEL through its provider's LiteLLM gateway
  with the gateway's bearer key, which goes into a mode-600 file that each
  episode worker reads and drops, never into the optimizer's environment. Every
  optimizer call goes through the example's sandbox/claude shim with the
  rendered settings.
- The workflow sets AGENT_MODEL=gpt-oss-120b-a100 (RITS) for this leg, because
  ci_setup.sh's provider preflight reads AGENT_MODEL and does not load tier
  overrides. After assert_run.py it runs the example's assert_itest.py.
- aw_agent.py: AW_KEY_HEADER=bearer sends the key as the standard API key
  (api_key_env_name) instead of a named header; RITS's header stays the
  default.

Verified here: provisioning from an empty cache (77 s) and warm (26 s, every
step skipped); cap-evolve check passes on the project run_suite.sh generates; one
real AppWorld episode through the gateway path, with a local stand-in gateway,
sent `Authorization: Bearer` and the rits/ wire model id.

Tests: core/tests/test_benchmarks_appworld_leg.py drives run_suite.sh appworld
with a stub interpreter (no model call) and checks the project, spec, secrets
file, settings, the missing-cache failure, the provisioning steps and the
workflow entry. examples/appworld/tests/test_aw_agent.py covers both auth modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erry-ai#874, 2)

A second pass over finding 2 in AppWorld's real REPL found blocked modules still
reachable by four routes:
- private aliases in allowed modules: random._os, collections._sys,
  enum.bltns, fnmatch.posixpath (42 aliases in all)
- dynamic importers: pydoc.locate("os"), pydoc.resolve, unittest.mock.patch("os...")
- yaml tags that build any object: yaml.load("!!python/name:os.environ")
- attribute walks from a string: operator.attrgetter("_os.environ"),
  "{0._os.environ[K]}".format(m)

- code_guard_violations refuses every private (`_x`) attribute and getattr
  constant, the public aliases (posix, posixpath, ntpath, genericpath, bltns),
  attrgetter/methodcaller, and .format/.format_map strings with an
  attribute or index field (or a non-literal format string). Plain "{}" and
  "{name:d}" fields and f-strings still pass.
- pydoc, pydoc_data, unittest and yaml are removed from AppWorld's import
  allowlist. The seed prompt's code imports none of them.

Verified in the real REPL: all 42 aliases and every route above are refused,
and no planted secret leaks. A scripted agent episode (API docs, Spotify login,
playlist listing, json/datetime/re/format, complete_task) runs with 0 guard
blocks. check_injection.py (A4) passes.

Tests: tests/test_aw_agent.py (bypass and legitimate cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OsherElhadad
OsherElhadad merged commit 4c59b91 into skillberry-ai:main Nov 5, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

algorithm Optimization algorithms: GEPA / SkillOpt / hill-climb documentation Improvements or additions to documentation enhancement New feature or request honesty Honesty/consistency of reported results (brand-critical)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants