Skip to content

Commit 41e5b50

Browse files
committed
fix(skill-optimizer): address PR #64 review findings
13 review findings, all valid, all fixed: 1. CLI infers provider from model id (claude-* -> anthropic, gpt-*/o* -> openai). Mixed-provider example in commands/skill-optimize.md no longer requires --evaluator-provider for gpt-4o-mini. 2. NaN guard on numeric flag parsing: --epochs notanumber now errors fast instead of producing NaN in config. 3. SKILL.md drops the 'ReflACT' label since it is not a SkillOpt term; loop is referred to by stage names only. 4. optimization_validation gains prompt_hash + unique(skill_slug, prompt_hash); upsertValidation now uses ON CONFLICT DO UPDATE and persists learning_id from trajectoriesToValidation. Eliminates duplicate validation rows on repeated train() runs. 5. aggregate.patchKey now includes payload, so two distinct add proposals at the same anchor are preserved. Conflict counter moved to a separate anchor-level set so 'same anchor, different payloads' is still surfaced as a conflict. 6. apply.ts replace branch uses skill.replace(anchor, () => payload) function form. Payloads containing $1 / $& / $` are no longer mangled as backreferences. 7. llm.ts fails closed on unknown models: PRICE_PER_M_TOKENS lookup throws with the known-model list instead of silently returning {0,0} and bypassing budget cap. 8. llm.ts postJson gains an abortable timeout (default 120s, override via SKILL_OPTIMIZER_TIMEOUT_MS). Timer cleared on response end and on error to avoid leaks. 9. trainer.ts adds a preflight guardBudget() check before every paid LLM call (baseline eval, reflect, evaluate, slow update, slow update eval). Loop short-circuits when remaining budget cannot cover the call. 10. trainer.ts flushes spent / accepted / rejected / epochs / bestSkillHash / bestScore via flushAndEnd() before every endRun() call (completed / failed / stopped). Counters now persist even on early-exit branches. 11. trainer.ts slow update now passes recentlyAcceptedDiffs (last 8) and uses delta >= acceptThreshold against the gate, identical to the per-step gate. Previously consolidation saw an empty diff list and used raw >= bestScore. 12. trainer.ts writeBestSkill strips any prior <!-- skill-optimizer: ... --> stamp before writing the new one, so re-runs do not accumulate stamps. stripExistingStamp exported for test coverage. 13. Shared parse.ts (stripFencesAndParse) extracted from reflect.ts / validate.ts / slow.ts. All three modules now share identical fence-strip + try/catch JSON parse behavior. Tests: 13/13 pass (4 new cases for the new behavior). Build + typecheck clean.
1 parent 7a87f81 commit 41e5b50

14 files changed

Lines changed: 403 additions & 239 deletions

File tree

commands/skill-optimize.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Run an offline, budget-capped optimization loop over a skill's accumulated `lear
88

99
## Quick Start
1010

11-
```
11+
```text
1212
/skill-optimize <slug> [--epochs 3] [--budget-usd 0.50]
1313
```
1414

@@ -28,10 +28,12 @@ Run an offline, budget-capped optimization loop over a skill's accumulated `lear
2828

2929
## Examples
3030

