mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* feat(skillopt): foundation modules — types, lr-schedule, benchmark, score, audit, lock
* feat(skillopt): edit primitives — apply-edits (D5+D9), rejected-buffer LRU, version-store (D8 history-intent-first)
* feat(skillopt): rollout (D2 gateway.toolLoop + D13 read-only allowlist), reflect (D7 two calls), validate-gate (D12 median+epsilon, D4 parallel), preflight (D3), bundled-skill-gate (D16)
* feat(skillopt): orchestrator (D6 slow-update, D10 ASCII diagrams, D11 caching), checkpoint, bootstrap (D15 sentinel), CLI dispatch + help
* feat(skillopt): cycle phase (F1 dream-loop wiring), PROTECTED_JOB_NAMES + MCP op (F6 admin scope + allowlist) + Minion handler (F7 --background)
* feat(skillopt): full cathedral — --all batch (F4), --target-models fleet (F5), write-capture (F10), held-out scaffold (F11), adversarial suite 41 cases (F2), E2E PGLite (F3), meta-skill bundle (T7), reflect+judge evals (F8+F9), docs (T10)
* chore: bump version to v0.42.0.0 (MINOR — significant new feature)
* fix(skillopt): wire trajectories from forward gate to reflect + fix parseEditsResponse parser misuse
Two related v0.42.0.0 bugs that conspired to make `runSkillOpt` structurally
unable to accept any candidate edit. Either alone would have killed self-evolution;
together they made the loop a no-op for every input.
**Bug 1 (orchestrator gap):** `runOptimizationLoop` in orchestrator.ts called
`runReflect({successes: [], failures: []})` with hardcoded empty arrays. The
forward gate's `scoredRollouts` were computed then voided. `runReflect`
short-circuits both modes when their batches are empty, so the optimizer was
never asked to propose an edit. Every step hit the no_edits_applied branch.
Fix: add `scoredRollouts: ScoredRollout[]` to `GateResult` and
`runsPerTask?: number` to `ValidateGateOpts`. Forward pass uses
`runsPerTask: 1`; orchestrator partitions returned rollouts by `score >= 0.5`
and threads real successes + failures into `runReflect`.
**Bug 2 (parser misuse):** `parseEditsResponse` in reflect.ts routed every
optimizer response through `parseJudgeJson` first. `parseJudgeJson` looks for
a `score` key (it's a judge-output parser, not an edits parser) and returns
null for any JSON without one — including the well-formed `{"edits": [...]}`
the optimizer is contractually required to emit. The function then early-
returned `[]` and the actual `tryExtractEdits` path on the next line was
unreachable dead code.
Fix: drop the wrong-typed guard. `parseEditsResponse` now calls
`tryExtractEdits` directly. Export it so `reflect.test.ts` can pin the
contract independently of the chat transport.
**Why this slipped through 152 prior skillopt tests:** zero unit coverage
of `parseEditsResponse` or `runReflect`. The existing E2E `all-reject` case
asserted no_improvement (which was true for the wrong reason — empty edits,
not gate rejection). Both bugs were structurally invisible to the existing
test surface.
**New coverage:**
- `test/skillopt/reflect.test.ts` (15 cases):
- 8 `parseEditsResponse` cases including the IRON-RULE regression pin
for the v0.42.0.1 fix (`{"edits": [...]}` JSON must survive the parser).
- 7 `runReflect` D7 contract cases: both modes fire, empty-batch skips,
additive token usage, one-mode-throws-other-still-works, rejected-buffer
flows into anti-bias prompt.
- Documents the trailing-comma limitation as an explicit out-of-scope pin
(so a future tightening of `tryExtractEdits` lights this test up
intentionally).
- `test/e2e/skillopt-loop.serial.test.ts` (7 cases):
- HAPPY PATH: stubbed `gateway.chat` acts as both target agent (emits
sections based on skill content) and optimizer (proposes a real
add-Citations edit). Drives `runSkillOpt` end-to-end against PGLite.
Asserts outcome=accepted, SKILL.md mutated with new section,
frontmatter preserved (D5), history has one committed row,
best.md mirrors disk, delta > epsilon, receipt fields populated.
- 5 broken cases (each isolates a distinct orchestrator-visible failure):
1. Below-baseline regression: optimizer proposes a destructive edit;
gate rejects with reason=below_baseline; SKILL.md unchanged;
rejected-buffer captures the bad edit for anti-bias context.
2. Malformed reflect JSON: orchestrator degrades gracefully to
no_improvement without crashing.
3. Anchor-not-found: applyEditBatch rejects all; sel gate skipped;
rejected-buffer captures with reason=apply_failed.
4. Budget exhausted mid-step: outcome=aborted, no pending rows survive.
5. Converged-skill re-run: starting from already-perfect skill →
no_improvement (no thrash on a well-tuned starting point).
- IDEMPOTENT RE-RUN: drive runSkillOpt twice in sequence. Run 1 accepts.
Run 2 sees improved baseline, no failures, returns no_improvement.
SKILL.md byte-identical to post-run-1; history still has exactly 1
committed row. Proves stability at the fixed point.
All hermetic (no DATABASE_URL, no API keys). PGLite in-memory engine,
tempdir SKILL.md + benchmark, stubbed gateway.chat via
`__setChatTransportForTests`. `.serial.test.ts` because the stub installs
module state and the loop walks shared disk state across epochs.
Test counts after fix: 174 skillopt-surface tests pass (149 pre-existing
unit + 15 new reflect unit + 3 existing E2E + 7 new E2E). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cycle): align ALL_PHASES skillopt position with actual dispatch order
v0.42.0.0 added skillopt to ALL_PHASES right after `patterns` (line 127), but
the dispatch block in runCycle (line ~1912) actually runs skillopt between
`conversation_facts_backfill` and `embed`. The two were inconsistent, and the
serial test `report.phases.map(p => p.phase)).toEqual(ALL_PHASES)` was failing
on master because of it.
A second pre-existing failure: the two phase-count assertions in
`test/core/cycle.serial.test.ts` still said `toBe(20)` even though
ALL_PHASES grew to 21 when skillopt was added. The author bumped the array
but forgot the test.
Two fixes, one commit:
1. Move `'skillopt'` in ALL_PHASES from after `patterns` to between
`conversation_facts_backfill` and `embed`, matching where runCycle
actually dispatches it. Runtime behavior is unchanged — only the
declaration order moves. Updated the surrounding comment to call out
the position invariant and reference the test that pins it.
2. Update both `toBe(20)` assertions in cycle.serial.test.ts to `toBe(21)`
with a v0.42.0.0 history line in the running comments.
Why declaration follows runtime (not the other way around): the comment
intent ("Runs AFTER patterns — graph-fresh") is still satisfied because
"after the entire main graph-mutating cluster" is strictly fresher than
"right after patterns". No design intent is lost.
Test result: cycle.serial.test.ts is now 28/28 (was 27/28 on master + my
prior commit). Skillopt suite still 174/174.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): bump PHASE_SCOPE assertion to 21 + fix skill-optimizer Anti-Patterns case
Two CI failures pre-existing on this branch since the v0.42.0.0 skillopt
cathedral landed; master is green because skillopt didn't exist there yet.
1. test/phase-scope-coverage.test.ts asserted ALL_PHASES.length === 20.
skillopt is the 21st phase. Bumped to 21 with v0.42.0.0 history line
in the comment chain. Sibling fix to the cycle.serial.test.ts bump
in commit 08ad2468.
2. skills/skill-optimizer/SKILL.md had `## Anti-patterns` (lowercase p).
skills-conformance.test.ts asserts `## Anti-Patterns` (capital P) as
the required section header. Single-character rename.
Local: 174 skillopt-surface tests + 6 phase-scope tests + 249 skills-
conformance tests all green. Typecheck clean.
Remaining CI delta: 5 put_page facts backstop failures in shard 10 that
reproduce only on Linux CI, not locally even with empty env / cleared
HOME / max-concurrency=1. The error surface is `r.isError === true` with
no further detail captured in the bun:test output. Pushing these 2 fixes
first to narrow the CI signal; will instrument if the 5 persist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(e2e): align dream-cycle-phase-order + onboard-full-flow with v0.41/v0.42 reality
Two stale E2E assertion files surfaced by a full local E2E run against
real Postgres (the gbrain-test-pg container on port 5434). Neither file
is in the CI E2E job (CI only runs mechanical.test.ts + mcp.test.ts +
skills.test.ts + zeroentropy-live.test.ts), so the drift has been latent.
1. `test/e2e/dream-cycle-phase-order-pglite.test.ts`
EXPECTED_PHASES was missing 4 phases that landed in master since the
list was last revised:
- extract_atoms (v0.41 T9 — atom extraction, after extract_facts)
- synthesize_concepts (v0.41 T9 — concept synthesis, after patterns)
- conversation_facts_backfill (v0.41.11.0, after calibration_profile)
- skillopt (v0.42.0.0 — self-evolving skills, between
conversation_facts_backfill and embed)
Updated to 21 entries in the actual runtime dispatch order (matches
ALL_PHASES exactly). 5/5 tests in the file pass after.
2. `test/e2e/onboard-full-flow.test.ts`
`runAllOnboardChecks` shape test asserted exactly 4 checks; v0.42's
type-unification cathedral (PR #1542, T13-T15) added 3 more
(`pack_upgrade_available`, `type_proliferation`, `dangling_aliases`)
for a total of 7. And `empty brain returns 0 remediations` regressed
because `pack_upgrade_available` can emit a manual_only remediation
on brains where gbrain-base@1.x is active and gbrain-base-v2 is
registered as a successor. Tightened that assertion to `total <= 1`
AND kept a per-check guard asserting takes_count remediations stay 0
(the original test's load-bearing claim — A12 two-gate consent).
13/13 tests in the file pass after.
Honest scope: 4 other E2E files still fail locally after this commit
(cycle.test.ts, dream.test.ts, phantom-redirect.test.ts,
sync-lock-recovery.test.ts), each for a distinct pre-existing master
bug unrelated to v0.42 skillopt work:
- cycle.test.ts (5 fails): PostgresEngine.getConfig falls back to
db.getConnection() singleton via the `get sql()` getter when no
poolSize is set; the new conversation_facts_backfill phase chain
hits this fallback even though the test's setupDB() connects both
the singleton AND the engine. Race condition between the test's
singleton lifecycle and the phase's getConfig call. Deeper fix
needed in PostgresEngine.getConfig (use this._sql directly with
explicit fallback only on user-driven CLI paths).
- dream.test.ts (1 fail): expects "concepts/testing" slug to appear
in dream cycle output, gets empty array. Related to v0.42 concept
type-unification semantics.
- phantom-redirect.test.ts (2 fails): concurrent-sync race +
postgres-js text-string embedding survival. Master-level data-path
bug; would need its own fix wave.
- sync-lock-recovery.test.ts (1 fail): `gbrain sync --break-lock
--all` exits 0 but test expects 1 with a shell-loop hint. CLI
behavior changed in a master commit; need to either restore the
refusal behavior or update the assertion.
None of these 4 block CI (E2E job doesn't run them). Filed as a
TODOS.md entry for a follow-up wave; the 2 in this commit are the
ones that mirror v0.42 work landing.
Local: 130/136 E2E files green, 927/940 tests pass (was 925/940
before these fixes; the 2 files this commit fixes added 7 newly-
passing tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): quarantine query-cache-knobs-hash.test.ts to serial runner
CI shard 10 (commit 4d721077) failed 5 tests in the
`SemanticQueryCache cross-mode isolation (CDX-4 hotfix)` describe block,
all ~7-34ms each, all expecting writes/reads to round-trip through one
shared PGLite engine + a `beforeEach DELETE FROM query_cache`. Passes
9/9 locally; fails 5/9 on Linux CI under bun's default in-file
max-concurrency=4.
Classic intra-file concurrency race shape: test A's `beforeEach`
clears the table → test A's `store` writes a row → test B's
`beforeEach` (concurrent with A's `store`) clears the table → test A's
follow-up COUNT query returns 0. Same root cause that quarantined
`embed-stale.test.ts`, `brain-allowlist.test.ts`, and
`schema-pack-find-pack-successors.test.ts` to the serial runner in
prior fix waves (documented in v0.41.22.0 CI fix wave).
Fix: rename to `query-cache-knobs-hash.serial.test.ts` so the v0.26.7
serial-tests runner picks it up at `max-concurrency=1`. Tests still
exercise the actual cache logic — no test deleted, no production code
changed. The describe block's `beforeAll` engine + `beforeEach`
TRUNCATE pattern works correctly at serial concurrency.
Local: 12/12 in this file + 52/52 in the serial runner. Production
SemanticQueryCache code is untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(heavy): frontmatter_scan_wallclock — opt into --no-embedding so CI runners work
Heavy tests workflow run 26542447602 (commit 483a5577) failed on the
first heavy script:
[fm_wallclock] FAIL: gbrain init exited non-zero
No embedding provider configured. Set one of:
OPENAI_API_KEY / ZEROENTROPY_API_KEY / VOYAGE_API_KEY
Or defer setup: gbrain init --pglite --no-embedding
The v0.37 D9 hard-require landed in init.ts: `gbrain init --pglite` now
refuses to proceed without an embedding provider configured. The
heavy-tests GitHub workflow doesn't pipe any embedding API keys
(deliberate — the heavy tests measure ops shape, not LLM behavior), so
every CI invocation now blocks at step 2 of this script.
The script's whole purpose is measuring `gbrain doctor`'s
frontmatter-scan wallclock — it never embeds, never calls
`gbrain embed`, never queries vectors. The right fix is to opt out of
the provider requirement via the same `--no-embedding` flag init.ts
already exposes for this exact "deferred setup" case.
Verified locally:
TMP=$(mktemp -d); GBRAIN_HOME="$TMP" \
bun run src/cli.ts init --pglite --yes --no-embedding
# exit 0, brain initialized.
No production code change. One-line + comment in the script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(heavy): sync_lock_regression — pass --no-embed so CI runs measure lock contention, not key absence
Heavy tests workflow run 26542545802 (commit 7962d312, after the
previous fm_wallclock fix) failed at the next heavy script in the chain:
[sync_lock_regression] outcomes: winners=0 losers=0 unknown=4
[sync_lock_regression] FAIL: expected 1 winner, got 0
[sync_lock_regression] FAIL: expected 3 lock-busy losers, got 0
Each of the 4 parallel `gbrain sync` invocations failed for the same
reason — none of them ever even got to the lock-acquire step:
Embedding model "zeroentropyai:zembed-1" requires ZEROENTROPY_API_KEY.
Re-run with --no-embed to import-only and embed later once the key is set.
The CI runner doesn't pipe any embedding-provider API keys (deliberate —
heavy tests measure ops shape, not LLM behavior), and sync now hard-fails
when its embed step can't reach a configured provider.
This script measures the writer-lock race shape — `gbrain-sync` row in
`gbrain_cycle_locks`, exactly-one-winner semantics, N-1 fail-fast losers
with "Another sync is in progress", zero leaked rows post-run. It never
needed embeddings; the original write predates the hard-require landing.
Fix: pass `--no-embed` to the sync invocation. Same kind of fix as
fm_wallclock (commit 7962d312) but on the sync side rather than init.
No production code touched. One-line change in the bash script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(heavy): sync_lock_regression — register source via psql + use --repo + tolerate doctor warns
Heavy tests run 26542638471 (commit 60145eee, after the --no-embed
fix) failed at the same script but at a downstream step:
> Source "default" has no local_path. Run: gbrain sources add default --path <path>
Three independent bugs in the script that all surfaced at once after
v0.41's source-registry landed:
1. `gbrain config set sync.repo_path` is the legacy way; sync now
reads `sources.local_path` first. Replaced with an upsert into the
sources table via psql:
INSERT INTO sources (id, name, local_path)
VALUES ('default', 'default', $BRAIN_DIR)
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path
Kept the legacy `config set sync.repo_path` line too as
belt-and-suspenders for any downstream caller that still reads it.
2. `gbrain sync --dir <path>` is silently ignored; sync's CLI parser
recognizes `--repo`, not `--dir`. Switched to `--repo`.
3. `bun run src/cli.ts doctor --json` at the top (used to apply
migrations as a side effect) exits non-zero whenever ANY check
warns — including the new "no embedding provider configured"
warning on a fresh CI runner. The script's `set -e` aborted at
line 53 before reaching any of the sync invocations. Added `|| true`
since the migration runs regardless of doctor's exit verdict.
Verified locally — `DATABASE_URL=... bash tests/heavy/sync_lock_regression.sh`
output:
[sync 1] rc= (lock-busy: 'Another sync is in progress')
[sync 2] rc=0 (winner)
[sync 3] rc= (lock-busy: 'Another sync is in progress')
[sync 4] rc= (lock-busy: 'Another sync is in progress')
outcomes: winners=1 losers=3 unknown=0
post-run gbrain_cycle_locks(gbrain-sync) row count: 0
OK — 1 winner, 3 lock-busy losers, no leaked lock rows.
Production code untouched. All three fixes are in the bash script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(skillopt): hands-on tutorial for auto-improving a skill + discoverability
There was no tutorial for skillopt — only a reference guide
(docs/guides/skillopt.md) that opens at --bootstrap-from-routing and
assumes you already understand benchmarks, and an agent-facing SKILL.md.
README had ZERO skillopt mention. The one thing a user must hand-author
(the benchmark JSONL) was taught nowhere with a worked example.
New: docs/tutorials/improving-skills-with-skillopt.md — Diataxis tutorial
(learning-oriented), copy-pasteable end to end:
1. mental model in two sentences (SKILL.md is the trainable param, the
agent is frozen)
2. write your first benchmark from scratch — a complete 15-task rule-judge
starter you paste and run, with the full check-op table
(contains/regex/section_present/max_chars/min_citations/tool_called/
tool_not_called)
3. --dry-run cost preview (and that it exits 2 by convention, not failure)
4. real run + reading accepted(0)/no_improvement(1)/aborted(2) with the
actual stderr output shape
5. where output lands (best.md, versions/, history.json, rejected.json,
audit jsonl)
6. accept/reject — bundled vs user skills, --no-mutate vs
--allow-mutate-bundled
7. iterate by sharpening the benchmark
The load-bearing fix the tutorial makes that the reference guide got wrong:
the DEFAULT --split 4:1:5 needs ~50 tasks before it runs (sel = N/10, floor
5). A first-time author writing 10-15 tasks hits `D_sel has N task(s)
(need >=5)` and bounces. The tutorial ships 15 tasks + `--split 1:1:1`
(clean 5/5/5) so the copy-paste path actually works. Verified against the
real loadBenchmark + splitBench: the exact shipped block parses 15 unique
tasks and splits 5/5/5 with sel>=5; the system's own error message confirms
"need ~50 total for 4:1:5".
Discoverability (Diataxis cross-linking):
- README.md tutorials section: new entry (was zero skillopt mention)
- docs/tutorials/README.md: added under ## Shipped
- docs/guides/skillopt.md: "New to this? Start with the tutorial" callout
Every claim devex-verified against source: exit-code map from
skillopt.ts (accepted:0/no_improvement:1/aborted:2/errored:2), stderr
format from skillopt.ts:286-292, check ops from score.ts, output paths
from SKILL.md, split math from benchmark.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: regenerate llms-full.txt after skillopt tutorial + README edit
Refreshes the inlined doc bundle so the committed llms-full.txt matches
fresh `bun run build:llms` output (test/build-llms.test.ts drift guard).
Picks up the README tutorials-section edit from c39dbdb1. The new tutorial
file itself isn't curated into scripts/llms-config.ts (the bundle curates
a fixed doc set, not every tutorial) — this is purely the README delta.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): stop embed-preflight leaking gateway config into facts-backstop shard
CI shard 10 failed 5 `put_page facts backstop` tests with:
[embed(openai:text-embedding-3-small)] Incorrect API key provided: sk-test
(captured by the diagnostic stderr added in a prior commit). Root cause is
a cross-file module-state leak, not a logic bug:
- `embed-preflight.test.ts` calls `configureGateway({env:{OPENAI_API_KEY:
'sk-test'}})` to drive credential-validation scenarios. It resets the
gateway `beforeEach` but never AFTER its last test, so it leaves the
gateway configured with `sk-test`.
- bun runs every file in a shard inside ONE process. The residual config
bleeds into the next file. When `facts-backstop-gating.test.ts` lands in
the same shard, its put_page calls see `isAvailable('embedding') === true`
(the key is *present*, just invalid), so put_page attempts a real embed
and 401s before the backstop gating even runs.
- It's intermittent across master merges because shard bin-packing changes
which files co-locate. (It "resolved" after the v107 merge earlier for
exactly this reason, then came back.)
R1/R2 test-isolation lint doesn't catch this — it's `configureGateway`
module state, not `process.env` or `mock.module`.
Two fixes, both using the gateway's own `resetGateway()` seam (no
process.env, R-compliant):
1. embed-preflight.test.ts — `afterAll(() => resetGateway())` so the leaker
cleans up after the whole file. Primary fix; also protects any OTHER
shard-mate that reads gateway state.
2. facts-backstop-gating.test.ts — `beforeEach(() => resetGateway())` so the
suite is deterministic regardless of ambient gateway config. Defense in
depth: isAvailable('embedding') is now reliably false → put_page uses
noEmbed → the import never embeds → only the backstop gating (the suite's
actual subject) is exercised.
Verified: running leaker+victim in one process (the shard repro) goes
16/16; full shard 10 goes 1208/1208 (was 5 fail in CI). Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(skillopt): make benchmark authoring an agent job, not a human chore
The prior tutorial taught a human to hand-write a 15-task benchmark — but
nobody does that. The real workflow is: user says "make skill X better,"
the AGENT authors the benchmark and runs the optimizer. The agent-facing
dispatcher didn't actually cover that.
Gap found: skill-optimizer/SKILL.md documented exactly one authoring path,
`--bootstrap-from-routing`, which (a) requires a pre-existing
routing-eval.jsonl (bootstrap-benchmark.ts:57-63 refuses without it) and
(b) generates tasks from ROUTING fixtures — which test dispatch ("does
this phrasing pick this skill"), not output quality. So an agent told to
improve a skill with no benchmark had no documented way to author a
*quality* benchmark; it'd have to reinvent the JSONL format the human
tutorial teaches.
Two fixes:
1. skills/skill-optimizer/SKILL.md — new "Authoring the benchmark yourself
(the common case)" section: read the target SKILL.md, generate ~15
realistic tasks, attach rule judges (contains/max_chars/min_citations/
section_present/regex/tool_called), write the JSONL, run with
`--split 1:1:1` (the default 4:1:5 needs ~50 tasks). Decision-tree row
"New skill, no benchmark" now says "Author one" instead of pointing at
bootstrap-from-routing; the bootstrap row is reframed as a head-start
that only applies when routing fixtures exist and notes routing tasks
test dispatch, not quality.
2. docs/tutorials/improving-skills-with-skillopt.md — new "The easiest
path: ask your agent" section up top. Tells humans to just tell their
agent "improve my X skill — write a benchmark first," and frames the
manual walkthrough as "read this when you want to understand or
hand-curate what the agent is doing."
Verified: conformance 249/0, resolver 99/0, build-llms drift guard 7/0,
cross-link resolves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(skillopt): --bootstrap-from-skill starter benchmark generator
Generate a quality benchmark from a skill's SKILL.md directly, no
routing-eval.jsonl required. One LLM call emits JSONL tasks (each with rule
judges) that the agent reviews + strengthens before optimizing.
- runBootstrapFromSkill: JSONL output parsed line-by-line with skip-bad-line
salvage (a truncated final line drops, the rest survive); a task is kept only
when >=2 valid rule checks survive; provider errors propagate instead of
collapsing to bootstrap_empty.
- --bootstrap-tasks N (default 15, cap 50); maxTokens scales with the count.
- Extracted assertBenchmarkAbsent + readSkillBodyOrThrow shared with the routing
bootstrap; hardened runBootstrap's routing-eval parse to skip malformed lines.
- CLI: --bootstrap-from-skill short-circuit + 6-way mutual exclusion; parseFlags
exported for unit tests. The benchmark-not-found hint + --help now point here.
- The generator's REVIEW line prints the paste-ready
`--bootstrap-reviewed --split 1:1:1` next command (the default 4:1:5 split
refuses a 15-task starter at D_sel >= 5).
- 20 hermetic cases incl. round-trip into loadBenchmark + splitBench(1:1:1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(skillopt): make --bootstrap-from-skill the primary no-benchmark path
The agent runs --bootstrap-from-skill, strengthens the generated judges (they
are weak drafts), deletes the sentinel, then runs --bootstrap-reviewed
--split 1:1:1. Freehand authoring is demoted to the fallback for the rare skill
the generator can't draft well. Updates the Iron Law, decision tree, and
anti-patterns to cover both bootstrap modes and the 15-task / --split 1:1:1
gotcha.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(release): v0.42.1.0 --bootstrap-from-skill
VERSION + package.json -> 0.42.1.0, CHANGELOG entry, CLAUDE.md skillopt
annotation, regenerated llms-full.txt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: surface --bootstrap-from-skill in README + skillopt reference
- docs/guides/skillopt.md: 30-second pitch leads with --bootstrap-from-skill;
flag table adds --bootstrap-from-skill + --bootstrap-tasks rows.
- README.md: skillopt tutorial pointer mentions generating a starter benchmark.
- Regenerated llms-full.txt (README is in the bundle).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): bump FULL_SIZE_BUDGET 700KB→750KB for legitimate CLAUDE.md growth
The skillopt wave annotations + merged v0.41.34-36 master releases pushed
llms-full.txt to 700,423 bytes — 423 over the 700KB cap — failing the
build-llms size-budget test on CI shard 6. CLAUDE.md is ~540KB (77% of the
bundle) and is the whole point of the one-fetch artifact, so it stays inlined;
the budget tracks its per-release growth. 750KB still fits 200k+ context models.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
248fb7a90f
commit
eefe8b5741
+14
-1
@@ -35,7 +35,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status']);
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'skillopt']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
@@ -48,6 +48,9 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
'models',
|
||||
'cache',
|
||||
'brainstorm', 'lsd',
|
||||
// v0.41.20.0 skillopt's detailed HELP constant lives in
|
||||
// src/core/skillopt/help.ts; --help routes there via the dispatcher.
|
||||
'skillopt',
|
||||
// v0.39.3.0 WARN-5: capture's detailed HELP constant
|
||||
// (src/commands/capture.ts:90+) was unreachable because the dispatcher's
|
||||
// generic short-circuit (printCliOnlyHelp at :204-208) fired before
|
||||
@@ -1551,6 +1554,16 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runLsdCommand(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'skillopt': {
|
||||
// v0.41.20.0 — Self-evolving skill optimization (SkillOpt-paper-grounded).
|
||||
// Mutating CLI: validation-gated (D12), budget-capped (D3), per-skill
|
||||
// DB-locked (D14), bundled-skill-gated (D16), bootstrap-sentinel-reviewed
|
||||
// (D15). See: src/core/skillopt/ + plan at
|
||||
// ~/.claude/plans/system-instruction-you-are-working-drifting-falcon.md.
|
||||
const { runSkillOptCommand } = await import('./commands/skillopt.ts');
|
||||
await runSkillOptCommand(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'calibration': {
|
||||
// v0.36.1.0 (T7): print/regenerate the active calibration profile.
|
||||
// MCP op `get_calibration_profile` (read-scoped) backs the same data path.
|
||||
|
||||
+45
-7
@@ -1640,10 +1640,6 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
|
||||
if (!data.target_pack) {
|
||||
throw new Error(`unify-types: missing required 'target_pack' parameter`);
|
||||
}
|
||||
// Build a minimal OperationContext shim. Real context is constructed
|
||||
// by the CLI/MCP dispatch layer; handlers don't have one, so we build
|
||||
// one with engine + null cfg + remote=false (trusted local caller —
|
||||
// PROTECTED handler enforced at submit_job).
|
||||
const ctx = {
|
||||
engine,
|
||||
cfg: null,
|
||||
@@ -1651,17 +1647,59 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
|
||||
} as unknown as import('../core/operations.ts').OperationContext;
|
||||
return await runUnifyTypes(ctx, {
|
||||
target_pack: data.target_pack,
|
||||
apply: data.apply ?? true, // worker invocation defaults to apply
|
||||
apply: data.apply ?? true,
|
||||
sourceId: data.sourceId,
|
||||
onProgress: (msg: string) => {
|
||||
// Stream to job.updateProgress (DB-backed) AND stderr (operator visibility).
|
||||
job.updateProgress({ phase: 'unify-types', message: msg }).catch(() => {});
|
||||
process.stderr.write(msg + '\n');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
process.stderr.write('[minion worker] brain-health-100 handlers registered (12 ops, 4 protected) + embed-backfill (v0.40) + embed-catch-up (v0.42) + unify-types (v0.42)\n');
|
||||
// v0.42.0.0 SkillOpt Minion handler — for --background CLI invocations.
|
||||
// PROTECTED by name so MCP submission rejects (only trusted CLI can
|
||||
// submit). Threaded SkillOptOpts JSON in job.data.
|
||||
worker.register('skillopt', async (job) => {
|
||||
const { runSkillOpt } = await import('../core/skillopt/orchestrator.ts');
|
||||
const data = (job.data ?? {}) as Record<string, unknown>;
|
||||
const skillsDir = String(data.skills_dir ?? '');
|
||||
const skillName = String(data.skill_name ?? '');
|
||||
const benchmarkPath = String(data.benchmark_path ?? '');
|
||||
if (!skillsDir || !skillName || !benchmarkPath) {
|
||||
throw new Error(`skillopt handler: missing required job.data fields (skills_dir, skill_name, benchmark_path)`);
|
||||
}
|
||||
const result = await runSkillOpt({
|
||||
engine,
|
||||
skillName,
|
||||
skillsDir,
|
||||
benchmarkPath,
|
||||
epochs: Number(data.epochs ?? 4),
|
||||
batchSize: Number(data.batch_size ?? 8),
|
||||
lr: Number(data.lr ?? 4),
|
||||
lrSchedule: (data.lr_schedule as 'cosine' | 'linear' | 'constant') ?? 'cosine',
|
||||
split: (data.split as [number, number, number]) ?? [4, 1, 5],
|
||||
optimizerModel: String(data.optimizer_model ?? 'anthropic:claude-opus-4-7'),
|
||||
targetModel: String(data.target_model ?? 'anthropic:claude-sonnet-4-6'),
|
||||
judgeModel: String(data.judge_model ?? 'anthropic:claude-sonnet-4-6'),
|
||||
mode: (data.mode as 'patch' | 'rewrite') ?? 'patch',
|
||||
dryRun: Boolean(data.dry_run),
|
||||
noMutate: Boolean(data.no_mutate),
|
||||
allowMutateBundled: Boolean(data.allow_mutate_bundled),
|
||||
bootstrapReviewed: Boolean(data.bootstrap_reviewed),
|
||||
json: true,
|
||||
maxCostUsd: Number(data.max_cost_usd ?? 5.0),
|
||||
maxRuntimeMin: Number(data.max_runtime_min ?? 30),
|
||||
force: Boolean(data.force),
|
||||
});
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
receipt: result.receipt,
|
||||
mutated_skill_file: result.mutatedSkillFile,
|
||||
proposed_path: result.proposedPath,
|
||||
};
|
||||
});
|
||||
|
||||
process.stderr.write('[minion worker] brain-health-100 handlers registered (12 ops, 4 protected) + embed-backfill (v0.40) + embed-catch-up (v0.42) + unify-types (v0.42) + skillopt (v0.42.0.0, protected)\n');
|
||||
|
||||
// Plugin discovery — one line per discovered plugin (mirrors the
|
||||
// openclaw-seam startup line convention from v0.11+). Loaded
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* `gbrain skillopt <skill> [flags]` CLI dispatcher.
|
||||
*
|
||||
* Top-level command (not under `gbrain eval`) because it MUTATES files.
|
||||
* See: src/core/skillopt/ for the implementation modules.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import { resolveModel } from '../core/model-config.ts';
|
||||
import { autoDetectSkillsDirReadOnly } from '../core/repo-root.ts';
|
||||
import { runBootstrap, runBootstrapFromSkill } from '../core/skillopt/bootstrap-benchmark.ts';
|
||||
import { SKILLOPT_HELP_TEXT } from '../core/skillopt/help.ts';
|
||||
import { runSkillOpt, parseSplit } from '../core/skillopt/orchestrator.ts';
|
||||
import { serializeError, StructuredAgentError } from '../core/errors.ts';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import type { SkillOptOpts } from '../core/skillopt/types.ts';
|
||||
|
||||
interface ParsedFlags {
|
||||
skillName: string;
|
||||
benchmarkPath?: string;
|
||||
bootstrapFromRouting: boolean;
|
||||
bootstrapFromSkill: boolean;
|
||||
/** Number of starter tasks for --bootstrap-from-skill (default 15, cap 50). */
|
||||
bootstrapTasks?: number;
|
||||
bootstrapReviewed: boolean;
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
lr: number;
|
||||
lrSchedule: 'cosine' | 'linear' | 'constant';
|
||||
split: [number, number, number];
|
||||
optimizerModel?: string;
|
||||
targetModel?: string;
|
||||
judgeModel?: string;
|
||||
mode: 'patch' | 'rewrite';
|
||||
dryRun: boolean;
|
||||
noMutate: boolean;
|
||||
allowMutateBundled: boolean;
|
||||
json: boolean;
|
||||
maxCostUsd: number;
|
||||
maxRuntimeMin: number;
|
||||
force: boolean;
|
||||
resumeRunId?: string;
|
||||
skillsDir?: string;
|
||||
help: boolean;
|
||||
/** F4: optimize every skill under skillsDir with a benchmark. */
|
||||
all: boolean;
|
||||
/** F4: brain-wide cost cap for --all (per-skill cap stays --max-cost-usd). */
|
||||
brainWideMaxCostUsd?: number;
|
||||
/** F5: comma-separated list of target models for fleet mode. */
|
||||
targetModelsFleet?: string[];
|
||||
}
|
||||
|
||||
export async function runSkillOptCommand(engine: BrainEngine | null, args: string[]): Promise<void> {
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
process.stdout.write(SKILLOPT_HELP_TEXT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let parsed: ParsedFlags;
|
||||
try {
|
||||
parsed = parseFlags(args);
|
||||
} catch (err) {
|
||||
process.stderr.write(`gbrain skillopt: ${err instanceof Error ? err.message : String(err)}\n\n`);
|
||||
process.stderr.write(SKILLOPT_HELP_TEXT);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (parsed.help) {
|
||||
process.stdout.write(SKILLOPT_HELP_TEXT);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!engine) {
|
||||
process.stderr.write('gbrain skillopt: requires a configured brain (engine connection failed)\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Resolve skills dir.
|
||||
const detected = autoDetectSkillsDirReadOnly(process.cwd());
|
||||
const skillsDir = parsed.skillsDir ?? detected.dir;
|
||||
if (!skillsDir) {
|
||||
process.stderr.write(`gbrain skillopt: cannot find skills directory. Pass --skills-dir <path> or run from a workspace with a skills/ directory.\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Resolve models via the tier system.
|
||||
const optimizerModel = parsed.optimizerModel
|
||||
?? await resolveModel(engine, { tier: 'deep', fallback: 'anthropic:claude-opus-4-7' });
|
||||
const targetModel = parsed.targetModel
|
||||
?? await resolveModel(engine, { tier: 'subagent', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
const judgeModel = parsed.judgeModel
|
||||
?? await resolveModel(engine, { tier: 'reasoning', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
|
||||
// ── Bootstrap mode (short-circuits before the optimization loop) ────────
|
||||
if (parsed.bootstrapFromRouting) {
|
||||
try {
|
||||
const result = await runBootstrap({
|
||||
skillsDir,
|
||||
skillName: parsed.skillName,
|
||||
optimizerModel,
|
||||
force: parsed.force,
|
||||
});
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bootstrap-from-skill mode (short-circuits before the optimization loop) ─
|
||||
// Reads SKILL.md directly (no routing-eval needed), emits a full starter
|
||||
// benchmark, writes the D15 sentinel. Provider errors propagate so the user
|
||||
// sees the real failure instead of "0 tasks".
|
||||
if (parsed.bootstrapFromSkill) {
|
||||
try {
|
||||
const result = await runBootstrapFromSkill({
|
||||
skillsDir,
|
||||
skillName: parsed.skillName,
|
||||
optimizerModel,
|
||||
taskCount: parsed.bootstrapTasks ?? 15,
|
||||
force: parsed.force,
|
||||
});
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── F4: --all batch mode ────────────────────────────────────────────────
|
||||
if (parsed.all) {
|
||||
try {
|
||||
const { runBatchAll } = await import('../core/skillopt/batch.ts');
|
||||
const result = await runBatchAll({
|
||||
engine,
|
||||
skillsDir,
|
||||
perSkillMaxCostUsd: parsed.maxCostUsd,
|
||||
brainWideMaxCostUsd: parsed.brainWideMaxCostUsd ?? 10.0,
|
||||
optimizerModel,
|
||||
targetModel,
|
||||
judgeModel,
|
||||
epochs: parsed.epochs,
|
||||
batchSize: parsed.batchSize,
|
||||
lr: parsed.lr,
|
||||
lrSchedule: parsed.lrSchedule,
|
||||
split: parsed.split,
|
||||
dryRun: parsed.dryRun,
|
||||
noMutate: parsed.noMutate,
|
||||
allowMutateBundled: parsed.allowMutateBundled,
|
||||
force: parsed.force,
|
||||
});
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify({ schema_version: 1, ...result }) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`[skillopt --all] Scanned ${result.skills_scanned} skills, ran ${result.skills_run}\n`);
|
||||
process.stderr.write(`[skillopt --all] Accepted: ${result.accepted}, no_improvement: ${result.no_improvement}, errored: ${result.errored}\n`);
|
||||
process.stderr.write(`[skillopt --all] Total cost: $${result.cumulative_cost_usd.toFixed(2)} (cap $${(parsed.brainWideMaxCostUsd ?? 10).toFixed(2)})\n`);
|
||||
}
|
||||
// Exit code: 0 if at least one accepted, 1 if scanned but none accepted, 2 if errored.
|
||||
const exitCode = result.errored > 0 && result.accepted === 0 ? 2
|
||||
: result.accepted === 0 ? 1
|
||||
: 0;
|
||||
process.exit(exitCode);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── F5: --target-models fleet mode ──────────────────────────────────────
|
||||
if (parsed.targetModelsFleet) {
|
||||
try {
|
||||
const benchmarkPath = parsed.benchmarkPath ??
|
||||
path.join(skillsDir, parsed.skillName, 'skillopt-benchmark.jsonl');
|
||||
const { runFleet } = await import('../core/skillopt/batch.ts');
|
||||
const result = await runFleet({
|
||||
engine,
|
||||
skillName: parsed.skillName,
|
||||
skillsDir,
|
||||
benchmarkPath,
|
||||
targetModels: parsed.targetModelsFleet,
|
||||
optimizerModel,
|
||||
judgeModel,
|
||||
epochs: parsed.epochs,
|
||||
batchSize: parsed.batchSize,
|
||||
lr: parsed.lr,
|
||||
lrSchedule: parsed.lrSchedule,
|
||||
split: parsed.split,
|
||||
dryRun: parsed.dryRun,
|
||||
noMutate: parsed.noMutate,
|
||||
allowMutateBundled: parsed.allowMutateBundled,
|
||||
bootstrapReviewed: parsed.bootstrapReviewed,
|
||||
maxCostUsd: parsed.maxCostUsd,
|
||||
maxRuntimeMin: parsed.maxRuntimeMin,
|
||||
force: parsed.force,
|
||||
});
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify({ schema_version: 1, ...result }) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`[skillopt fleet] Per-model scores for '${parsed.skillName}':\n`);
|
||||
for (const p of result.per_model) {
|
||||
process.stderr.write(` ${p.target_model}: outcome=${p.outcome} score=${p.best_sel_score.toFixed(3)} cost=$${p.final_cost_usd.toFixed(2)}\n`);
|
||||
}
|
||||
if (result.best_model) {
|
||||
process.stderr.write(`[skillopt fleet] Best model: ${result.best_model} (score ${result.best_score?.toFixed(3) ?? '0'})\n`);
|
||||
}
|
||||
}
|
||||
process.exit(result.best_model ? 0 : 1);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Build benchmark path.
|
||||
const benchmarkPath = parsed.benchmarkPath ??
|
||||
path.join(skillsDir, parsed.skillName, 'skillopt-benchmark.jsonl');
|
||||
|
||||
// ── F7: --background submit to Minion queue ─────────────────────────────
|
||||
// skillopt is in PROTECTED_JOB_NAMES, so we can't use the generic
|
||||
// maybeBackground helper (which doesn't pass allowProtectedSubmit). Inline
|
||||
// a small submit that does. Behavior mirrors maybeBackground: writes
|
||||
// `job_id=N` to stdout, exits 0; `--follow` execs `gbrain jobs follow`.
|
||||
if (args.includes('--background')) {
|
||||
if (engine.kind === 'pglite') {
|
||||
process.stderr.write('[--background] PGLite has no worker daemon; running inline.\n');
|
||||
} else {
|
||||
try {
|
||||
const { MinionQueue } = await import('../core/minions/queue.ts');
|
||||
const queue = new MinionQueue(engine);
|
||||
const jobData = {
|
||||
skills_dir: skillsDir,
|
||||
skill_name: parsed.skillName,
|
||||
benchmark_path: benchmarkPath,
|
||||
epochs: parsed.epochs,
|
||||
batch_size: parsed.batchSize,
|
||||
lr: parsed.lr,
|
||||
lr_schedule: parsed.lrSchedule,
|
||||
split: parsed.split,
|
||||
optimizer_model: optimizerModel,
|
||||
target_model: targetModel,
|
||||
judge_model: judgeModel,
|
||||
mode: parsed.mode,
|
||||
dry_run: parsed.dryRun,
|
||||
no_mutate: parsed.noMutate,
|
||||
allow_mutate_bundled: parsed.allowMutateBundled,
|
||||
bootstrap_reviewed: parsed.bootstrapReviewed,
|
||||
max_cost_usd: parsed.maxCostUsd,
|
||||
max_runtime_min: parsed.maxRuntimeMin,
|
||||
force: parsed.force,
|
||||
};
|
||||
const job = await queue.add('skillopt', jobData, {
|
||||
queue: 'default',
|
||||
idempotency_key: `cli:skillopt:${parsed.skillName}`,
|
||||
max_attempts: 1,
|
||||
}, { allowProtectedSubmit: true });
|
||||
process.stdout.write(`job_id=${job.id}\n`);
|
||||
if (args.includes('--follow')) {
|
||||
const { spawn } = await import('child_process');
|
||||
const cmd = process.argv[0] ?? 'bun';
|
||||
const script = process.argv[1] ?? '';
|
||||
const child = spawn(cmd, [script, 'jobs', 'follow', String(job.id)], { stdio: 'inherit' });
|
||||
await new Promise<void>((resolve) => child.on('exit', () => resolve()));
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build SkillOptOpts.
|
||||
const opts: SkillOptOpts = {
|
||||
engine,
|
||||
skillName: parsed.skillName,
|
||||
skillsDir,
|
||||
benchmarkPath,
|
||||
epochs: parsed.epochs,
|
||||
batchSize: parsed.batchSize,
|
||||
lr: parsed.lr,
|
||||
lrSchedule: parsed.lrSchedule,
|
||||
split: parsed.split,
|
||||
optimizerModel,
|
||||
targetModel,
|
||||
judgeModel,
|
||||
mode: parsed.mode,
|
||||
dryRun: parsed.dryRun,
|
||||
noMutate: parsed.noMutate,
|
||||
allowMutateBundled: parsed.allowMutateBundled,
|
||||
bootstrapReviewed: parsed.bootstrapReviewed,
|
||||
json: parsed.json,
|
||||
maxCostUsd: parsed.maxCostUsd,
|
||||
maxRuntimeMin: parsed.maxRuntimeMin,
|
||||
force: parsed.force,
|
||||
...(parsed.resumeRunId ? { resumeRunId: parsed.resumeRunId } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runSkillOpt(opts);
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
schema_version: 1,
|
||||
outcome: result.outcome,
|
||||
receipt: result.receipt,
|
||||
mutated_skill_file: result.mutatedSkillFile,
|
||||
...(result.proposedPath ? { proposed_path: result.proposedPath } : {}),
|
||||
}) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`[skillopt] Outcome: ${result.outcome}\n`);
|
||||
process.stderr.write(`[skillopt] Best sel-score: ${(result.receipt.best_sel_score ?? 0).toFixed(3)}\n`);
|
||||
process.stderr.write(`[skillopt] Final cost: $${(result.receipt.final_cost_usd ?? 0).toFixed(2)}\n`);
|
||||
if (result.mutatedSkillFile) {
|
||||
process.stderr.write(`[skillopt] SKILL.md rewritten with ${result.receipt.total_steps ?? 0} optimization steps.\n`);
|
||||
} else if (result.proposedPath) {
|
||||
process.stderr.write(`[skillopt] Proposed improvements written to ${result.proposedPath}. Review + copy manually.\n`);
|
||||
}
|
||||
}
|
||||
// Exit codes: 0 accepted, 1 no improvement, 2 aborted, 3 errored.
|
||||
const exitMap = { accepted: 0, no_improvement: 1, aborted: 2, errored: 2 };
|
||||
process.exit(exitMap[result.outcome]);
|
||||
} catch (err) {
|
||||
handleErrorAndExit(err, parsed.json, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for unit tests (CLI flag parsing, --bootstrap-tasks cap, mutual exclusion). */
|
||||
export function parseFlags(args: string[]): ParsedFlags {
|
||||
let skillName = '';
|
||||
let benchmarkPath: string | undefined;
|
||||
let bootstrapFromRouting = false;
|
||||
let bootstrapFromSkill = false;
|
||||
let bootstrapTasks: number | undefined;
|
||||
let bootstrapReviewed = false;
|
||||
let epochs = 4;
|
||||
let batchSize = 8;
|
||||
let lr = 4;
|
||||
let lrSchedule: 'cosine' | 'linear' | 'constant' = 'cosine';
|
||||
let splitStr = '4:1:5';
|
||||
let optimizerModel: string | undefined;
|
||||
let targetModel: string | undefined;
|
||||
let judgeModel: string | undefined;
|
||||
let mode: 'patch' | 'rewrite' = 'patch';
|
||||
let dryRun = false;
|
||||
let noMutate = false;
|
||||
let allowMutateBundled = false;
|
||||
let json = false;
|
||||
let maxCostUsd = 5.0;
|
||||
let maxRuntimeMin = 30;
|
||||
let force = false;
|
||||
let resumeRunId: string | undefined;
|
||||
let skillsDir: string | undefined;
|
||||
let help = false;
|
||||
let all = false;
|
||||
let brainWideMaxCostUsd: number | undefined;
|
||||
let targetModelsFleet: string[] | undefined;
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const a = args[i]!;
|
||||
if (a === '--help' || a === '-h') { help = true; i += 1; continue; }
|
||||
if (a === '--benchmark') { benchmarkPath = args[++i]; i += 1; continue; }
|
||||
if (a === '--bootstrap-from-routing') { bootstrapFromRouting = true; i += 1; continue; }
|
||||
if (a === '--bootstrap-from-skill') { bootstrapFromSkill = true; i += 1; continue; }
|
||||
if (a === '--bootstrap-tasks') {
|
||||
const n = mustInt(args[++i], '--bootstrap-tasks');
|
||||
if (n > 50) throw new Error(`--bootstrap-tasks max is 50 (got ${n})`);
|
||||
bootstrapTasks = n;
|
||||
i += 1; continue;
|
||||
}
|
||||
if (a === '--bootstrap-reviewed') { bootstrapReviewed = true; i += 1; continue; }
|
||||
if (a === '--epochs') { epochs = mustInt(args[++i], '--epochs'); i += 1; continue; }
|
||||
if (a === '--batch-size') { batchSize = mustInt(args[++i], '--batch-size'); i += 1; continue; }
|
||||
if (a === '--lr') { lr = mustInt(args[++i], '--lr'); i += 1; continue; }
|
||||
if (a === '--lr-schedule') {
|
||||
const v = args[++i];
|
||||
if (v !== 'cosine' && v !== 'linear' && v !== 'constant') {
|
||||
throw new Error(`--lr-schedule must be cosine|linear|constant (got '${v}')`);
|
||||
}
|
||||
lrSchedule = v;
|
||||
i += 1; continue;
|
||||
}
|
||||
if (a === '--split') { splitStr = args[++i]!; i += 1; continue; }
|
||||
if (a === '--optimizer-model') { optimizerModel = args[++i]; i += 1; continue; }
|
||||
if (a === '--target-model') { targetModel = args[++i]; i += 1; continue; }
|
||||
if (a === '--judge-model') { judgeModel = args[++i]; i += 1; continue; }
|
||||
if (a === '--patch') { mode = 'patch'; i += 1; continue; }
|
||||
if (a === '--rewrite') { mode = 'rewrite'; i += 1; continue; }
|
||||
if (a === '--dry-run') { dryRun = true; i += 1; continue; }
|
||||
if (a === '--no-mutate') { noMutate = true; i += 1; continue; }
|
||||
if (a === '--allow-mutate-bundled') { allowMutateBundled = true; i += 1; continue; }
|
||||
if (a === '--json') { json = true; i += 1; continue; }
|
||||
if (a === '--max-cost-usd') { maxCostUsd = mustFloat(args[++i], '--max-cost-usd'); i += 1; continue; }
|
||||
if (a === '--max-runtime-min') { maxRuntimeMin = mustInt(args[++i], '--max-runtime-min'); i += 1; continue; }
|
||||
if (a === '--force') { force = true; i += 1; continue; }
|
||||
if (a === '--resume') { resumeRunId = args[++i]; i += 1; continue; }
|
||||
if (a === '--skills-dir') { skillsDir = args[++i]; i += 1; continue; }
|
||||
if (a === '--all') { all = true; i += 1; continue; }
|
||||
if (a === '--brain-wide-max-cost-usd') { brainWideMaxCostUsd = mustFloat(args[++i], '--brain-wide-max-cost-usd'); i += 1; continue; }
|
||||
if (a === '--target-models') {
|
||||
// F5: comma-separated list. Mutually exclusive with --target-model
|
||||
// (single). Triggers fleet mode.
|
||||
const v = args[++i];
|
||||
if (!v) throw new Error(`--target-models requires a comma-separated list`);
|
||||
targetModelsFleet = v.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (targetModelsFleet.length === 0) throw new Error(`--target-models cannot be empty`);
|
||||
i += 1; continue;
|
||||
}
|
||||
if (a.startsWith('--')) { throw new Error(`unknown flag '${a}'`); }
|
||||
if (!skillName) { skillName = a; i += 1; continue; }
|
||||
throw new Error(`unexpected positional '${a}'`);
|
||||
}
|
||||
|
||||
// --all does NOT require a skill name (it iterates over all skills).
|
||||
if (!all && !skillName) throw new Error('skill name is required (positional arg), or use --all for batch mode');
|
||||
// Mutual-exclusion check: --benchmark and --bootstrap-from-routing.
|
||||
if (benchmarkPath && bootstrapFromRouting) {
|
||||
throw new Error(`--benchmark and --bootstrap-from-routing are mutually exclusive`);
|
||||
}
|
||||
// --all forbids per-skill bootstrap (use the standalone bootstrap path
|
||||
// per skill instead).
|
||||
if (all && bootstrapFromRouting) {
|
||||
throw new Error(`--all and --bootstrap-from-routing are mutually exclusive (run bootstrap per skill)`);
|
||||
}
|
||||
// --bootstrap-from-skill is a standalone short-circuit: it cannot combine with
|
||||
// the other-source / multi-run flags. (--background / --follow are already
|
||||
// rejected by the unknown-flag guard since parseFlags doesn't parse them.)
|
||||
if (bootstrapFromSkill) {
|
||||
if (bootstrapFromRouting) throw new Error(`--bootstrap-from-skill and --bootstrap-from-routing are mutually exclusive`);
|
||||
if (benchmarkPath) throw new Error(`--bootstrap-from-skill and --benchmark are mutually exclusive`);
|
||||
if (all) throw new Error(`--bootstrap-from-skill and --all are mutually exclusive (run bootstrap per skill)`);
|
||||
if (targetModelsFleet) throw new Error(`--bootstrap-from-skill and --target-models are mutually exclusive`);
|
||||
if (resumeRunId) throw new Error(`--bootstrap-from-skill and --resume are mutually exclusive`);
|
||||
}
|
||||
// --bootstrap-tasks only applies to --bootstrap-from-skill.
|
||||
if (bootstrapTasks !== undefined && !bootstrapFromSkill) {
|
||||
throw new Error(`--bootstrap-tasks requires --bootstrap-from-skill`);
|
||||
}
|
||||
// --target-models and --target-model are mutually exclusive.
|
||||
if (targetModelsFleet && targetModel) {
|
||||
throw new Error(`--target-models and --target-model are mutually exclusive`);
|
||||
}
|
||||
// --target-models + --all is not yet supported (would multiply N×M runs;
|
||||
// file as v0.42 follow-up if needed).
|
||||
if (targetModelsFleet && all) {
|
||||
throw new Error(`--target-models and --all are mutually exclusive in v1`);
|
||||
}
|
||||
|
||||
return {
|
||||
skillName,
|
||||
...(benchmarkPath !== undefined ? { benchmarkPath } : {}),
|
||||
bootstrapFromRouting,
|
||||
bootstrapFromSkill,
|
||||
...(bootstrapTasks !== undefined ? { bootstrapTasks } : {}),
|
||||
bootstrapReviewed,
|
||||
epochs,
|
||||
batchSize,
|
||||
lr,
|
||||
lrSchedule,
|
||||
split: parseSplit(splitStr),
|
||||
...(optimizerModel !== undefined ? { optimizerModel } : {}),
|
||||
...(targetModel !== undefined ? { targetModel } : {}),
|
||||
...(judgeModel !== undefined ? { judgeModel } : {}),
|
||||
mode,
|
||||
dryRun,
|
||||
noMutate,
|
||||
allowMutateBundled,
|
||||
json,
|
||||
maxCostUsd,
|
||||
maxRuntimeMin,
|
||||
force,
|
||||
...(resumeRunId !== undefined ? { resumeRunId } : {}),
|
||||
...(skillsDir !== undefined ? { skillsDir } : {}),
|
||||
help,
|
||||
all,
|
||||
...(brainWideMaxCostUsd !== undefined ? { brainWideMaxCostUsd } : {}),
|
||||
...(targetModelsFleet !== undefined ? { targetModelsFleet } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mustInt(v: string | undefined, flag: string): number {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`${flag} requires a positive integer (got '${v}')`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function mustFloat(v: string | undefined, flag: string): number {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw new Error(`${flag} requires a positive number (got '${v}')`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function handleErrorAndExit(err: unknown, json: boolean, exitCode: number): never {
|
||||
if (json) {
|
||||
const envelope = err instanceof StructuredAgentError ? err.envelope : serializeError(err);
|
||||
process.stderr.write(JSON.stringify({ ok: false, error: envelope }) + '\n');
|
||||
} else {
|
||||
process.stderr.write(`gbrain skillopt: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
if (err instanceof StructuredAgentError && err.envelope.hint) {
|
||||
process.stderr.write(` hint: ${err.envelope.hint}\n`);
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
+58
-1
@@ -85,7 +85,13 @@ export type CyclePhase =
|
||||
// see comment above PHASE_SCOPE). Wraps the per-source loop in ONE
|
||||
// brain-wide BudgetTracker and passes it through opts.budgetTracker
|
||||
// so the core's auto-wrap doesn't REPLACE it.
|
||||
| 'conversation_facts_backfill';
|
||||
| 'conversation_facts_backfill'
|
||||
// v0.41.20.0 — SkillOpt-paper-grounded self-evolving skills. Default OFF;
|
||||
// walks skills with stale skillopt-benchmark.jsonl AND last_run_at >7d.
|
||||
// Per-skill cost cap $0.50; brain-wide cap $2.00. Bundled-skill safety
|
||||
// (D16): never auto-mutates bundled skills — emits proposed.md instead
|
||||
// for user review.
|
||||
| 'skillopt';
|
||||
|
||||
export const ALL_PHASES: CyclePhase[] = [
|
||||
'lint',
|
||||
@@ -146,6 +152,18 @@ export const ALL_PHASES: CyclePhase[] = [
|
||||
// block placement, which runs between the calibration trio and embed),
|
||||
// and BEFORE embed so newly-inserted facts get embedded same-cycle.
|
||||
'conversation_facts_backfill',
|
||||
// v0.41.20.0 SkillOpt — self-evolving skills phase. Dispatch order
|
||||
// places it AFTER the main graph-mutating cluster (extract, patterns,
|
||||
// consolidate, calibration, conversation-facts) so any skill that
|
||||
// depends on cross-session themes gets optimized against the freshest
|
||||
// state — strictly fresher than "right after patterns" since downstream
|
||||
// phases also mutate state the optimizer reads. Default OFF; opt-in via
|
||||
// `gbrain config set cycle.skillopt.enabled true`. Bundled-skill safety
|
||||
// (D16): never auto-mutates bundled skills. Position MUST match the
|
||||
// dispatch block in runCycle (see line ~1912) — pinned by the
|
||||
// `report.phases.map(p => p.phase)).toEqual(ALL_PHASES)` assertion in
|
||||
// test/core/cycle.serial.test.ts.
|
||||
'skillopt',
|
||||
'embed',
|
||||
'orphans',
|
||||
// v0.39 T12: passive schema-suggest. Runs LATE so post-sync brain state
|
||||
@@ -208,6 +226,9 @@ export const PHASE_SCOPE: Record<CyclePhase, PhaseScope> = {
|
||||
// fanout enforcement today (per the comment above); the phase
|
||||
// wrapper does its own multi-source loop via listSources().
|
||||
conversation_facts_backfill: 'source',
|
||||
// v0.41.20.0 SkillOpt — global (walks the skills/ directory; per-skill
|
||||
// DB lock inside D14 handles cross-source coordination).
|
||||
skillopt: 'global',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -245,6 +266,10 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
|
||||
'synthesize_concepts',
|
||||
// v0.41.11.0 — inserts facts + writes terminal audit rows; needs lock.
|
||||
'conversation_facts_backfill',
|
||||
// v0.41.20.0 SkillOpt — writes SKILL.md + skillopt/ artifacts; needs lock.
|
||||
// Per-skill lock (D14) is acquired inside runSkillOpt; this NEEDS_LOCK
|
||||
// entry covers the cycle-level coordination.
|
||||
'skillopt',
|
||||
'embed',
|
||||
'purge',
|
||||
]);
|
||||
@@ -1936,6 +1961,38 @@ export async function runCycle(
|
||||
await safeYield(opts.yieldBetweenPhases);
|
||||
}
|
||||
|
||||
// ── v0.41.20.0: SkillOpt phase (default OFF, opt-in). ──────────
|
||||
// Walks skills with skillopt-benchmark.jsonl AND stale last_run_at
|
||||
// (>7d). Per-skill cap $0.50; brain-wide cap $2.00. Bundled-skill
|
||||
// safety (D16): the phase ALWAYS runs in --no-mutate mode — proposed
|
||||
// bests land at skills/<name>/skillopt/best.md for review.
|
||||
if (phases.includes('skillopt')) {
|
||||
checkAborted(opts.signal);
|
||||
if (!engine) {
|
||||
phaseResults.push({
|
||||
phase: 'skillopt' as never,
|
||||
status: 'skipped',
|
||||
duration_ms: 0,
|
||||
summary: 'no database connected',
|
||||
details: { reason: 'no_database' },
|
||||
});
|
||||
} else {
|
||||
progress.start('cycle.skillopt');
|
||||
const { runPhaseSkillopt } = await import('./skillopt/cycle-phase.ts');
|
||||
const { result, duration_ms } = await timePhase(() =>
|
||||
runPhaseSkillopt({
|
||||
engine,
|
||||
dryRun,
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
}),
|
||||
);
|
||||
result.duration_ms = duration_ms;
|
||||
phaseResults.push(result as never);
|
||||
progress.finish();
|
||||
}
|
||||
await safeYield(opts.yieldBetweenPhases);
|
||||
}
|
||||
|
||||
// ── Phase 8: embed ──────────────────────────────────────────
|
||||
if (phases.includes('embed')) {
|
||||
checkAborted(opts.signal);
|
||||
|
||||
@@ -51,6 +51,11 @@ export const PROTECTED_JOB_NAMES: ReadonlySet<string> = new Set([
|
||||
// can't auto-apply; user must run `gbrain onboard --auto-with-prompt`
|
||||
// or submit explicitly via `gbrain jobs submit unify-types --allow-protected`.
|
||||
'unify-types',
|
||||
// v0.42.0.0 — SkillOpt: optimizer Sonnet/Opus loops over a benchmark.
|
||||
// Preemptive register entry (v1 is CLI-only foreground; future Minion
|
||||
// handler must reject MCP submission). Costs user money (optimizer +
|
||||
// judge + rollouts) so PROTECTED is the right posture.
|
||||
'skillopt',
|
||||
]);
|
||||
|
||||
/** Check a job name against the protected set. Normalizes whitespace first. */
|
||||
|
||||
@@ -4458,6 +4458,90 @@ const run_onboard: Operation = {
|
||||
},
|
||||
};
|
||||
|
||||
// v0.41.20.0 SkillOpt — MCP exposure (admin scope + per-skill allowlist
|
||||
// via the resolver inside the handler). Designed for trusted admin tokens
|
||||
// that want to drive optimization remotely; the same trust gates as the
|
||||
// CLI fire (working tree, install path, lock acquisition, bundled-skill
|
||||
// guard). NOT localOnly so admin HTTP MCP clients can invoke.
|
||||
const run_skillopt: Operation = {
|
||||
name: 'run_skillopt',
|
||||
description: 'Run SkillOpt against a single skill. Admin scope; mutating; rate-limited per-skill via DB lock. See gbrain skillopt CLI for the full flag surface.',
|
||||
params: {
|
||||
skill_name: { type: 'string', required: true, description: 'Kebab-case skill name (resolves to skills/<name>/SKILL.md)' },
|
||||
benchmark_path: { type: 'string', description: 'Absolute path to benchmark JSONL; defaults to skills/<name>/skillopt-benchmark.jsonl' },
|
||||
epochs: { type: 'number', description: 'Default 4' },
|
||||
batch_size: { type: 'number', description: 'Default 8' },
|
||||
lr: { type: 'number', description: 'Default 4' },
|
||||
max_cost_usd: { type: 'number', description: 'Default 5.00' },
|
||||
no_mutate: { type: 'boolean', description: 'Write proposed.md without replacing SKILL.md' },
|
||||
allow_mutate_bundled: { type: 'boolean', description: 'Required to mutate bundled skills' },
|
||||
dry_run: { type: 'boolean', description: 'Cost preview, no LLM calls' },
|
||||
},
|
||||
mutating: true,
|
||||
scope: 'admin',
|
||||
localOnly: false,
|
||||
handler: async (ctx, p) => {
|
||||
if (ctx.remote !== false) {
|
||||
// Remote: enforce per-skill allowlist read from config.
|
||||
// `skillopt.allowed_skills` is a JSON-array config of skill names
|
||||
// an admin-scoped OAuth client may target. Default DENY-ALL: when
|
||||
// unset, MCP cannot drive skillopt on any skill.
|
||||
const allowedRaw = await ctx.engine.getConfig('skillopt.allowed_skills');
|
||||
let allowed: string[] = [];
|
||||
try {
|
||||
if (allowedRaw) allowed = JSON.parse(allowedRaw) as string[];
|
||||
} catch { /* fall through to deny */ }
|
||||
const skillName = (p.skill_name as string) ?? '';
|
||||
if (!allowed.includes(skillName)) {
|
||||
throw new OperationError(`run_skillopt: skill '${skillName}' is not in skillopt.allowed_skills allowlist (default deny-all for remote callers)`, 'permission_denied');
|
||||
}
|
||||
}
|
||||
const { runSkillOpt } = await import('./skillopt/orchestrator.ts');
|
||||
const { autoDetectSkillsDirReadOnly } = await import('./repo-root.ts');
|
||||
const { resolveModel } = await import('./model-config.ts');
|
||||
const detected = autoDetectSkillsDirReadOnly(process.cwd());
|
||||
const skillsDir = detected.dir;
|
||||
if (!skillsDir) {
|
||||
throw new OperationError('run_skillopt: skills directory not found', 'config_error');
|
||||
}
|
||||
const optimizerModel = await resolveModel(ctx.engine, { tier: 'deep', fallback: 'anthropic:claude-opus-4-7' });
|
||||
const targetModel = await resolveModel(ctx.engine, { tier: 'subagent', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
const judgeModel = await resolveModel(ctx.engine, { tier: 'reasoning', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
const skillName = p.skill_name as string;
|
||||
const benchmarkPath = (p.benchmark_path as string) ??
|
||||
`${skillsDir}/${skillName}/skillopt-benchmark.jsonl`;
|
||||
const result = await runSkillOpt({
|
||||
engine: ctx.engine,
|
||||
skillName,
|
||||
skillsDir,
|
||||
benchmarkPath,
|
||||
epochs: (p.epochs as number) ?? 4,
|
||||
batchSize: (p.batch_size as number) ?? 8,
|
||||
lr: (p.lr as number) ?? 4,
|
||||
lrSchedule: 'cosine',
|
||||
split: [4, 1, 5],
|
||||
optimizerModel,
|
||||
targetModel,
|
||||
judgeModel,
|
||||
mode: 'patch',
|
||||
dryRun: (p.dry_run as boolean) === true,
|
||||
noMutate: (p.no_mutate as boolean) === true,
|
||||
allowMutateBundled: (p.allow_mutate_bundled as boolean) === true,
|
||||
bootstrapReviewed: false,
|
||||
json: true,
|
||||
maxCostUsd: (p.max_cost_usd as number) ?? 5.0,
|
||||
maxRuntimeMin: 30,
|
||||
force: false,
|
||||
});
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
receipt: result.receipt,
|
||||
mutated_skill_file: result.mutatedSkillFile,
|
||||
proposed_path: result.proposedPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const operations: Operation[] = [
|
||||
// Page CRUD
|
||||
get_page, put_page, delete_page, list_pages,
|
||||
@@ -4532,6 +4616,11 @@ export const operations: Operation[] = [
|
||||
schema_apply_mutations, reload_schema_pack,
|
||||
// v0.41.18.0 (T16, A7, codex #5)
|
||||
run_onboard,
|
||||
// v0.41.20.0 SkillOpt — admin-scoped MCP op for remote optimization.
|
||||
// Per-skill allowlist via `skillopt.allowed_skills` config (default
|
||||
// deny-all for remote callers). NOT localOnly so admin OAuth clients
|
||||
// can submit; CLI bypass via ctx.remote === false.
|
||||
run_skillopt,
|
||||
];
|
||||
|
||||
export const operationsByName = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* SkillOpt edit application — pure markdown patching with safety guards.
|
||||
*
|
||||
* Per the v0.41.20.0 plan decisions:
|
||||
* D5: Forbid frontmatter mutation. Only the BODY slice (after the closing
|
||||
* `---\n` fence) is mutable. Any edit whose anchor would resolve into
|
||||
* the frontmatter is rejected.
|
||||
* D9: Returns a tagged `EditResult` (`{outcome: 'applied' | 'rejected', ...}`).
|
||||
* NEVER throws on rejection — throws are reserved for caller errors
|
||||
* (e.g. dirty-tree, install-path) which are pre-flight gates handled
|
||||
* at the orchestrator boundary.
|
||||
*
|
||||
* Three edit ops:
|
||||
* - `add`: insert content after a unique anchor (heading title or quoted
|
||||
* line). Refuses 0 or 2+ matches.
|
||||
* - `replace`: exact-match find-and-replace. Refuses 0 or 2+ matches.
|
||||
* - `delete`: remove an exact-match span. Refuses 0 or 2+ matches.
|
||||
*
|
||||
* Inside-code-fence guard: tracks fence depth line-by-line so edits inside
|
||||
* a ```fence``` are rejected (don't break example code blocks).
|
||||
*
|
||||
* Atomic writes happen at the caller (version-store.ts). This module is
|
||||
* pure: input is text, output is text + outcome.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname } from 'node:path';
|
||||
import type { EditOp, EditResult, EditRejectionReason } from './types.ts';
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply one edit to skill text. Returns tagged result (D9).
|
||||
*
|
||||
* The input `text` MUST be the full SKILL.md content (frontmatter included).
|
||||
* We split off the frontmatter internally so the optimizer can never
|
||||
* accidentally mutate `triggers:`, `brain_first:`, etc. (D5).
|
||||
*/
|
||||
export function applyEdit(text: string, edit: EditOp): EditResult {
|
||||
const split = splitFrontmatter(text);
|
||||
const body = split.body;
|
||||
const bodyStart = split.bodyStart;
|
||||
|
||||
// Apply the edit to the body slice only.
|
||||
const result = applyEditToBody(body, edit, bodyStart);
|
||||
if (result.outcome === 'rejected') return result;
|
||||
|
||||
// Reassemble: frontmatter + new body.
|
||||
const newText = text.slice(0, bodyStart) + result.newText;
|
||||
return { outcome: 'applied', edit, newText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sequence of edits, respecting the LR budget. Edits are tried in
|
||||
* order; rejected edits are returned with their reasons. The first
|
||||
* `lrBudget` APPLIED edits commit; remaining edits beyond the budget are
|
||||
* silently skipped (returned as `{outcome: 'rejected', reason: 'no_change',
|
||||
* detail: 'lr_budget_exhausted'}`).
|
||||
*
|
||||
* Returns the final text AFTER all applied edits.
|
||||
*/
|
||||
export function applyEditBatch(
|
||||
text: string,
|
||||
edits: EditOp[],
|
||||
lrBudget: number,
|
||||
): { newText: string; results: EditResult[] } {
|
||||
let cur = text;
|
||||
const results: EditResult[] = [];
|
||||
let appliedCount = 0;
|
||||
for (const edit of edits) {
|
||||
if (appliedCount >= lrBudget) {
|
||||
results.push({ outcome: 'rejected', edit, reason: 'no_change', detail: 'lr_budget_exhausted' });
|
||||
continue;
|
||||
}
|
||||
const r = applyEdit(cur, edit);
|
||||
results.push(r);
|
||||
if (r.outcome === 'applied') {
|
||||
cur = r.newText;
|
||||
appliedCount += 1;
|
||||
}
|
||||
}
|
||||
return { newText: cur, results };
|
||||
}
|
||||
|
||||
// ─── Frontmatter split (D5) ───────────────────────────────────────────────
|
||||
|
||||
interface FrontmatterSplit {
|
||||
body: string;
|
||||
/** Offset into the original text where body begins (after closing `---\n`). */
|
||||
bodyStart: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split SKILL.md into (frontmatter, body). Returns body and the offset
|
||||
* where the body starts in the original text. When there's no frontmatter
|
||||
* fence, the whole text IS the body and bodyStart=0.
|
||||
*/
|
||||
export function splitFrontmatter(text: string): FrontmatterSplit {
|
||||
// Match the leading `---\n...frontmatter...\n---\n` block.
|
||||
const m = text.match(/^---\n[\s\S]*?\n---\n/);
|
||||
if (!m) return { body: text, bodyStart: 0 };
|
||||
return { body: text.slice(m[0].length), bodyStart: m[0].length };
|
||||
}
|
||||
|
||||
// ─── Body edit application ────────────────────────────────────────────────
|
||||
|
||||
function applyEditToBody(body: string, edit: EditOp, bodyStartOffset: number):
|
||||
| { outcome: 'applied'; newText: string }
|
||||
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string } {
|
||||
switch (edit.op) {
|
||||
case 'add':
|
||||
return applyAdd(body, edit, bodyStartOffset);
|
||||
case 'replace':
|
||||
return applyReplace(body, edit, bodyStartOffset);
|
||||
case 'delete':
|
||||
return applyDelete(body, edit, bodyStartOffset);
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdd(body: string, edit: EditOp & { op: 'add' }, bodyStartOffset: number):
|
||||
| { outcome: 'applied'; newText: string }
|
||||
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string } {
|
||||
const anchor = edit.anchor.trim();
|
||||
if (!anchor) return { outcome: 'rejected', edit, reason: 'anchor_not_found', detail: 'empty anchor' };
|
||||
|
||||
// Heading-style anchors: "## Heading Title" or just "Heading Title".
|
||||
// Try heading match first; fallback to exact-line match.
|
||||
const headingMatches = findHeadingMatches(body, anchor);
|
||||
let insertAfter: number;
|
||||
if (headingMatches.length === 1) {
|
||||
insertAfter = headingMatches[0]!.endOfLine;
|
||||
} else if (headingMatches.length === 0) {
|
||||
const lineMatches = findExactLineMatches(body, anchor);
|
||||
if (lineMatches.length === 0) {
|
||||
return { outcome: 'rejected', edit, reason: 'anchor_not_found' };
|
||||
}
|
||||
if (lineMatches.length > 1) {
|
||||
return { outcome: 'rejected', edit, reason: 'anchor_ambiguous', detail: `${lineMatches.length} matches` };
|
||||
}
|
||||
insertAfter = lineMatches[0]!.endOfLine;
|
||||
} else {
|
||||
return { outcome: 'rejected', edit, reason: 'anchor_ambiguous', detail: `${headingMatches.length} heading matches` };
|
||||
}
|
||||
|
||||
// Inside-code-fence guard: refuse if insert point is inside a ```fence```.
|
||||
if (isInsideCodeFence(body, insertAfter)) {
|
||||
return { outcome: 'rejected', edit, reason: 'inside_code_fence' };
|
||||
}
|
||||
|
||||
// No need to check crosses_frontmatter — we're operating on body only,
|
||||
// and bodyStartOffset is preserved by the caller.
|
||||
void bodyStartOffset;
|
||||
|
||||
// Insert content on a new line after the anchor.
|
||||
const insertion = '\n' + edit.content.trimEnd() + '\n';
|
||||
const newBody = body.slice(0, insertAfter) + insertion + body.slice(insertAfter);
|
||||
if (newBody === body) {
|
||||
return { outcome: 'rejected', edit, reason: 'no_change' };
|
||||
}
|
||||
return { outcome: 'applied', newText: newBody };
|
||||
}
|
||||
|
||||
function applyReplace(body: string, edit: EditOp & { op: 'replace' }, bodyStartOffset: number):
|
||||
| { outcome: 'applied'; newText: string }
|
||||
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string } {
|
||||
const target = edit.target;
|
||||
if (!target) return { outcome: 'rejected', edit, reason: 'target_not_found', detail: 'empty target' };
|
||||
|
||||
const occurrences = countOccurrences(body, target);
|
||||
if (occurrences === 0) return { outcome: 'rejected', edit, reason: 'target_not_found' };
|
||||
if (occurrences > 1) {
|
||||
return { outcome: 'rejected', edit, reason: 'target_ambiguous', detail: `${occurrences} matches` };
|
||||
}
|
||||
const matchIdx = body.indexOf(target);
|
||||
if (isInsideCodeFence(body, matchIdx)) {
|
||||
return { outcome: 'rejected', edit, reason: 'inside_code_fence' };
|
||||
}
|
||||
void bodyStartOffset;
|
||||
const newBody = body.slice(0, matchIdx) + edit.replacement + body.slice(matchIdx + target.length);
|
||||
if (newBody === body) {
|
||||
return { outcome: 'rejected', edit, reason: 'no_change' };
|
||||
}
|
||||
return { outcome: 'applied', newText: newBody };
|
||||
}
|
||||
|
||||
function applyDelete(body: string, edit: EditOp & { op: 'delete' }, bodyStartOffset: number):
|
||||
| { outcome: 'applied'; newText: string }
|
||||
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string } {
|
||||
const target = edit.target;
|
||||
if (!target) return { outcome: 'rejected', edit, reason: 'target_not_found', detail: 'empty target' };
|
||||
|
||||
const occurrences = countOccurrences(body, target);
|
||||
if (occurrences === 0) return { outcome: 'rejected', edit, reason: 'target_not_found' };
|
||||
if (occurrences > 1) {
|
||||
return { outcome: 'rejected', edit, reason: 'target_ambiguous', detail: `${occurrences} matches` };
|
||||
}
|
||||
const matchIdx = body.indexOf(target);
|
||||
if (isInsideCodeFence(body, matchIdx)) {
|
||||
return { outcome: 'rejected', edit, reason: 'inside_code_fence' };
|
||||
}
|
||||
void bodyStartOffset;
|
||||
// Delete the target plus a trailing newline if present (keep markdown tidy).
|
||||
const after = matchIdx + target.length;
|
||||
const hasTrailingNl = body[after] === '\n';
|
||||
const cutEnd = hasTrailingNl ? after + 1 : after;
|
||||
const newBody = body.slice(0, matchIdx) + body.slice(cutEnd);
|
||||
if (newBody === body) {
|
||||
return { outcome: 'rejected', edit, reason: 'no_change' };
|
||||
}
|
||||
return { outcome: 'applied', newText: newBody };
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface MatchPos {
|
||||
startOfLine: number;
|
||||
endOfLine: number;
|
||||
}
|
||||
|
||||
function findHeadingMatches(body: string, anchor: string): MatchPos[] {
|
||||
const heading = anchor.replace(/^#+\s*/, '').trim();
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`^(#{1,6})\\s+${escaped}\\s*$`, 'gm');
|
||||
const out: MatchPos[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(body)) !== null) {
|
||||
const startOfLine = match.index;
|
||||
const endOfLine = startOfLine + match[0].length;
|
||||
out.push({ startOfLine, endOfLine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findExactLineMatches(body: string, anchor: string): MatchPos[] {
|
||||
const target = anchor.trim();
|
||||
const lines = body.split('\n');
|
||||
const out: MatchPos[] = [];
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim() === target) {
|
||||
out.push({ startOfLine: offset, endOfLine: offset + line.length });
|
||||
}
|
||||
offset += line.length + 1; // +1 for the \n
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
if (needle.length === 0) return 0;
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
while ((pos = haystack.indexOf(needle, pos)) !== -1) {
|
||||
count += 1;
|
||||
pos += needle.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether `offset` falls inside a fenced code block (``` ... ```).
|
||||
* Tracks fence depth line-by-line. Tolerates malformed fences (unclosed
|
||||
* blocks) by treating them as "everything after the opening fence is inside".
|
||||
*/
|
||||
export function isInsideCodeFence(body: string, offset: number): boolean {
|
||||
if (offset < 0 || offset > body.length) return false;
|
||||
const before = body.slice(0, offset);
|
||||
const lines = before.split('\n');
|
||||
let inFence = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(/^```/)) inFence = !inFence;
|
||||
}
|
||||
return inFence;
|
||||
}
|
||||
|
||||
// ─── Pre-flight gates (called by orchestrator before applyEdit loop) ──────
|
||||
|
||||
/**
|
||||
* Check git working-tree status for a specific file. Returns:
|
||||
* - 'clean': file matches HEAD or doesn't exist in git.
|
||||
* - 'dirty': file has uncommitted changes.
|
||||
* - 'not_a_repo': dir is not in a git repo (no gate fires).
|
||||
*
|
||||
* Mirrors src/core/skill-fix-gates.ts:getWorkingTreeStatus.
|
||||
*/
|
||||
export function getWorkingTreeStatusForFile(filePath: string): 'clean' | 'dirty' | 'not_a_repo' {
|
||||
try {
|
||||
const cwd = dirname(filePath);
|
||||
// First check we're in a repo.
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
|
||||
} catch {
|
||||
return 'not_a_repo';
|
||||
}
|
||||
// Then check status on this specific file.
|
||||
const out = execFileSync('git', ['status', '--porcelain', '--', filePath], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return out.length === 0 ? 'clean' : 'dirty';
|
||||
} catch {
|
||||
return 'not_a_repo';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write via .tmp + fsync + rename. Mirrors version-store.ts pattern
|
||||
* but provided here for ad-hoc callers (orchestrator uses version-store).
|
||||
*/
|
||||
export function atomicWrite(filePath: string, content: string): void {
|
||||
const tmp = filePath + '.tmp';
|
||||
const fd = fs.openSync(tmp, 'w');
|
||||
try {
|
||||
fs.writeFileSync(fd, content, { encoding: 'utf8' });
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SkillOpt audit JSONL writer. Built on the v0.40.4.0 audit-writer cathedral.
|
||||
*
|
||||
* Events land at `~/.gbrain/audit/skillopt-YYYY-Www.jsonl` (ISO-week rotated;
|
||||
* honors `GBRAIN_AUDIT_DIR`).
|
||||
*
|
||||
* Per codex C5 free-fix: skill_name is in clear. Skill names are public in
|
||||
* the repo (live on GitHub); hashing them is over-privacy and would make
|
||||
* doctor's paste-ready hints unactionable. Task TEXT remains SHA-256-prefix
|
||||
* hashed (8 hex) because task content can carry private benchmark inputs.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createAuditWriter, type AuditWriter } from '../audit/audit-writer.ts';
|
||||
import type { EditOp } from './types.ts';
|
||||
|
||||
/** Discriminated union of every event kind emitted to the audit trail. */
|
||||
export type SkilloptEvent =
|
||||
| { kind: 'run_start'; run_id: string; skill: string; skill_sha8: string;
|
||||
benchmark_sha8: string; target_model: string; optimizer_model: string;
|
||||
judge_model: string; epochs: number; batch_size: number; lr: number;
|
||||
lr_schedule: string; max_cost_usd: number; ts: string }
|
||||
| { kind: 'step'; run_id: string; skill: string; epoch: number; step: number;
|
||||
sel_score_median: number; sel_score_runs: number[]; accepted: boolean;
|
||||
edits_attempted: number; edits_applied: number; delta: number;
|
||||
reason?: string; cumulative_cost_usd: number; ts: string }
|
||||
| { kind: 'edit_rejected'; run_id: string; skill: string; epoch: number;
|
||||
step: number; edit_kind: EditOp['op']; rejection_reason: string;
|
||||
ts: string }
|
||||
| { kind: 'slow_update'; run_id: string; skill: string; epoch: number;
|
||||
meta_edit_proposed: boolean; meta_edit_accepted: boolean; ts: string }
|
||||
| { kind: 'run_end'; run_id: string; skill: string; outcome: 'accepted' |
|
||||
'no_improvement' | 'aborted' | 'errored'; epochs_completed: number;
|
||||
total_steps: number; baseline_sel_score?: number; best_sel_score?: number;
|
||||
baseline_test_score?: number; test_score?: number; final_cost_usd: number;
|
||||
ts: string }
|
||||
| { kind: 'abort'; run_id: string; skill: string; reason: 'budget_exhausted' |
|
||||
'runtime_exhausted' | 'dirty_tree' | 'lock_busy' | 'sentinel_pending' |
|
||||
'bundled_skill_no_flag' | 'd_sel_too_small' | 'sigint'; detail?: string;
|
||||
ts: string };
|
||||
|
||||
let _writer: AuditWriter<SkilloptEvent> | null = null;
|
||||
|
||||
function getWriter(): AuditWriter<SkilloptEvent> {
|
||||
if (_writer === null) {
|
||||
_writer = createAuditWriter<SkilloptEvent>({
|
||||
featureName: 'skillopt',
|
||||
errorLabel: 'skillopt-audit',
|
||||
errorTrailer: '; run continues',
|
||||
});
|
||||
}
|
||||
return _writer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam — reset the cached writer so tests with mocked GBRAIN_AUDIT_DIR
|
||||
* see writes land in the right tempdir.
|
||||
*/
|
||||
export function _resetAuditWriterForTests(): void {
|
||||
_writer = null;
|
||||
}
|
||||
|
||||
/** Append an event to the SkillOpt audit JSONL. Best-effort; never throws. */
|
||||
export function logEvent(event: Omit<SkilloptEvent, 'ts'> & { ts?: string }): void {
|
||||
getWriter().log(event as Omit<SkilloptEvent, 'ts'> & { ts?: string });
|
||||
}
|
||||
|
||||
/** Read events from current + previous ISO week, filtered by N-day window. */
|
||||
export function readRecentEvents(days = 7, now: Date = new Date()): SkilloptEvent[] {
|
||||
return getWriter().readRecent(days, now);
|
||||
}
|
||||
|
||||
/** Compute the SHA-256-prefix-8 of a string (for privacy-hashing task text). */
|
||||
export function sha8(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/** Resolve audit dir (honors GBRAIN_AUDIT_DIR). */
|
||||
export function resolveAuditDir(): string {
|
||||
return getWriter().resolveDir();
|
||||
}
|
||||
|
||||
/** Compute the current ISO-week filename (for tests + doctor surface). */
|
||||
export function currentAuditFilename(now: Date = new Date()): string {
|
||||
return getWriter().computeFilename(now);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* SkillOpt `--all` cross-skill batch mode + `--target-model multi`
|
||||
* cross-model fleet (F4 + F5).
|
||||
*
|
||||
* `--all`: walk every skill under skillsDir that has skillopt-benchmark.jsonl
|
||||
* and run optimization sequentially with a brain-wide BudgetTracker. Same
|
||||
* shape as the dream-cycle phase wrapper but driven by the CLI flag.
|
||||
*
|
||||
* `--target-model multi`: instead of optimizing for a single target model,
|
||||
* optimize ONCE and capture per-model receipts so the user can pick the
|
||||
* best skill-per-model. Implemented as N parallel runSkillOpt invocations
|
||||
* (one per model) with shared optimizer + judge models but different
|
||||
* target-models.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { runSkillOpt } from './orchestrator.ts';
|
||||
import type { RunReceipt, SkillOptOpts } from './types.ts';
|
||||
|
||||
export interface BatchAllOpts {
|
||||
engine: BrainEngine;
|
||||
skillsDir: string;
|
||||
/** Per-skill budget (each skill gets its own tracker). */
|
||||
perSkillMaxCostUsd: number;
|
||||
/** Brain-wide cumulative ceiling. */
|
||||
brainWideMaxCostUsd: number;
|
||||
/** Common knobs threaded to each skill. */
|
||||
optimizerModel: string;
|
||||
targetModel: string;
|
||||
judgeModel: string;
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
lr: number;
|
||||
lrSchedule: 'cosine' | 'linear' | 'constant';
|
||||
split: [number, number, number];
|
||||
dryRun: boolean;
|
||||
noMutate: boolean;
|
||||
allowMutateBundled: boolean;
|
||||
force: boolean;
|
||||
/** Optional filter — only run skills whose name passes this predicate. */
|
||||
filter?: (skillName: string) => boolean;
|
||||
}
|
||||
|
||||
export interface BatchAllResult {
|
||||
skills_scanned: number;
|
||||
skills_run: number;
|
||||
accepted: number;
|
||||
no_improvement: number;
|
||||
errored: number;
|
||||
brain_wide_cap_reached: boolean;
|
||||
cumulative_cost_usd: number;
|
||||
per_skill: Array<{
|
||||
skill: string;
|
||||
outcome: 'accepted' | 'no_improvement' | 'aborted' | 'errored' | 'skipped_cap';
|
||||
cost_usd: number;
|
||||
receipt?: RunReceipt;
|
||||
reason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function runBatchAll(opts: BatchAllOpts): Promise<BatchAllResult> {
|
||||
const skills = collectSkillsWithBenchmarks(opts.skillsDir).filter(
|
||||
(s) => !opts.filter || opts.filter(s),
|
||||
);
|
||||
const out: BatchAllResult = {
|
||||
skills_scanned: skills.length,
|
||||
skills_run: 0,
|
||||
accepted: 0,
|
||||
no_improvement: 0,
|
||||
errored: 0,
|
||||
brain_wide_cap_reached: false,
|
||||
cumulative_cost_usd: 0,
|
||||
per_skill: [],
|
||||
};
|
||||
|
||||
for (const skillName of skills) {
|
||||
if (out.cumulative_cost_usd >= opts.brainWideMaxCostUsd) {
|
||||
out.brain_wide_cap_reached = true;
|
||||
out.per_skill.push({ skill: skillName, outcome: 'skipped_cap', cost_usd: 0, reason: 'brain_wide_cap_reached' });
|
||||
continue;
|
||||
}
|
||||
const remaining = opts.brainWideMaxCostUsd - out.cumulative_cost_usd;
|
||||
const cap = Math.min(opts.perSkillMaxCostUsd, remaining);
|
||||
const benchmarkPath = path.join(opts.skillsDir, skillName, 'skillopt-benchmark.jsonl');
|
||||
|
||||
const skillOptOpts: SkillOptOpts = {
|
||||
engine: opts.engine,
|
||||
skillName,
|
||||
skillsDir: opts.skillsDir,
|
||||
benchmarkPath,
|
||||
epochs: opts.epochs,
|
||||
batchSize: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lrSchedule: opts.lrSchedule,
|
||||
split: opts.split,
|
||||
optimizerModel: opts.optimizerModel,
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
mode: 'patch',
|
||||
dryRun: opts.dryRun,
|
||||
noMutate: opts.noMutate,
|
||||
allowMutateBundled: opts.allowMutateBundled,
|
||||
bootstrapReviewed: false,
|
||||
json: true,
|
||||
maxCostUsd: cap,
|
||||
maxRuntimeMin: 30,
|
||||
force: opts.force,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runSkillOpt(skillOptOpts);
|
||||
const spent = result.receipt.final_cost_usd ?? 0;
|
||||
out.cumulative_cost_usd += spent;
|
||||
out.skills_run += 1;
|
||||
if (result.outcome === 'accepted') out.accepted += 1;
|
||||
else if (result.outcome === 'no_improvement') out.no_improvement += 1;
|
||||
else if (result.outcome === 'errored') out.errored += 1;
|
||||
out.per_skill.push({
|
||||
skill: skillName,
|
||||
outcome: result.outcome as never,
|
||||
cost_usd: spent,
|
||||
receipt: result.receipt,
|
||||
});
|
||||
} catch (err) {
|
||||
out.errored += 1;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
out.per_skill.push({ skill: skillName, outcome: 'errored', cost_usd: 0, reason: msg });
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FleetOpts {
|
||||
/** Same shape as SkillOptOpts but with N target models instead of 1. */
|
||||
engine: BrainEngine;
|
||||
skillName: string;
|
||||
skillsDir: string;
|
||||
benchmarkPath: string;
|
||||
targetModels: string[];
|
||||
optimizerModel: string;
|
||||
judgeModel: string;
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
lr: number;
|
||||
lrSchedule: 'cosine' | 'linear' | 'constant';
|
||||
split: [number, number, number];
|
||||
dryRun: boolean;
|
||||
noMutate: boolean;
|
||||
allowMutateBundled: boolean;
|
||||
bootstrapReviewed: boolean;
|
||||
maxCostUsd: number;
|
||||
maxRuntimeMin: number;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface FleetResult {
|
||||
skill: string;
|
||||
per_model: Array<{
|
||||
target_model: string;
|
||||
outcome: 'accepted' | 'no_improvement' | 'aborted' | 'errored';
|
||||
best_sel_score: number;
|
||||
final_cost_usd: number;
|
||||
receipt: RunReceipt;
|
||||
}>;
|
||||
best_model?: string;
|
||||
best_score?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run N parallel SkillOpt invocations against the same skill with
|
||||
* different target models. Per-target-model receipts so the operator can
|
||||
* see which model the skill optimized best against.
|
||||
*
|
||||
* IMPORTANT: when targetModels.length > 1 AND noMutate is false, the
|
||||
* orchestrator's per-skill DB lock would serialize them anyway. We
|
||||
* force `noMutate: true` for fleet runs — the operator picks a winner
|
||||
* by inspecting the per-model receipts + best.md from each subdir.
|
||||
*
|
||||
* Each per-target invocation writes its outputs under
|
||||
* `skills/<name>/skillopt/fleet/<model-slug>/` instead of the canonical
|
||||
* `skills/<name>/skillopt/` path, so the receipts don't clobber each other.
|
||||
*/
|
||||
export async function runFleet(opts: FleetOpts): Promise<FleetResult> {
|
||||
if (opts.targetModels.length === 0) {
|
||||
throw new Error('runFleet: targetModels must be non-empty');
|
||||
}
|
||||
|
||||
// Fleet runs are ALWAYS no-mutate. The operator must explicitly pick
|
||||
// a winner and copy its best.md to SKILL.md.
|
||||
const noMutate = true;
|
||||
|
||||
const promises = opts.targetModels.map(async (targetModel) => {
|
||||
const slug = slugifyModel(targetModel);
|
||||
// Use a per-model subdirectory by pointing skillsDir at a synthesized
|
||||
// path that includes the slug. Mkdir the path so apply-edits + version-
|
||||
// store work inside it. Copy the SKILL.md into the per-model dir
|
||||
// up-front so each fleet run sees the same baseline.
|
||||
const fleetDir = path.join(opts.skillsDir, opts.skillName, 'skillopt', 'fleet', slug);
|
||||
fs.mkdirSync(fleetDir, { recursive: true });
|
||||
// Per-model "skills dir" sees only this one skill.
|
||||
const perModelSkillsDir = path.join(opts.skillsDir, opts.skillName, 'skillopt', 'fleet', slug, 'staging');
|
||||
fs.mkdirSync(path.join(perModelSkillsDir, opts.skillName), { recursive: true });
|
||||
const stagingSkillPath = path.join(perModelSkillsDir, opts.skillName, 'SKILL.md');
|
||||
const baselinePath = path.join(opts.skillsDir, opts.skillName, 'SKILL.md');
|
||||
fs.copyFileSync(baselinePath, stagingSkillPath);
|
||||
|
||||
const skillOptOpts: SkillOptOpts = {
|
||||
engine: opts.engine,
|
||||
skillName: opts.skillName,
|
||||
skillsDir: perModelSkillsDir,
|
||||
benchmarkPath: opts.benchmarkPath,
|
||||
epochs: opts.epochs,
|
||||
batchSize: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lrSchedule: opts.lrSchedule,
|
||||
split: opts.split,
|
||||
optimizerModel: opts.optimizerModel,
|
||||
targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
mode: 'patch',
|
||||
dryRun: opts.dryRun,
|
||||
noMutate,
|
||||
allowMutateBundled: opts.allowMutateBundled,
|
||||
bootstrapReviewed: opts.bootstrapReviewed,
|
||||
json: true,
|
||||
maxCostUsd: opts.maxCostUsd,
|
||||
maxRuntimeMin: opts.maxRuntimeMin,
|
||||
force: opts.force,
|
||||
};
|
||||
const result = await runSkillOpt(skillOptOpts);
|
||||
return {
|
||||
target_model: targetModel,
|
||||
outcome: result.outcome as 'accepted' | 'no_improvement' | 'aborted' | 'errored',
|
||||
best_sel_score: result.receipt.best_sel_score ?? 0,
|
||||
final_cost_usd: result.receipt.final_cost_usd ?? 0,
|
||||
receipt: result.receipt,
|
||||
};
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(promises);
|
||||
const per_model = settled.map((s, i) => {
|
||||
if (s.status === 'fulfilled') return s.value;
|
||||
return {
|
||||
target_model: opts.targetModels[i]!,
|
||||
outcome: 'errored' as const,
|
||||
best_sel_score: 0,
|
||||
final_cost_usd: 0,
|
||||
receipt: {} as RunReceipt,
|
||||
};
|
||||
});
|
||||
|
||||
const result: FleetResult = { skill: opts.skillName, per_model };
|
||||
|
||||
// Pick the best-scoring model.
|
||||
const winning = per_model.reduce<{ model: string; score: number } | null>((acc, p) => {
|
||||
if (p.outcome !== 'accepted' && p.outcome !== 'no_improvement') return acc;
|
||||
if (acc === null || p.best_sel_score > acc.score) {
|
||||
return { model: p.target_model, score: p.best_sel_score };
|
||||
}
|
||||
return acc;
|
||||
}, null);
|
||||
if (winning) {
|
||||
result.best_model = winning.model;
|
||||
result.best_score = winning.score;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function slugifyModel(model: string): string {
|
||||
return model.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
||||
}
|
||||
|
||||
function collectSkillsWithBenchmarks(skillsDir: string): string[] {
|
||||
if (!fs.existsSync(skillsDir)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(skillsDir)) {
|
||||
const dir = path.join(skillsDir, entry);
|
||||
let isDir = false;
|
||||
try { isDir = fs.statSync(dir).isDirectory(); } catch { /* skip */ }
|
||||
if (!isDir) continue;
|
||||
const benchPath = path.join(dir, 'skillopt-benchmark.jsonl');
|
||||
if (fs.existsSync(benchPath)) out.push(entry);
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* SkillOpt benchmark loader, validator, and splitter.
|
||||
*
|
||||
* Benchmark format: JSONL with one task per line:
|
||||
* {"task_id":"x","task":"...","judge":{"kind":"rule","checks":[...]}}
|
||||
*
|
||||
* Enforces (at load time, fail-loud with paste-ready hints):
|
||||
* - File exists and is non-empty.
|
||||
* - Every row parses as JSON.
|
||||
* - Every row has task_id (unique), task (non-empty string), judge.kind ∈ {rule,llm,qrels}.
|
||||
* - Judge-specific shape validation (rule.checks array, llm.rubric string, qrels.expected_slugs).
|
||||
* - D17: D_sel >= 5 after split — refuses below floor with `--split` override hint.
|
||||
* - D15: refuses bootstrap output that still has the BOOTSTRAP_PENDING_REVIEW sentinel.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import { errorFor } from '../errors.ts';
|
||||
import {
|
||||
type Benchmark,
|
||||
type BenchmarkSplit,
|
||||
type BenchmarkTask,
|
||||
type Judge,
|
||||
type RuleCheck,
|
||||
type RuleCheckOp,
|
||||
BOOTSTRAP_PENDING_REVIEW,
|
||||
D_SEL_MIN_SIZE,
|
||||
} from './types.ts';
|
||||
|
||||
const VALID_RULE_OPS: ReadonlySet<RuleCheckOp> = new Set([
|
||||
'contains',
|
||||
'regex',
|
||||
'section_present',
|
||||
'max_chars',
|
||||
'min_citations',
|
||||
'tool_called',
|
||||
'tool_not_called',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Load + validate a benchmark JSONL file.
|
||||
*
|
||||
* @param path absolute path to the benchmark file.
|
||||
* @param opts.bootstrapReviewed set true after the user passed --bootstrap-reviewed.
|
||||
* When false (default), the loader refuses files that still carry the
|
||||
* BOOTSTRAP_PENDING_REVIEW sentinel line (D15).
|
||||
*/
|
||||
export function loadBenchmark(
|
||||
path: string,
|
||||
opts: { bootstrapReviewed?: boolean } = {},
|
||||
): Benchmark {
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(path, 'utf8');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw errorFor({
|
||||
class: 'BenchmarkNotFound',
|
||||
code: 'benchmark_not_found',
|
||||
message: `Benchmark file unreadable: ${path} (${msg})`,
|
||||
hint: `Auto-generate a starter with 'gbrain skillopt <skill> --bootstrap-from-skill' (reads SKILL.md), then review + run with --bootstrap-reviewed --split 1:1:1. Or pass --bootstrap-from-routing if a routing-eval.jsonl exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (content.trim().length === 0) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkEmpty',
|
||||
code: 'benchmark_empty',
|
||||
message: `Benchmark file is empty: ${path}`,
|
||||
hint: `Add at least ${D_SEL_MIN_SIZE} tasks (one JSON object per line) — D_sel requires >=${D_SEL_MIN_SIZE} after split.`,
|
||||
});
|
||||
}
|
||||
|
||||
// D15: detect the bootstrap-pending sentinel before parsing rows.
|
||||
// Sentinel is always the LAST non-empty line of the file. A user who's
|
||||
// hand-reviewed the bootstrap deletes the line before re-running.
|
||||
const allLines = content.split('\n');
|
||||
const lastNonEmpty = [...allLines].reverse().find((l) => l.trim().length > 0);
|
||||
if (lastNonEmpty && lastNonEmpty.trim() === BOOTSTRAP_PENDING_REVIEW) {
|
||||
if (!opts.bootstrapReviewed) {
|
||||
throw errorFor({
|
||||
class: 'BootstrapPendingReview',
|
||||
code: 'bootstrap_pending_review',
|
||||
message: `Benchmark at ${path} is a bootstrap output awaiting human review.`,
|
||||
hint: `Review the file, delete the trailing '${BOOTSTRAP_PENDING_REVIEW}' line, then re-run with --bootstrap-reviewed.`,
|
||||
});
|
||||
}
|
||||
} else if (opts.bootstrapReviewed) {
|
||||
// User passed --bootstrap-reviewed but the sentinel is already gone.
|
||||
// This is fine — the flag is idempotent. Don't error.
|
||||
}
|
||||
|
||||
// Parse rows.
|
||||
const tasks: BenchmarkTask[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const rows = allLines.filter((l) => l.trim().length > 0 && l.trim() !== BOOTSTRAP_PENDING_REVIEW);
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const line = rows[i]!;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_malformed',
|
||||
message: `Row ${i + 1} is not valid JSON: ${msg}`,
|
||||
hint: `Fix the offending line in ${path}; benchmarks are one JSON object per line.`,
|
||||
});
|
||||
}
|
||||
|
||||
const task = validateRow(parsed, i + 1, path);
|
||||
if (seenIds.has(task.task_id)) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkDuplicateId',
|
||||
code: 'benchmark_duplicate_task_id',
|
||||
message: `Duplicate task_id '${task.task_id}' at row ${i + 1}.`,
|
||||
hint: `Every task_id in ${path} must be unique.`,
|
||||
});
|
||||
}
|
||||
seenIds.add(task.task_id);
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkEmpty',
|
||||
code: 'benchmark_empty',
|
||||
message: `Benchmark file at ${path} has no tasks.`,
|
||||
hint: `Add at least ${D_SEL_MIN_SIZE} tasks.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source_path: path,
|
||||
tasks,
|
||||
benchmark_sha8: computeBenchmarkSha8(tasks),
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate a single parsed row. Throws StructuredAgentError on failure. */
|
||||
function validateRow(parsed: unknown, rowNum: number, path: string): BenchmarkTask {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_malformed',
|
||||
message: `Row ${rowNum} is not an object.`,
|
||||
hint: `Each line in ${path} must be a JSON object with task_id, task, judge.`,
|
||||
});
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
const task_id = typeof obj.task_id === 'string' ? obj.task_id.trim() : '';
|
||||
if (!task_id) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_missing_task_id',
|
||||
message: `Row ${rowNum} is missing a non-empty task_id.`,
|
||||
hint: `Add a unique task_id string to row ${rowNum} of ${path}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const task = typeof obj.task === 'string' ? obj.task : '';
|
||||
if (!task.trim()) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_missing_task',
|
||||
message: `Row ${rowNum} (${task_id}) is missing a non-empty task description.`,
|
||||
hint: `Add a 'task' field describing the prompt to run against the skill.`,
|
||||
});
|
||||
}
|
||||
|
||||
const judge = validateJudge(obj.judge, rowNum, task_id, path);
|
||||
return { task_id, task, judge };
|
||||
}
|
||||
|
||||
function validateJudge(raw: unknown, rowNum: number, task_id: string, path: string): Judge {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_missing_judge',
|
||||
message: `Row ${rowNum} (${task_id}) is missing a judge object.`,
|
||||
hint: `Add a 'judge' field with shape {"kind":"rule"|"llm"|"qrels", ...}.`,
|
||||
});
|
||||
}
|
||||
const j = raw as Record<string, unknown>;
|
||||
const kind = j.kind;
|
||||
if (kind === 'rule') {
|
||||
const checks = j.checks;
|
||||
if (!Array.isArray(checks) || checks.length === 0) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_judge_rule_no_checks',
|
||||
message: `Row ${rowNum} (${task_id}) judge.kind='rule' needs a non-empty checks array.`,
|
||||
hint: `Add at least one check, e.g. {"op":"max_chars","arg":4000}.`,
|
||||
});
|
||||
}
|
||||
const validated: RuleCheck[] = checks.map((c, ci) => validateRuleCheck(c, rowNum, ci, task_id, path));
|
||||
return { kind: 'rule', checks: validated };
|
||||
}
|
||||
if (kind === 'llm') {
|
||||
const rubric = typeof j.rubric === 'string' ? j.rubric : '';
|
||||
if (!rubric.trim()) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_judge_llm_no_rubric',
|
||||
message: `Row ${rowNum} (${task_id}) judge.kind='llm' needs a non-empty rubric string.`,
|
||||
hint: `Add a 'rubric' field describing how to score the output 0..1.`,
|
||||
});
|
||||
}
|
||||
const model = typeof j.model === 'string' ? j.model : undefined;
|
||||
return model !== undefined ? { kind: 'llm', rubric, model } : { kind: 'llm', rubric };
|
||||
}
|
||||
if (kind === 'qrels') {
|
||||
const expected_slugs = Array.isArray(j.expected_slugs) ? j.expected_slugs : null;
|
||||
if (!expected_slugs || expected_slugs.length === 0 || !expected_slugs.every((s) => typeof s === 'string')) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_judge_qrels_no_expected',
|
||||
message: `Row ${rowNum} (${task_id}) judge.kind='qrels' needs expected_slugs: string[].`,
|
||||
hint: `Add an array of expected slugs the retrieval should return.`,
|
||||
});
|
||||
}
|
||||
const k = typeof j.k === 'number' && j.k > 0 ? Math.floor(j.k) : 10;
|
||||
return { kind: 'qrels', expected_slugs: expected_slugs as string[], k };
|
||||
}
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_judge_unknown_kind',
|
||||
message: `Row ${rowNum} (${task_id}) judge.kind='${String(kind)}' is not one of rule|llm|qrels.`,
|
||||
hint: `Use one of: {"kind":"rule","checks":[...]}, {"kind":"llm","rubric":"..."}, {"kind":"qrels","expected_slugs":[...]}.`,
|
||||
});
|
||||
}
|
||||
|
||||
function validateRuleCheck(
|
||||
raw: unknown,
|
||||
rowNum: number,
|
||||
checkIdx: number,
|
||||
task_id: string,
|
||||
path: string,
|
||||
): RuleCheck {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_rule_check_malformed',
|
||||
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} is not an object.`,
|
||||
hint: `Each check must be {"op":"...","arg":...}.`,
|
||||
});
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
const op = c.op;
|
||||
if (typeof op !== 'string' || !VALID_RULE_OPS.has(op as RuleCheckOp)) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_rule_check_unknown_op',
|
||||
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} has unknown op '${String(op)}'.`,
|
||||
hint: `Valid ops: ${[...VALID_RULE_OPS].join(', ')}.`,
|
||||
});
|
||||
}
|
||||
const arg = c.arg;
|
||||
if (typeof arg !== 'string' && typeof arg !== 'number') {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkMalformed',
|
||||
code: 'benchmark_rule_check_bad_arg',
|
||||
message: `Row ${rowNum} (${task_id}) rule check #${checkIdx} has non-string/number arg.`,
|
||||
hint: `arg must be a string (contains, regex, section_present, tool_called, tool_not_called) or number (max_chars, min_citations).`,
|
||||
});
|
||||
}
|
||||
return { op: op as RuleCheckOp, arg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a deterministic SHA-256-prefix-8 over the benchmark contents.
|
||||
* Stable across whitespace changes (re-serializes the parsed tasks).
|
||||
*/
|
||||
export function computeBenchmarkSha8(tasks: BenchmarkTask[]): string {
|
||||
const canonical = tasks
|
||||
.slice()
|
||||
.sort((a, b) => (a.task_id < b.task_id ? -1 : a.task_id > b.task_id ? 1 : 0))
|
||||
.map((t) => JSON.stringify({ task_id: t.task_id, task: t.task, judge: t.judge }))
|
||||
.join('\n');
|
||||
return createHash('sha256').update(canonical).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a benchmark deterministically by ratio. Sorts by task_id for stable
|
||||
* splits across runs (paper: benchmarks must not shuffle between epochs).
|
||||
*
|
||||
* Returns `{train, sel, test}`. D17: refuses if D_sel < 5 — caller must
|
||||
* either add more tasks or pass an explicit --split override (which still
|
||||
* routes through this function with the override ratio).
|
||||
*/
|
||||
export function splitBench(
|
||||
benchmark: Benchmark,
|
||||
ratio: [number, number, number],
|
||||
opts: { allowSmallSel?: boolean } = {},
|
||||
): BenchmarkSplit {
|
||||
const [r1, r2, r3] = ratio;
|
||||
if (r1 <= 0 || r2 <= 0 || r3 <= 0) {
|
||||
throw errorFor({
|
||||
class: 'BadSplit',
|
||||
code: 'split_bad_ratio',
|
||||
message: `Split ratio ${ratio.join(':')} has a zero or negative segment.`,
|
||||
hint: `Use positive integers like 4:1:5.`,
|
||||
});
|
||||
}
|
||||
|
||||
const sorted = benchmark.tasks
|
||||
.slice()
|
||||
.sort((a, b) => (a.task_id < b.task_id ? -1 : a.task_id > b.task_id ? 1 : 0));
|
||||
|
||||
const total = r1 + r2 + r3;
|
||||
const n = sorted.length;
|
||||
// Round to nearest int; ensure all three buckets get at least 1 if n>=3.
|
||||
const trainN = Math.max(1, Math.floor((r1 * n) / total));
|
||||
const selN = Math.max(1, Math.floor((r2 * n) / total));
|
||||
const testN = Math.max(1, n - trainN - selN);
|
||||
|
||||
const train = sorted.slice(0, trainN);
|
||||
const sel = sorted.slice(trainN, trainN + selN);
|
||||
const test = sorted.slice(trainN + selN, trainN + selN + testN);
|
||||
|
||||
// D17: refuse if D_sel < 5 unless explicitly overridden.
|
||||
if (sel.length < D_SEL_MIN_SIZE && !opts.allowSmallSel) {
|
||||
throw errorFor({
|
||||
class: 'DSelTooSmall',
|
||||
code: 'd_sel_too_small',
|
||||
message: `D_sel has ${sel.length} task(s) after split (need >=${D_SEL_MIN_SIZE} for meaningful validation).`,
|
||||
hint: `Add more tasks to the benchmark (need ~${Math.ceil((D_SEL_MIN_SIZE * total) / r2)} total for ${ratio.join(':')}) or pass --split with a larger sel segment.`,
|
||||
});
|
||||
}
|
||||
|
||||
return { train, sel, test };
|
||||
}
|
||||
|
||||
/** Parse a split string like "4:1:5" into a tuple. */
|
||||
export function parseSplit(s: string): [number, number, number] {
|
||||
const parts = s.split(':').map((p) => Number(p.trim()));
|
||||
if (parts.length !== 3 || parts.some((p) => !Number.isFinite(p) || p <= 0)) {
|
||||
throw errorFor({
|
||||
class: 'BadSplit',
|
||||
code: 'split_unparseable',
|
||||
message: `Invalid --split value '${s}'.`,
|
||||
hint: `Use three positive integers separated by ':', e.g. '4:1:5'.`,
|
||||
});
|
||||
}
|
||||
return [parts[0]!, parts[1]!, parts[2]!];
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* SkillOpt bootstrap benchmark generators (D15).
|
||||
*
|
||||
* Two generators write `skills/<name>/skillopt-benchmark.jsonl`, both gated by
|
||||
* the BOOTSTRAP_PENDING_REVIEW sentinel (final line) — the user must hand-review,
|
||||
* delete the sentinel, then re-run with --bootstrap-reviewed before SkillOpt can
|
||||
* use the file. Both refuse to overwrite an existing benchmark unless --force.
|
||||
*
|
||||
* 1. runBootstrap (--bootstrap-from-routing): reads `routing-eval.jsonl` and
|
||||
* makes one LLM call PER routing intent to emit rule checks. Tests dispatch
|
||||
* phrasing, not output quality. Requires a pre-existing routing-eval.
|
||||
*
|
||||
* 2. runBootstrapFromSkill (--bootstrap-from-skill): reads the SKILL.md itself
|
||||
* and makes ONE LLM call that emits a full starter benchmark (tasks + rule
|
||||
* judges) as JSONL — no routing-eval dependency. JSONL output is parsed
|
||||
* line-by-line with skip-bad-line salvage (D5) so a truncated final line
|
||||
* drops instead of zeroing the run; a task is kept only when >=2 valid rule
|
||||
* checks survive (D6). The generated checks are weak DRAFTS the reviewer is
|
||||
* expected to strengthen.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { chat as gatewayChat } from '../ai/gateway.ts';
|
||||
import { errorFor } from '../errors.ts';
|
||||
import { atomicWrite } from './apply-edits.ts';
|
||||
import { BOOTSTRAP_PENDING_REVIEW, type RuleCheck } from './types.ts';
|
||||
|
||||
const BOOTSTRAP_SYSTEM = `You are SkillOpt's bootstrap-benchmark generator. Given a user intent that triggers a SKILL, generate 2-4 deterministic rule checks that would verify a successful execution.
|
||||
|
||||
Output ONLY a single JSON object on one line:
|
||||
{"checks": [{"op": "<op>", "arg": <arg>}, ...]}
|
||||
|
||||
Valid ops:
|
||||
- contains: arg: string — output must contain this substring
|
||||
- regex: arg: regex string — output must match
|
||||
- section_present: arg: heading text — output must have this ## heading
|
||||
- max_chars: arg: number — output ≤ N chars
|
||||
- min_citations: arg: number — output has ≥N citations
|
||||
- tool_called: arg: tool name — agent called this tool
|
||||
- tool_not_called: arg: tool name — agent avoided this tool
|
||||
|
||||
Be SPECIFIC. "max_chars: 4000" is more useful than "max_chars: 999999". A skill that should produce a structured report should have section_present checks.`;
|
||||
|
||||
const BOOTSTRAP_FROM_SKILL_SYSTEM = `You are SkillOpt's from-skill benchmark generator. Given a SKILL.md, infer what the skill is supposed to PRODUCE and what a GOOD output looks like, then generate realistic benchmark tasks that test OUTPUT QUALITY (not routing).
|
||||
|
||||
Output JSONL: ONE JSON object per line, NO surrounding array, NO prose, NO markdown fences. Each line is exactly:
|
||||
{"task": "<a realistic user prompt this skill handles>", "checks": [{"op": "<op>", "arg": <arg>}, ...]}
|
||||
|
||||
Each task MUST carry at least 2 deterministic rule checks. Valid ops:
|
||||
- contains: arg: string — output must contain this substring
|
||||
- regex: arg: regex string — output must match
|
||||
- section_present: arg: heading text — output must have this ## heading
|
||||
- max_chars: arg: number — output <= N chars
|
||||
- min_citations: arg: number — output has >=N citations
|
||||
- tool_called: arg: tool name — agent called this tool
|
||||
- tool_not_called: arg: tool name — agent avoided this tool
|
||||
|
||||
Rules:
|
||||
- Cover the boring middle the skill actually handles, not just edge cases.
|
||||
- Be SPECIFIC: "max_chars: 4000" beats "max_chars: 999999"; real heading names beat invented ones.
|
||||
- Only use tool_called / tool_not_called for tools the skill ACTUALLY declares in its frontmatter "tools:" list. Do NOT invent tool names.
|
||||
- Prefer 3-4 checks per task when the skill's quality bar supports them.`;
|
||||
|
||||
export interface BootstrapOpts {
|
||||
skillsDir: string;
|
||||
skillName: string;
|
||||
optimizerModel: string;
|
||||
force?: boolean;
|
||||
/** Test seam — substitute gateway.chat. */
|
||||
chatFn?: typeof gatewayChat;
|
||||
}
|
||||
|
||||
export interface BootstrapResult {
|
||||
outputPath: string;
|
||||
rowsGenerated: number;
|
||||
rowsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runBootstrap(opts: BootstrapOpts): Promise<BootstrapResult> {
|
||||
const { skillsDir, skillName, optimizerModel, force } = opts;
|
||||
const chat = opts.chatFn ?? gatewayChat;
|
||||
|
||||
const routingPath = path.join(skillsDir, skillName, 'routing-eval.jsonl');
|
||||
if (!fs.existsSync(routingPath)) {
|
||||
throw errorFor({
|
||||
class: 'NoRoutingEval',
|
||||
code: 'no_routing_eval',
|
||||
message: `Cannot bootstrap: ${routingPath} does not exist.`,
|
||||
hint: `Create a routing-eval.jsonl file first (gbrain skillify scaffold <name> generates one).`,
|
||||
});
|
||||
}
|
||||
|
||||
const outputPath = path.join(skillsDir, skillName, 'skillopt-benchmark.jsonl');
|
||||
assertBenchmarkAbsent(outputPath, !!force);
|
||||
|
||||
// Read the skill body for context.
|
||||
const skillPath = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
const skillBody = readSkillBodyOrThrow(skillPath);
|
||||
|
||||
// Parse routing-eval rows; skip malformed lines instead of crashing the whole
|
||||
// bootstrap on one bad line.
|
||||
const routingRows = fs.readFileSync(routingPath, 'utf8')
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => {
|
||||
try { return JSON.parse(l) as { intent: string; expected_skill: string }; } catch { return null; }
|
||||
})
|
||||
.filter((r): r is { intent: string; expected_skill: string } =>
|
||||
r !== null && typeof r.intent === 'string' && typeof r.expected_skill === 'string');
|
||||
|
||||
const generated: string[] = [];
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < routingRows.length; i++) {
|
||||
const row = routingRows[i]!;
|
||||
if (row.expected_skill !== skillName) continue; // Only generate for our skill.
|
||||
const userMsg = `SKILL BODY:\n${skillBody.slice(0, 4000)}\n\nUSER INTENT:\n${row.intent}\n\nGenerate 2-4 rule checks the agent's response should pass.`;
|
||||
try {
|
||||
const result = await chat({
|
||||
model: optimizerModel,
|
||||
system: BOOTSTRAP_SYSTEM,
|
||||
messages: [{ role: 'user', content: userMsg }],
|
||||
maxTokens: 500,
|
||||
cacheSystem: true,
|
||||
});
|
||||
const checks = parseChecksResponse(result.text);
|
||||
if (checks.length === 0) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
generated.push(JSON.stringify({
|
||||
task_id: `bootstrap-${String(i + 1).padStart(3, '0')}`,
|
||||
task: row.intent,
|
||||
judge: { kind: 'rule', checks },
|
||||
}));
|
||||
} catch (err) {
|
||||
skipped += 1;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt] bootstrap row ${i + 1} failed: ${msg}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (generated.length === 0) {
|
||||
throw errorFor({
|
||||
class: 'BootstrapEmpty',
|
||||
code: 'bootstrap_empty',
|
||||
message: `Bootstrap generated 0 tasks (all rows skipped or routing-eval has no matching rows for '${skillName}').`,
|
||||
hint: `Check that routing-eval.jsonl has rows where expected_skill='${skillName}' and the optimizer model is reachable.`,
|
||||
});
|
||||
}
|
||||
|
||||
const output = [...generated, BOOTSTRAP_PENDING_REVIEW, ''].join('\n');
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
atomicWrite(outputPath, output);
|
||||
|
||||
process.stderr.write(`[skillopt] Bootstrap wrote ${generated.length} tasks to ${outputPath} (${skipped} rows skipped).\n`);
|
||||
process.stderr.write(`[skillopt] REVIEW the file, then delete the trailing '${BOOTSTRAP_PENDING_REVIEW}' line and re-run with --bootstrap-reviewed.\n`);
|
||||
|
||||
return { outputPath, rowsGenerated: generated.length, rowsSkipped: skipped };
|
||||
}
|
||||
|
||||
function parseChecksResponse(raw: string): RuleCheck[] {
|
||||
try {
|
||||
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
|
||||
const cleaned = (fenced ? fenced[1]! : raw).trim();
|
||||
const parsed = JSON.parse(cleaned) as { checks?: unknown };
|
||||
if (parsed && Array.isArray(parsed.checks)) {
|
||||
return validateChecks(parsed.checks);
|
||||
}
|
||||
} catch { /* try fallback */ }
|
||||
// Fallback: first {...} substring.
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]) as { checks?: unknown };
|
||||
if (parsed && Array.isArray(parsed.checks)) {
|
||||
return validateChecks(parsed.checks);
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateChecks(raw: unknown[]): RuleCheck[] {
|
||||
const VALID = new Set(['contains', 'regex', 'section_present', 'max_chars', 'min_citations', 'tool_called', 'tool_not_called']);
|
||||
const out: RuleCheck[] = [];
|
||||
for (const r of raw) {
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const o = r as Record<string, unknown>;
|
||||
if (typeof o.op === 'string' && VALID.has(o.op) && (typeof o.arg === 'string' || typeof o.arg === 'number')) {
|
||||
out.push({ op: o.op as RuleCheck['op'], arg: o.arg });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── from-skill generator (--bootstrap-from-skill) ──────────────────────────
|
||||
|
||||
export interface BootstrapFromSkillOpts {
|
||||
skillsDir: string;
|
||||
skillName: string;
|
||||
optimizerModel: string;
|
||||
/** How many starter tasks to request. Default 15. CLI caps the flag at 50. */
|
||||
taskCount?: number;
|
||||
force?: boolean;
|
||||
/** Test seam — substitute gateway.chat. */
|
||||
chatFn?: typeof gatewayChat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a starter benchmark from the SKILL.md alone (no routing-eval).
|
||||
*
|
||||
* One LLM call emits JSONL tasks; we salvage line-by-line (D5) and keep a task
|
||||
* only when >=2 valid rule checks survive (D6). Provider/transport errors from
|
||||
* the chat call PROPAGATE — they are NOT collapsed into bootstrap_empty, so the
|
||||
* CLI surfaces the real failure instead of a misleading "0 tasks" message.
|
||||
*/
|
||||
export async function runBootstrapFromSkill(opts: BootstrapFromSkillOpts): Promise<BootstrapResult> {
|
||||
const { skillsDir, skillName, optimizerModel } = opts;
|
||||
const taskCount = opts.taskCount ?? 15;
|
||||
const chat = opts.chatFn ?? gatewayChat;
|
||||
|
||||
const outputPath = path.join(skillsDir, skillName, 'skillopt-benchmark.jsonl');
|
||||
assertBenchmarkAbsent(outputPath, !!opts.force);
|
||||
|
||||
const skillPath = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
const skillBody = readSkillBodyOrThrow(skillPath);
|
||||
|
||||
const userMsg = `SKILL BODY:\n${skillBody.slice(0, 8000)}\n\nGenerate ${taskCount} realistic benchmark tasks as JSONL (one JSON object per line, no array, no fences). Each task needs at least 2 rule checks. Output ONLY the JSONL lines.`;
|
||||
|
||||
// NOTE: no try/catch here — a provider/transport throw propagates to the CLI.
|
||||
const result = await chat({
|
||||
model: optimizerModel,
|
||||
system: BOOTSTRAP_FROM_SKILL_SYSTEM,
|
||||
messages: [{ role: 'user', content: userMsg }],
|
||||
maxTokens: Math.min(8000, Math.max(4000, taskCount * 220)),
|
||||
cacheSystem: true,
|
||||
});
|
||||
|
||||
const { generated, skipped } = parseSkillBenchmarkJsonl(result.text, skillName);
|
||||
|
||||
if (generated.length === 0) {
|
||||
throw errorFor({
|
||||
class: 'BootstrapEmpty',
|
||||
code: 'bootstrap_empty',
|
||||
message: `Bootstrap-from-skill generated 0 usable tasks for '${skillName}'.`,
|
||||
hint: `The model returned no parseable JSONL tasks with >=2 valid checks. Re-run, or verify the optimizer model is reachable.`,
|
||||
});
|
||||
}
|
||||
|
||||
const output = [...generated, BOOTSTRAP_PENDING_REVIEW, ''].join('\n');
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
atomicWrite(outputPath, output);
|
||||
|
||||
process.stderr.write(`[skillopt] Bootstrap-from-skill wrote ${generated.length} tasks to ${outputPath} (${skipped} dropped).\n`);
|
||||
if (generated.length < 15) {
|
||||
process.stderr.write(`[skillopt] WARNING: only ${generated.length} task(s) generated. The recommended --split 1:1:1 needs >=15 (D_sel >= 5); below that the optimizer refuses with d_sel_too_small. Add tasks or re-run.\n`);
|
||||
}
|
||||
process.stderr.write(`[skillopt] REVIEW + STRENGTHEN the generated rule checks (they are weak drafts), delete the trailing '${BOOTSTRAP_PENDING_REVIEW}' line, then run:\n`);
|
||||
process.stderr.write(`[skillopt] gbrain skillopt ${skillName} --bootstrap-reviewed --split 1:1:1\n`);
|
||||
|
||||
return { outputPath, rowsGenerated: generated.length, rowsSkipped: skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSONL benchmark tasks from the model's from-skill output (D5 salvage).
|
||||
*
|
||||
* Strips a single optional wrapping ```json/```jsonl fence, then parses line by
|
||||
* line. A malformed line (incl. a truncated final line) is skipped, not fatal —
|
||||
* the rest survive. A task is kept only when >=2 valid rule checks survive
|
||||
* validation (D6); otherwise the whole task is dropped and counted. task_ids are
|
||||
* assigned contiguously over KEPT tasks (<skillName>-001..NNN) so they're unique
|
||||
* and stable for loadBenchmark's duplicate-id check.
|
||||
*/
|
||||
function parseSkillBenchmarkJsonl(raw: string, skillName: string): { generated: string[]; skipped: number } {
|
||||
const fence = raw.match(/```(?:json|jsonl)?\s*\n?([\s\S]*?)```/i);
|
||||
const body = fence ? fence[1]! : raw;
|
||||
const lines = body.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
|
||||
const generated: string[] = [];
|
||||
let skipped = 0;
|
||||
for (const line of lines) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') { skipped += 1; continue; }
|
||||
const o = parsed as Record<string, unknown>;
|
||||
const task = typeof o.task === 'string' ? o.task.trim() : '';
|
||||
if (!task) { skipped += 1; continue; }
|
||||
const checks = Array.isArray(o.checks) ? validateChecks(o.checks) : [];
|
||||
if (checks.length < 2) { skipped += 1; continue; } // D6: drop the whole task
|
||||
generated.push(JSON.stringify({
|
||||
task_id: `${skillName}-${String(generated.length + 1).padStart(3, '0')}`,
|
||||
task,
|
||||
judge: { kind: 'rule', checks },
|
||||
}));
|
||||
}
|
||||
return { generated, skipped };
|
||||
}
|
||||
|
||||
// ─── shared helpers (used by both bootstrap generators) ─────────────────────
|
||||
|
||||
/** Overwrite guard: refuse to clobber an existing benchmark unless force. */
|
||||
function assertBenchmarkAbsent(outputPath: string, force: boolean): void {
|
||||
if (fs.existsSync(outputPath) && !force) {
|
||||
throw errorFor({
|
||||
class: 'BenchmarkExists',
|
||||
code: 'benchmark_exists',
|
||||
message: `Benchmark already exists at ${outputPath}.`,
|
||||
hint: `Pass --force to overwrite, or remove the file first.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Read SKILL.md or throw a structured no_skill_md error. */
|
||||
function readSkillBodyOrThrow(skillPath: string): string {
|
||||
try {
|
||||
return fs.readFileSync(skillPath, 'utf8');
|
||||
} catch {
|
||||
throw errorFor({
|
||||
class: 'NoSkill',
|
||||
code: 'no_skill_md',
|
||||
message: `Cannot read ${skillPath}.`,
|
||||
hint: `The skill must exist before bootstrapping its benchmark.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SkillOpt bundled-skill mutation gate (D16).
|
||||
*
|
||||
* A "bundled" skill is one that lives in the gbrain repo's `skills/` tree
|
||||
* (shipped alongside the binary). These are load-bearing for production
|
||||
* workflows; mutating them via SkillOpt without explicit operator opt-in
|
||||
* is too risky. By default, bundled-skill optimization runs in `--no-mutate`
|
||||
* mode automatically — the proposed best is written to `proposed.md` for
|
||||
* human review.
|
||||
*
|
||||
* User-owned skills (under a user's `~/.gbrain/skills/` or their own
|
||||
* project's `skills/`) are NOT bundled — they can be mutated freely.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { autoDetectSkillsDirReadOnly } from '../repo-root.ts';
|
||||
import type { BundledSkillContext } from './types.ts';
|
||||
|
||||
/**
|
||||
* Build the bundled-skill context for a (skillsDir, skillName) pair.
|
||||
*
|
||||
* The resolution chain:
|
||||
* 1. Resolve the absolute skill path: `skillsDir/<name>/SKILL.md`.
|
||||
* 2. Check whether the skillsDir came from the install-path fallback
|
||||
* (i.e. it's `<gbrain-install>/skills`). If so, this is a bundled skill.
|
||||
* 3. Otherwise, the skill is user-owned and freely mutable.
|
||||
*/
|
||||
export function getBundledSkillContext(skillsDir: string, skillName: string): BundledSkillContext {
|
||||
const skillPath = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
// Detect bundled by checking whether autoDetectSkillsDirReadOnly would
|
||||
// have routed here via the install-path fallback. The simpler heuristic:
|
||||
// if the resolved skillsDir is under the gbrain install tree (somewhere
|
||||
// up the chain has node_modules/gbrain or VERSION matching this binary),
|
||||
// it's bundled. Use the read-only detector to find the canonical install
|
||||
// skillsDir and compare.
|
||||
const detected = autoDetectSkillsDirReadOnly(process.cwd());
|
||||
const isBundled = detected.source === 'install_path' && detected.dir !== null
|
||||
&& resolvesToSame(detected.dir, skillsDir);
|
||||
return { skillName, skillsDir, skillPath, isBundled };
|
||||
}
|
||||
|
||||
function resolvesToSame(a: string, b: string): boolean {
|
||||
try {
|
||||
return fs.realpathSync(a) === fs.realpathSync(b);
|
||||
} catch {
|
||||
return path.resolve(a) === path.resolve(b);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether SkillOpt should mutate SKILL.md or just write proposed.md.
|
||||
*
|
||||
* Logic:
|
||||
* - `--no-mutate` flag → never mutate (write proposed.md).
|
||||
* - Bundled skill + no `--allow-mutate-bundled` → never mutate (D16).
|
||||
* - User-owned skill (or bundled + `--allow-mutate-bundled`) → mutate
|
||||
* in place.
|
||||
*/
|
||||
export function shouldMutateSkillFile(
|
||||
ctx: BundledSkillContext,
|
||||
flags: { noMutate: boolean; allowMutateBundled: boolean },
|
||||
): { mutate: boolean; reason?: string } {
|
||||
if (flags.noMutate) {
|
||||
return { mutate: false, reason: 'user_passed_no_mutate' };
|
||||
}
|
||||
if (ctx.isBundled && !flags.allowMutateBundled) {
|
||||
return { mutate: false, reason: 'bundled_skill_requires_allow_flag' };
|
||||
}
|
||||
return { mutate: true };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* SkillOpt run checkpoint. Lightweight per-run state for --resume support.
|
||||
*
|
||||
* Persists at `skills/<name>/skillopt/checkpoint-<run_id>.json`:
|
||||
* {
|
||||
* schema: 1,
|
||||
* run_id, skill, skill_sha8, benchmark_sha8,
|
||||
* epochs, batch_size, lr, lr_schedule,
|
||||
* best_sel_score, best_skill_text,
|
||||
* last_completed_epoch, last_completed_step,
|
||||
* cumulative_cost_usd,
|
||||
* started_at, last_updated_at
|
||||
* }
|
||||
*
|
||||
* Atomic write via .tmp + rename. On --resume <run_id>, the orchestrator
|
||||
* reads this file and skips epochs/steps that completed.
|
||||
*
|
||||
* 7-day GC: stale checkpoints older than 7 days are removed by the dream
|
||||
* cycle's purge phase (T6 wiring).
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { atomicWrite } from './apply-edits.ts';
|
||||
|
||||
const CHECKPOINT_SCHEMA = 1;
|
||||
|
||||
export interface RunCheckpoint {
|
||||
schema: 1;
|
||||
run_id: string;
|
||||
skill: string;
|
||||
skill_sha8: string;
|
||||
benchmark_sha8: string;
|
||||
optimizer_model: string;
|
||||
target_model: string;
|
||||
judge_model: string;
|
||||
epochs: number;
|
||||
batch_size: number;
|
||||
lr: number;
|
||||
lr_schedule: 'cosine' | 'linear' | 'constant';
|
||||
/** Highest sel-score achieved so far across the run. */
|
||||
best_sel_score: number;
|
||||
/** The skill text that produced best_sel_score. */
|
||||
best_skill_text: string;
|
||||
/** Most recent successfully-completed (epoch, step). 0/0 = nothing yet. */
|
||||
last_completed_epoch: number;
|
||||
last_completed_step: number;
|
||||
cumulative_cost_usd: number;
|
||||
started_at: string;
|
||||
last_updated_at: string;
|
||||
}
|
||||
|
||||
export function checkpointPath(skillsDir: string, skillName: string, runId: string): string {
|
||||
return path.join(skillsDir, skillName, 'skillopt', `checkpoint-${runId}.json`);
|
||||
}
|
||||
|
||||
export function loadCheckpoint(skillsDir: string, skillName: string, runId: string): RunCheckpoint | null {
|
||||
const p = checkpointPath(skillsDir, skillName, runId);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const parsed = JSON.parse(raw) as RunCheckpoint;
|
||||
if (parsed.schema !== CHECKPOINT_SCHEMA) return null;
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt] checkpoint unreadable (${msg}); starting fresh\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCheckpoint(skillsDir: string, skillName: string, cp: RunCheckpoint): void {
|
||||
const p = checkpointPath(skillsDir, skillName, cp.run_id);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
const payload = { ...cp, last_updated_at: new Date().toISOString() };
|
||||
atomicWrite(p, JSON.stringify(payload, null, 2) + '\n');
|
||||
}
|
||||
|
||||
export function deleteCheckpoint(skillsDir: string, skillName: string, runId: string): void {
|
||||
const p = checkpointPath(skillsDir, skillName, runId);
|
||||
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* GC stale checkpoints older than `maxAgeDays` (default 7). Called by the
|
||||
* dream cycle's purge phase. Returns the count of removed files.
|
||||
*/
|
||||
export function gcStaleCheckpoints(skillsDir: string, maxAgeDays: number = 7): number {
|
||||
if (!fs.existsSync(skillsDir)) return 0;
|
||||
const cutoffMs = Date.now() - maxAgeDays * 86400 * 1000;
|
||||
let removed = 0;
|
||||
for (const skillName of safeReaddir(skillsDir)) {
|
||||
const dir = path.join(skillsDir, skillName, 'skillopt');
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
for (const entry of safeReaddir(dir)) {
|
||||
if (!entry.startsWith('checkpoint-') || !entry.endsWith('.json')) continue;
|
||||
const p = path.join(dir, entry);
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.mtimeMs < cutoffMs) {
|
||||
fs.unlinkSync(p);
|
||||
removed += 1;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function safeReaddir(dir: string): string[] {
|
||||
try {
|
||||
return fs.readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* SkillOpt dream-cycle phase wrapper.
|
||||
*
|
||||
* Walks every skill that has `skillopt-benchmark.jsonl` AND a stale
|
||||
* `last_run_at` (>7d by default; configurable). Per-skill cap $0.50;
|
||||
* brain-wide cap $2.00 (both configurable). Bundled-skill safety
|
||||
* (D16): bundled skills never auto-mutate — proposed.md is written
|
||||
* to `~/.gbrain/skillopt-proposed-bundled/<skill>.md` for review.
|
||||
*
|
||||
* Per-skill last-run state lives in `config` table keyed
|
||||
* `cycle.skillopt.last_run.<skill>` so the cycle is cheap to re-enter
|
||||
* (don't re-run the same skill every cycle).
|
||||
*
|
||||
* Each per-skill invocation runs with epochs=1 (incremental nightly
|
||||
* improvement, not full optimization). Users who want a full multi-epoch
|
||||
* run invoke `gbrain skillopt <name> --epochs N` directly.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { autoDetectSkillsDirReadOnly } from '../repo-root.ts';
|
||||
import { resolveModel } from '../model-config.ts';
|
||||
import { runSkillOpt } from './orchestrator.ts';
|
||||
import { parseSplit } from './benchmark.ts';
|
||||
import type { SkillOptOpts } from './types.ts';
|
||||
|
||||
export interface SkilloptPhaseOpts {
|
||||
engine: BrainEngine;
|
||||
dryRun?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SkilloptPhaseResult {
|
||||
phase: 'skillopt';
|
||||
status: 'ok' | 'skipped' | 'warn' | 'fail';
|
||||
duration_ms: number;
|
||||
summary: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SkillCandidate {
|
||||
name: string;
|
||||
benchmarkPath: string;
|
||||
lastRunAt: number | null;
|
||||
}
|
||||
|
||||
/** Default per-skill cost cap for the phase. */
|
||||
const DEFAULT_PER_SKILL_CAP_USD = 0.50;
|
||||
/** Default brain-wide cost cap for one cycle. */
|
||||
const DEFAULT_BRAIN_WIDE_CAP_USD = 2.00;
|
||||
/** Default stale threshold (skip skills that ran within this window). */
|
||||
const DEFAULT_STALE_DAYS = 7;
|
||||
|
||||
export async function runPhaseSkillopt(opts: SkilloptPhaseOpts): Promise<SkilloptPhaseResult> {
|
||||
const { engine } = opts;
|
||||
const start = Date.now();
|
||||
|
||||
// Read the feature flag. Default OFF.
|
||||
let enabled = false;
|
||||
try {
|
||||
const v = await engine.getConfig('cycle.skillopt.enabled');
|
||||
enabled = v === 'true';
|
||||
} catch { /* default OFF */ }
|
||||
if (!enabled) {
|
||||
return {
|
||||
phase: 'skillopt',
|
||||
status: 'skipped',
|
||||
duration_ms: Date.now() - start,
|
||||
summary: 'feature flag off (gbrain config set cycle.skillopt.enabled true to enable)',
|
||||
details: { reason: 'feature_flag_off' },
|
||||
};
|
||||
}
|
||||
|
||||
// Per-skill + brain-wide cost caps.
|
||||
const perSkillCap = await readNumericConfig(engine, 'cycle.skillopt.per_skill_cap_usd', DEFAULT_PER_SKILL_CAP_USD);
|
||||
const brainWideCap = await readNumericConfig(engine, 'cycle.skillopt.brain_wide_cap_usd', DEFAULT_BRAIN_WIDE_CAP_USD);
|
||||
const staleDays = await readNumericConfig(engine, 'cycle.skillopt.stale_days', DEFAULT_STALE_DAYS);
|
||||
|
||||
// Locate skills dir.
|
||||
const detected = autoDetectSkillsDirReadOnly(process.cwd());
|
||||
const skillsDir = detected.dir;
|
||||
if (!skillsDir) {
|
||||
return {
|
||||
phase: 'skillopt',
|
||||
status: 'skipped',
|
||||
duration_ms: Date.now() - start,
|
||||
summary: 'no skills directory found',
|
||||
details: { reason: 'no_skills_dir' },
|
||||
};
|
||||
}
|
||||
|
||||
// Walk skills dir; pick candidates with skillopt-benchmark.jsonl + stale last_run_at.
|
||||
const candidates = await collectCandidates(engine, skillsDir, staleDays);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
phase: 'skillopt',
|
||||
status: 'ok',
|
||||
duration_ms: Date.now() - start,
|
||||
summary: 'no stale skills with benchmarks; nothing to optimize',
|
||||
details: { skills_scanned: 0, candidates: 0, brain_wide_cap_usd: brainWideCap },
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve models once. Tiers default to deep/subagent/reasoning.
|
||||
const optimizerModel = await resolveModel(engine, { tier: 'deep', fallback: 'anthropic:claude-opus-4-7' });
|
||||
const targetModel = await resolveModel(engine, { tier: 'subagent', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
const judgeModel = await resolveModel(engine, { tier: 'reasoning', fallback: 'anthropic:claude-sonnet-4-6' });
|
||||
|
||||
// Run per-skill. Each invocation gets its own per-skill cap; we track
|
||||
// cumulative cost across the cycle and bail when brain-wide cap hit.
|
||||
const results: Array<{ skill: string; outcome: string; cost_usd: number; reason?: string }> = [];
|
||||
let cumulativeCostUsd = 0;
|
||||
let skipped_brain_wide_cap = 0;
|
||||
|
||||
for (const c of candidates) {
|
||||
if (opts.signal?.aborted) break;
|
||||
if (cumulativeCostUsd >= brainWideCap) {
|
||||
skipped_brain_wide_cap += 1;
|
||||
results.push({ skill: c.name, outcome: 'skipped', cost_usd: 0, reason: 'brain_wide_cap_reached' });
|
||||
continue;
|
||||
}
|
||||
// Cap the per-skill spend at min(per_skill_cap, remaining_brain_wide).
|
||||
const remaining = brainWideCap - cumulativeCostUsd;
|
||||
const effectiveCap = Math.min(perSkillCap, remaining);
|
||||
|
||||
try {
|
||||
const split = parseSplit('4:1:5');
|
||||
const skillOptOpts: SkillOptOpts = {
|
||||
engine,
|
||||
skillName: c.name,
|
||||
skillsDir,
|
||||
benchmarkPath: c.benchmarkPath,
|
||||
epochs: 1, // incremental nightly: ONE epoch per cycle
|
||||
batchSize: 4, // smaller batch for the nightly path
|
||||
lr: 4,
|
||||
lrSchedule: 'cosine',
|
||||
split,
|
||||
optimizerModel,
|
||||
targetModel,
|
||||
judgeModel,
|
||||
mode: 'patch',
|
||||
dryRun: opts.dryRun ?? false,
|
||||
// Bundled-skill safety: dream-cycle NEVER auto-mutates bundled skills.
|
||||
// For bundled skills we set --no-mutate; the user reviews proposed.md
|
||||
// at their own cadence.
|
||||
noMutate: true, // ALL dream-cycle runs are no-mutate by default
|
||||
allowMutateBundled: false,
|
||||
bootstrapReviewed: false,
|
||||
json: true,
|
||||
maxCostUsd: effectiveCap,
|
||||
maxRuntimeMin: 10, // shorter wall-clock cap for the nightly path
|
||||
force: false,
|
||||
};
|
||||
const result = await runSkillOpt(skillOptOpts);
|
||||
const spent = result.receipt.final_cost_usd ?? 0;
|
||||
cumulativeCostUsd += spent;
|
||||
results.push({
|
||||
skill: c.name,
|
||||
outcome: result.outcome,
|
||||
cost_usd: spent,
|
||||
});
|
||||
// Persist last_run_at so we don't re-enter every cycle.
|
||||
await engine.setConfig(`cycle.skillopt.last_run.${c.name}`, String(Date.now()));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
results.push({ skill: c.name, outcome: 'errored', cost_usd: 0, reason: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const accepted = results.filter((r) => r.outcome === 'accepted').length;
|
||||
const noImprovement = results.filter((r) => r.outcome === 'no_improvement').length;
|
||||
const errored = results.filter((r) => r.outcome === 'errored').length;
|
||||
|
||||
return {
|
||||
phase: 'skillopt',
|
||||
status: errored > 0 ? 'warn' : 'ok',
|
||||
duration_ms: Date.now() - start,
|
||||
summary: `optimized ${accepted}/${candidates.length} skills (${noImprovement} no-improvement, ${errored} errored, ${skipped_brain_wide_cap} skipped over brain-wide cap)`,
|
||||
details: {
|
||||
skills_scanned: candidates.length,
|
||||
accepted,
|
||||
no_improvement: noImprovement,
|
||||
errored,
|
||||
skipped_brain_wide_cap,
|
||||
cumulative_cost_usd: cumulativeCostUsd,
|
||||
brain_wide_cap_usd: brainWideCap,
|
||||
per_skill_cap_usd: perSkillCap,
|
||||
results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk skillsDir and return skills that have a benchmark file AND a stale
|
||||
* last_run_at (older than staleDays, or never run).
|
||||
*/
|
||||
async function collectCandidates(
|
||||
engine: BrainEngine,
|
||||
skillsDir: string,
|
||||
staleDays: number,
|
||||
): Promise<SkillCandidate[]> {
|
||||
const out: SkillCandidate[] = [];
|
||||
if (!fs.existsSync(skillsDir)) return out;
|
||||
const cutoffMs = Date.now() - staleDays * 86400 * 1000;
|
||||
for (const entry of fs.readdirSync(skillsDir)) {
|
||||
const skillDir = path.join(skillsDir, entry);
|
||||
if (!fs.statSync(skillDir).isDirectory()) continue;
|
||||
const benchPath = path.join(skillDir, 'skillopt-benchmark.jsonl');
|
||||
if (!fs.existsSync(benchPath)) continue;
|
||||
// Read last_run_at.
|
||||
let lastRunAt: number | null = null;
|
||||
try {
|
||||
const v = await engine.getConfig(`cycle.skillopt.last_run.${entry}`);
|
||||
if (v) lastRunAt = Number(v);
|
||||
} catch { /* fall through */ }
|
||||
if (lastRunAt !== null && lastRunAt >= cutoffMs) {
|
||||
continue; // ran recently; skip
|
||||
}
|
||||
out.push({ name: entry, benchmarkPath: benchPath, lastRunAt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readNumericConfig(engine: BrainEngine, key: string, defaultValue: number): Promise<number> {
|
||||
try {
|
||||
const v = await engine.getConfig(key);
|
||||
if (v) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return defaultValue;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* SkillOpt held-out real-user test set (F11).
|
||||
*
|
||||
* Even with the validation gate (D12) + bundled-skill gate (D16), a skill
|
||||
* optimized against its own benchmark may regress on real user workflows.
|
||||
* F11 adds an OPTIONAL independent signal:
|
||||
*
|
||||
* 1. Capture infrastructure: opt-in via `gbrain config set
|
||||
* skillopt.capture_enabled true`. Real production rollouts of the
|
||||
* skill get appended as JSONL rows to
|
||||
* `~/.gbrain/skillopt-captures/<skill>/<run>.jsonl`.
|
||||
*
|
||||
* 2. Held-out validation gate: when `--held-out <path>` is passed to
|
||||
* `gbrain skillopt`, the orchestrator runs the candidate skill
|
||||
* against the held-out set BEFORE committing the mutation. If the
|
||||
* candidate's held-out score is BELOW baseline, the mutation is
|
||||
* refused (returns 'no_improvement' even if D_sel was happy).
|
||||
*
|
||||
* 3. `--allow-mutate-bundled` for bundled skills requires `--held-out
|
||||
* <path>` AND a passing held-out gate. Closes the "benchmark gaming
|
||||
* hole" codex identified.
|
||||
*
|
||||
* Held-out format: same JSONL shape as skillopt-benchmark.jsonl (task_id,
|
||||
* task, judge). The judge MAY be `kind: 'rule'` for cheap deterministic
|
||||
* checks, or `kind: 'llm'` if the user wants a real-judge signal.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { loadBenchmark } from './benchmark.ts';
|
||||
import type { BenchmarkTask } from './types.ts';
|
||||
import { runValidationGate } from './validate-gate.ts';
|
||||
|
||||
const CAPTURE_CONFIG_KEY = 'skillopt.capture_enabled';
|
||||
|
||||
export function capturesDir(): string {
|
||||
const home = process.env.GBRAIN_HOME ?? process.env.HOME ?? '';
|
||||
return path.join(home, '.gbrain', 'skillopt-captures');
|
||||
}
|
||||
|
||||
export function capturePath(skillName: string, runId: string): string {
|
||||
return path.join(capturesDir(), skillName, `${runId}.jsonl`);
|
||||
}
|
||||
|
||||
/** Read the opt-in flag. Default false. */
|
||||
export async function isCaptureEnabled(engine: BrainEngine): Promise<boolean> {
|
||||
try {
|
||||
const v = await engine.getConfig(CAPTURE_CONFIG_KEY);
|
||||
return v === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CapturedRollout {
|
||||
ts: string;
|
||||
skill_name: string;
|
||||
task: string;
|
||||
final_text: string;
|
||||
tool_calls: Array<{ name: string; failed?: boolean }>;
|
||||
/** Optional user-supplied label for whether this rollout was "good". */
|
||||
label?: 'good' | 'bad' | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one captured rollout to the per-skill JSONL. Best-effort —
|
||||
* stderr-warns on write failure, never throws.
|
||||
*/
|
||||
export function appendCapture(skillName: string, runId: string, row: CapturedRollout): void {
|
||||
const file = capturePath(skillName, runId);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt-capture] write failed for ${skillName} (${msg}); capture skipped\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a held-out JSONL file. Same shape as benchmark; reuses loadBenchmark
|
||||
* for validation + parsing.
|
||||
*/
|
||||
export function loadHeldOut(heldOutPath: string): BenchmarkTask[] {
|
||||
if (!fs.existsSync(heldOutPath)) {
|
||||
throw new Error(`Held-out file does not exist: ${heldOutPath}`);
|
||||
}
|
||||
// Reuse the benchmark loader; it enforces the same shape contract.
|
||||
// bootstrapReviewed:true bypasses the sentinel check (held-out files
|
||||
// are user-curated, not LLM-bootstrapped).
|
||||
const bench = loadBenchmark(heldOutPath, { bootstrapReviewed: true });
|
||||
return bench.tasks;
|
||||
}
|
||||
|
||||
export interface HeldOutGateOpts {
|
||||
engine: BrainEngine;
|
||||
candidateSkillText: string;
|
||||
baselineSkillText: string;
|
||||
heldOutTasks: BenchmarkTask[];
|
||||
targetModel: string;
|
||||
judgeModel: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface HeldOutGateResult {
|
||||
baselineScore: number;
|
||||
candidateScore: number;
|
||||
/** Candidate passes when score >= baseline (no margin — held-out is the safety net, not the discriminator). */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the held-out gate. The candidate is accepted only if its held-out
|
||||
* score is >= the baseline's held-out score. No epsilon margin (the D_sel
|
||||
* gate already filtered for noise; held-out is an independent confirmation,
|
||||
* not a second discriminator).
|
||||
*/
|
||||
export async function runHeldOutGate(opts: HeldOutGateOpts): Promise<HeldOutGateResult> {
|
||||
if (opts.heldOutTasks.length === 0) {
|
||||
// No held-out data: pass vacuously, log a stderr warn.
|
||||
process.stderr.write(`[skillopt-heldout] held-out task set is empty; gate passes vacuously\n`);
|
||||
return { baselineScore: 0, candidateScore: 0, passed: true };
|
||||
}
|
||||
|
||||
const baseline = await runValidationGate({
|
||||
engine: opts.engine,
|
||||
candidateSkillText: opts.baselineSkillText,
|
||||
selSet: opts.heldOutTasks,
|
||||
bestScore: -1, // any score accepts; we want the score itself
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
...(opts.abortSignal ? { abortSignal: opts.abortSignal } : {}),
|
||||
});
|
||||
const candidate = await runValidationGate({
|
||||
engine: opts.engine,
|
||||
candidateSkillText: opts.candidateSkillText,
|
||||
selSet: opts.heldOutTasks,
|
||||
bestScore: -1,
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
...(opts.abortSignal ? { abortSignal: opts.abortSignal } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
baselineScore: baseline.selScore,
|
||||
candidateScore: candidate.selScore,
|
||||
passed: candidate.selScore >= baseline.selScore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* gbrain skillopt --help text.
|
||||
*/
|
||||
|
||||
export const SKILLOPT_HELP_TEXT = `gbrain skillopt <skill-name> [flags]
|
||||
|
||||
Self-evolving skill optimization. Treats SKILL.md as the trainable parameters
|
||||
of a frozen agent. Validation-gated, budget-capped, atomic-versioned.
|
||||
|
||||
Based on SkillOpt (arXiv 2605.23904, MSR May 2026).
|
||||
|
||||
Required (one of):
|
||||
--benchmark <path> JSONL benchmark file
|
||||
--bootstrap-from-skill Auto-build a starter benchmark from SKILL.md
|
||||
itself (no routing-eval needed). Emits ~15 tasks
|
||||
+ rule judges, writes the review sentinel. The
|
||||
recommended way to start a brand-new benchmark.
|
||||
--bootstrap-tasks N How many starter tasks --bootstrap-from-skill
|
||||
generates. Default 15, max 50.
|
||||
--bootstrap-from-routing Auto-build benchmark from routing-eval.jsonl
|
||||
(writes sentinel; requires --bootstrap-reviewed
|
||||
after human review)
|
||||
--bootstrap-reviewed Confirm bootstrap benchmark was hand-reviewed
|
||||
|
||||
Training knobs:
|
||||
--epochs N Default 4
|
||||
--batch-size N Default 8
|
||||
--lr N Max edits per step. Default 4
|
||||
--lr-schedule cosine|linear|constant
|
||||
Default cosine
|
||||
--split TRAIN:SEL:TEST Default "4:1:5"; refuses if D_sel < 5
|
||||
|
||||
Models:
|
||||
--optimizer-model MODEL Reflects + proposes. Default models.tier.deep
|
||||
--target-model MODEL Executes the skill. Default models.tier.subagent
|
||||
--judge-model MODEL Scores rollouts. Default models.tier.reasoning
|
||||
|
||||
Modes:
|
||||
--patch Edit ops only (default; safer)
|
||||
--rewrite Allow full rewrites of sections
|
||||
--dry-run Plan + cost estimate, no LLM calls
|
||||
--no-mutate Write proposed.md without replacing SKILL.md
|
||||
--allow-mutate-bundled Required when target skill is bundled
|
||||
--json Machine-readable stdout
|
||||
|
||||
Safety:
|
||||
--max-cost-usd N Hard cap. Default 5.00. Preflight refuses
|
||||
if estimate exceeds.
|
||||
--max-runtime-min N Wall-clock cap. Default 30
|
||||
--force Bypass dirty-working-tree refusal (rare)
|
||||
--resume <run-id> Resume a prior interrupted run
|
||||
|
||||
Batch + fleet + background:
|
||||
--all Optimize every skill with a benchmark
|
||||
(per-skill cap = --max-cost-usd; brain-wide
|
||||
cap = --brain-wide-max-cost-usd, default $10)
|
||||
--brain-wide-max-cost-usd N Cumulative ceiling for --all (default 10.00)
|
||||
--target-models a,b,c Fleet mode: optimize ONCE per model. Always
|
||||
runs no-mutate; per-model receipts under
|
||||
skills/<name>/skillopt/fleet/<slug>/
|
||||
--background Submit as a Minion job + print job_id; exits.
|
||||
Combine with --follow to attach.
|
||||
--write-capture Enable virtual put_page / submit_job /
|
||||
file_upload for write-flavored skills (no
|
||||
real writes — captured for judge inspection)
|
||||
--held-out <path> Independent held-out test set; gate refuses
|
||||
mutation if candidate's held-out score is
|
||||
below baseline.
|
||||
|
||||
Exit codes:
|
||||
0 = improved + accepted (or --no-mutate proposed.md written)
|
||||
1 = no improvement (best skill unchanged)
|
||||
2 = aborted by gate (dirty tree / over budget / bench validation / etc.)
|
||||
|
||||
Examples:
|
||||
# Generate a starter benchmark from the skill itself (recommended):
|
||||
gbrain skillopt meeting-prep --bootstrap-from-skill
|
||||
# ...then review + strengthen the judges, delete the sentinel line, and run:
|
||||
gbrain skillopt meeting-prep --bootstrap-reviewed --split 1:1:1
|
||||
|
||||
# Bootstrap benchmark from existing routing-eval, then review:
|
||||
gbrain skillopt meeting-prep --bootstrap-from-routing
|
||||
|
||||
# After review (sentinel deleted), run the optimizer:
|
||||
gbrain skillopt meeting-prep --bootstrap-reviewed
|
||||
|
||||
# Dry-run cost preview:
|
||||
gbrain skillopt meeting-prep --dry-run
|
||||
|
||||
# Optimize a bundled skill with explicit opt-in:
|
||||
gbrain skillopt brain-ops --allow-mutate-bundled
|
||||
|
||||
# Resume after interruption:
|
||||
gbrain skillopt meeting-prep --resume <run-id>
|
||||
|
||||
See: docs/guides/skillopt.md
|
||||
`;
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* SkillOpt per-skill DB lock (D14).
|
||||
*
|
||||
* Thin wrapper around `tryAcquireDbLock` from `src/core/db-lock.ts`. The
|
||||
* lock id is `skillopt:<skill-name>` so two concurrent `gbrain skillopt foo`
|
||||
* runs serialize cleanly without blocking other skills.
|
||||
*
|
||||
* Default TTL: 60 minutes — generous for a full epoch run, but the auto-
|
||||
* refresh inside `withSkilloptLock` bumps it every 15 minutes so a long
|
||||
* run never times out underneath itself.
|
||||
*
|
||||
* Why a DB lock instead of a filesystem `.lock`:
|
||||
* - Cross-host correct (matters for Conductor workspaces sharing a brain).
|
||||
* - Reuses the existing primitive (same TTL semantics as gbrain sync,
|
||||
* extract-conversation-facts, autopilot cycle).
|
||||
* - Crashed holders auto-release via TTL expiry (no PID-liveness landmine).
|
||||
*/
|
||||
|
||||
import { tryAcquireDbLock, type DbLockHandle } from '../db-lock.ts';
|
||||
import { errorFor } from '../errors.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
const DEFAULT_TTL_MINUTES = 60;
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Build the lock id for a given skill name. */
|
||||
export function lockIdFor(skillName: string): string {
|
||||
return `skillopt:${skillName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a per-skill SkillOpt lock. Returns null when another live holder
|
||||
* has the lock. Caller is responsible for releasing via `handle.release()`
|
||||
* (use `withSkilloptLock` for try/finally + refresh-loop semantics).
|
||||
*/
|
||||
export async function tryAcquireSkilloptLock(
|
||||
engine: BrainEngine,
|
||||
skillName: string,
|
||||
ttlMinutes: number = DEFAULT_TTL_MINUTES,
|
||||
): Promise<DbLockHandle | null> {
|
||||
return tryAcquireDbLock(engine, lockIdFor(skillName), ttlMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` while holding the per-skill SkillOpt lock with a background
|
||||
* refresh loop. Refreshes the TTL every 15 minutes (well under the 60min
|
||||
* default TTL so the lock never expires under an active run).
|
||||
*
|
||||
* Throws a StructuredAgentError with `code: 'lock_busy'` when another
|
||||
* live holder has the lock — the user is shown the paste-ready remediation
|
||||
* "another run is in progress; wait or check `gbrain jobs supervisor status`".
|
||||
*
|
||||
* Lock is always released on `fn` completion (success OR throw) via
|
||||
* try/finally. The background refresh interval is cleared in the finally.
|
||||
*/
|
||||
export async function withSkilloptLock<T>(
|
||||
engine: BrainEngine,
|
||||
skillName: string,
|
||||
fn: (handle: DbLockHandle) => Promise<T>,
|
||||
ttlMinutes: number = DEFAULT_TTL_MINUTES,
|
||||
refreshIntervalMs: number = DEFAULT_REFRESH_INTERVAL_MS,
|
||||
): Promise<T> {
|
||||
const handle = await tryAcquireSkilloptLock(engine, skillName, ttlMinutes);
|
||||
if (handle === null) {
|
||||
throw errorFor({
|
||||
class: 'LockBusy',
|
||||
code: 'lock_busy',
|
||||
message: `Another SkillOpt run is in progress for skill '${skillName}'.`,
|
||||
hint: `Wait for it to finish, or check 'gbrain jobs supervisor status'. Stale lock holders auto-expire after ${ttlMinutes} minutes.`,
|
||||
});
|
||||
}
|
||||
|
||||
const refresher = setInterval(() => {
|
||||
handle.refresh().catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt-lock] refresh failed for '${skillName}': ${msg}\n`);
|
||||
});
|
||||
}, refreshIntervalMs);
|
||||
// Don't keep the event loop alive on the refresh timer alone.
|
||||
if (typeof refresher.unref === 'function') refresher.unref();
|
||||
|
||||
try {
|
||||
return await fn(handle);
|
||||
} finally {
|
||||
clearInterval(refresher);
|
||||
try {
|
||||
await handle.release();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt-lock] release failed for '${skillName}': ${msg}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SkillOpt LR (learning-rate) schedules.
|
||||
*
|
||||
* The LR controls the max number of edits per step. Cosine is the default
|
||||
* per the SkillOpt paper's ablation (slightly better than linear or constant).
|
||||
*
|
||||
* All schedules return an INTEGER >= 1 (we always allow at least one edit
|
||||
* per step; otherwise the optimizer can never make progress).
|
||||
*
|
||||
* Pure functions — no side effects, no I/O, fully unit-testable.
|
||||
*
|
||||
* LR schedule shapes (visualized for base=4, totalSteps=10):
|
||||
*
|
||||
* cosine: 4 4 3 3 3 2 2 2 1 1 (smooth high→low decay)
|
||||
* linear: 4 3 3 3 2 2 2 1 1 1 (monotone descent)
|
||||
* constant: 4 4 4 4 4 4 4 4 4 4 (no decay)
|
||||
*
|
||||
* The cosine curve peaks early (more aggressive when the skill is the most
|
||||
* unrefined) and tapers (fewer edits as the skill converges).
|
||||
*/
|
||||
|
||||
/** Floor of all schedules; the LR can never drop below 1. */
|
||||
const MIN_LR = 1;
|
||||
|
||||
/**
|
||||
* Cosine-decay schedule. Peaks at `base` for t=1; decays to ~1 by totalSteps.
|
||||
*
|
||||
* Formula: `0.5 * base * (1 + cos((t-1) * pi / (totalSteps-1)))` rounded
|
||||
* up, then clamped to [MIN_LR, base].
|
||||
*/
|
||||
export function cosineLr(base: number, t: number, totalSteps: number): number {
|
||||
if (base < MIN_LR) return MIN_LR;
|
||||
if (totalSteps <= 1) return base;
|
||||
const tClamped = Math.max(1, Math.min(t, totalSteps));
|
||||
const phase = ((tClamped - 1) * Math.PI) / (totalSteps - 1);
|
||||
const raw = 0.5 * base * (1 + Math.cos(phase));
|
||||
return Math.max(MIN_LR, Math.min(base, Math.ceil(raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-decay schedule. Starts at `base` for t=1; ends at MIN_LR for
|
||||
* t=totalSteps. Monotonically non-increasing.
|
||||
*
|
||||
* Formula: `base - (base - MIN_LR) * (t-1) / (totalSteps-1)` rounded up.
|
||||
*/
|
||||
export function linearLr(base: number, t: number, totalSteps: number): number {
|
||||
if (base < MIN_LR) return MIN_LR;
|
||||
if (totalSteps <= 1) return base;
|
||||
const tClamped = Math.max(1, Math.min(t, totalSteps));
|
||||
const raw = base - ((base - MIN_LR) * (tClamped - 1)) / (totalSteps - 1);
|
||||
return Math.max(MIN_LR, Math.min(base, Math.ceil(raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant schedule. Returns `base` every step (clamped to MIN_LR).
|
||||
*/
|
||||
export function constantLr(base: number, _t: number, _totalSteps: number): number {
|
||||
return Math.max(MIN_LR, base);
|
||||
}
|
||||
|
||||
/** Type alias for the function signature shared by all three schedules. */
|
||||
export type LrScheduleFn = (base: number, t: number, totalSteps: number) => number;
|
||||
|
||||
/** Resolve a schedule name to its function. */
|
||||
export function resolveLrSchedule(name: 'cosine' | 'linear' | 'constant'): LrScheduleFn {
|
||||
switch (name) {
|
||||
case 'cosine': return cosineLr;
|
||||
case 'linear': return linearLr;
|
||||
case 'constant': return constantLr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* SkillOpt main loop — runSkillOpt.
|
||||
*
|
||||
* ┌─ LR cosine-decay curve (default base=4, totalSteps=10) ────────────────┐
|
||||
* │ │
|
||||
* │ 4 ●─● │
|
||||
* │ \ │
|
||||
* │ 3 ●─●─● │
|
||||
* │ \ │
|
||||
* │ 2 ●─●─● │
|
||||
* │ \ │
|
||||
* │ 1 ●─●─● │
|
||||
* │ ─┼──┼──┼──┼──┼──┼──┼──┼──┼──┼─ │
|
||||
* │ 1 2 3 4 5 6 7 8 9 10 │
|
||||
* │ │
|
||||
* │ Peaks early (most aggressive when skill is least refined), tapers │
|
||||
* │ as the skill converges. Schedule lives in lr-schedule.ts. │
|
||||
* └───────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌─ Run state machine ────────────────────────────────────────────────────┐
|
||||
* │ │
|
||||
* │ start ──► lock_acquire ──► preflight ──► resume_or_init │
|
||||
* │ │ │
|
||||
* │ ▼ │
|
||||
* │ ┌──── epoch_start ◄────┐ │
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ forward_pass │ │
|
||||
* │ │ (rollouts batch) │ │
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ backward_pass │ │
|
||||
* │ │ (reflect ×2 D7) │ │
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ rank_and_clip │ │
|
||||
* │ │ (LR budget) │ │
|
||||
* │ │ │ │ │
|
||||
* │ │ ▼ │ │
|
||||
* │ │ validation_gate ─────┘ │
|
||||
* │ │ (D12 median+ε) │
|
||||
* │ │ │ │
|
||||
* │ │ accept ──┤── reject │
|
||||
* │ │ │ │ │ │
|
||||
* │ │ ▼ │ ▼ │
|
||||
* │ │ commit │ rejected_buffer │
|
||||
* │ │ (D8) │ │
|
||||
* │ │ │ │ │
|
||||
* │ │ └─►◄──┘ │
|
||||
* │ │ │ │
|
||||
* │ │ ▼ │
|
||||
* │ │ epoch_end ──► slow_update (D6) │
|
||||
* │ │ │ │
|
||||
* │ └───────────┘ │
|
||||
* │ │ all epochs done │
|
||||
* │ ▼ │
|
||||
* │ final_test ──► run_end │
|
||||
* └────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌─ Validation gate decision tree (D12) ──────────────────────────────────┐
|
||||
* │ │
|
||||
* │ candidate edits applied │
|
||||
* │ │ │
|
||||
* │ ▼ │
|
||||
* │ for each sel-task in parallel (cap=4 per D4): │
|
||||
* │ median(score_run_1, score_run_2, score_run_3) ← VALIDATION_RUNS=3 │
|
||||
* │ │ │
|
||||
* │ ▼ │
|
||||
* │ mean(per_task_medians) = sel_score │
|
||||
* │ │ │
|
||||
* │ ▼ │
|
||||
* │ sel_score > best_score + 0.05 ? ← VALIDATION_EPSILON │
|
||||
* │ │ yes │ no │
|
||||
* │ ▼ ▼ │
|
||||
* │ ACCEPT REJECT → rejected-buffer │
|
||||
* │ commit via D8 │
|
||||
* └────────────────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import { BudgetTracker } from '../budget/budget-tracker.ts';
|
||||
import { withBudgetTracker } from '../ai/gateway.ts';
|
||||
import { errorFor } from '../errors.ts';
|
||||
import { applyEditBatch, getWorkingTreeStatusForFile } from './apply-edits.ts';
|
||||
import { logEvent, sha8 } from './audit.ts';
|
||||
import { loadBenchmark, splitBench, parseSplit } from './benchmark.ts';
|
||||
import { getBundledSkillContext, shouldMutateSkillFile } from './bundled-skill-gate.ts';
|
||||
import { loadCheckpoint, saveCheckpoint, deleteCheckpoint, type RunCheckpoint } from './checkpoint.ts';
|
||||
import { withSkilloptLock } from './lock.ts';
|
||||
import { resolveLrSchedule } from './lr-schedule.ts';
|
||||
import { preflight, formatPreflightReport } from './preflight.ts';
|
||||
import { isRejected, loadRejectedBuffer, makeRejectedEntry, saveRejectedBuffer } from './rejected-buffer.ts';
|
||||
import { runReflect } from './reflect.ts';
|
||||
import { acceptCandidate, bestPath, revertAllPending, skillPath } from './version-store.ts';
|
||||
import { runValidationGate } from './validate-gate.ts';
|
||||
import type { SkillOptOpts, EditOp, RunReceipt } from './types.ts';
|
||||
|
||||
export interface RunSkillOptResult {
|
||||
outcome: 'accepted' | 'no_improvement' | 'aborted' | 'errored';
|
||||
receipt: RunReceipt;
|
||||
/** Final SKILL.md text (committed or proposed). */
|
||||
finalText: string;
|
||||
/** True when SKILL.md was actually rewritten; false for --no-mutate / bundled-skill paths. */
|
||||
mutatedSkillFile: boolean;
|
||||
/** When mutate was skipped, path where the proposed.md was written. */
|
||||
proposedPath?: string;
|
||||
}
|
||||
|
||||
export async function runSkillOpt(opts: SkillOptOpts): Promise<RunSkillOptResult> {
|
||||
const { engine, skillName, skillsDir } = opts;
|
||||
|
||||
// ── Pre-flight gates (fail-loud BEFORE any LLM spend) ───────────────────
|
||||
const skillFile = skillPath(skillsDir, skillName);
|
||||
if (!fs.existsSync(skillFile)) {
|
||||
throw errorFor({
|
||||
class: 'NoSkill',
|
||||
code: 'no_skill_md',
|
||||
message: `Cannot find SKILL.md for '${skillName}' at ${skillFile}.`,
|
||||
hint: `Create the skill first via 'gbrain skillify scaffold ${skillName}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Working-tree gate.
|
||||
if (!opts.force) {
|
||||
const status = getWorkingTreeStatusForFile(skillFile);
|
||||
if (status === 'dirty') {
|
||||
throw errorFor({
|
||||
class: 'DirtyTree',
|
||||
code: 'dirty_tree',
|
||||
message: `${skillFile} has uncommitted changes.`,
|
||||
hint: `Commit or stash changes before running skillopt, or pass --force to override.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bundled-skill gate (D16).
|
||||
const bundledCtx = getBundledSkillContext(skillsDir, skillName);
|
||||
const mutateDecision = shouldMutateSkillFile(bundledCtx, {
|
||||
noMutate: opts.noMutate,
|
||||
allowMutateBundled: opts.allowMutateBundled,
|
||||
});
|
||||
|
||||
// Load + validate benchmark (D17 floor enforcement, D15 sentinel check).
|
||||
const bench = loadBenchmark(opts.benchmarkPath, { bootstrapReviewed: opts.bootstrapReviewed });
|
||||
const split = splitBench(bench, opts.split);
|
||||
|
||||
// ── Cost preflight (D3) ─────────────────────────────────────────────────
|
||||
const preflightResult = preflight({
|
||||
epochs: opts.epochs,
|
||||
batchSize: opts.batchSize,
|
||||
trainSize: split.train.length,
|
||||
selSize: split.sel.length,
|
||||
testSize: split.test.length,
|
||||
optimizerModel: opts.optimizerModel,
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
maxCostUsd: opts.maxCostUsd,
|
||||
interactive: process.stderr.isTTY === true,
|
||||
});
|
||||
if (opts.json !== true) {
|
||||
process.stderr.write(formatPreflightReport(preflightResult.estimate, {
|
||||
epochs: opts.epochs, batchSize: opts.batchSize,
|
||||
trainSize: split.train.length, selSize: split.sel.length, testSize: split.test.length,
|
||||
optimizerModel: opts.optimizerModel, targetModel: opts.targetModel, judgeModel: opts.judgeModel,
|
||||
maxCostUsd: opts.maxCostUsd,
|
||||
}) + '\n');
|
||||
}
|
||||
if (!preflightResult.proceed) {
|
||||
throw errorFor({
|
||||
class: 'CostCapExceeded',
|
||||
code: 'cost_cap_exceeded',
|
||||
message: preflightResult.abort_reason ?? 'preflight refused to proceed',
|
||||
hint: `Raise --max-cost-usd or reduce knobs.`,
|
||||
});
|
||||
}
|
||||
|
||||
// --dry-run short-circuits BEFORE the lock + LLM calls.
|
||||
if (opts.dryRun) {
|
||||
const receipt: RunReceipt = {
|
||||
run_id: opts.resumeRunId ?? randomUUID(),
|
||||
skill: skillName,
|
||||
skill_sha8: sha8(fs.readFileSync(skillFile, 'utf8')),
|
||||
benchmark_sha8: bench.benchmark_sha8,
|
||||
optimizer_model: opts.optimizerModel,
|
||||
target_model: opts.targetModel,
|
||||
judge_model: opts.judgeModel,
|
||||
epochs: opts.epochs,
|
||||
batch_size: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lr_schedule: opts.lrSchedule,
|
||||
max_cost_usd: opts.maxCostUsd,
|
||||
started_at: new Date().toISOString(),
|
||||
outcome: 'aborted',
|
||||
};
|
||||
return { outcome: 'aborted', receipt, finalText: fs.readFileSync(skillFile, 'utf8'), mutatedSkillFile: false };
|
||||
}
|
||||
|
||||
// ── Acquire per-skill lock (D14) ────────────────────────────────────────
|
||||
return await withSkilloptLock(engine, skillName, async () => {
|
||||
return runOptimizationLoop(opts, bench, split, bundledCtx, mutateDecision);
|
||||
});
|
||||
}
|
||||
|
||||
async function runOptimizationLoop(
|
||||
opts: SkillOptOpts,
|
||||
bench: ReturnType<typeof loadBenchmark>,
|
||||
split: ReturnType<typeof splitBench>,
|
||||
bundledCtx: ReturnType<typeof getBundledSkillContext>,
|
||||
mutateDecision: ReturnType<typeof shouldMutateSkillFile>,
|
||||
): Promise<RunSkillOptResult> {
|
||||
const { skillName, skillsDir } = opts;
|
||||
const skillFile = skillPath(skillsDir, skillName);
|
||||
|
||||
// Crash-recovery sweep (D8): revert any pending rows from a prior crashed run.
|
||||
revertAllPending(skillsDir, skillName);
|
||||
|
||||
// Load baseline skill text.
|
||||
const baselineText = fs.readFileSync(skillFile, 'utf8');
|
||||
const baselineSha8 = sha8(baselineText);
|
||||
|
||||
// Resume or init checkpoint.
|
||||
const runId = opts.resumeRunId ?? randomUUID();
|
||||
let checkpoint = opts.resumeRunId ? loadCheckpoint(skillsDir, skillName, opts.resumeRunId) : null;
|
||||
if (!checkpoint) {
|
||||
checkpoint = {
|
||||
schema: 1,
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
skill_sha8: baselineSha8,
|
||||
benchmark_sha8: bench.benchmark_sha8,
|
||||
optimizer_model: opts.optimizerModel,
|
||||
target_model: opts.targetModel,
|
||||
judge_model: opts.judgeModel,
|
||||
epochs: opts.epochs,
|
||||
batch_size: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lr_schedule: opts.lrSchedule,
|
||||
best_sel_score: 0,
|
||||
best_skill_text: baselineText,
|
||||
last_completed_epoch: 0,
|
||||
last_completed_step: 0,
|
||||
cumulative_cost_usd: 0,
|
||||
started_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
};
|
||||
saveCheckpoint(skillsDir, skillName, checkpoint);
|
||||
}
|
||||
|
||||
// Initial audit row.
|
||||
logEvent({
|
||||
kind: 'run_start',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
skill_sha8: baselineSha8,
|
||||
benchmark_sha8: bench.benchmark_sha8,
|
||||
target_model: opts.targetModel,
|
||||
optimizer_model: opts.optimizerModel,
|
||||
judge_model: opts.judgeModel,
|
||||
epochs: opts.epochs,
|
||||
batch_size: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lr_schedule: opts.lrSchedule,
|
||||
max_cost_usd: opts.maxCostUsd,
|
||||
} as never);
|
||||
|
||||
// Budget tracker for the whole run. BudgetExhausted propagates as
|
||||
// MUST_ABORT through every gateway.chat call.
|
||||
const tracker = new BudgetTracker({
|
||||
maxCostUsd: opts.maxCostUsd,
|
||||
label: `skillopt:${skillName}`,
|
||||
});
|
||||
|
||||
const scheduleFn = resolveLrSchedule(opts.lrSchedule);
|
||||
const totalSteps = opts.epochs * Math.max(1, Math.floor(split.train.length / opts.batchSize));
|
||||
|
||||
// Run the loop inside withBudgetTracker so every nested gateway call composes.
|
||||
let outcome: 'accepted' | 'no_improvement' | 'aborted' | 'errored' = 'no_improvement';
|
||||
let finalText = checkpoint.best_skill_text;
|
||||
let totalStepsRun = 0;
|
||||
|
||||
try {
|
||||
await withBudgetTracker(tracker, async () => {
|
||||
// Baseline eval: score the baseline skill on D_sel to set best_sel_score.
|
||||
// We use the FULL validation gate with median-of-3 for a stable baseline.
|
||||
const baselineGate = await runValidationGate({
|
||||
engine: opts.engine,
|
||||
candidateSkillText: baselineText,
|
||||
selSet: split.sel,
|
||||
bestScore: -1, // any score > -1 + 0.05 accepts; we just want the score.
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
});
|
||||
if (checkpoint!.best_sel_score === 0) {
|
||||
// Fresh run; set baseline.
|
||||
checkpoint!.best_sel_score = baselineGate.selScore;
|
||||
checkpoint!.best_skill_text = baselineText;
|
||||
saveCheckpoint(skillsDir, skillName, checkpoint!);
|
||||
}
|
||||
const baselineSelScore = baselineGate.selScore;
|
||||
|
||||
// Epoch loop.
|
||||
for (let epoch = checkpoint!.last_completed_epoch + 1; epoch <= opts.epochs; epoch++) {
|
||||
const stepsPerEpoch = Math.max(1, Math.floor(split.train.length / opts.batchSize));
|
||||
const startStep = epoch === checkpoint!.last_completed_epoch + 1 ? checkpoint!.last_completed_step + 1 : 1;
|
||||
const epochStartBest = checkpoint!.best_sel_score;
|
||||
|
||||
for (let step = startStep; step <= stepsPerEpoch; step++) {
|
||||
totalStepsRun += 1;
|
||||
const globalStep = (epoch - 1) * stepsPerEpoch + step;
|
||||
const lrBudget = scheduleFn(opts.lr, globalStep, totalSteps);
|
||||
|
||||
// Sample a batch from D_train (round-robin to keep deterministic).
|
||||
const batchStart = ((step - 1) * opts.batchSize) % split.train.length;
|
||||
const batch = split.train.slice(batchStart, batchStart + opts.batchSize);
|
||||
|
||||
// FORWARD PASS: run rollouts on each batch task using current best.
|
||||
// runsPerTask=1 — for the train batch we only need a rough partition
|
||||
// into successes/failures, not the median-of-3 noise rejection the
|
||||
// sel-side gate uses. ScoredRollouts come back via GateResult.
|
||||
const forwardGate = await runValidationGate({
|
||||
engine: opts.engine,
|
||||
candidateSkillText: checkpoint!.best_skill_text,
|
||||
selSet: batch,
|
||||
bestScore: -1,
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
runsPerTask: 1,
|
||||
});
|
||||
// Partition into successes vs failures (>= 0.5 threshold). Reflect
|
||||
// gets the actual scored trajectories so failure-mode + success-mode
|
||||
// analysis can ground in real agent behavior (D7).
|
||||
const successes = forwardGate.scoredRollouts.filter((r) => r.score >= 0.5);
|
||||
const failures = forwardGate.scoredRollouts.filter((r) => r.score < 0.5);
|
||||
|
||||
// BACKWARD PASS: D7 two reflect calls (failures + successes).
|
||||
const rejected = loadRejectedBuffer(skillsDir, skillName);
|
||||
const reflectResult = await runReflect({
|
||||
skillBodyText: checkpoint!.best_skill_text,
|
||||
successes,
|
||||
failures,
|
||||
rejected,
|
||||
optimizerModel: opts.optimizerModel,
|
||||
abortSignal: undefined,
|
||||
});
|
||||
|
||||
// Merge + rank + LR-clip.
|
||||
const allEdits: EditOp[] = [...reflectResult.failureEdits, ...reflectResult.successEdits];
|
||||
// Drop edits already in rejected buffer.
|
||||
const fresh = allEdits.filter((e) => !isRejected(rejected, checkpoint!.best_skill_text, [e]));
|
||||
// Apply under LR budget.
|
||||
const applied = applyEditBatch(checkpoint!.best_skill_text, fresh, lrBudget);
|
||||
|
||||
if (applied.results.every((r) => r.outcome === 'rejected')) {
|
||||
// Nothing applied; record rejected entries + skip gate.
|
||||
const newRejections = fresh.map((e) =>
|
||||
makeRejectedEntry(checkpoint!.best_skill_text, [e], 'apply_failed'),
|
||||
);
|
||||
saveRejectedBuffer(skillsDir, skillName, newRejections);
|
||||
logEvent({
|
||||
kind: 'step',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
epoch,
|
||||
step,
|
||||
sel_score_median: checkpoint!.best_sel_score,
|
||||
sel_score_runs: [],
|
||||
accepted: false,
|
||||
edits_attempted: fresh.length,
|
||||
edits_applied: 0,
|
||||
delta: 0,
|
||||
reason: 'no_edits_applied',
|
||||
cumulative_cost_usd: tracker.snapshot().cumulativeCostUsd,
|
||||
} as never);
|
||||
continue;
|
||||
}
|
||||
|
||||
// VALIDATION GATE (D12 median-of-3 + epsilon=0.05, D4 parallel).
|
||||
const gate = await runValidationGate({
|
||||
engine: opts.engine,
|
||||
candidateSkillText: applied.newText,
|
||||
selSet: split.sel,
|
||||
bestScore: checkpoint!.best_sel_score,
|
||||
targetModel: opts.targetModel,
|
||||
judgeModel: opts.judgeModel,
|
||||
});
|
||||
|
||||
if (gate.accepted) {
|
||||
const delta = gate.selScore - checkpoint!.best_sel_score;
|
||||
// ACCEPT: D8 commit via version-store.
|
||||
if (mutateDecision.mutate) {
|
||||
acceptCandidate({
|
||||
skillsDir,
|
||||
skillName,
|
||||
runId,
|
||||
epoch,
|
||||
step,
|
||||
edits: fresh,
|
||||
candidateText: applied.newText,
|
||||
selScore: gate.selScore,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
checkpoint!.best_sel_score = gate.selScore;
|
||||
checkpoint!.best_skill_text = applied.newText;
|
||||
checkpoint!.last_completed_epoch = epoch;
|
||||
checkpoint!.last_completed_step = step;
|
||||
checkpoint!.cumulative_cost_usd = tracker.snapshot().cumulativeCostUsd;
|
||||
saveCheckpoint(skillsDir, skillName, checkpoint!);
|
||||
|
||||
logEvent({
|
||||
kind: 'step',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
epoch,
|
||||
step,
|
||||
sel_score_median: gate.selScore,
|
||||
sel_score_runs: gate.perTaskMedians.map((t) => t.median),
|
||||
accepted: true,
|
||||
edits_attempted: fresh.length,
|
||||
edits_applied: applied.results.filter((r) => r.outcome === 'applied').length,
|
||||
delta,
|
||||
cumulative_cost_usd: tracker.snapshot().cumulativeCostUsd,
|
||||
} as never);
|
||||
outcome = 'accepted';
|
||||
finalText = applied.newText;
|
||||
} else {
|
||||
// REJECT: push to rejected-buffer.
|
||||
const newRejections = fresh.map((e) =>
|
||||
makeRejectedEntry(checkpoint!.best_skill_text, [e], `validation_gate_${gate.reason ?? 'rejected'}`),
|
||||
);
|
||||
saveRejectedBuffer(skillsDir, skillName, newRejections);
|
||||
logEvent({
|
||||
kind: 'step',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
epoch,
|
||||
step,
|
||||
sel_score_median: gate.selScore,
|
||||
sel_score_runs: gate.perTaskMedians.map((t) => t.median),
|
||||
accepted: false,
|
||||
edits_attempted: fresh.length,
|
||||
edits_applied: applied.results.filter((r) => r.outcome === 'applied').length,
|
||||
delta: gate.selScore - checkpoint!.best_sel_score,
|
||||
reason: gate.reason ?? 'rejected',
|
||||
cumulative_cost_usd: tracker.snapshot().cumulativeCostUsd,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
// D6 SLOW UPDATE: if no improvement this epoch, propose one meta-edit.
|
||||
if (checkpoint!.best_sel_score === epochStartBest) {
|
||||
logEvent({
|
||||
kind: 'slow_update',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
epoch,
|
||||
meta_edit_proposed: false, // Simplified for v1; full meta-update is TODO.
|
||||
meta_edit_accepted: false,
|
||||
} as never);
|
||||
}
|
||||
|
||||
checkpoint!.last_completed_epoch = epoch;
|
||||
checkpoint!.last_completed_step = 0;
|
||||
saveCheckpoint(skillsDir, skillName, checkpoint!);
|
||||
}
|
||||
|
||||
// FINAL TEST: score the best skill on D_test.
|
||||
// For v1, we don't fire the test eval — that's a follow-up.
|
||||
// The final receipt records the baseline + best sel scores.
|
||||
void baselineSelScore;
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('BudgetExhausted') || msg.includes('budget_exhausted')) {
|
||||
outcome = 'aborted';
|
||||
logEvent({
|
||||
kind: 'abort',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
reason: 'budget_exhausted',
|
||||
detail: msg,
|
||||
} as never);
|
||||
} else {
|
||||
outcome = 'errored';
|
||||
logEvent({
|
||||
kind: 'abort',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
reason: 'sigint',
|
||||
detail: msg,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
// If --no-mutate or bundled+!allowMutateBundled: write proposed.md instead.
|
||||
let mutatedSkillFile = false;
|
||||
let proposedPath: string | undefined;
|
||||
// Widen back to the full union — TS narrowed `outcome` inside the try/catch
|
||||
// to the catch's assignment values only (it can't prove the async callback ran).
|
||||
const finalOutcome = outcome as 'accepted' | 'no_improvement' | 'aborted' | 'errored';
|
||||
if (!mutateDecision.mutate && finalOutcome === 'accepted') {
|
||||
proposedPath = bestPath(skillsDir, skillName); // best.md doubles as proposed.md
|
||||
// Note: acceptCandidate is gated by mutateDecision.mutate above, so best.md
|
||||
// isn't written in --no-mutate mode. Write it explicitly here.
|
||||
// (Simplified for v1 — a follow-up routes the proposed-only path cleanly.)
|
||||
} else if (mutateDecision.mutate) {
|
||||
mutatedSkillFile = finalOutcome === 'accepted';
|
||||
}
|
||||
|
||||
// Final receipt.
|
||||
const receipt: RunReceipt = {
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
skill_sha8: baselineSha8,
|
||||
benchmark_sha8: bench.benchmark_sha8,
|
||||
optimizer_model: opts.optimizerModel,
|
||||
target_model: opts.targetModel,
|
||||
judge_model: opts.judgeModel,
|
||||
epochs: opts.epochs,
|
||||
batch_size: opts.batchSize,
|
||||
lr: opts.lr,
|
||||
lr_schedule: opts.lrSchedule,
|
||||
max_cost_usd: opts.maxCostUsd,
|
||||
started_at: checkpoint.started_at,
|
||||
ended_at: new Date().toISOString(),
|
||||
outcome,
|
||||
baseline_sel_score: 0,
|
||||
best_sel_score: checkpoint.best_sel_score,
|
||||
final_cost_usd: tracker.snapshot().cumulativeCostUsd,
|
||||
total_steps: totalStepsRun,
|
||||
epochs_completed: checkpoint.last_completed_epoch,
|
||||
};
|
||||
|
||||
logEvent({
|
||||
kind: 'run_end',
|
||||
run_id: runId,
|
||||
skill: skillName,
|
||||
outcome,
|
||||
epochs_completed: checkpoint.last_completed_epoch,
|
||||
total_steps: totalStepsRun,
|
||||
best_sel_score: checkpoint.best_sel_score,
|
||||
final_cost_usd: tracker.snapshot().cumulativeCostUsd,
|
||||
} as never);
|
||||
|
||||
// Clean checkpoint on success (resume not needed).
|
||||
if (finalOutcome === 'accepted' || finalOutcome === 'no_improvement') {
|
||||
deleteCheckpoint(skillsDir, skillName, runId);
|
||||
}
|
||||
|
||||
return {
|
||||
outcome,
|
||||
receipt,
|
||||
finalText,
|
||||
mutatedSkillFile,
|
||||
...(proposedPath ? { proposedPath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export parseSplit so the CLI can validate flags without importing
|
||||
// benchmark.ts directly.
|
||||
export { parseSplit };
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* SkillOpt cost preflight (D3).
|
||||
*
|
||||
* Estimates the total USD cost of a run BEFORE any LLM call fires.
|
||||
* Refuses to start when estimate > --max-cost-usd. In TTY, prompts the
|
||||
* user with a 10-second Ctrl-C grace window (mirrors the progressive-batch
|
||||
* cost-prompt UX).
|
||||
*
|
||||
* Cost model (rough but consistent):
|
||||
*
|
||||
* Per step:
|
||||
* - batch_size rollouts × target-model price
|
||||
* - 2 reflect calls (D7) × optimizer-model price
|
||||
* - sel_size sel-tasks × VALIDATION_RUNS_PER_TASK × target-model price
|
||||
* - sel_size sel-tasks × VALIDATION_RUNS_PER_TASK × judge-model price
|
||||
*
|
||||
* Total:
|
||||
* - 1× baseline eval on D_sel
|
||||
* - epochs × steps_per_epoch × per-step cost
|
||||
* - epochs × 1 slow-update reflect call (if no improvement that epoch)
|
||||
* - 1× final test eval on D_test
|
||||
*
|
||||
* Prices come from existing per-model pricing tables (Anthropic +
|
||||
* embedding-pricing). For unknown providers we fail-loud — same posture
|
||||
* as BudgetTracker's TX2 contract.
|
||||
*/
|
||||
|
||||
import { ANTHROPIC_PRICING } from '../anthropic-pricing.ts';
|
||||
import { VALIDATION_RUNS_PER_TASK } from './types.ts';
|
||||
|
||||
/** Conservative per-rollout token estimates (input + output). */
|
||||
const ROLLOUT_INPUT_TOKENS = 3000; // skill + task prompt + tool defs
|
||||
const ROLLOUT_OUTPUT_TOKENS = 800;
|
||||
const REFLECT_INPUT_TOKENS = 8000; // skill + trajectories + rejected buffer
|
||||
const REFLECT_OUTPUT_TOKENS = 1500;
|
||||
const JUDGE_INPUT_TOKENS = 2000; // rubric + agent output
|
||||
const JUDGE_OUTPUT_TOKENS = 200;
|
||||
|
||||
export interface PreflightOpts {
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
trainSize: number;
|
||||
selSize: number;
|
||||
testSize: number;
|
||||
optimizerModel: string;
|
||||
targetModel: string;
|
||||
judgeModel: string;
|
||||
maxCostUsd: number;
|
||||
/** When true, print the prompt to stderr + use Ctrl-C grace. Default false (non-TTY). */
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface PreflightEstimate {
|
||||
steps_per_epoch: number;
|
||||
total_steps: number;
|
||||
rollout_calls: number;
|
||||
reflect_calls: number;
|
||||
judge_calls: number;
|
||||
est_input_tokens: number;
|
||||
est_output_tokens: number;
|
||||
est_cost_usd: number;
|
||||
/** Per-model breakdown for the audit. */
|
||||
per_model_cost_usd: Record<string, number>;
|
||||
/** True when est_cost_usd > maxCostUsd (caller should refuse or prompt). */
|
||||
exceeds_cap: boolean;
|
||||
}
|
||||
|
||||
export interface PreflightResult {
|
||||
estimate: PreflightEstimate;
|
||||
/** When false, caller should abort. When true, run may proceed. */
|
||||
proceed: boolean;
|
||||
/** Reason for abort, if proceed=false. */
|
||||
abort_reason?: string;
|
||||
}
|
||||
|
||||
export function estimateCost(opts: PreflightOpts): PreflightEstimate {
|
||||
const stepsPerEpoch = Math.max(1, Math.floor(opts.trainSize / opts.batchSize));
|
||||
const totalSteps = opts.epochs * stepsPerEpoch;
|
||||
|
||||
// Per-step counts.
|
||||
const rolloutsPerStep = opts.batchSize;
|
||||
const reflectsPerStep = 2; // D7: two reflect calls
|
||||
const sel_runs_per_step = opts.selSize * VALIDATION_RUNS_PER_TASK;
|
||||
|
||||
// Cumulative counts across the whole run.
|
||||
const rollout_calls = totalSteps * rolloutsPerStep
|
||||
+ opts.selSize * VALIDATION_RUNS_PER_TASK // baseline sel eval
|
||||
+ opts.selSize * VALIDATION_RUNS_PER_TASK * totalSteps // per-step sel validation
|
||||
+ opts.testSize; // final test eval
|
||||
const reflect_calls = totalSteps * reflectsPerStep
|
||||
+ opts.epochs; // slow-update meta calls
|
||||
const judge_calls = opts.selSize // baseline (1 per task; median-of-3 is in the rollout count already? — no, judge runs per rollout)
|
||||
+ opts.selSize * VALIDATION_RUNS_PER_TASK * totalSteps // per-step validation
|
||||
+ opts.testSize; // final test judges
|
||||
|
||||
// Cost per call type.
|
||||
const targetPrice = lookupPrice(opts.targetModel);
|
||||
const optimizerPrice = lookupPrice(opts.optimizerModel);
|
||||
const judgePrice = lookupPrice(opts.judgeModel);
|
||||
|
||||
const rolloutCost = rollout_calls * (
|
||||
(ROLLOUT_INPUT_TOKENS * targetPrice.input) / 1_000_000
|
||||
+ (ROLLOUT_OUTPUT_TOKENS * targetPrice.output) / 1_000_000
|
||||
);
|
||||
const reflectCost = reflect_calls * (
|
||||
(REFLECT_INPUT_TOKENS * optimizerPrice.input) / 1_000_000
|
||||
+ (REFLECT_OUTPUT_TOKENS * optimizerPrice.output) / 1_000_000
|
||||
);
|
||||
const judgeCost = judge_calls * (
|
||||
(JUDGE_INPUT_TOKENS * judgePrice.input) / 1_000_000
|
||||
+ (JUDGE_OUTPUT_TOKENS * judgePrice.output) / 1_000_000
|
||||
);
|
||||
|
||||
// D11 prompt caching gives ~50% discount on stable layers. Apply
|
||||
// conservatively (assume 50% of optimizer + judge tokens are cached).
|
||||
const cachedReflectCost = reflectCost * 0.6;
|
||||
const cachedJudgeCost = judgeCost * 0.6;
|
||||
|
||||
const total = rolloutCost + cachedReflectCost + cachedJudgeCost;
|
||||
void sel_runs_per_step;
|
||||
|
||||
return {
|
||||
steps_per_epoch: stepsPerEpoch,
|
||||
total_steps: totalSteps,
|
||||
rollout_calls,
|
||||
reflect_calls,
|
||||
judge_calls,
|
||||
est_input_tokens: rollout_calls * ROLLOUT_INPUT_TOKENS + reflect_calls * REFLECT_INPUT_TOKENS + judge_calls * JUDGE_INPUT_TOKENS,
|
||||
est_output_tokens: rollout_calls * ROLLOUT_OUTPUT_TOKENS + reflect_calls * REFLECT_OUTPUT_TOKENS + judge_calls * JUDGE_OUTPUT_TOKENS,
|
||||
est_cost_usd: total,
|
||||
per_model_cost_usd: {
|
||||
[opts.targetModel]: rolloutCost,
|
||||
[opts.optimizerModel]: cachedReflectCost,
|
||||
[opts.judgeModel]: cachedJudgeCost,
|
||||
},
|
||||
exceeds_cap: total > opts.maxCostUsd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a human-readable preflight summary to stderr. Caller may follow
|
||||
* with a Ctrl-C grace prompt in TTY mode (see runPreflightPrompt).
|
||||
*/
|
||||
export function formatPreflightReport(est: PreflightEstimate, opts: PreflightOpts): string {
|
||||
return [
|
||||
`[skillopt] Cost estimate for ${opts.epochs} epochs × ${est.steps_per_epoch} steps × ${opts.batchSize} rollouts:`,
|
||||
` Rollouts: ${est.rollout_calls.toLocaleString()} calls`,
|
||||
` Reflects: ${est.reflect_calls.toLocaleString()} calls`,
|
||||
` Judges: ${est.judge_calls.toLocaleString()} calls`,
|
||||
` Tokens: ~${(est.est_input_tokens / 1000).toFixed(0)}K in / ~${(est.est_output_tokens / 1000).toFixed(0)}K out`,
|
||||
` Est. cost: $${est.est_cost_usd.toFixed(2)} (cap: $${opts.maxCostUsd.toFixed(2)})`,
|
||||
est.exceeds_cap ? ` WARNING: estimate exceeds --max-cost-usd cap.` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision wrapper. Returns {proceed: false, abort_reason} when the
|
||||
* estimate over-shoots the cap (caller exits 2). Returns {proceed: true}
|
||||
* otherwise. Interactive=true callers should print formatPreflightReport
|
||||
* + a Ctrl-C grace window separately.
|
||||
*/
|
||||
export function preflight(opts: PreflightOpts): PreflightResult {
|
||||
const estimate = estimateCost(opts);
|
||||
if (estimate.exceeds_cap) {
|
||||
return {
|
||||
estimate,
|
||||
proceed: false,
|
||||
abort_reason: `estimated cost $${estimate.est_cost_usd.toFixed(2)} exceeds --max-cost-usd $${opts.maxCostUsd.toFixed(2)}. Raise the cap with --max-cost-usd ${Math.ceil(estimate.est_cost_usd)} or reduce --epochs/--batch-size.`,
|
||||
};
|
||||
}
|
||||
return { estimate, proceed: true };
|
||||
}
|
||||
|
||||
function lookupPrice(model: string): { input: number; output: number } {
|
||||
// Anthropic models — strip provider prefix.
|
||||
const bare = model.startsWith('anthropic:') ? model.slice('anthropic:'.length) : model;
|
||||
const anth = (ANTHROPIC_PRICING as Record<string, { input: number; output: number }>)[bare];
|
||||
if (anth) return anth;
|
||||
// Conservative fallback: assume Sonnet-tier pricing for unknown providers.
|
||||
// Don't throw — preflight is for warning, not gating. The actual budget
|
||||
// tracker (BudgetTracker TX2) will fail-loud at run time if pricing is
|
||||
// truly unknown.
|
||||
return { input: 3.0, output: 15.0 };
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* SkillOpt reflect: ask the optimizer model to propose edits to SKILL.md
|
||||
* based on a batch of scored rollouts.
|
||||
*
|
||||
* D7: TWO reflect calls per step — one for failures, one for successes.
|
||||
* Paper-faithful: each call uses its own rubric prompt so attention isn't
|
||||
* conflated between "what went wrong" and "what went right" analyses.
|
||||
*
|
||||
* D11: optimizer system prompt is cached via cacheSystem=true (stable
|
||||
* across all reflect calls in a run; ~$0.30/run savings).
|
||||
*
|
||||
* The reflect call also receives the rejected-edit buffer as anti-bias
|
||||
* context so the optimizer doesn't re-propose previously-failing edits.
|
||||
*/
|
||||
|
||||
import { chat as gatewayChat } from '../ai/gateway.ts';
|
||||
import type { EditOp, ScoredRollout } from './types.ts';
|
||||
import type { RejectedEntry } from './rejected-buffer.ts';
|
||||
|
||||
const FAILURE_REFLECT_SYSTEM = `You are SkillOpt's optimizer. You analyze AGENT FAILURE TRAJECTORIES and propose specific edits to a SKILL document so the agent does better next time.
|
||||
|
||||
Output ONLY a single JSON object on one or more lines:
|
||||
{"edits": [{"op": "add|replace|delete", ...}, ...]}
|
||||
|
||||
Edit ops:
|
||||
add: {"op": "add", "anchor": "<exact heading text>", "content": "<new markdown>", "reason": "<one sentence>"}
|
||||
replace: {"op": "replace", "target": "<exact text to find>", "replacement": "<new text>", "reason": "<one sentence>"}
|
||||
delete: {"op": "delete", "target": "<exact text to remove>", "reason": "<one sentence>"}
|
||||
|
||||
Rules:
|
||||
- Each edit MUST address a SPECIFIC failure pattern you observed.
|
||||
- anchor / target MUST be uniquely identifiable in the skill body (exact match).
|
||||
- Do NOT propose edits already in the rejected-edit history — those were tried and didn't help.
|
||||
- Be SURGICAL. Small targeted edits outperform large rewrites.
|
||||
- Do NOT modify the YAML frontmatter (triggers, brain_first, etc.) — that's out of scope.
|
||||
- Output at MOST 8 edits. The orchestrator's LR budget will rank-and-clip further.`;
|
||||
|
||||
const SUCCESS_REFLECT_SYSTEM = `You are SkillOpt's optimizer. You analyze AGENT SUCCESS TRAJECTORIES and propose specific edits to a SKILL document so the agent CONSISTENTLY does what worked here.
|
||||
|
||||
Output format and rules are identical to the failure-reflect mode — same {edits: [...]} shape.
|
||||
|
||||
When successes are present, look for: which rules were FOLLOWED to produce success, which rules could be MADE EXPLICIT (not yet stated, but exemplified), which anti-patterns the agent successfully AVOIDED that should be stated.
|
||||
|
||||
Be SURGICAL. Don't restate things that are already in the skill. Don't modify frontmatter.`;
|
||||
|
||||
export interface ReflectOpts {
|
||||
skillBodyText: string;
|
||||
/** Successful rollouts (score >= 0.5). */
|
||||
successes: ScoredRollout[];
|
||||
/** Failed rollouts (score < 0.5). */
|
||||
failures: ScoredRollout[];
|
||||
/** Rejected-edit buffer for anti-bias context. */
|
||||
rejected: readonly RejectedEntry[];
|
||||
optimizerModel: string;
|
||||
/** Test seam — substitute for gateway.chat. */
|
||||
chatFn?: typeof gatewayChat;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ReflectResult {
|
||||
/** Edits proposed from FAILURE analysis. */
|
||||
failureEdits: EditOp[];
|
||||
/** Edits proposed from SUCCESS analysis. */
|
||||
successEdits: EditOp[];
|
||||
/** Token usage across both calls (for cost tracking). */
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_creation_tokens: number;
|
||||
};
|
||||
/** Any per-call errors (for audit). */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* D7: fire two reflect calls (failures + successes). Empty batches skip
|
||||
* their reflect call (no point asking for edits without data).
|
||||
*/
|
||||
export async function runReflect(opts: ReflectOpts): Promise<ReflectResult> {
|
||||
const usage = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 };
|
||||
const errors: string[] = [];
|
||||
|
||||
const failureEdits = opts.failures.length > 0
|
||||
? await callReflect('failure', opts, FAILURE_REFLECT_SYSTEM, opts.failures, usage, errors)
|
||||
: [];
|
||||
const successEdits = opts.successes.length > 0
|
||||
? await callReflect('success', opts, SUCCESS_REFLECT_SYSTEM, opts.successes, usage, errors)
|
||||
: [];
|
||||
|
||||
return { failureEdits, successEdits, usage, errors };
|
||||
}
|
||||
|
||||
async function callReflect(
|
||||
mode: 'failure' | 'success',
|
||||
opts: ReflectOpts,
|
||||
system: string,
|
||||
scoredRollouts: ScoredRollout[],
|
||||
cumUsage: ReflectResult['usage'],
|
||||
errors: string[],
|
||||
): Promise<EditOp[]> {
|
||||
const chat = opts.chatFn ?? gatewayChat;
|
||||
const userMsg = buildReflectUserMessage(opts.skillBodyText, scoredRollouts, opts.rejected);
|
||||
try {
|
||||
const result = await chat({
|
||||
model: opts.optimizerModel,
|
||||
system,
|
||||
messages: [{ role: 'user', content: userMsg }],
|
||||
maxTokens: 2048,
|
||||
cacheSystem: true, // D11
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
cumUsage.input_tokens += result.usage.input_tokens;
|
||||
cumUsage.output_tokens += result.usage.output_tokens;
|
||||
cumUsage.cache_read_tokens += result.usage.cache_read_tokens;
|
||||
cumUsage.cache_creation_tokens += result.usage.cache_creation_tokens;
|
||||
return parseEditsResponse(result.text);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`reflect_${mode}_failed: ${msg}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildReflectUserMessage(
|
||||
skillBody: string,
|
||||
rollouts: ScoredRollout[],
|
||||
rejected: readonly RejectedEntry[],
|
||||
): string {
|
||||
const trajectoryBlocks = rollouts.map((r, i) => {
|
||||
const tcSummary = r.trajectory.tool_calls
|
||||
.map((tc) => ` - ${tc.name}${tc.failed ? ' [FAILED]' : ''}`)
|
||||
.join('\n');
|
||||
return `--- ROLLOUT ${i + 1} (score=${r.score.toFixed(2)}) ---
|
||||
TASK: ${r.trajectory.task}
|
||||
TOOL CALLS:
|
||||
${tcSummary || ' (none)'}
|
||||
OUTPUT:
|
||||
${truncate(r.trajectory.final_text, 2000)}
|
||||
${r.rationale ? `JUDGE RATIONALE: ${r.rationale}` : ''}`;
|
||||
}).join('\n\n');
|
||||
|
||||
const rejectedSummary = rejected.length > 0
|
||||
? `\n\n--- PREVIOUSLY REJECTED EDITS (do not re-propose) ---\n${rejected.slice(0, 20).map((r) => `- ${r.reason}: ${JSON.stringify(r.edits)}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
return `CURRENT SKILL BODY:
|
||||
${truncate(skillBody, 5000)}
|
||||
|
||||
OBSERVED ROLLOUTS:
|
||||
${trajectoryBlocks}${rejectedSummary}
|
||||
|
||||
Propose edits to improve the skill. Output the {edits: [...]} JSON only.`;
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + `\n...(truncated, ${s.length - max} more chars)` : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `{edits: [...]}` from optimizer output. Tolerates ```fenced blocks```,
|
||||
* trailing commas, prose-wrapped JSON. Returns [] when no recoverable edits
|
||||
* are found (caller treats as "this reflect call produced no usable edits"
|
||||
* — same effect as the optimizer returning {edits: []}).
|
||||
*
|
||||
* EXPORTED so reflect.test.ts can pin the parser independently of the chat
|
||||
* transport. Pre-v0.42.0.1 this lived behind a `parseJudgeJson` early-return
|
||||
* guard that always failed (judge-JSON checks for a `score` key, not `edits`),
|
||||
* making every optimizer call silently produce zero edits. The bug survived
|
||||
* v0.42.0.0 because no unit test exercised this parser; the orchestrator's
|
||||
* `successes/failures: []` hardcoding masked it end-to-end too.
|
||||
*/
|
||||
export function parseEditsResponse(raw: string): EditOp[] {
|
||||
return tryExtractEdits(raw);
|
||||
}
|
||||
|
||||
function tryExtractEdits(raw: string): EditOp[] {
|
||||
try {
|
||||
// Strip fences first.
|
||||
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
|
||||
const cleaned = (fenced ? fenced[1]! : raw).trim();
|
||||
// Try direct parse.
|
||||
const direct = JSON.parse(cleaned);
|
||||
if (direct && typeof direct === 'object' && Array.isArray((direct as { edits?: unknown }).edits)) {
|
||||
return validateEdits((direct as { edits: unknown[] }).edits);
|
||||
}
|
||||
} catch { /* try next strategy */ }
|
||||
// Fallback: extract first {...} substring.
|
||||
const match = raw.match(/\{[\s\S]*\}/);
|
||||
if (!match) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { edits?: unknown }).edits)) {
|
||||
return validateEdits((parsed as { edits: unknown[] }).edits);
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateEdits(raw: unknown[]): EditOp[] {
|
||||
const out: EditOp[] = [];
|
||||
for (const r of raw) {
|
||||
if (!r || typeof r !== 'object') continue;
|
||||
const o = r as Record<string, unknown>;
|
||||
if (o.op === 'add' && typeof o.anchor === 'string' && typeof o.content === 'string') {
|
||||
out.push({ op: 'add', anchor: o.anchor, content: o.content, reason: typeof o.reason === 'string' ? o.reason : undefined });
|
||||
} else if (o.op === 'replace' && typeof o.target === 'string' && typeof o.replacement === 'string') {
|
||||
out.push({ op: 'replace', target: o.target, replacement: o.replacement, reason: typeof o.reason === 'string' ? o.reason : undefined });
|
||||
} else if (o.op === 'delete' && typeof o.target === 'string') {
|
||||
out.push({ op: 'delete', target: o.target, reason: typeof o.reason === 'string' ? o.reason : undefined });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* SkillOpt rejected-edit buffer.
|
||||
*
|
||||
* Persists rejected edits across runs so the optimizer doesn't propose
|
||||
* the same losing edit twice. Bounded LRU (cap=100) prevents unbounded
|
||||
* growth on long-lived skills.
|
||||
*
|
||||
* Key: SHA-256 (8 hex) of canonical-JSON({skill_text_at_rejection, edits}).
|
||||
* The skill_text part makes the key STATE-AWARE: an edit rejected against
|
||||
* version A of the skill is allowed to be re-proposed against version B
|
||||
* (the optimizer might find it works differently in the new state).
|
||||
*
|
||||
* File format: JSON object `{schema: 1, entries: [...]}` at
|
||||
* `skills/<name>/skillopt/rejected.json`.
|
||||
*
|
||||
* Atomic writes via .tmp + rename (mirrors gbrain's atomic-write convention).
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { atomicWrite } from './apply-edits.ts';
|
||||
import type { EditOp } from './types.ts';
|
||||
|
||||
/** Bounded LRU cap. Older entries garbage-collect when this many accumulate. */
|
||||
export const REJECTED_BUFFER_CAP = 100;
|
||||
|
||||
export interface RejectedEntry {
|
||||
key: string;
|
||||
/** SHA-256-prefix-8 of the skill text the edits were proposed against. */
|
||||
skill_sha8: string;
|
||||
edits: EditOp[];
|
||||
reason: string;
|
||||
/** ISO-8601 timestamp; used for LRU ordering. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface RejectedFile {
|
||||
schema: 1;
|
||||
entries: RejectedEntry[];
|
||||
}
|
||||
|
||||
/** Compute the file path for a skill's rejected buffer. */
|
||||
export function rejectedFilePath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skillsDir, skillName, 'skillopt', 'rejected.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the dedup key for (skill_text, edits). Two identical edit
|
||||
* proposals against the SAME skill text produce the SAME key.
|
||||
*/
|
||||
export function rejectedKey(skillText: string, edits: EditOp[]): string {
|
||||
const canonical = JSON.stringify({
|
||||
skill_sha8: sha8(skillText),
|
||||
edits: edits.map(canonicalEdit),
|
||||
});
|
||||
return sha8(canonical);
|
||||
}
|
||||
|
||||
function canonicalEdit(e: EditOp): unknown {
|
||||
// Stable property ordering so semantically-identical edits hash identically.
|
||||
switch (e.op) {
|
||||
case 'add': return { op: 'add', anchor: e.anchor, content: e.content };
|
||||
case 'replace': return { op: 'replace', target: e.target, replacement: e.replacement };
|
||||
case 'delete': return { op: 'delete', target: e.target };
|
||||
}
|
||||
}
|
||||
|
||||
function sha8(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the rejected buffer. Returns empty array when file is missing
|
||||
* (fresh skill) or corrupt (log + start fresh — same posture as
|
||||
* import-checkpoint.ts).
|
||||
*/
|
||||
export function loadRejectedBuffer(skillsDir: string, skillName: string): RejectedEntry[] {
|
||||
const p = rejectedFilePath(skillsDir, skillName);
|
||||
if (!fs.existsSync(p)) return [];
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return [];
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schema !== 1 || !Array.isArray(obj.entries)) return [];
|
||||
// Type-narrow without full validation — entries from our own writes
|
||||
// are trusted; corrupted entries simply replay as rejection attempts
|
||||
// when their key fails to match.
|
||||
return obj.entries as RejectedEntry[];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt] rejected.json unreadable for ${skillName} (${msg}); starting fresh\n`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append rejected entries to the buffer, bounded by LRU cap. Atomic write.
|
||||
*
|
||||
* Dedup: if an entry with the same key already exists, it's promoted to
|
||||
* the head (LRU touch) rather than duplicated.
|
||||
*/
|
||||
export function saveRejectedBuffer(
|
||||
skillsDir: string,
|
||||
skillName: string,
|
||||
newEntries: RejectedEntry[],
|
||||
): void {
|
||||
const p = rejectedFilePath(skillsDir, skillName);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
|
||||
const existing = loadRejectedBuffer(skillsDir, skillName);
|
||||
const byKey = new Map<string, RejectedEntry>();
|
||||
// Existing entries first (so newer entries with same key override).
|
||||
for (const e of existing) byKey.set(e.key, e);
|
||||
for (const e of newEntries) byKey.set(e.key, e);
|
||||
|
||||
const merged = [...byKey.values()].sort((a, b) => {
|
||||
// Newest first by ts so LRU truncation keeps fresh entries.
|
||||
return a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0;
|
||||
});
|
||||
const bounded = merged.slice(0, REJECTED_BUFFER_CAP);
|
||||
|
||||
const payload: RejectedFile = { schema: 1, entries: bounded };
|
||||
atomicWrite(p, JSON.stringify(payload, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/** Is the proposed edit set already in the rejected buffer? */
|
||||
export function isRejected(
|
||||
buffer: readonly RejectedEntry[],
|
||||
skillText: string,
|
||||
edits: EditOp[],
|
||||
): boolean {
|
||||
const key = rejectedKey(skillText, edits);
|
||||
return buffer.some((e) => e.key === key);
|
||||
}
|
||||
|
||||
/** Build a fresh rejected entry from edits + reason at the current time. */
|
||||
export function makeRejectedEntry(skillText: string, edits: EditOp[], reason: string): RejectedEntry {
|
||||
return {
|
||||
key: rejectedKey(skillText, edits),
|
||||
skill_sha8: sha8(skillText),
|
||||
edits,
|
||||
reason,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* SkillOpt rollout: execute a skill against one benchmark task.
|
||||
*
|
||||
* Per D2: uses `gateway.toolLoop` directly with NO persistence callbacks.
|
||||
* SkillOpt rollouts are transient eval data — they don't pollute
|
||||
* `subagent_messages` / `subagent_tool_executions`.
|
||||
*
|
||||
* Per D13: tool registry is the read-only subset of BRAIN_TOOL_ALLOWLIST.
|
||||
* Excluded ops: `put_page`, `submit_job`, `file_upload` (the latter two
|
||||
* aren't in BRAIN_TOOL_ALLOWLIST anyway, but documenting for clarity).
|
||||
* The optimizer's loop can call `search`, `query`, `get_page`, etc., but
|
||||
* cannot WRITE to the user's brain.
|
||||
*
|
||||
* Each rollout returns a `Trajectory` capturing the final assistant text,
|
||||
* tool calls (with inputs + outputs), token usage, and stop reason. The
|
||||
* caller (orchestrator) feeds the trajectory to the judge for scoring.
|
||||
*/
|
||||
|
||||
import { chat as gatewayChat, toolLoop, type ChatMessage, type ChatToolDef, type ToolHandler } from '../ai/gateway.ts';
|
||||
import { BRAIN_TOOL_ALLOWLIST } from '../minions/tools/brain-allowlist.ts';
|
||||
import { operations, type OperationContext } from '../operations.ts';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { BenchmarkTask, Trajectory } from './types.ts';
|
||||
|
||||
/**
|
||||
* D13: which tools SkillOpt rollouts are allowed to call.
|
||||
*
|
||||
* Derived from BRAIN_TOOL_ALLOWLIST minus `put_page` (only mutating op in
|
||||
* the base set). New mutating ops MUST be added here AND BRAIN_TOOL_ALLOWLIST
|
||||
* mustn't be silently widened; the rollout test pins zero-write invariant.
|
||||
*/
|
||||
export const READ_ONLY_BRAIN_TOOLS: ReadonlySet<string> = new Set(
|
||||
[...BRAIN_TOOL_ALLOWLIST].filter((name) => name !== 'put_page'),
|
||||
);
|
||||
|
||||
export interface RolloutOpts {
|
||||
engine: BrainEngine;
|
||||
skillText: string;
|
||||
task: BenchmarkTask;
|
||||
/** Provider:model string for the target model. */
|
||||
targetModel: string;
|
||||
/** Max agent turns. Default 20. */
|
||||
maxTurns?: number;
|
||||
/** AbortSignal for cooperative cancellation (e.g. budget exhausted). */
|
||||
abortSignal?: AbortSignal;
|
||||
/** F10: when true, enable write-capture (virtual put_page/submit_job/file_upload). */
|
||||
writeCapture?: boolean;
|
||||
/** Test seam — substitute the toolLoop call. */
|
||||
toolLoopFn?: typeof toolLoop;
|
||||
/** Test seam — substitute chat (currently unused; toolLoop wraps chat). */
|
||||
chatFn?: typeof gatewayChat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one rollout. Returns a Trajectory; never throws on agent-side failures
|
||||
* (max_turns, refusal, aborted) — those land in `trajectory.stop_reason` so
|
||||
* the caller can score them appropriately.
|
||||
*
|
||||
* Throws ONLY on infrastructure errors (no engine, unknown target model) or
|
||||
* BudgetExhausted (which propagates via gateway → MUST_ABORT_ERROR_TAGS).
|
||||
*/
|
||||
export async function runRollout(opts: RolloutOpts): Promise<Trajectory> {
|
||||
const { engine, skillText, task, targetModel } = opts;
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Build the tool handlers + defs. F10: when write-capture is on, swap
|
||||
// in the virtual-write registry (read-only base + virtual put_page /
|
||||
// submit_job / file_upload). Default: read-only allowlist only (D13).
|
||||
const ctx = buildOpContext(engine);
|
||||
let defs, handlers;
|
||||
if (opts.writeCapture) {
|
||||
const { buildWriteCaptureRegistry } = await import('./write-capture.ts');
|
||||
const registry = buildWriteCaptureRegistry(engine);
|
||||
defs = registry.defs;
|
||||
handlers = registry.handlers;
|
||||
} else {
|
||||
const r = buildReadOnlyToolRegistry(ctx);
|
||||
defs = r.defs;
|
||||
handlers = r.handlers;
|
||||
}
|
||||
|
||||
// The skill text becomes the system prompt (D11: cacheSystem=true so the
|
||||
// candidate skill is cached across all rollouts in a single batch).
|
||||
const system = skillText;
|
||||
const initialMessages: ChatMessage[] = [{ role: 'user', content: task.task }];
|
||||
|
||||
const toolLoopImpl = opts.toolLoopFn ?? toolLoop;
|
||||
|
||||
// Capture tool calls as they fire. The toolLoop's callbacks fire in
|
||||
// ordering: onToolCallStart -> handler.execute -> onToolCallComplete|Failed.
|
||||
// We capture outputs in a Map keyed by gbrainToolUseId so we can stitch
|
||||
// them into the trajectory's tool_calls array (preserving call order).
|
||||
const toolCalls: Trajectory['tool_calls'] = [];
|
||||
const callsById = new Map<string, number>(); // gbrainToolUseId -> index in toolCalls
|
||||
let nextOrdinal = 0;
|
||||
|
||||
const result = await toolLoopImpl({
|
||||
model: targetModel,
|
||||
system,
|
||||
initialMessages,
|
||||
tools: defs,
|
||||
toolHandlers: handlers,
|
||||
maxTurns: opts.maxTurns ?? 20,
|
||||
cacheSystem: true, // D11: candidate skill is stable for a step's batch.
|
||||
abortSignal: opts.abortSignal,
|
||||
onToolCallStart: async (_turnIdx, _messageIdx, _ordinal, toolName, input, providerToolCallId) => {
|
||||
const gbrainToolUseId = `skillopt-${nextOrdinal++}-${providerToolCallId}`;
|
||||
const idx = toolCalls.length;
|
||||
callsById.set(gbrainToolUseId, idx);
|
||||
toolCalls.push({ name: stripBrainPrefix(toolName), input });
|
||||
return { gbrainToolUseId };
|
||||
},
|
||||
onToolCallComplete: async (gbrainToolUseId, output) => {
|
||||
const idx = callsById.get(gbrainToolUseId);
|
||||
if (idx !== undefined && toolCalls[idx]) {
|
||||
toolCalls[idx]!.output = output;
|
||||
}
|
||||
},
|
||||
onToolCallFailed: async (gbrainToolUseId, error) => {
|
||||
const idx = callsById.get(gbrainToolUseId);
|
||||
if (idx !== undefined && toolCalls[idx]) {
|
||||
toolCalls[idx]!.failed = true;
|
||||
toolCalls[idx]!.output = { error };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Final assistant text: concatenate text blocks from the last assistant message.
|
||||
const lastAssistant = [...result.messages].reverse().find((m) => m.role === 'assistant');
|
||||
let finalText = '';
|
||||
if (lastAssistant && typeof lastAssistant.content === 'string') {
|
||||
finalText = lastAssistant.content;
|
||||
} else if (lastAssistant && Array.isArray(lastAssistant.content)) {
|
||||
finalText = lastAssistant.content
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => ('text' in b ? b.text : ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
task_id: task.task_id,
|
||||
task: task.task,
|
||||
final_text: finalText,
|
||||
tool_calls: toolCalls,
|
||||
usage: result.totalUsage,
|
||||
turns: result.totalTurns,
|
||||
stop_reason: result.stopReason,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tool registry construction ──────────────────────────────────────────
|
||||
|
||||
function buildOpContext(engine: BrainEngine): OperationContext {
|
||||
const cfg = loadConfig();
|
||||
return {
|
||||
engine,
|
||||
config: cfg ?? ({} as never),
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
|
||||
dryRun: false,
|
||||
// SkillOpt rollouts ARE remote-equivalent (the optimizer chose what to call,
|
||||
// not the user) → set remote: true. Per-op trust gates that check `remote`
|
||||
// see this and apply remote-tightening rules. Read-only ops aren't affected.
|
||||
remote: true,
|
||||
// v0.34 D4: sourceId is required on OperationContext. SkillOpt rollouts
|
||||
// operate on the default source — the agent under test reads the brain
|
||||
// it would normally read.
|
||||
sourceId: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
interface ToolRegistry {
|
||||
defs: ChatToolDef[];
|
||||
handlers: Map<string, ToolHandler>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the read-only tool registry that SkillOpt rollouts call into.
|
||||
*
|
||||
* Tool names are prefixed `brain_` for Anthropic-name compliance (same
|
||||
* convention as the subagent handler's buildBrainTools).
|
||||
*/
|
||||
function buildReadOnlyToolRegistry(ctx: OperationContext): ToolRegistry {
|
||||
const defs: ChatToolDef[] = [];
|
||||
const handlers = new Map<string, ToolHandler>();
|
||||
for (const op of operations) {
|
||||
if (!READ_ONLY_BRAIN_TOOLS.has(op.name)) continue;
|
||||
const toolName = `brain_${op.name}`;
|
||||
defs.push({
|
||||
name: toolName,
|
||||
description: op.description,
|
||||
inputSchema: paramsToSchema(op.params),
|
||||
});
|
||||
handlers.set(toolName, {
|
||||
idempotent: true, // All read-only ops are idempotent by construction.
|
||||
execute: async (input: unknown) => {
|
||||
return op.handler(ctx, (input as Record<string, unknown>) ?? {});
|
||||
},
|
||||
});
|
||||
}
|
||||
return { defs, handlers };
|
||||
}
|
||||
|
||||
function stripBrainPrefix(toolName: string): string {
|
||||
return toolName.startsWith('brain_') ? toolName.slice('brain_'.length) : toolName;
|
||||
}
|
||||
|
||||
function paramsToSchema(params: Record<string, { type: string; description?: string; required?: boolean }>): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object' as const,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(params).map(([k, v]) => [k, { type: v.type, description: v.description }]),
|
||||
),
|
||||
required: Object.entries(params).filter(([, v]) => v.required).map(([k]) => k),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* SkillOpt scoring: three judge modes (rule, llm, qrels).
|
||||
*
|
||||
* Each scorer returns a 0..1 score (1 = best). Sub-1 scores are partial
|
||||
* credit; 0 means total failure. The validation gate's median-of-3 (D12)
|
||||
* is implemented in validate-gate.ts; this module is just the
|
||||
* per-trajectory scoring primitives.
|
||||
*
|
||||
* `judge: llm` uses gateway.chat with the v0.40+ 4-strategy JSON repair
|
||||
* (parseModelJSON from cross-modal-eval). On parse failure the scorer
|
||||
* returns score=0 (pessimistic fallback) AND records the error string on
|
||||
* `ScoredRollout.judge_error` so the audit trail can surface it.
|
||||
*
|
||||
* `judge: qrels` reuses src/core/search/eval.ts IR metrics. Score is
|
||||
* nDCG@k (more discriminating than P@k for the optimization signal).
|
||||
*/
|
||||
|
||||
import { chat as gatewayChat } from '../ai/gateway.ts';
|
||||
import { ndcgAtK } from '../search/eval.ts';
|
||||
import type { Judge, RuleCheck, ScoredRollout, Trajectory } from './types.ts';
|
||||
|
||||
/** Score a trajectory against a judge. Returns a ScoredRollout. */
|
||||
export async function scoreTrajectory(
|
||||
trajectory: Trajectory,
|
||||
judge: Judge,
|
||||
opts: {
|
||||
judgeModel?: string;
|
||||
/** Test seam — substitute for gateway.chat. */
|
||||
chatFn?: typeof gatewayChat;
|
||||
/** Test seam — substitute clock for cache invalidation. */
|
||||
now?: () => Date;
|
||||
} = {},
|
||||
): Promise<ScoredRollout> {
|
||||
switch (judge.kind) {
|
||||
case 'rule':
|
||||
return { trajectory, score: scoreRule(trajectory, judge.checks) };
|
||||
case 'llm':
|
||||
return scoreLlm(trajectory, judge.rubric, judge.model ?? opts.judgeModel, opts);
|
||||
case 'qrels':
|
||||
return { trajectory, score: scoreQrels(trajectory, judge.expected_slugs, judge.k) };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rule judge ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score a trajectory against a list of rule checks. Returns the FRACTION
|
||||
* of checks that pass. 0 = all fail, 1 = all pass.
|
||||
*/
|
||||
export function scoreRule(trajectory: Trajectory, checks: RuleCheck[]): number {
|
||||
if (checks.length === 0) return 0;
|
||||
let passing = 0;
|
||||
for (const c of checks) {
|
||||
if (applyCheck(trajectory, c)) passing += 1;
|
||||
}
|
||||
return passing / checks.length;
|
||||
}
|
||||
|
||||
function applyCheck(trajectory: Trajectory, check: RuleCheck): boolean {
|
||||
const text = trajectory.final_text;
|
||||
switch (check.op) {
|
||||
case 'contains':
|
||||
return typeof check.arg === 'string' && text.includes(check.arg);
|
||||
case 'regex': {
|
||||
if (typeof check.arg !== 'string') return false;
|
||||
try {
|
||||
return new RegExp(check.arg, 'm').test(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
case 'section_present': {
|
||||
if (typeof check.arg !== 'string') return false;
|
||||
// Match the heading (any depth, plus the literal text). Trim args
|
||||
// and allow leading-# variants.
|
||||
const heading = check.arg.replace(/^#+\s*/, '').trim();
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`^#{1,6}\\s+${escaped}\\s*$`, 'mi');
|
||||
return re.test(text);
|
||||
}
|
||||
case 'max_chars':
|
||||
return typeof check.arg === 'number' && text.length <= check.arg;
|
||||
case 'min_citations':
|
||||
return typeof check.arg === 'number' && countCitations(text) >= check.arg;
|
||||
case 'tool_called':
|
||||
return typeof check.arg === 'string' &&
|
||||
trajectory.tool_calls.some((tc) => tc.name === check.arg && !tc.failed);
|
||||
case 'tool_not_called':
|
||||
return typeof check.arg === 'string' &&
|
||||
!trajectory.tool_calls.some((tc) => tc.name === check.arg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count citation-like spans in the output. Recognized shapes:
|
||||
* - Markdown links: `[text](url-or-slug)`
|
||||
* - Brain-page references: `wiki/...`, `people/...`, `companies/...`
|
||||
* - Footnote-style: `[N]` where N is digits.
|
||||
*/
|
||||
export function countCitations(text: string): number {
|
||||
const mdLinks = (text.match(/\[[^\]]+\]\([^)]+\)/g) ?? []).length;
|
||||
const brainRefs = (text.match(/\b(?:wiki|people|companies|deals|topics|concepts|projects|writing|originals)\/[a-z0-9][a-z0-9-\/]*\b/gi) ?? []).length;
|
||||
const footnotes = (text.match(/\[\d+\]/g) ?? []).length;
|
||||
return mdLinks + brainRefs + footnotes;
|
||||
}
|
||||
|
||||
// ─── LLM judge ───────────────────────────────────────────────────────────
|
||||
|
||||
const LLM_JUDGE_SYSTEM = `You are a strict, fair judge scoring an agent's output against a rubric.
|
||||
|
||||
Output ONLY a single JSON object on a single line:
|
||||
{"score": <number 0..1>, "rationale": "<one-sentence reason>"}
|
||||
|
||||
No prose before or after. No code fences. No extra fields. The score MUST be a number between 0.0 and 1.0 inclusive.`;
|
||||
|
||||
/**
|
||||
* Parse a `{score, rationale}` JSON object from raw LLM text. Tolerates:
|
||||
* - Leading/trailing whitespace.
|
||||
* - Markdown code fences (```json ... ```).
|
||||
* - Prose before or after the JSON (extracts first {...} object).
|
||||
* - Trailing commas inside the object.
|
||||
*
|
||||
* Returns null when no recoverable object is found (caller treats as judge
|
||||
* error + pessimistic fallback score=0).
|
||||
*/
|
||||
export function parseJudgeJson(raw: string): { score: number | string; rationale?: string } | null {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||
// Strip markdown fences if present.
|
||||
const fenced = raw.match(/```(?:json)?\s*\n?([\s\S]*?)```/i);
|
||||
const cleaned = (fenced ? fenced[1]! : raw).trim();
|
||||
// Try direct parse first.
|
||||
const direct = tryJsonParse(cleaned);
|
||||
if (direct && typeof direct === 'object' && 'score' in (direct as object)) {
|
||||
return direct as { score: number | string; rationale?: string };
|
||||
}
|
||||
// Extract first {...} substring.
|
||||
const match = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (!match) return null;
|
||||
const obj = match[0];
|
||||
const second = tryJsonParse(obj);
|
||||
if (second && typeof second === 'object' && 'score' in (second as object)) {
|
||||
return second as { score: number | string; rationale?: string };
|
||||
}
|
||||
// Last attempt: strip trailing commas.
|
||||
const repaired = obj.replace(/,(\s*[}\]])/g, '$1');
|
||||
const third = tryJsonParse(repaired);
|
||||
if (third && typeof third === 'object' && 'score' in (third as object)) {
|
||||
return third as { score: number | string; rationale?: string };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryJsonParse(s: string): unknown | null {
|
||||
try { return JSON.parse(s); } catch { return null; }
|
||||
}
|
||||
|
||||
async function scoreLlm(
|
||||
trajectory: Trajectory,
|
||||
rubric: string,
|
||||
judgeModel: string | undefined,
|
||||
opts: { chatFn?: typeof gatewayChat },
|
||||
): Promise<ScoredRollout> {
|
||||
const chat = opts.chatFn ?? gatewayChat;
|
||||
const userMsg = `RUBRIC: ${rubric}\n\nAGENT OUTPUT:\n${trajectory.final_text}\n\nScore the output against the rubric. Reply with the JSON object only.`;
|
||||
try {
|
||||
const result = await chat({
|
||||
model: judgeModel,
|
||||
system: LLM_JUDGE_SYSTEM,
|
||||
messages: [{ role: 'user', content: userMsg }],
|
||||
maxTokens: 200,
|
||||
cacheSystem: true, // D11: judge system prompt is stable across calls.
|
||||
});
|
||||
const parsed = parseJudgeJson(result.text);
|
||||
if (!parsed) {
|
||||
return { trajectory, score: 0, judge_error: 'llm_parse_failed' };
|
||||
}
|
||||
if (!('score' in parsed)) {
|
||||
return { trajectory, score: 0, judge_error: 'llm_parse_no_score_field' };
|
||||
}
|
||||
const raw = parsed.score;
|
||||
let score = typeof raw === 'number' ? raw : Number(raw);
|
||||
if (!Number.isFinite(score)) {
|
||||
return { trajectory, score: 0, judge_error: 'llm_parse_score_not_number' };
|
||||
}
|
||||
if (score < 0) score = 0;
|
||||
if (score > 1) score = 1;
|
||||
const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : undefined;
|
||||
return rationale !== undefined
|
||||
? { trajectory, score, rationale }
|
||||
: { trajectory, score };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Pessimistic fallback (D12 paper-faithful: judge failure = score 0,
|
||||
// not throw; the median-of-3 + epsilon gate handles a single error
|
||||
// gracefully, only consistent judge failure breaks the run).
|
||||
return { trajectory, score: 0, judge_error: `llm_call_failed: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Qrels judge (retrieval flavor) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score a trajectory against expected retrieval slugs.
|
||||
*
|
||||
* Extracts the candidate slugs from the trajectory's tool calls (any
|
||||
* `search` / `query` / `get_page` / `list_pages` op output that returns
|
||||
* page rows), then computes nDCG@k against expected_slugs.
|
||||
*
|
||||
* Returns 0 when no retrieval tool was called (the skill didn't even try).
|
||||
*/
|
||||
export function scoreQrels(
|
||||
trajectory: Trajectory,
|
||||
expectedSlugs: string[],
|
||||
k: number,
|
||||
): number {
|
||||
const candidateSlugs = extractRetrievedSlugs(trajectory);
|
||||
if (candidateSlugs.length === 0) return 0;
|
||||
// ndcgAtK expects (hits, grades:Map<slug,number>, k). All expected slugs
|
||||
// get grade 1 (binary relevance) — qrels mode is "did the skill retrieve
|
||||
// what we expected?", not "did it rank them in our preferred order."
|
||||
const grades = new Map<string, number>();
|
||||
for (const s of expectedSlugs) grades.set(s, 1);
|
||||
return ndcgAtK(candidateSlugs, grades, k);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the trajectory's tool calls and collect slugs from any search/query/
|
||||
* list_pages/get_page output that returns row arrays with `slug` fields.
|
||||
* Tolerant of shape variation.
|
||||
*/
|
||||
export function extractRetrievedSlugs(trajectory: Trajectory): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const call of trajectory.tool_calls) {
|
||||
if (!call.output || call.failed) continue;
|
||||
const slugs = pickSlugs(call.output);
|
||||
for (const slug of slugs) {
|
||||
if (!seen.has(slug)) {
|
||||
seen.add(slug);
|
||||
out.push(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickSlugs(output: unknown): string[] {
|
||||
if (!output) return [];
|
||||
if (typeof output === 'string') {
|
||||
// Some ops return a single slug string.
|
||||
return output.includes('/') ? [output] : [];
|
||||
}
|
||||
if (Array.isArray(output)) {
|
||||
return output.flatMap(pickSlugs);
|
||||
}
|
||||
if (typeof output === 'object') {
|
||||
const obj = output as Record<string, unknown>;
|
||||
const out: string[] = [];
|
||||
if (typeof obj.slug === 'string') out.push(obj.slug);
|
||||
if (Array.isArray(obj.results)) out.push(...pickSlugs(obj.results));
|
||||
if (Array.isArray(obj.pages)) out.push(...pickSlugs(obj.pages));
|
||||
if (Array.isArray(obj.matches)) out.push(...pickSlugs(obj.matches));
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* SkillOpt v1 types — single source of truth for the optimization loop.
|
||||
*
|
||||
* The optimizer treats SKILL.md as the trainable parameters of a frozen
|
||||
* agent. See plan: ~/.claude/plans/system-instruction-you-are-working-drifting-falcon.md.
|
||||
*
|
||||
* Per the v0.41.20.0 plan decisions:
|
||||
* D2: rollout uses gateway.toolLoop (no DB pollution).
|
||||
* D5: frontmatter mutation is forbidden; edits operate on body slice only.
|
||||
* D9: applyEdit returns a tagged result, not throws.
|
||||
* D12: validation gate uses median-of-3 + epsilon=0.05.
|
||||
* D17: D_sel >= 5 floor enforced at benchmark-load time.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
// ─── Benchmarks + judges ──────────────────────────────────────────────────
|
||||
|
||||
/** Rule-check kinds for `judge: rule`. Each is deterministic and free. */
|
||||
export type RuleCheckOp =
|
||||
| 'contains'
|
||||
| 'regex'
|
||||
| 'section_present'
|
||||
| 'max_chars'
|
||||
| 'min_citations'
|
||||
| 'tool_called'
|
||||
| 'tool_not_called';
|
||||
|
||||
export interface RuleCheck {
|
||||
op: RuleCheckOp;
|
||||
arg: string | number;
|
||||
}
|
||||
|
||||
export type JudgeKind = 'rule' | 'llm' | 'qrels';
|
||||
|
||||
export type Judge =
|
||||
| { kind: 'rule'; checks: RuleCheck[] }
|
||||
| { kind: 'llm'; rubric: string; model?: string }
|
||||
| { kind: 'qrels'; expected_slugs: string[]; k: number };
|
||||
|
||||
export interface BenchmarkTask {
|
||||
task_id: string;
|
||||
task: string;
|
||||
judge: Judge;
|
||||
}
|
||||
|
||||
export interface Benchmark {
|
||||
/** Path the benchmark was loaded from (for error messages). */
|
||||
source_path: string;
|
||||
tasks: BenchmarkTask[];
|
||||
/** SHA-256 of the canonical JSON, truncated to 16 hex. Stable across reorderings. */
|
||||
benchmark_sha8: string;
|
||||
}
|
||||
|
||||
/** D17: enforced floor for D_sel. */
|
||||
export const D_SEL_MIN_SIZE = 5;
|
||||
|
||||
export interface BenchmarkSplit {
|
||||
train: BenchmarkTask[];
|
||||
sel: BenchmarkTask[];
|
||||
test: BenchmarkTask[];
|
||||
}
|
||||
|
||||
// ─── Edit ops ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** D9: applyEdit returns a tagged result, not throws. */
|
||||
export type EditOp =
|
||||
| { op: 'add'; anchor: string; content: string; reason?: string }
|
||||
| { op: 'replace'; target: string; replacement: string; reason?: string }
|
||||
| { op: 'delete'; target: string; reason?: string };
|
||||
|
||||
export type EditRejectionReason =
|
||||
| 'anchor_not_found'
|
||||
| 'anchor_ambiguous'
|
||||
| 'target_not_found'
|
||||
| 'target_ambiguous'
|
||||
| 'inside_code_fence'
|
||||
| 'crosses_frontmatter'
|
||||
| 'working_tree_dirty'
|
||||
| 'install_path'
|
||||
| 'no_change';
|
||||
|
||||
export type EditResult =
|
||||
| { outcome: 'applied'; edit: EditOp; newText: string }
|
||||
| { outcome: 'rejected'; edit: EditOp; reason: EditRejectionReason; detail?: string };
|
||||
|
||||
// ─── Trajectories + rollouts ──────────────────────────────────────────────
|
||||
|
||||
/** What a rollout produced. Captured in-process; never persisted to DB (D2). */
|
||||
export interface Trajectory {
|
||||
task_id: string;
|
||||
task: string;
|
||||
/** Final assistant text (typically the user-visible output). */
|
||||
final_text: string;
|
||||
/** Tool calls observed during the rollout, in order. */
|
||||
tool_calls: Array<{ name: string; input: unknown; output?: unknown; failed?: boolean }>;
|
||||
/** Token usage from gateway.toolLoop. */
|
||||
usage: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_creation_tokens: number;
|
||||
};
|
||||
/** Number of agent turns the loop took. */
|
||||
turns: number;
|
||||
/** End reason from gateway.toolLoop. */
|
||||
stop_reason: 'end' | 'max_turns' | 'refusal' | 'content_filter' | 'aborted' | 'unrecoverable';
|
||||
/** Wall-clock duration in ms. */
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface ScoredRollout {
|
||||
trajectory: Trajectory;
|
||||
/** 0..1 score from the judge. */
|
||||
score: number;
|
||||
/** Optional per-task rationale (LLM judge only). */
|
||||
rationale?: string;
|
||||
/** If the judge itself errored (parse fail, timeout), this is set. */
|
||||
judge_error?: string;
|
||||
}
|
||||
|
||||
// ─── Run state + receipts ─────────────────────────────────────────────────
|
||||
|
||||
export interface SkillOptOpts {
|
||||
/** Kebab-case skill name. Resolves to `skills/<name>/SKILL.md`. */
|
||||
skillName: string;
|
||||
/** Absolute path to the benchmark JSONL file. */
|
||||
benchmarkPath: string;
|
||||
/** Brain engine (required for D14 lock + read-only tool calls during rollouts). */
|
||||
engine: BrainEngine;
|
||||
/** Skills directory root (used for bundled-skill detection, install-path gate). */
|
||||
skillsDir: string;
|
||||
|
||||
// Training knobs.
|
||||
epochs: number;
|
||||
batchSize: number;
|
||||
/** Max edits per step (the LR). */
|
||||
lr: number;
|
||||
lrSchedule: 'cosine' | 'linear' | 'constant';
|
||||
/** Split ratio as 3-tuple, e.g. [4, 1, 5] for 4:1:5. */
|
||||
split: [number, number, number];
|
||||
|
||||
// Models.
|
||||
optimizerModel: string;
|
||||
targetModel: string;
|
||||
judgeModel: string;
|
||||
|
||||
// Modes.
|
||||
mode: 'patch' | 'rewrite';
|
||||
dryRun: boolean;
|
||||
noMutate: boolean;
|
||||
allowMutateBundled: boolean;
|
||||
bootstrapReviewed: boolean;
|
||||
/** F10: enable write-capture mode for write-flavored skills. */
|
||||
writeCapture?: boolean;
|
||||
/** F11: optional held-out test set path (validates winner before mutate). */
|
||||
heldOutPath?: string;
|
||||
json: boolean;
|
||||
|
||||
// Safety.
|
||||
maxCostUsd: number;
|
||||
maxRuntimeMin: number;
|
||||
force: boolean;
|
||||
resumeRunId?: string;
|
||||
}
|
||||
|
||||
export interface StepRecord {
|
||||
epoch: number;
|
||||
step: number;
|
||||
sel_score_median: number;
|
||||
sel_score_runs: number[];
|
||||
accepted: boolean;
|
||||
edits_attempted: number;
|
||||
edits_applied: number;
|
||||
delta: number;
|
||||
reason?: string;
|
||||
cumulative_cost_usd: number;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface RunReceipt {
|
||||
run_id: string;
|
||||
skill: string;
|
||||
skill_sha8: string;
|
||||
benchmark_sha8: string;
|
||||
optimizer_model: string;
|
||||
target_model: string;
|
||||
judge_model: string;
|
||||
epochs: number;
|
||||
batch_size: number;
|
||||
lr: number;
|
||||
lr_schedule: 'cosine' | 'linear' | 'constant';
|
||||
max_cost_usd: number;
|
||||
started_at: string;
|
||||
ended_at?: string;
|
||||
outcome?: 'accepted' | 'no_improvement' | 'aborted' | 'errored';
|
||||
baseline_sel_score?: number;
|
||||
best_sel_score?: number;
|
||||
baseline_test_score?: number;
|
||||
test_score?: number;
|
||||
final_cost_usd?: number;
|
||||
total_steps?: number;
|
||||
epochs_completed?: number;
|
||||
}
|
||||
|
||||
export interface HistoryRow {
|
||||
/** D8: pending → committed two-phase commit. */
|
||||
status: 'pending' | 'committed';
|
||||
run_id: string;
|
||||
version_n: number;
|
||||
ts: string;
|
||||
edits: EditOp[];
|
||||
sel_score: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
// ─── Validation gate (D12) ────────────────────────────────────────────────
|
||||
|
||||
/** D12: epsilon margin floor for accepting a candidate. */
|
||||
export const VALIDATION_EPSILON = 0.05;
|
||||
/** D12: number of judge runs per sel-task for noise rejection. */
|
||||
export const VALIDATION_RUNS_PER_TASK = 3;
|
||||
|
||||
export interface GateInput {
|
||||
candidateSkillText: string;
|
||||
selSet: BenchmarkTask[];
|
||||
/** Best score so far on D_sel (epsilon-margin compare against this). */
|
||||
bestScore: number;
|
||||
}
|
||||
|
||||
export interface GateResult {
|
||||
accepted: boolean;
|
||||
/** Per-task median across N runs. */
|
||||
perTaskMedians: Array<{ task_id: string; median: number; runs: number[] }>;
|
||||
/** Mean of per-task medians. */
|
||||
selScore: number;
|
||||
reason?: 'no_margin' | 'below_baseline' | 'all_judge_errors';
|
||||
/**
|
||||
* Every scored rollout this gate produced, in selSet order (runs per task
|
||||
* flattened). Used by the orchestrator's forward pass to partition into
|
||||
* successes/failures for reflect. Sel-side gates also surface this but the
|
||||
* orchestrator only reads it for the forward path (runsPerTask=1).
|
||||
*/
|
||||
scoredRollouts: ScoredRollout[];
|
||||
}
|
||||
|
||||
// ─── Bootstrap sentinel (D15) ─────────────────────────────────────────────
|
||||
|
||||
/** D15: sentinel line written at the end of bootstrap-from-routing output. */
|
||||
export const BOOTSTRAP_PENDING_REVIEW = '# BOOTSTRAP_PENDING_REVIEW';
|
||||
|
||||
// ─── Bundled-skill gate (D16) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* D16: a skill is "bundled" when its SKILL.md lives under the repo's
|
||||
* canonical `skills/` directory (relative to the gbrain install root).
|
||||
* Bundled skills require `--allow-mutate-bundled` to overwrite.
|
||||
*/
|
||||
export interface BundledSkillContext {
|
||||
skillName: string;
|
||||
skillsDir: string;
|
||||
/** Resolved absolute path to skills/<name>/SKILL.md. */
|
||||
skillPath: string;
|
||||
/** True when the skillsDir is the repo's `skills/` (install path). */
|
||||
isBundled: boolean;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* SkillOpt validation gate (D12 + D4).
|
||||
*
|
||||
* D12 — median-of-3 judge runs per sel-task + epsilon=0.05 margin.
|
||||
* D4 — parallel sel-task scoring via runWithLimit cap=4.
|
||||
*
|
||||
* Pseudocode:
|
||||
*
|
||||
* For each sel-task t in selSet:
|
||||
* scores[t] = median(await Promise.all([
|
||||
* scoreTrajectory(rollout(candidate, t), judge), // run 1
|
||||
* scoreTrajectory(rollout(candidate, t), judge), // run 2
|
||||
* scoreTrajectory(rollout(candidate, t), judge), // run 3
|
||||
* ]))
|
||||
* sel_score = mean(scores)
|
||||
* if sel_score > best_score + 0.05: ACCEPT
|
||||
* else: REJECT
|
||||
*
|
||||
* The 3 rollout calls per sel-task share the same candidate-skill prompt
|
||||
* which is cached (D11), so the effective cost ~1.3x not 3x.
|
||||
*/
|
||||
|
||||
import { runWithLimit } from '../worker-pool.ts';
|
||||
import { runRollout, type RolloutOpts } from './rollout.ts';
|
||||
import { scoreTrajectory } from './score.ts';
|
||||
import type { BenchmarkTask, GateInput, GateResult, ScoredRollout } from './types.ts';
|
||||
import { VALIDATION_EPSILON, VALIDATION_RUNS_PER_TASK } from './types.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
export interface ValidateGateOpts extends Omit<GateInput, 'selSet'> {
|
||||
selSet: BenchmarkTask[];
|
||||
engine: BrainEngine;
|
||||
targetModel: string;
|
||||
judgeModel?: string;
|
||||
/** D4: max in-flight sel-task evaluations. Default 4. */
|
||||
concurrency?: number;
|
||||
/**
|
||||
* Runs per task for the median-of-N. Default `VALIDATION_RUNS_PER_TASK` (3)
|
||||
* for sel-side acceptance gates. The orchestrator's forward pass passes 1
|
||||
* because we only need a rough partition into successes/failures, not noise
|
||||
* rejection. Must be >= 1.
|
||||
*/
|
||||
runsPerTask?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
/** Test seam — substitute rollout. */
|
||||
rolloutFn?: typeof runRollout;
|
||||
/** Test seam — substitute scoreTrajectory. */
|
||||
scoreFn?: typeof scoreTrajectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation gate against a candidate skill. Returns GateResult
|
||||
* with accept/reject + per-task medians for the audit JSONL.
|
||||
*/
|
||||
export async function runValidationGate(opts: ValidateGateOpts): Promise<GateResult> {
|
||||
const rolloutFn = opts.rolloutFn ?? runRollout;
|
||||
const scoreFn = opts.scoreFn ?? scoreTrajectory;
|
||||
const concurrency = opts.concurrency ?? 4;
|
||||
// Forward pass uses 1; sel/baseline use VALIDATION_RUNS_PER_TASK (3). Floor
|
||||
// at 1 (a runsPerTask of 0 would silently produce an empty median).
|
||||
const runsPerTask = Math.max(1, opts.runsPerTask ?? VALIDATION_RUNS_PER_TASK);
|
||||
|
||||
interface PerTask {
|
||||
task_id: string;
|
||||
median: number;
|
||||
runs: number[];
|
||||
rollouts: ScoredRollout[];
|
||||
}
|
||||
|
||||
// Per sel-task, run N rollouts + N judges, take median.
|
||||
const settled = await runWithLimit({
|
||||
items: opts.selSet,
|
||||
limit: concurrency,
|
||||
fn: async (task: BenchmarkTask): Promise<PerTask> => {
|
||||
const runs: number[] = [];
|
||||
const rollouts: ScoredRollout[] = [];
|
||||
for (let i = 0; i < runsPerTask; i++) {
|
||||
const rolloutOpts: RolloutOpts = {
|
||||
engine: opts.engine,
|
||||
skillText: opts.candidateSkillText,
|
||||
task,
|
||||
targetModel: opts.targetModel,
|
||||
abortSignal: opts.abortSignal,
|
||||
};
|
||||
const trajectory = await rolloutFn(rolloutOpts);
|
||||
const scored: ScoredRollout = await scoreFn(trajectory, task.judge, {
|
||||
judgeModel: opts.judgeModel,
|
||||
});
|
||||
runs.push(scored.score);
|
||||
rollouts.push(scored);
|
||||
}
|
||||
return { task_id: task.task_id, median: median(runs), runs, rollouts };
|
||||
},
|
||||
signal: opts.abortSignal,
|
||||
});
|
||||
|
||||
// SettledItem<TOut>[] — extract successful results; treat errors as score=0
|
||||
// (pessimistic fallback consistent with the judge fail-open posture).
|
||||
// Errored tasks contribute no scoredRollouts (caller's reflect sees fewer
|
||||
// trajectories rather than fabricated zero-score entries).
|
||||
const perTaskMedians = settled.map((s, idx) => {
|
||||
if (s && s.ok) return { task_id: s.value.task_id, median: s.value.median, runs: s.value.runs };
|
||||
return { task_id: opts.selSet[idx]!.task_id, median: 0, runs: [] };
|
||||
});
|
||||
const scoredRollouts: ScoredRollout[] = settled.flatMap((s) => (s && s.ok) ? s.value.rollouts : []);
|
||||
const selScore = perTaskMedians.length === 0
|
||||
? 0
|
||||
: perTaskMedians.reduce((acc, r) => acc + r.median, 0) / perTaskMedians.length;
|
||||
|
||||
// D12 accept rule: strict > best + epsilon. Ties/sub-epsilon-gains are
|
||||
// rejected (paper-faithful — protects against noise-as-improvement).
|
||||
const threshold = opts.bestScore + VALIDATION_EPSILON;
|
||||
const accepted = selScore > threshold;
|
||||
|
||||
let reason: GateResult['reason'];
|
||||
if (!accepted) {
|
||||
if (selScore <= opts.bestScore) reason = 'below_baseline';
|
||||
else reason = 'no_margin';
|
||||
}
|
||||
return { accepted, perTaskMedians, selScore, scoredRollouts, ...(reason ? { reason } : {}) };
|
||||
}
|
||||
|
||||
/** Pure median for an array of numbers. Returns 0 for empty array. */
|
||||
export function median(values: readonly number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 1
|
||||
? sorted[mid]!
|
||||
: (sorted[mid - 1]! + sorted[mid]!) / 2;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* SkillOpt version store with history-intent-first atomic write ordering (D8).
|
||||
*
|
||||
* Each accepted candidate write touches 4 files. We do them in this order
|
||||
* so a crash mid-sequence can be cleanly recovered:
|
||||
*
|
||||
* 1. history.json — append `{status: 'pending', ...}`
|
||||
* 2. versions/vNNNN_eE_sS.md — snapshot of the candidate
|
||||
* 3. best.md — pointer copy of the candidate (always = current best)
|
||||
* 4. SKILL.md — canonical (THIS is the commit step)
|
||||
* 5. history.json — flip pending → committed
|
||||
*
|
||||
* Crash-resume logic: if a pending row exists with no committed counterpart,
|
||||
* remove the pending row, delete the snapshot, and revert best.md to the
|
||||
* prior version (or initial baseline if no prior version exists).
|
||||
*
|
||||
* Concurrency: a per-skill DB lock (`lock.ts`) prevents two SkillOpt runs
|
||||
* from racing on this directory. Within a single run we hold the lock for
|
||||
* the full optimization, so the in-process logic doesn't need additional
|
||||
* synchronization.
|
||||
*
|
||||
* Layout under `skills/<name>/skillopt/`:
|
||||
*
|
||||
* history.json
|
||||
* best.md
|
||||
* versions/
|
||||
* v0001_e1_s1.md
|
||||
* v0002_e1_s2.md
|
||||
* ...
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { atomicWrite } from './apply-edits.ts';
|
||||
import type { EditOp, HistoryRow } from './types.ts';
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export function skilloptDir(skillsDir: string, skillName: string): string {
|
||||
return path.join(skillsDir, skillName, 'skillopt');
|
||||
}
|
||||
|
||||
export function versionsDir(skillsDir: string, skillName: string): string {
|
||||
return path.join(skilloptDir(skillsDir, skillName), 'versions');
|
||||
}
|
||||
|
||||
export function historyPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skilloptDir(skillsDir, skillName), 'history.json');
|
||||
}
|
||||
|
||||
export function bestPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skilloptDir(skillsDir, skillName), 'best.md');
|
||||
}
|
||||
|
||||
export function skillPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skillsDir, skillName, 'SKILL.md');
|
||||
}
|
||||
|
||||
export function versionPath(
|
||||
skillsDir: string,
|
||||
skillName: string,
|
||||
versionN: number,
|
||||
epoch: number,
|
||||
step: number,
|
||||
): string {
|
||||
const n = String(versionN).padStart(4, '0');
|
||||
return path.join(versionsDir(skillsDir, skillName), `v${n}_e${epoch}_s${step}.md`);
|
||||
}
|
||||
|
||||
// ─── History I/O ─────────────────────────────────────────────────────────
|
||||
|
||||
interface HistoryFile {
|
||||
schema: 1;
|
||||
rows: HistoryRow[];
|
||||
}
|
||||
|
||||
export function loadHistory(skillsDir: string, skillName: string): HistoryRow[] {
|
||||
const p = historyPath(skillsDir, skillName);
|
||||
if (!fs.existsSync(p)) return [];
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return [];
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schema !== 1 || !Array.isArray(obj.rows)) return [];
|
||||
return obj.rows as HistoryRow[];
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[skillopt] history.json unreadable for ${skillName} (${msg})\n`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory(skillsDir: string, skillName: string, rows: HistoryRow[]): void {
|
||||
const p = historyPath(skillsDir, skillName);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
atomicWrite(p, JSON.stringify({ schema: 1, rows } satisfies HistoryFile, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// ─── D8 two-phase commit ─────────────────────────────────────────────────
|
||||
|
||||
export interface AcceptInput {
|
||||
skillsDir: string;
|
||||
skillName: string;
|
||||
runId: string;
|
||||
epoch: number;
|
||||
step: number;
|
||||
edits: EditOp[];
|
||||
candidateText: string;
|
||||
selScore: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface AcceptResult {
|
||||
versionN: number;
|
||||
versionFilePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a candidate via D8's history-intent-first ordering.
|
||||
*
|
||||
* Returns the version number assigned + the path to the snapshot file.
|
||||
* On any thrown error during steps 2-4, the caller's outer try/finally
|
||||
* should invoke `revertPending(run_id)` to clean up.
|
||||
*/
|
||||
export function acceptCandidate(input: AcceptInput): AcceptResult {
|
||||
const { skillsDir, skillName, runId, epoch, step, edits, candidateText, selScore, delta } = input;
|
||||
|
||||
// Ensure dir exists.
|
||||
fs.mkdirSync(versionsDir(skillsDir, skillName), { recursive: true });
|
||||
|
||||
// Compute version_n (next sequence after the highest committed row).
|
||||
const history = loadHistory(skillsDir, skillName);
|
||||
const maxCommitted = history
|
||||
.filter((r) => r.status === 'committed')
|
||||
.reduce((m, r) => Math.max(m, r.version_n), 0);
|
||||
const versionN = maxCommitted + 1;
|
||||
|
||||
// Step 1: append history row (pending).
|
||||
const ts = new Date().toISOString();
|
||||
const pendingRow: HistoryRow = {
|
||||
status: 'pending',
|
||||
run_id: runId,
|
||||
version_n: versionN,
|
||||
ts,
|
||||
edits,
|
||||
sel_score: selScore,
|
||||
delta,
|
||||
};
|
||||
writeHistory(skillsDir, skillName, [...history, pendingRow]);
|
||||
|
||||
// Step 2: write snapshot.
|
||||
const verPath = versionPath(skillsDir, skillName, versionN, epoch, step);
|
||||
atomicWrite(verPath, candidateText);
|
||||
|
||||
// Step 3: write best.md pointer.
|
||||
atomicWrite(bestPath(skillsDir, skillName), candidateText);
|
||||
|
||||
// Step 4: write SKILL.md (THIS is the commit step).
|
||||
atomicWrite(skillPath(skillsDir, skillName), candidateText);
|
||||
|
||||
// Step 5: flip pending → committed.
|
||||
const updated = loadHistory(skillsDir, skillName).map((r) =>
|
||||
r.run_id === runId && r.version_n === versionN && r.status === 'pending'
|
||||
? { ...r, status: 'committed' as const }
|
||||
: r,
|
||||
);
|
||||
writeHistory(skillsDir, skillName, updated);
|
||||
|
||||
return { versionN, versionFilePath: verPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-recovery: walk history.json, find any row with `status: 'pending'`
|
||||
* whose committed counterpart doesn't exist, and revert:
|
||||
* - Delete the snapshot file.
|
||||
* - Remove the pending row from history.json.
|
||||
* - Restore best.md to the most-recent COMMITTED version (or delete it
|
||||
* if no committed version exists).
|
||||
*
|
||||
* Does NOT touch SKILL.md — the ordering invariant is that SKILL.md only
|
||||
* gets rewritten AFTER best.md, so if SKILL.md never got rewritten then
|
||||
* it still matches the prior committed state.
|
||||
*
|
||||
* Returns the number of pending rows that were reverted.
|
||||
*/
|
||||
export function revertAllPending(skillsDir: string, skillName: string): number {
|
||||
const history = loadHistory(skillsDir, skillName);
|
||||
const pending = history.filter((r) => r.status === 'pending');
|
||||
if (pending.length === 0) return 0;
|
||||
|
||||
for (const row of pending) {
|
||||
// Delete the snapshot. We don't know epoch/step from the history row
|
||||
// alone, so we search the versions dir for files matching `vNNNN_*.md`.
|
||||
const verN = String(row.version_n).padStart(4, '0');
|
||||
const dir = versionsDir(skillsDir, skillName);
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
if (entry.startsWith(`v${verN}_`)) {
|
||||
try { fs.unlinkSync(path.join(dir, entry)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore best.md to the most-recent COMMITTED version (or remove it if
|
||||
// no committed version exists).
|
||||
const committed = history.filter((r) => r.status === 'committed');
|
||||
const bestP = bestPath(skillsDir, skillName);
|
||||
if (committed.length === 0) {
|
||||
if (fs.existsSync(bestP)) {
|
||||
try { fs.unlinkSync(bestP); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
const highest = committed.reduce((m, r) => (r.version_n > m.version_n ? r : m), committed[0]!);
|
||||
const dir = versionsDir(skillsDir, skillName);
|
||||
const verN = String(highest.version_n).padStart(4, '0');
|
||||
if (fs.existsSync(dir)) {
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
if (entry.startsWith(`v${verN}_`)) {
|
||||
const src = path.join(dir, entry);
|
||||
atomicWrite(bestP, fs.readFileSync(src, 'utf8'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trim pending rows out of history.
|
||||
const kept = history.filter((r) => r.status !== 'pending');
|
||||
writeHistory(skillsDir, skillName, kept);
|
||||
return pending.length;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* SkillOpt write-flavored skill optimization via mocked-write capture (F10).
|
||||
*
|
||||
* v1 SkillOpt rollouts use a read-only tool allowlist (D13). That works
|
||||
* for read-flavored skills (search-then-summarize, query-then-render) but
|
||||
* breaks for skills whose job IS to write — `put_page`, `submit_job`,
|
||||
* `file_upload`. F10 closes the gap: enable `--write-capture` and the
|
||||
* rollout's `put_page` calls land in an in-memory virtual brain rather
|
||||
* than persisting to the user's real DB. The judge sees the captured
|
||||
* virtual writes (slugs, frontmatter, body) and scores accordingly.
|
||||
*
|
||||
* Hermetic by construction: virtual writes never touch the engine.
|
||||
* Idempotent at the slug level — repeated put_page for the same slug
|
||||
* within a single rollout updates the virtual record (matches real
|
||||
* put_page behavior).
|
||||
*
|
||||
* Trajectory tool_calls keeps the put_page entries so `judge: rule` checks
|
||||
* like `tool_called('put_page')` work; the captured page contents are
|
||||
* surfaced as the tool output for downstream judges (LLM rubric can see
|
||||
* the proposed page text and grade it).
|
||||
*/
|
||||
|
||||
import type { ChatToolDef, ToolHandler } from '../ai/gateway.ts';
|
||||
import { operations, type OperationContext } from '../operations.ts';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import { BRAIN_TOOL_ALLOWLIST } from '../minions/tools/brain-allowlist.ts';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
/**
|
||||
* A single captured write from a write-flavored rollout. Persists in
|
||||
* memory for the duration of one rollout (re-created per task).
|
||||
*/
|
||||
export interface CapturedWrite {
|
||||
op: 'put_page' | 'submit_job' | 'file_upload';
|
||||
/** Captured slug (for put_page) or job name (for submit_job). */
|
||||
key: string;
|
||||
/** Full input object to the op. */
|
||||
input: Record<string, unknown>;
|
||||
/** Order of write within the rollout. */
|
||||
ordinal: number;
|
||||
}
|
||||
|
||||
export interface WriteCaptureRegistry {
|
||||
/** Read-only base tool defs (search/query/get_page/etc). */
|
||||
baseDefs: ChatToolDef[];
|
||||
/** Read-only base handlers. */
|
||||
baseHandlers: Map<string, ToolHandler>;
|
||||
/** Capture-mode tool defs (adds put_page + submit_job + file_upload with virtual semantics). */
|
||||
defs: ChatToolDef[];
|
||||
/** Capture-mode handlers (adds the virtual write handlers). */
|
||||
handlers: Map<string, ToolHandler>;
|
||||
/** Read-only access to the captured writes for this rollout. */
|
||||
getWrites(): readonly CapturedWrite[];
|
||||
/** Read-only access to the in-memory virtual brain (slug -> CapturedWrite). */
|
||||
getVirtualPages(): ReadonlyMap<string, CapturedWrite>;
|
||||
}
|
||||
|
||||
const WRITE_OPS: ReadonlySet<string> = new Set(['put_page', 'submit_job', 'file_upload']);
|
||||
|
||||
/**
|
||||
* Build a write-capture registry. Each call returns a FRESH capture set;
|
||||
* the orchestrator should create one per rollout so writes don't leak
|
||||
* across tasks within the same batch.
|
||||
*/
|
||||
export function buildWriteCaptureRegistry(engine: BrainEngine): WriteCaptureRegistry {
|
||||
const writes: CapturedWrite[] = [];
|
||||
const virtualPages = new Map<string, CapturedWrite>();
|
||||
let nextOrdinal = 0;
|
||||
|
||||
const ctx = buildOpContext(engine);
|
||||
const baseDefs: ChatToolDef[] = [];
|
||||
const baseHandlers = new Map<string, ToolHandler>();
|
||||
|
||||
// Build the read-only base (same shape as rollout.ts uses).
|
||||
for (const op of operations) {
|
||||
if (!BRAIN_TOOL_ALLOWLIST.has(op.name)) continue;
|
||||
if (WRITE_OPS.has(op.name)) continue; // skip write ops; virtual versions land below
|
||||
const toolName = `brain_${op.name}`;
|
||||
baseDefs.push({
|
||||
name: toolName,
|
||||
description: op.description,
|
||||
inputSchema: paramsToSchema(op.params),
|
||||
});
|
||||
baseHandlers.set(toolName, {
|
||||
idempotent: true,
|
||||
execute: async (input: unknown) => op.handler(ctx, (input as Record<string, unknown>) ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Virtual put_page. Captures the write; returns the slug + 'virtual: true'
|
||||
// so the agent's loop knows the write succeeded (or thinks it did) AND
|
||||
// the judge can see it was a virtual write.
|
||||
const defs = [...baseDefs];
|
||||
const handlers = new Map(baseHandlers);
|
||||
defs.push({
|
||||
name: 'brain_put_page',
|
||||
description: '[write-capture mode] Write a page to the brain. In write-capture mode, the write is captured in-memory for judge inspection but NOT persisted.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slug: { type: 'string', description: 'Page slug' },
|
||||
content: { type: 'string', description: 'Markdown body' },
|
||||
type: { type: 'string', description: 'Page type (optional)' },
|
||||
frontmatter: { type: 'object', description: 'YAML frontmatter (optional)' },
|
||||
},
|
||||
required: ['slug', 'content'],
|
||||
},
|
||||
});
|
||||
handlers.set('brain_put_page', {
|
||||
idempotent: true,
|
||||
execute: async (input: unknown) => {
|
||||
const i = (input as Record<string, unknown>) ?? {};
|
||||
const slug = String(i.slug ?? '');
|
||||
if (!slug) throw new Error('virtual put_page: slug required');
|
||||
const captured: CapturedWrite = {
|
||||
op: 'put_page',
|
||||
key: slug,
|
||||
input: i,
|
||||
ordinal: nextOrdinal++,
|
||||
};
|
||||
writes.push(captured);
|
||||
virtualPages.set(slug, captured);
|
||||
return { ok: true, virtual: true, slug, note: 'Captured by SkillOpt write-capture mode; not persisted.' };
|
||||
},
|
||||
});
|
||||
defs.push({
|
||||
name: 'brain_submit_job',
|
||||
description: '[write-capture mode] Submit a Minion job. Captured in-memory; not actually submitted.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Job name' },
|
||||
params: { type: 'object', description: 'Job params (optional)' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
});
|
||||
handlers.set('brain_submit_job', {
|
||||
idempotent: true,
|
||||
execute: async (input: unknown) => {
|
||||
const i = (input as Record<string, unknown>) ?? {};
|
||||
const name = String(i.name ?? '');
|
||||
if (!name) throw new Error('virtual submit_job: name required');
|
||||
const captured: CapturedWrite = {
|
||||
op: 'submit_job',
|
||||
key: name,
|
||||
input: i,
|
||||
ordinal: nextOrdinal++,
|
||||
};
|
||||
writes.push(captured);
|
||||
return { ok: true, virtual: true, job_name: name, note: 'Captured by SkillOpt write-capture mode; not submitted.' };
|
||||
},
|
||||
});
|
||||
defs.push({
|
||||
name: 'brain_file_upload',
|
||||
description: '[write-capture mode] Upload a file to brain storage. Captured in-memory; nothing written to disk.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Local file path' },
|
||||
page_slug: { type: 'string', description: 'Associate with page (optional)' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
});
|
||||
handlers.set('brain_file_upload', {
|
||||
idempotent: true,
|
||||
execute: async (input: unknown) => {
|
||||
const i = (input as Record<string, unknown>) ?? {};
|
||||
const filePath = String(i.path ?? '');
|
||||
if (!filePath) throw new Error('virtual file_upload: path required');
|
||||
const captured: CapturedWrite = {
|
||||
op: 'file_upload',
|
||||
key: filePath,
|
||||
input: i,
|
||||
ordinal: nextOrdinal++,
|
||||
};
|
||||
writes.push(captured);
|
||||
return { ok: true, virtual: true, path: filePath, note: 'Captured by SkillOpt write-capture mode; nothing uploaded.' };
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
baseDefs,
|
||||
baseHandlers,
|
||||
defs,
|
||||
handlers,
|
||||
getWrites: () => writes,
|
||||
getVirtualPages: () => virtualPages,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpContext(engine: BrainEngine): OperationContext {
|
||||
const cfg = loadConfig();
|
||||
return {
|
||||
engine,
|
||||
config: cfg ?? ({} as never),
|
||||
logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as never,
|
||||
dryRun: false,
|
||||
remote: true,
|
||||
sourceId: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
function paramsToSchema(params: Record<string, { type: string; description?: string; required?: boolean }>): Record<string, unknown> {
|
||||
return {
|
||||
type: 'object' as const,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(params).map(([k, v]) => [k, { type: v.type, description: v.description }]),
|
||||
),
|
||||
required: Object.entries(params).filter(([, v]) => v.required).map(([k]) => k),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user