31-
```
31+
```text
3232
/skill-optimize pro-workflow
3333
/skill-optimize wiki-research-loop --budget-usd 1.0 --epochs 5
34-
/skill-optimize wrap-up --optimizer-model claude-opus-4-7 --evaluator-model gpt-4o-mini --json
34+
/skill-optimize wrap-up --optimizer-model claude-opus-4-7 --evaluator-model gpt-4o-mini
3535
```
3636

37+
The third example mixes providers. The CLI infers the provider from the model id (`claude-*` → anthropic, `gpt-*` / `o*` → openai), so you do not need `--evaluator-provider openai` for `gpt-4o-mini`. Pass an explicit `--optimizer-provider` / `--evaluator-provider` to override inference.
38+
3739
See [skills/skill-optimizer/SKILL.md](../skills/skill-optimizer/SKILL.md) for full mechanics, defaults, and the SkillOpt provenance.

scripts/optimize-skill.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,33 @@ function parseArgs(argv) {
3535
if (!a.startsWith('--')) continue;
3636
const key = a.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
3737
const v = argv[++i];
38-
if (intKeys.has(key)) out[key] = parseInt(v, 10);
39-
else if (floatKeys.has(key)) out[key] = parseFloat(v);
40-
else out[key] = v;
38+
if (v === undefined || v === '') {
39+
throw new Error(`Missing value for --${key}`);
40+
}
41+
if (intKeys.has(key)) {
42+
const parsed = parseInt(v, 10);
43+
if (!Number.isFinite(parsed)) throw new Error(`Invalid integer value for --${key}: ${v}`);
44+
out[key] = parsed;
45+
} else if (floatKeys.has(key)) {
46+
const parsed = parseFloat(v);
47+
if (!Number.isFinite(parsed)) throw new Error(`Invalid number value for --${key}: ${v}`);
48+
out[key] = parsed;
49+
} else {
50+
out[key] = v;
51+
}
4152
}
4253
return out;
4354
}
4455

56+
function inferProvider(model) {
57+
if (!model) return null;
58+
if (/^claude-/.test(model)) return 'anthropic';
59+
if (/^(gpt-|o\d|chatgpt-)/i.test(model) || /openai/i.test(model)) return 'openai';
60+
if (/(?:^|\/)(?:llama-|mistralai\/|accounts\/fireworks)/i.test(model)) return 'fireworks';
61+
if (/\//.test(model)) return 'openrouter';
62+
return null;
63+
}
64+
4565
function findSkillPath(slug) {
4666
const candidates = [
4767
path.join(ROOT, 'skills', slug, 'SKILL.md'),
@@ -52,11 +72,27 @@ function findSkillPath(slug) {
5272
}
5373

5474
(async () => {
55-
const args = parseArgs(process.argv);
75+
let args;
76+
try {
77+
args = parseArgs(process.argv);
78+
} catch (err) {
79+
process.stderr.write(`${err.message}\n`);
80+
process.exit(1);
81+
}
5682
if (!args.slug) {
5783
process.stderr.write('Usage: optimize-skill --slug <name> [--skill-path <path>] [options]\n');
5884
process.exit(1);
5985
}
86+
const userSetOptProvider = process.argv.includes('--optimizer-provider');
87+
const userSetEvalProvider = process.argv.includes('--evaluator-provider');
88+
if (!userSetOptProvider) {
89+
const inferred = inferProvider(args.optimizerModel);
90+
if (inferred) args.optimizerProvider = inferred;
91+
}
92+
if (!userSetEvalProvider) {
93+
const inferred = inferProvider(args.evaluatorModel);
94+
if (inferred) args.evaluatorProvider = inferred;
95+
}
6096
const skillPath = args.skillPath || findSkillPath(args.slug);
6197
if (!skillPath || !fs.existsSync(skillPath)) {
6298
process.stderr.write(`SKILL.md not found for slug "${args.slug}"\n`);

skills/skill-optimizer/SKILL.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ Do not use when:
1919
- The user wants real-time edits (this is offline, single-shot)
2020
- No `ANTHROPIC_API_KEY` (or equivalent provider key) is available
2121

22-
## Architecture (mirrors SkillOpt)
23-
24-
```
25-
rollout pull recent learnings from SQLite (existing learn-rule rows)
26-
reflect optimizer LLM analyzes a minibatch, proposes add/delete/replace patches
27-
aggregate vote-merge patches across minibatches
28-
select clip by LR budget (default: 3 adds, 2 deletes, 3 replaces per step)
29-
update apply selected patches to a candidate skill content
30-
evaluate evaluator LLM scores candidate against held-out validation items
31-
gate accept candidate only if weighted score >= current + acceptThreshold
32-
slow update at epoch boundary, consolidate accepted edits into a coherent rewrite
22+
## Architecture (mirrors SkillOpt's six-stage loop)
23+
24+
```text
25+
rollout pull recent learnings from SQLite (existing learn-rule rows)
26+
reflect optimizer LLM analyzes a minibatch, proposes add/delete/replace patches
27+
aggregate vote-merge patches across minibatches
28+
select clip by LR budget (default: 3 adds, 2 deletes, 3 replaces per step)
29+
update apply selected patches to a candidate skill content
30+
evaluate evaluator LLM scores candidate against held-out validation items
31+
gate accept candidate only if weighted score >= current + acceptThreshold
32+
slow update at epoch boundary, consolidate accepted edits into a coherent rewrite
3333
```
3434

3535
Failed candidates are stored in a rejection buffer and fed back to the next reflect step so the optimizer doesn't propose the same patch twice.
@@ -84,4 +84,4 @@ sqlite3 ~/.pro-workflow/data.db "SELECT id, skill_slug, initial_score, best_scor
8484

8585
## Provenance
8686

87-
Inspired by Microsoft SkillOpt (arXiv:2605.23904). The ReflACT 6-stage pipeline, LR budget, rejection buffer, and slow / meta update mechanics are adapted to pro-workflow's existing SQLite + learn-rule data plane. No SkillOpt code is reused.
87+
Inspired by Microsoft SkillOpt (arXiv:2605.23904). The six-stage rollout/reflect/aggregate/select/update/evaluate pipeline, LR budget, rejection buffer, and slow / meta update mechanics are adapted to pro-workflow's existing SQLite + learn-rule data plane. No SkillOpt code is reused. "ReflACT" is not a SkillOpt term and is not used here; the loop is referred to by stage names only.

src/db/schema.sql

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,14 @@ CREATE TABLE IF NOT EXISTS optimization_validation (
232232
id INTEGER PRIMARY KEY AUTOINCREMENT,
233233
skill_slug TEXT NOT NULL,
234234
learning_id INTEGER REFERENCES learnings(id) ON DELETE SET NULL,
235+
prompt_hash TEXT NOT NULL,
235236
prompt TEXT NOT NULL,
236237
expected TEXT NOT NULL,
237238
weight REAL NOT NULL DEFAULT 1.0,
238239
frozen_at TEXT DEFAULT (datetime('now'))
239240
);
240241
CREATE INDEX IF NOT EXISTS idx_opt_val_skill ON optimization_validation(skill_slug);
242+
CREATE UNIQUE INDEX IF NOT EXISTS idx_opt_val_unique ON optimization_validation(skill_slug, prompt_hash);
241243

242244
CREATE TABLE IF NOT EXISTS optimization_rejections (
243245
id INTEGER PRIMARY KEY AUTOINCREMENT,

src/optimizer/__tests__/apply.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
33
import { applyPatches } from '../apply';
44
import { aggregatePatches } from '../aggregate';
55
import { clipByLR } from '../clip';
6+
import { stripExistingStamp } from '../trainer';
67
import type { Patch } from '../types';
78

89
describe('applyPatches', () => {
@@ -75,6 +76,38 @@ describe('aggregatePatches', () => {
7576
});
7677
});
7778

79+
describe('apply replace handles $-sequences verbatim', () => {
80+
it('does not treat $1 in payload as a backreference', () => {
81+
const out = applyPatches('# A\nold\n', [
82+
{ op: 'replace', anchor: 'old', payload: 'cost: $1 per unit, $2 each' },
83+
]);
84+
assert.match(out.content, /cost: \$1 per unit, \$2 each/);
85+
});
86+
});
87+
88+
describe('aggregate keeps distinct payloads at same anchor', () => {
89+
it('does not collide different add payloads at same anchor', () => {
90+
const out = aggregatePatches([
91+
[{ op: 'add', anchor: '## X', payload: 'foo' }],
92+
[{ op: 'add', anchor: '## X', payload: 'bar' }],
93+
]);
94+
assert.equal(out.merged.length, 2);
95+
assert.ok(out.conflicts >= 1);
96+
});
97+
});
98+
99+
describe('stripExistingStamp', () => {
100+
it('removes the trailing optimizer stamp', () => {
101+
const stamped = '# Skill\nbody\n<!-- skill-optimizer: hash=abc slug=foo -->\n';
102+
assert.equal(stripExistingStamp(stamped), '# Skill\nbody');
103+
});
104+
105+
it('leaves unstamped content alone', () => {
106+
const plain = '# Skill\nbody\n';
107+
assert.equal(stripExistingStamp(plain), plain);
108+
});
109+
});
110+
78111
describe('clipByLR', () => {
79112
it('respects per-op budget', () => {
80113
const patches: Patch[] = [

src/optimizer/aggregate.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,29 @@ export interface AggregateResult {
88

99
export function aggregatePatches(batches: Patch[][]): AggregateResult {
1010
const seen = new Map<string, { patch: Patch; votes: number }>();
11-
let conflicts = 0;
11+
const anchorSeen = new Map<string, Set<string>>();
1212

1313
for (const batch of batches) {
1414
for (const patch of batch) {
1515
const key = patchKey(patch);
1616
const prior = seen.get(key);
17-
if (!prior) {
18-
seen.set(key, { patch, votes: 1 });
19-
continue;
20-
}
21-
if (prior.patch.op === patch.op && prior.patch.payload === patch.payload) {
17+
if (prior) {
2218
prior.votes++;
2319
} else {
24-
conflicts++;
25-
if (prior.votes < 2) seen.set(key, { patch, votes: 1 });
20+
seen.set(key, { patch, votes: 1 });
2621
}
22+
const anchorKey = anchorKeyFor(patch);
23+
const set = anchorSeen.get(anchorKey) ?? new Set<string>();
24+
set.add(patch.payload);
25+
anchorSeen.set(anchorKey, set);
2726
}
2827
}
2928

29+
let conflicts = 0;
30+
for (const variants of anchorSeen.values()) {
31+
if (variants.size > 1) conflicts += variants.size - 1;
32+
}
33+
3034
const ordered = [...seen.values()].sort((a, b) => {
3135
if (a.patch.op !== b.patch.op) return opRank(a.patch.op) - opRank(b.patch.op);
3236
return b.votes - a.votes;
@@ -37,6 +41,10 @@ export function aggregatePatches(batches: Patch[][]): AggregateResult {
3741
}
3842

3943
function patchKey(p: Patch): string {
44+
return `${p.op}::${p.anchor.trim().toLowerCase()}::${p.payload}`;
45+
}
46+
47+
function anchorKeyFor(p: Patch): string {
4048
return `${p.op}::${p.anchor.trim().toLowerCase()}`;
4149
}
4250

src/optimizer/apply.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function applyOne(skill: string, patch: Patch): { ok: true; content: string } |
5454
if (!skill.includes(patch.anchor)) {
5555
return { ok: false, reason: `anchor not found: ${truncate(patch.anchor)}` };
5656
}
57-
return { ok: true, content: skill.replace(patch.anchor, patch.payload) };
57+
return { ok: true, content: skill.replace(patch.anchor, () => patch.payload) };
5858
}
5959

6060
return { ok: false, reason: `unknown op: ${(patch as Patch).op}` };

src/optimizer/llm.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as https from 'node:https';
22

3+
const DEFAULT_TIMEOUT_MS = 120_000;
4+
35
export type Provider = 'anthropic' | 'openai' | 'openrouter' | 'fireworks';
46

57
export interface LLMRequest {
@@ -46,11 +48,19 @@ export async function callLLM(req: LLMRequest): Promise<LLMResponse> {
4648
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }
4749
: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' };
4850

51+
const price = PRICE_PER_M_TOKENS[req.model];
52+
if (!price) {
53+
const known = Object.keys(PRICE_PER_M_TOKENS).join(', ');
54+
throw new Error(
55+
`Unknown model "${req.model}". Budget cap cannot be enforced. ` +
56+
`Add a price entry to PRICE_PER_M_TOKENS in src/optimizer/llm.ts or pick one of: ${known}.`,
57+
);
58+
}
59+
4960
const raw = await postJson(cfg.host, cfg.path, headers, body);
5061
const parsed = JSON.parse(raw);
5162
const text = extractText(req.provider, parsed);
5263
const usage = extractUsage(req.provider, parsed);
53-
const price = PRICE_PER_M_TOKENS[req.model] ?? { input: 0, output: 0 };
5464
const costUsd = (usage.input / 1_000_000) * price.input + (usage.output / 1_000_000) * price.output;
5565

5666
return { text, inputTokens: usage.input, outputTokens: usage.output, costUsd };
@@ -99,20 +109,39 @@ function extractUsage(provider: Provider, body: unknown): { input: number; outpu
99109
}
100110

101111
function postJson(host: string, path: string, headers: Record<string, string>, body: string): Promise<string> {
112+
const timeoutMs = parseInt(process.env.SKILL_OPTIMIZER_TIMEOUT_MS ?? '', 10) || DEFAULT_TIMEOUT_MS;
102113
return new Promise((resolve, reject) => {
114+
let timer: NodeJS.Timeout | null = null;
115+
let settled = false;
116+
const settle = (fn: () => void) => {
117+
if (settled) return;
118+
settled = true;
119+
if (timer) {
120+
clearTimeout(timer);
121+
timer = null;
122+
}
123+
fn();
124+
};
103125
const req = https.request(
104126
{ host, path, method: 'POST', headers: { ...headers, 'content-length': Buffer.byteLength(body).toString() } },
105127
(res) => {
106128
const chunks: Buffer[] = [];
107129
res.on('data', (c) => chunks.push(c));
108130
res.on('end', () => {
109131
const text = Buffer.concat(chunks).toString('utf8');
110-
if ((res.statusCode ?? 500) >= 400) reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`));
111-
else resolve(text);
132+
if ((res.statusCode ?? 500) >= 400) {
133+
settle(() => reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`)));
134+
} else {
135+
settle(() => resolve(text));
136+
}
112137
});
138+
res.on('error', (err) => settle(() => reject(err)));
113139
},
114140
);
115-
req.on('error', reject);
141+
req.on('error', (err) => settle(() => reject(err)));
142+
timer = setTimeout(() => {
143+
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
144+
}, timeoutMs);
116145
req.write(body);
117146
req.end();
118147
});

src/optimizer/parse.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function stripFencesAndParse<T = unknown>(text: string): T | null {
2+
const stripped = text.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
3+
try {
4+
return JSON.parse(stripped) as T;
5+
} catch {
6+
return null;
7+
}
8+
}

src/optimizer/reflect.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { LRBudget, Patch, ReflectInput, ReflectOutput, Rejection, Trajectory } from './types';
22
import { callLLM, type Provider } from './llm';
3+
import { stripFencesAndParse } from './parse';
34

45
export interface ReflectArgs {
56
input: ReflectInput;
@@ -67,15 +68,8 @@ function formatRejection(r: Rejection): Record<string, unknown> {
6768
}
6869

6970
function parsePatches(text: string): Patch[] {
70-
const stripped = stripFences(text).trim();
71-
let obj: unknown;
72-
try {
73-
obj = JSON.parse(stripped);
74-
} catch {
75-
return [];
76-
}
77-
const root = obj as { patches?: unknown };
78-
if (!Array.isArray(root.patches)) return [];
71+
const root = stripFencesAndParse<{ patches?: unknown }>(text);
72+
if (!root || !Array.isArray(root.patches)) return [];
7973
const out: Patch[] = [];
8074
for (const raw of root.patches) {
8175
const p = raw as Record<string, unknown>;
@@ -87,17 +81,8 @@ function parsePatches(text: string): Patch[] {
8781
}
8882

8983
function extractReasoning(text: string): string {
90-
const stripped = stripFences(text).trim();
91-
try {
92-
const obj = JSON.parse(stripped) as { reasoning?: string };
93-
return obj.reasoning ?? '';
94-
} catch {
95-
return '';
96-
}
97-
}
98-
99-
function stripFences(s: string): string {
100-
return s.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
84+
const root = stripFencesAndParse<{ reasoning?: string }>(text);
85+
return root?.reasoning ?? '';
10186
}
10287

10388
export const __test = { parsePatches, extractReasoning };

0 commit comments

Comments
 (0)