@crewhaus/prompt-optimizer-claude 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pillar 2 — `prompt-optimizer-claude`. The model-driven MutationProvider
3
+ * that closes the gap flagged at
4
+ * [packages/prompt-optimizer/src/index.ts:91](../../prompt-optimizer/src/index.ts):
5
+ * "Real-world prompt tuners (DSPy, OPRO) use model-driven rewriting; v0
6
+ * of this module ships rule-based mutations." This package IS that
7
+ * model-driven rewriter.
8
+ *
9
+ * Behaviour:
10
+ * 1. Receives the current best Candidate via the MutationProvider
11
+ * seam (state.best.prompt + state.trajectory).
12
+ * 2. Selects a window of failing dev samples (the ones whose recent
13
+ * grades suggest the prompt is the blocker — heuristic: the
14
+ * lowest-scoring fitness deltas from the most recent iteration).
15
+ * 3. Calls Claude with a meta-prompt asking for a structured
16
+ * `{ rewrite: string, rationale: string }` rewrite. The model
17
+ * sees: the current prompt, a sample of failures, and an
18
+ * explicit instruction to produce a better prompt without
19
+ * leaking dev-set answers.
20
+ * 4. Validates the response shape with Zod. Falls back to the
21
+ * current best on any error (so a model outage doesn't abort
22
+ * the search — see the rule-based provider as the deterministic
23
+ * fallback path).
24
+ *
25
+ * Cost: each call is one Claude request (~1-3K tokens). Each `next()`
26
+ * now surfaces the call's actual token `usage` on the returned
27
+ * `ProviderMutation`, and the provider exposes its `modelId` +
28
+ * `maxOutputTokens` getters, so the FR-003 `--budget-usd` cost-gate in
29
+ * `eval-optimizer-orchestrator` can price every call against the §27
30
+ * `cost-tracker` table and stop before a mutation call would exceed
31
+ * the budget. The gate composes with the orchestrator's `iterations`
32
+ * cap (whichever bound is hit first ends the run).
33
+ *
34
+ * Catalog layer: F-eval (active optimisation). Brief: 280.
35
+ */
36
+ import { type ProviderAdapter } from "@crewhaus/adapter-anthropic";
37
+ import { CrewhausError } from "@crewhaus/errors";
38
+ import type { MutationProvider, OptimizerState, ProviderMutation } from "@crewhaus/prompt-optimizer";
39
+ export declare class ClaudeMutationProviderError extends CrewhausError {
40
+ readonly name = "ClaudeMutationProviderError";
41
+ constructor(message: string, cause?: unknown);
42
+ }
43
+ export type ClaudeMutationProviderOptions = {
44
+ /** Provider adapter (typically the Anthropic adapter). */
45
+ readonly adapter: ProviderAdapter;
46
+ /** Model id, e.g. "claude-sonnet-4-5". */
47
+ readonly model: string;
48
+ /** Maximum failures to include in the meta-prompt (default 5). */
49
+ readonly maxFailuresInPrompt?: number;
50
+ /** Lowest score threshold below which a sample counts as a "failure" (default 0.5). */
51
+ readonly failureThreshold?: number;
52
+ /** Maximum tokens for the rewrite response (default 2048). */
53
+ readonly maxTokens?: number;
54
+ /**
55
+ * Override the meta-prompt's system block. Useful for evals that
56
+ * have domain-specific rewrite constraints. Defaults to the
57
+ * production prompt above.
58
+ */
59
+ readonly systemOverride?: string;
60
+ };
61
+ /**
62
+ * Build a `MutationProvider` that delegates each candidate generation
63
+ * to a Claude (or any `ProviderAdapter`-compatible) model call.
64
+ */
65
+ export declare class ClaudeMutationProvider implements MutationProvider {
66
+ readonly name = "claude";
67
+ private readonly adapter;
68
+ private readonly model;
69
+ private readonly maxFailuresInPrompt;
70
+ private readonly failureThreshold;
71
+ private readonly maxTokens;
72
+ private readonly systemBlock;
73
+ constructor(opts: ClaudeMutationProviderOptions);
74
+ /**
75
+ * The model id this provider calls, exposed read-only so the FR-003
76
+ * cost-gate can price each call via `resolvePricing(DEFAULT_PRICING,
77
+ * "anthropic", modelId)`. The `MutationProvider` interface only
78
+ * guarantees `name` + `next()`; the orchestrator feature-detects this
79
+ * getter and falls back to a zero-cost meter for providers that don't
80
+ * expose it.
81
+ */
82
+ get modelId(): string;
83
+ /**
84
+ * The provider id of the injected adapter, exposed read-only so the
85
+ * FR-003 cost-gate prices calls against the REAL provider's pricing
86
+ * table instead of assuming "anthropic". Feature-detected by the
87
+ * orchestrator like `modelId` — providers without the getter price as
88
+ * Anthropic (the historical behaviour).
89
+ */
90
+ get providerId(): string;
91
+ /**
92
+ * The output-token ceiling for each call, exposed read-only so the
93
+ * cost-gate can compute a worst-case pre-call estimate (the gate must
94
+ * decide BEFORE a call whether it would exceed the budget).
95
+ */
96
+ get maxOutputTokens(): number;
97
+ /**
98
+ * FR-003 — exact serialized INPUT character count this provider would
99
+ * transmit for `state`, so the cost-gate prices the *real* meta-prompt
100
+ * rather than just `best.prompt.length + a fixed overhead`. The naive
101
+ * estimate (prompt length only) under-counts the system block AND the
102
+ * rendered dev-set failure block (each failure's input + expected_output)
103
+ * that `next()` actually sends; with a large dev window + small maxTokens
104
+ * that deficit could let a gate-passing call exceed the budget after the
105
+ * fact. Returning the full system+user char count here makes the
106
+ * `chars/4` token estimate cover everything the model is billed for, so
107
+ * the orchestrator's estimate-before guarantee holds unconditionally
108
+ * (input is now bounded from above, output is already the ceiling).
109
+ *
110
+ * The orchestrator feature-detects this method (it is not part of the
111
+ * `MutationProvider` interface); providers that omit it fall back to the
112
+ * `best.prompt.length + metaOverheadChars` heuristic.
113
+ */
114
+ estimateInputChars(state: OptimizerState): number;
115
+ next(state: OptimizerState): Promise<ProviderMutation>;
116
+ /** Build the user message for the meta-prompt. */
117
+ private buildUserMessage;
118
+ /** Identify failure samples from the trajectory. */
119
+ private selectFailures;
120
+ /**
121
+ * Fallback when the model can't produce a usable rewrite. Returns
122
+ * the current best verbatim so the search loop records a no-op
123
+ * iteration. The orchestrator logs the fallback reason. When the
124
+ * model DID round-trip (returned text/JSON that turned out unusable),
125
+ * the `usage` actually consumed is forwarded so the cost-gate still
126
+ * accounts for the spend; a stream error (no completed call) passes
127
+ * no usage and is therefore charged zero.
128
+ */
129
+ private fallback;
130
+ }
131
+ /** Convenience factory mirroring `createAnthropicAdapter` ergonomics. */
132
+ export declare function createClaudeMutationProvider(opts: ClaudeMutationProviderOptions): ClaudeMutationProvider;
package/dist/index.js ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Pillar 2 — `prompt-optimizer-claude`. The model-driven MutationProvider
3
+ * that closes the gap flagged at
4
+ * [packages/prompt-optimizer/src/index.ts:91](../../prompt-optimizer/src/index.ts):
5
+ * "Real-world prompt tuners (DSPy, OPRO) use model-driven rewriting; v0
6
+ * of this module ships rule-based mutations." This package IS that
7
+ * model-driven rewriter.
8
+ *
9
+ * Behaviour:
10
+ * 1. Receives the current best Candidate via the MutationProvider
11
+ * seam (state.best.prompt + state.trajectory).
12
+ * 2. Selects a window of failing dev samples (the ones whose recent
13
+ * grades suggest the prompt is the blocker — heuristic: the
14
+ * lowest-scoring fitness deltas from the most recent iteration).
15
+ * 3. Calls Claude with a meta-prompt asking for a structured
16
+ * `{ rewrite: string, rationale: string }` rewrite. The model
17
+ * sees: the current prompt, a sample of failures, and an
18
+ * explicit instruction to produce a better prompt without
19
+ * leaking dev-set answers.
20
+ * 4. Validates the response shape with Zod. Falls back to the
21
+ * current best on any error (so a model outage doesn't abort
22
+ * the search — see the rule-based provider as the deterministic
23
+ * fallback path).
24
+ *
25
+ * Cost: each call is one Claude request (~1-3K tokens). Each `next()`
26
+ * now surfaces the call's actual token `usage` on the returned
27
+ * `ProviderMutation`, and the provider exposes its `modelId` +
28
+ * `maxOutputTokens` getters, so the FR-003 `--budget-usd` cost-gate in
29
+ * `eval-optimizer-orchestrator` can price every call against the §27
30
+ * `cost-tracker` table and stop before a mutation call would exceed
31
+ * the budget. The gate composes with the orchestrator's `iterations`
32
+ * cap (whichever bound is hit first ends the run).
33
+ *
34
+ * Catalog layer: F-eval (active optimisation). Brief: 280.
35
+ */
36
+ import { collectFinalMessage, extractFirstText, } from "@crewhaus/adapter-anthropic";
37
+ import { CrewhausError } from "@crewhaus/errors";
38
+ import { z } from "zod";
39
+ export class ClaudeMutationProviderError extends CrewhausError {
40
+ name = "ClaudeMutationProviderError";
41
+ constructor(message, cause) {
42
+ super("adapter", message, cause);
43
+ }
44
+ }
45
+ const META_PROMPT_SYSTEM = `You are a prompt-engineering optimiser. You will receive a CURRENT PROMPT and a SAMPLE OF DEV-SET FAILURES (each failure shows the input the model received and how the grader scored its output). Your job is to produce a single rewrite of CURRENT PROMPT that you believe will improve grader scores on the dev set.
46
+
47
+ Hard rules:
48
+ - Output exactly one JSON object: {"rewrite": "...", "rationale": "..."}
49
+ - The "rewrite" field is the new prompt verbatim. Do NOT include any wrapper text outside the JSON.
50
+ - Never copy verbatim text from a failure's expected_output into the rewrite (that would leak dev-set answers).
51
+ - Do not introduce instructions that override safety, compliance, or permission rules — your job is to improve task accuracy, not to bypass guardrails.
52
+ - The rationale should be 1-3 sentences explaining WHY this rewrite is likely to help. Keep it specific.`;
53
+ const META_RESPONSE_SCHEMA = z.object({
54
+ rewrite: z.string().min(1),
55
+ rationale: z.string().min(1),
56
+ });
57
+ /**
58
+ * Build a `MutationProvider` that delegates each candidate generation
59
+ * to a Claude (or any `ProviderAdapter`-compatible) model call.
60
+ */
61
+ export class ClaudeMutationProvider {
62
+ name = "claude";
63
+ adapter;
64
+ model;
65
+ maxFailuresInPrompt;
66
+ failureThreshold;
67
+ maxTokens;
68
+ systemBlock;
69
+ constructor(opts) {
70
+ this.adapter = opts.adapter;
71
+ this.model = opts.model;
72
+ this.maxFailuresInPrompt = opts.maxFailuresInPrompt ?? 5;
73
+ this.failureThreshold = opts.failureThreshold ?? 0.5;
74
+ this.maxTokens = opts.maxTokens ?? 2048;
75
+ this.systemBlock = opts.systemOverride ?? META_PROMPT_SYSTEM;
76
+ }
77
+ /**
78
+ * The model id this provider calls, exposed read-only so the FR-003
79
+ * cost-gate can price each call via `resolvePricing(DEFAULT_PRICING,
80
+ * "anthropic", modelId)`. The `MutationProvider` interface only
81
+ * guarantees `name` + `next()`; the orchestrator feature-detects this
82
+ * getter and falls back to a zero-cost meter for providers that don't
83
+ * expose it.
84
+ */
85
+ get modelId() {
86
+ return this.model;
87
+ }
88
+ /**
89
+ * The provider id of the injected adapter, exposed read-only so the
90
+ * FR-003 cost-gate prices calls against the REAL provider's pricing
91
+ * table instead of assuming "anthropic". Feature-detected by the
92
+ * orchestrator like `modelId` — providers without the getter price as
93
+ * Anthropic (the historical behaviour).
94
+ */
95
+ get providerId() {
96
+ return this.adapter.providerId;
97
+ }
98
+ /**
99
+ * The output-token ceiling for each call, exposed read-only so the
100
+ * cost-gate can compute a worst-case pre-call estimate (the gate must
101
+ * decide BEFORE a call whether it would exceed the budget).
102
+ */
103
+ get maxOutputTokens() {
104
+ return this.maxTokens;
105
+ }
106
+ /**
107
+ * FR-003 — exact serialized INPUT character count this provider would
108
+ * transmit for `state`, so the cost-gate prices the *real* meta-prompt
109
+ * rather than just `best.prompt.length + a fixed overhead`. The naive
110
+ * estimate (prompt length only) under-counts the system block AND the
111
+ * rendered dev-set failure block (each failure's input + expected_output)
112
+ * that `next()` actually sends; with a large dev window + small maxTokens
113
+ * that deficit could let a gate-passing call exceed the budget after the
114
+ * fact. Returning the full system+user char count here makes the
115
+ * `chars/4` token estimate cover everything the model is billed for, so
116
+ * the orchestrator's estimate-before guarantee holds unconditionally
117
+ * (input is now bounded from above, output is already the ceiling).
118
+ *
119
+ * The orchestrator feature-detects this method (it is not part of the
120
+ * `MutationProvider` interface); providers that omit it fall back to the
121
+ * `best.prompt.length + metaOverheadChars` heuristic.
122
+ */
123
+ estimateInputChars(state) {
124
+ const failures = this.selectFailures(state);
125
+ const userMessage = this.buildUserMessage(state.best.prompt, failures);
126
+ return this.systemBlock.length + userMessage.length;
127
+ }
128
+ async next(state) {
129
+ const failures = this.selectFailures(state);
130
+ const userMessage = this.buildUserMessage(state.best.prompt, failures);
131
+ // The call's actual token usage, captured so the FR-003 cost-gate
132
+ // can fold it into a running spend total. Stays undefined until the
133
+ // model call round-trips; a failed/unusable response still records
134
+ // whatever was consumed (the model DID run) so spend is accounted.
135
+ let callUsage;
136
+ let rawText;
137
+ try {
138
+ const final = await collectFinalMessage(this.adapter.stream({
139
+ model: this.model,
140
+ system: [{ type: "text", text: this.systemBlock }],
141
+ messages: [{ role: "user", content: userMessage }],
142
+ maxTokens: this.maxTokens,
143
+ }));
144
+ callUsage = {
145
+ input: final.usage.input,
146
+ output: final.usage.output,
147
+ ...(final.usage.cacheRead !== undefined ? { cacheRead: final.usage.cacheRead } : {}),
148
+ };
149
+ rawText = extractFirstText(final);
150
+ }
151
+ catch (err) {
152
+ // Mutator unavailability is not fatal — fall back to current best.
153
+ // The orchestrator's outer loop will record a degenerate iteration
154
+ // (score unchanged) and the search continues. No usage is available
155
+ // on a stream error (the call did not complete), so spend is zero.
156
+ return this.fallback(state, `model error: ${err.message}`);
157
+ }
158
+ if (rawText === undefined) {
159
+ return this.fallback(state, "model returned no text block", callUsage);
160
+ }
161
+ // Extract JSON: tolerate ```json fences and leading prose. We
162
+ // search for the first balanced `{...}` substring.
163
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/);
164
+ if (jsonMatch === null) {
165
+ return this.fallback(state, "model response did not contain a JSON object", callUsage);
166
+ }
167
+ let parsed;
168
+ try {
169
+ const raw = JSON.parse(jsonMatch[0]);
170
+ parsed = META_RESPONSE_SCHEMA.parse(raw);
171
+ }
172
+ catch (err) {
173
+ return this.fallback(state, `model response failed schema validation: ${err.message}`, callUsage);
174
+ }
175
+ const mutation = { kind: "rephrase-instruction" };
176
+ // We use the rephrase-instruction kind to record the mutation in
177
+ // the trajectory; the actual rewrite is full-replacement, not the
178
+ // rule-based provider's "append a sentence" behaviour. Future work:
179
+ // add a distinct `{ kind: "model-rewrite"; rationale: string }`
180
+ // variant to the Mutation union for better trajectory metadata.
181
+ return {
182
+ prompt: parsed.rewrite,
183
+ mutations: [mutation],
184
+ rationale: parsed.rationale,
185
+ ...(callUsage !== undefined ? { usage: callUsage } : {}),
186
+ };
187
+ }
188
+ /** Build the user message for the meta-prompt. */
189
+ buildUserMessage(currentPrompt, failures) {
190
+ const failureBlock = failures.length === 0
191
+ ? "(No dev-set failures available yet — propose a refinement that improves clarity, specificity, or instruction-following.)"
192
+ : failures
193
+ .map((f, i) => `--- Failure ${i + 1} (observed score ${f.observedScore.toFixed(2)}) ---\nInput: ${f.input}\n${f.expected !== undefined ? `Expected output (do NOT copy this verbatim into the rewrite): ${f.expected}\n` : ""}`)
194
+ .join("\n");
195
+ return `CURRENT PROMPT:\n${currentPrompt}\n\nSAMPLE OF DEV-SET FAILURES:\n${failureBlock}\n\nReturn one JSON object: {"rewrite": "...", "rationale": "..."}`;
196
+ }
197
+ /** Identify failure samples from the trajectory. */
198
+ selectFailures(state) {
199
+ // The trajectory records aggregate scores per candidate, not per
200
+ // sample. For v0 we surface the dev set as raw inputs and let
201
+ // the model reason about them generically; future iterations can
202
+ // wire a per-sample grade map through the OptimizerState. The
203
+ // result is still useful because the model sees the dev set
204
+ // distribution even without per-sample grades.
205
+ return state.devSet.slice(0, this.maxFailuresInPrompt).map((s) => ({
206
+ input: s.input,
207
+ // Use the trajectory's most recent score as a coarse signal.
208
+ observedScore: state.best.score,
209
+ ...(s.expected_output !== undefined ? { expected: s.expected_output } : {}),
210
+ }));
211
+ }
212
+ /**
213
+ * Fallback when the model can't produce a usable rewrite. Returns
214
+ * the current best verbatim so the search loop records a no-op
215
+ * iteration. The orchestrator logs the fallback reason. When the
216
+ * model DID round-trip (returned text/JSON that turned out unusable),
217
+ * the `usage` actually consumed is forwarded so the cost-gate still
218
+ * accounts for the spend; a stream error (no completed call) passes
219
+ * no usage and is therefore charged zero.
220
+ */
221
+ fallback(state, reason, usage) {
222
+ return {
223
+ prompt: state.best.prompt,
224
+ mutations: [],
225
+ rationale: `claude-fallback: ${reason}`,
226
+ ...(usage !== undefined ? { usage } : {}),
227
+ };
228
+ }
229
+ }
230
+ /** Convenience factory mirroring `createAnthropicAdapter` ergonomics. */
231
+ export function createClaudeMutationProvider(opts) {
232
+ return new ClaudeMutationProvider(opts);
233
+ }
package/package.json CHANGED
@@ -1,25 +1,28 @@
1
1
  {
2
2
  "name": "@crewhaus/prompt-optimizer-claude",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Pillar-2 model-driven MutationProvider — asks Claude to rewrite the prompt given dev-set failures. Closes the v0 prompt-optimizer's L91 'rule-based-only' gap.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
18
  "@anthropic-ai/sdk": "^0.96.0",
16
- "@crewhaus/adapter-anthropic": "0.1.4",
17
- "@crewhaus/errors": "0.1.4",
18
- "@crewhaus/prompt-optimizer": "0.1.4",
19
+ "@crewhaus/adapter-anthropic": "0.1.5",
20
+ "@crewhaus/errors": "0.1.5",
21
+ "@crewhaus/prompt-optimizer": "0.1.5",
19
22
  "zod": "^3.23.8"
20
23
  },
21
24
  "devDependencies": {
22
- "@crewhaus/eval-dataset": "0.1.4"
25
+ "@crewhaus/eval-dataset": "0.1.5"
23
26
  },
24
27
  "license": "Apache-2.0",
25
28
  "author": {
@@ -39,5 +42,5 @@
39
42
  "publishConfig": {
40
43
  "access": "public"
41
44
  },
42
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
43
46
  }
package/src/index.test.ts DELETED
@@ -1,324 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
3
- import type { Sample } from "@crewhaus/eval-dataset";
4
- import type { OptimizerState } from "@crewhaus/prompt-optimizer";
5
- import {
6
- ClaudeMutationProvider,
7
- ClaudeMutationProviderError,
8
- createClaudeMutationProvider,
9
- } from "./index";
10
-
11
- const SAMPLE_TRAIN: ReadonlyArray<Sample> = [
12
- { id: "t1", input: "What is 2+2?", expected_output: "4" },
13
- { id: "t2", input: "Capital of France?", expected_output: "Paris" },
14
- ];
15
-
16
- const SAMPLE_DEV: ReadonlyArray<Sample> = [
17
- { id: "d1", input: "What is 3+3?", expected_output: "6" },
18
- { id: "d2", input: "Capital of Germany?", expected_output: "Berlin" },
19
- ];
20
-
21
- /**
22
- * Build a mock provider adapter whose `.stream()` yields the StreamEvent
23
- * sequence that produces a single text message with the given content.
24
- * Matches the canonical event protocol consumed by
25
- * `consumeStream`/`collectFinalMessage`.
26
- */
27
- function mockAdapter(
28
- content: string,
29
- usage: { input: number; output: number; cacheRead?: number } = { input: 0, output: 0 },
30
- ): ProviderAdapter {
31
- return {
32
- id: "mock",
33
- features: {
34
- caching: "none",
35
- thinking: false,
36
- multimodal: { input: false, output: false },
37
- },
38
- // biome-ignore lint/suspicious/noExplicitAny: minimal mock
39
- stream(_params: any): AsyncIterable<any> {
40
- return (async function* () {
41
- // message_start carries input (+ cache) counts; message_delta
42
- // carries the running output count — matches the real protocol.
43
- yield {
44
- kind: "message_start",
45
- usage: {
46
- input: usage.input,
47
- output: 0,
48
- ...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
49
- },
50
- };
51
- yield {
52
- kind: "content_block_start",
53
- index: 0,
54
- block: { type: "text", text: "" },
55
- };
56
- yield {
57
- kind: "content_block_delta",
58
- index: 0,
59
- delta: { type: "text_delta", text: content },
60
- };
61
- yield { kind: "content_block_stop", index: 0 };
62
- yield {
63
- kind: "message_delta",
64
- stopReason: "end_turn",
65
- usage: { input: usage.input, output: usage.output },
66
- };
67
- yield { kind: "message_stop" };
68
- })();
69
- },
70
- } as unknown as ProviderAdapter;
71
- }
72
-
73
- const baseState: OptimizerState = {
74
- iteration: 1,
75
- best: {
76
- id: "candidate-0",
77
- prompt: "You are a helpful assistant.",
78
- mutations: [],
79
- score: 0.4,
80
- },
81
- trajectory: [],
82
- trainSet: SAMPLE_TRAIN,
83
- devSet: SAMPLE_DEV,
84
- };
85
-
86
- describe("ClaudeMutationProvider", () => {
87
- test("exposes the adapter's providerId for the FR-003 cost-gate (provider-agnostic pricing)", () => {
88
- const adapter = {
89
- ...mockAdapter("{}"),
90
- providerId: "openai",
91
- } as unknown as ProviderAdapter;
92
- const provider = new ClaudeMutationProvider({ adapter, model: "gpt-4o-mini" });
93
- expect(provider.providerId).toBe("openai");
94
- expect(provider.modelId).toBe("gpt-4o-mini");
95
- });
96
-
97
- test("parses a clean JSON response and returns it as the rewrite", async () => {
98
- const adapter = mockAdapter(
99
- `{"rewrite": "Think step by step before answering. Be concise.", "rationale": "Adds explicit chain-of-thought scaffolding which helps on multi-step problems."}`,
100
- );
101
- const provider = new ClaudeMutationProvider({
102
- adapter,
103
- model: "claude-sonnet-4-5",
104
- });
105
- const result = await provider.next(baseState);
106
- expect(result.prompt).toBe("Think step by step before answering. Be concise.");
107
- expect(result.rationale).toContain("chain-of-thought");
108
- expect(result.mutations).toHaveLength(1);
109
- });
110
-
111
- test("tolerates code-fence wrapping around the JSON", async () => {
112
- const adapter = mockAdapter(
113
- "```json\n" +
114
- `{"rewrite": "Be precise and direct.", "rationale": "Removes ambiguity."}` +
115
- "\n```",
116
- );
117
- const provider = new ClaudeMutationProvider({
118
- adapter,
119
- model: "claude-sonnet-4-5",
120
- });
121
- const result = await provider.next(baseState);
122
- expect(result.prompt).toBe("Be precise and direct.");
123
- });
124
-
125
- test("falls back to current best on malformed JSON (no abort)", async () => {
126
- const adapter = mockAdapter("not actually json");
127
- const provider = new ClaudeMutationProvider({
128
- adapter,
129
- model: "claude-sonnet-4-5",
130
- });
131
- const result = await provider.next(baseState);
132
- expect(result.prompt).toBe(baseState.best.prompt);
133
- expect(result.rationale).toContain("claude-fallback");
134
- });
135
-
136
- test("falls back when the model errors out", async () => {
137
- const adapter = {
138
- id: "mock",
139
- features: {
140
- caching: "none",
141
- thinking: false,
142
- multimodal: { input: false, output: false },
143
- },
144
- // biome-ignore lint/suspicious/noExplicitAny: minimal mock
145
- stream(_params: any): AsyncIterable<any> {
146
- return (async function* () {
147
- throw new Error("model unavailable");
148
- // biome-ignore lint/correctness/noUnreachable: keep typed as async generator
149
- yield;
150
- })();
151
- },
152
- } as unknown as ProviderAdapter;
153
- const provider = new ClaudeMutationProvider({
154
- adapter,
155
- model: "claude-sonnet-4-5",
156
- });
157
- const result = await provider.next(baseState);
158
- expect(result.prompt).toBe(baseState.best.prompt);
159
- expect(result.rationale).toContain("claude-fallback");
160
- expect(result.rationale).toContain("model unavailable");
161
- });
162
-
163
- test("rejects responses missing required schema fields", async () => {
164
- const adapter = mockAdapter(`{"rewrite": "Only rewrite, no rationale"}`);
165
- const provider = new ClaudeMutationProvider({
166
- adapter,
167
- model: "claude-sonnet-4-5",
168
- });
169
- const result = await provider.next(baseState);
170
- expect(result.prompt).toBe(baseState.best.prompt);
171
- expect(result.rationale).toContain("claude-fallback");
172
- });
173
-
174
- test("name is 'claude' for trajectory logging", () => {
175
- const provider = new ClaudeMutationProvider({
176
- adapter: mockAdapter(""),
177
- model: "claude-sonnet-4-5",
178
- });
179
- expect(provider.name).toBe("claude");
180
- });
181
-
182
- // FR-003 — the provider surfaces per-call usage + exposes pricing getters.
183
- test("returns per-call usage from the response on the success path", async () => {
184
- const adapter = mockAdapter(`{"rewrite": "Be concise.", "rationale": "Removes ambiguity."}`, {
185
- input: 1234,
186
- output: 567,
187
- cacheRead: 89,
188
- });
189
- const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
190
- const result = await provider.next(baseState);
191
- expect(result.prompt).toBe("Be concise.");
192
- expect(result.usage).toEqual({ input: 1234, output: 567, cacheRead: 89 });
193
- });
194
-
195
- test("forwards consumed usage even when the response is unusable (JSON miss)", async () => {
196
- const adapter = mockAdapter("not actually json", { input: 100, output: 20 });
197
- const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
198
- const result = await provider.next(baseState);
199
- // Fell back to current best, but the spend the model DID incur is reported.
200
- expect(result.prompt).toBe(baseState.best.prompt);
201
- expect(result.rationale).toContain("claude-fallback");
202
- expect(result.usage).toEqual({ input: 100, output: 20 });
203
- });
204
-
205
- test("omits usage when the model call never completes (stream error)", async () => {
206
- const adapter = {
207
- id: "mock",
208
- features: { caching: "none", thinking: false, multimodal: { input: false, output: false } },
209
- // biome-ignore lint/suspicious/noExplicitAny: minimal mock
210
- stream(_params: any): AsyncIterable<any> {
211
- return (async function* () {
212
- throw new Error("model unavailable");
213
- // biome-ignore lint/correctness/noUnreachable: keep typed as async generator
214
- yield;
215
- })();
216
- },
217
- } as unknown as ProviderAdapter;
218
- const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
219
- const result = await provider.next(baseState);
220
- expect(result.prompt).toBe(baseState.best.prompt);
221
- expect(result.usage).toBeUndefined();
222
- });
223
-
224
- test("exposes modelId + maxOutputTokens getters for the cost-gate", () => {
225
- const provider = new ClaudeMutationProvider({
226
- adapter: mockAdapter(""),
227
- model: "claude-opus-4-7",
228
- maxTokens: 4096,
229
- });
230
- expect(provider.modelId).toBe("claude-opus-4-7");
231
- expect(provider.maxOutputTokens).toBe(4096);
232
- });
233
-
234
- test("maxOutputTokens getter defaults to 2048", () => {
235
- const provider = new ClaudeMutationProvider({
236
- adapter: mockAdapter(""),
237
- model: "claude-sonnet-4-5",
238
- });
239
- expect(provider.maxOutputTokens).toBe(2048);
240
- });
241
-
242
- // FR-003 (input-estimate gap) — estimateInputChars must count the FULL
243
- // serialized input (system block + rendered dev-set failure block), not
244
- // just the candidate prompt, so the orchestrator's cost-gate cannot
245
- // under-count input for a wide dev window.
246
- test("estimateInputChars counts the system block + rendered failure block", () => {
247
- const provider = new ClaudeMutationProvider({
248
- adapter: mockAdapter(""),
249
- model: "claude-sonnet-4-5",
250
- });
251
- const est = provider.estimateInputChars(baseState);
252
- // Strictly greater than the prompt length alone — the deficit the naive
253
- // `best.prompt.length` estimate would have missed.
254
- expect(est).toBeGreaterThan(baseState.best.prompt.length);
255
- // The estimate covers the rendered failure block: it exceeds the prompt
256
- // length PLUS the combined dev-set input + expected_output text that
257
- // next() serializes — exactly the content the naive `best.prompt.length`
258
- // heuristic ignored.
259
- const devTextChars = SAMPLE_DEV.reduce(
260
- (acc, s) => acc + s.input.length + (s.expected_output?.length ?? 0),
261
- 0,
262
- );
263
- expect(est).toBeGreaterThan(baseState.best.prompt.length + devTextChars);
264
- });
265
-
266
- test("createClaudeMutationProvider factory yields an equivalent provider", async () => {
267
- const adapter = mockAdapter(
268
- `{"rewrite": "Be concise and exact.", "rationale": "Tightens the instruction."}`,
269
- );
270
- const provider = createClaudeMutationProvider({
271
- adapter,
272
- model: "claude-sonnet-4-5",
273
- maxTokens: 1024,
274
- });
275
- expect(provider).toBeInstanceOf(ClaudeMutationProvider);
276
- expect(provider.name).toBe("claude");
277
- expect(provider.modelId).toBe("claude-sonnet-4-5");
278
- expect(provider.maxOutputTokens).toBe(1024);
279
- // Behaves like the directly-constructed provider end-to-end.
280
- const result = await provider.next(baseState);
281
- expect(result.prompt).toBe("Be concise and exact.");
282
- });
283
-
284
- test("estimateInputChars grows with the dev-set failure window", () => {
285
- const wideDev: ReadonlyArray<Sample> = [
286
- ...SAMPLE_DEV,
287
- { id: "d3", input: "x".repeat(5_000), expected_output: "y".repeat(5_000) },
288
- { id: "d4", input: "z".repeat(5_000), expected_output: "w".repeat(5_000) },
289
- ];
290
- const provider = new ClaudeMutationProvider({
291
- adapter: mockAdapter(""),
292
- model: "claude-sonnet-4-5",
293
- maxFailuresInPrompt: 4,
294
- });
295
- const narrow = provider.estimateInputChars(baseState);
296
- const wide = provider.estimateInputChars({ ...baseState, devSet: wideDev });
297
- // A larger window serializes more failure text → a larger input estimate,
298
- // exactly the case the original prompt-only heuristic under-counted.
299
- expect(wide).toBeGreaterThan(narrow + 18_000);
300
- });
301
- });
302
-
303
- describe("ClaudeMutationProviderError", () => {
304
- test("carries the adapter code, stable name, and cause chain", () => {
305
- const cause = new Error("stream aborted");
306
- const err = new ClaudeMutationProviderError("mutation provider failed", cause);
307
- expect(err).toBeInstanceOf(Error);
308
- expect(err.name).toBe("ClaudeMutationProviderError");
309
- expect(err.code).toBe("adapter");
310
- expect(err.message).toBe("mutation provider failed");
311
- expect(err.cause).toBe(cause);
312
- expect(err.toJSON()).toMatchObject({
313
- name: "ClaudeMutationProviderError",
314
- code: "adapter",
315
- message: "mutation provider failed",
316
- cause: { name: "Error", message: "stream aborted" },
317
- });
318
- });
319
-
320
- test("constructs without a cause", () => {
321
- const err = new ClaudeMutationProviderError("no cause");
322
- expect(err.cause).toBeUndefined();
323
- });
324
- });
package/src/index.ts DELETED
@@ -1,298 +0,0 @@
1
- /**
2
- * Pillar 2 — `prompt-optimizer-claude`. The model-driven MutationProvider
3
- * that closes the gap flagged at
4
- * [packages/prompt-optimizer/src/index.ts:91](../../prompt-optimizer/src/index.ts):
5
- * "Real-world prompt tuners (DSPy, OPRO) use model-driven rewriting; v0
6
- * of this module ships rule-based mutations." This package IS that
7
- * model-driven rewriter.
8
- *
9
- * Behaviour:
10
- * 1. Receives the current best Candidate via the MutationProvider
11
- * seam (state.best.prompt + state.trajectory).
12
- * 2. Selects a window of failing dev samples (the ones whose recent
13
- * grades suggest the prompt is the blocker — heuristic: the
14
- * lowest-scoring fitness deltas from the most recent iteration).
15
- * 3. Calls Claude with a meta-prompt asking for a structured
16
- * `{ rewrite: string, rationale: string }` rewrite. The model
17
- * sees: the current prompt, a sample of failures, and an
18
- * explicit instruction to produce a better prompt without
19
- * leaking dev-set answers.
20
- * 4. Validates the response shape with Zod. Falls back to the
21
- * current best on any error (so a model outage doesn't abort
22
- * the search — see the rule-based provider as the deterministic
23
- * fallback path).
24
- *
25
- * Cost: each call is one Claude request (~1-3K tokens). Each `next()`
26
- * now surfaces the call's actual token `usage` on the returned
27
- * `ProviderMutation`, and the provider exposes its `modelId` +
28
- * `maxOutputTokens` getters, so the FR-003 `--budget-usd` cost-gate in
29
- * `eval-optimizer-orchestrator` can price every call against the §27
30
- * `cost-tracker` table and stop before a mutation call would exceed
31
- * the budget. The gate composes with the orchestrator's `iterations`
32
- * cap (whichever bound is hit first ends the run).
33
- *
34
- * Catalog layer: F-eval (active optimisation). Brief: 280.
35
- */
36
- import {
37
- type ProviderAdapter,
38
- collectFinalMessage,
39
- extractFirstText,
40
- } from "@crewhaus/adapter-anthropic";
41
- import { CrewhausError } from "@crewhaus/errors";
42
- import type {
43
- Mutation,
44
- MutationProvider,
45
- OptimizerState,
46
- ProviderMutation,
47
- } from "@crewhaus/prompt-optimizer";
48
- import { z } from "zod";
49
-
50
- export class ClaudeMutationProviderError extends CrewhausError {
51
- override readonly name = "ClaudeMutationProviderError";
52
- constructor(message: string, cause?: unknown) {
53
- super("adapter", message, cause);
54
- }
55
- }
56
-
57
- const META_PROMPT_SYSTEM = `You are a prompt-engineering optimiser. You will receive a CURRENT PROMPT and a SAMPLE OF DEV-SET FAILURES (each failure shows the input the model received and how the grader scored its output). Your job is to produce a single rewrite of CURRENT PROMPT that you believe will improve grader scores on the dev set.
58
-
59
- Hard rules:
60
- - Output exactly one JSON object: {"rewrite": "...", "rationale": "..."}
61
- - The "rewrite" field is the new prompt verbatim. Do NOT include any wrapper text outside the JSON.
62
- - Never copy verbatim text from a failure's expected_output into the rewrite (that would leak dev-set answers).
63
- - Do not introduce instructions that override safety, compliance, or permission rules — your job is to improve task accuracy, not to bypass guardrails.
64
- - The rationale should be 1-3 sentences explaining WHY this rewrite is likely to help. Keep it specific.`;
65
-
66
- const META_RESPONSE_SCHEMA = z.object({
67
- rewrite: z.string().min(1),
68
- rationale: z.string().min(1),
69
- });
70
-
71
- export type ClaudeMutationProviderOptions = {
72
- /** Provider adapter (typically the Anthropic adapter). */
73
- readonly adapter: ProviderAdapter;
74
- /** Model id, e.g. "claude-sonnet-4-5". */
75
- readonly model: string;
76
- /** Maximum failures to include in the meta-prompt (default 5). */
77
- readonly maxFailuresInPrompt?: number;
78
- /** Lowest score threshold below which a sample counts as a "failure" (default 0.5). */
79
- readonly failureThreshold?: number;
80
- /** Maximum tokens for the rewrite response (default 2048). */
81
- readonly maxTokens?: number;
82
- /**
83
- * Override the meta-prompt's system block. Useful for evals that
84
- * have domain-specific rewrite constraints. Defaults to the
85
- * production prompt above.
86
- */
87
- readonly systemOverride?: string;
88
- };
89
-
90
- /**
91
- * Build a `MutationProvider` that delegates each candidate generation
92
- * to a Claude (or any `ProviderAdapter`-compatible) model call.
93
- */
94
- export class ClaudeMutationProvider implements MutationProvider {
95
- readonly name = "claude";
96
- private readonly adapter: ProviderAdapter;
97
- private readonly model: string;
98
- private readonly maxFailuresInPrompt: number;
99
- private readonly failureThreshold: number;
100
- private readonly maxTokens: number;
101
- private readonly systemBlock: string;
102
-
103
- constructor(opts: ClaudeMutationProviderOptions) {
104
- this.adapter = opts.adapter;
105
- this.model = opts.model;
106
- this.maxFailuresInPrompt = opts.maxFailuresInPrompt ?? 5;
107
- this.failureThreshold = opts.failureThreshold ?? 0.5;
108
- this.maxTokens = opts.maxTokens ?? 2048;
109
- this.systemBlock = opts.systemOverride ?? META_PROMPT_SYSTEM;
110
- }
111
-
112
- /**
113
- * The model id this provider calls, exposed read-only so the FR-003
114
- * cost-gate can price each call via `resolvePricing(DEFAULT_PRICING,
115
- * "anthropic", modelId)`. The `MutationProvider` interface only
116
- * guarantees `name` + `next()`; the orchestrator feature-detects this
117
- * getter and falls back to a zero-cost meter for providers that don't
118
- * expose it.
119
- */
120
- get modelId(): string {
121
- return this.model;
122
- }
123
-
124
- /**
125
- * The provider id of the injected adapter, exposed read-only so the
126
- * FR-003 cost-gate prices calls against the REAL provider's pricing
127
- * table instead of assuming "anthropic". Feature-detected by the
128
- * orchestrator like `modelId` — providers without the getter price as
129
- * Anthropic (the historical behaviour).
130
- */
131
- get providerId(): string {
132
- return this.adapter.providerId;
133
- }
134
-
135
- /**
136
- * The output-token ceiling for each call, exposed read-only so the
137
- * cost-gate can compute a worst-case pre-call estimate (the gate must
138
- * decide BEFORE a call whether it would exceed the budget).
139
- */
140
- get maxOutputTokens(): number {
141
- return this.maxTokens;
142
- }
143
-
144
- /**
145
- * FR-003 — exact serialized INPUT character count this provider would
146
- * transmit for `state`, so the cost-gate prices the *real* meta-prompt
147
- * rather than just `best.prompt.length + a fixed overhead`. The naive
148
- * estimate (prompt length only) under-counts the system block AND the
149
- * rendered dev-set failure block (each failure's input + expected_output)
150
- * that `next()` actually sends; with a large dev window + small maxTokens
151
- * that deficit could let a gate-passing call exceed the budget after the
152
- * fact. Returning the full system+user char count here makes the
153
- * `chars/4` token estimate cover everything the model is billed for, so
154
- * the orchestrator's estimate-before guarantee holds unconditionally
155
- * (input is now bounded from above, output is already the ceiling).
156
- *
157
- * The orchestrator feature-detects this method (it is not part of the
158
- * `MutationProvider` interface); providers that omit it fall back to the
159
- * `best.prompt.length + metaOverheadChars` heuristic.
160
- */
161
- estimateInputChars(state: OptimizerState): number {
162
- const failures = this.selectFailures(state);
163
- const userMessage = this.buildUserMessage(state.best.prompt, failures);
164
- return this.systemBlock.length + userMessage.length;
165
- }
166
-
167
- async next(state: OptimizerState): Promise<ProviderMutation> {
168
- const failures = this.selectFailures(state);
169
- const userMessage = this.buildUserMessage(state.best.prompt, failures);
170
-
171
- // The call's actual token usage, captured so the FR-003 cost-gate
172
- // can fold it into a running spend total. Stays undefined until the
173
- // model call round-trips; a failed/unusable response still records
174
- // whatever was consumed (the model DID run) so spend is accounted.
175
- let callUsage: ProviderMutation["usage"];
176
- let rawText: string | undefined;
177
- try {
178
- const final = await collectFinalMessage(
179
- this.adapter.stream({
180
- model: this.model,
181
- system: [{ type: "text", text: this.systemBlock }],
182
- messages: [{ role: "user", content: userMessage }],
183
- maxTokens: this.maxTokens,
184
- }),
185
- );
186
- callUsage = {
187
- input: final.usage.input,
188
- output: final.usage.output,
189
- ...(final.usage.cacheRead !== undefined ? { cacheRead: final.usage.cacheRead } : {}),
190
- };
191
- rawText = extractFirstText(final);
192
- } catch (err) {
193
- // Mutator unavailability is not fatal — fall back to current best.
194
- // The orchestrator's outer loop will record a degenerate iteration
195
- // (score unchanged) and the search continues. No usage is available
196
- // on a stream error (the call did not complete), so spend is zero.
197
- return this.fallback(state, `model error: ${(err as Error).message}`);
198
- }
199
- if (rawText === undefined) {
200
- return this.fallback(state, "model returned no text block", callUsage);
201
- }
202
-
203
- // Extract JSON: tolerate ```json fences and leading prose. We
204
- // search for the first balanced `{...}` substring.
205
- const jsonMatch = rawText.match(/\{[\s\S]*\}/);
206
- if (jsonMatch === null) {
207
- return this.fallback(state, "model response did not contain a JSON object", callUsage);
208
- }
209
- let parsed: { rewrite: string; rationale: string };
210
- try {
211
- const raw = JSON.parse(jsonMatch[0]);
212
- parsed = META_RESPONSE_SCHEMA.parse(raw);
213
- } catch (err) {
214
- return this.fallback(
215
- state,
216
- `model response failed schema validation: ${(err as Error).message}`,
217
- callUsage,
218
- );
219
- }
220
-
221
- const mutation: Mutation = { kind: "rephrase-instruction" };
222
- // We use the rephrase-instruction kind to record the mutation in
223
- // the trajectory; the actual rewrite is full-replacement, not the
224
- // rule-based provider's "append a sentence" behaviour. Future work:
225
- // add a distinct `{ kind: "model-rewrite"; rationale: string }`
226
- // variant to the Mutation union for better trajectory metadata.
227
- return {
228
- prompt: parsed.rewrite,
229
- mutations: [mutation],
230
- rationale: parsed.rationale,
231
- ...(callUsage !== undefined ? { usage: callUsage } : {}),
232
- };
233
- }
234
-
235
- /** Build the user message for the meta-prompt. */
236
- private buildUserMessage(
237
- currentPrompt: string,
238
- failures: ReadonlyArray<{ input: string; observedScore: number; expected?: string }>,
239
- ): string {
240
- const failureBlock =
241
- failures.length === 0
242
- ? "(No dev-set failures available yet — propose a refinement that improves clarity, specificity, or instruction-following.)"
243
- : failures
244
- .map(
245
- (f, i) =>
246
- `--- Failure ${i + 1} (observed score ${f.observedScore.toFixed(2)}) ---\nInput: ${f.input}\n${f.expected !== undefined ? `Expected output (do NOT copy this verbatim into the rewrite): ${f.expected}\n` : ""}`,
247
- )
248
- .join("\n");
249
- return `CURRENT PROMPT:\n${currentPrompt}\n\nSAMPLE OF DEV-SET FAILURES:\n${failureBlock}\n\nReturn one JSON object: {"rewrite": "...", "rationale": "..."}`;
250
- }
251
-
252
- /** Identify failure samples from the trajectory. */
253
- private selectFailures(
254
- state: OptimizerState,
255
- ): ReadonlyArray<{ input: string; observedScore: number; expected?: string }> {
256
- // The trajectory records aggregate scores per candidate, not per
257
- // sample. For v0 we surface the dev set as raw inputs and let
258
- // the model reason about them generically; future iterations can
259
- // wire a per-sample grade map through the OptimizerState. The
260
- // result is still useful because the model sees the dev set
261
- // distribution even without per-sample grades.
262
- return state.devSet.slice(0, this.maxFailuresInPrompt).map((s) => ({
263
- input: s.input,
264
- // Use the trajectory's most recent score as a coarse signal.
265
- observedScore: state.best.score,
266
- ...(s.expected_output !== undefined ? { expected: s.expected_output } : {}),
267
- }));
268
- }
269
-
270
- /**
271
- * Fallback when the model can't produce a usable rewrite. Returns
272
- * the current best verbatim so the search loop records a no-op
273
- * iteration. The orchestrator logs the fallback reason. When the
274
- * model DID round-trip (returned text/JSON that turned out unusable),
275
- * the `usage` actually consumed is forwarded so the cost-gate still
276
- * accounts for the spend; a stream error (no completed call) passes
277
- * no usage and is therefore charged zero.
278
- */
279
- private fallback(
280
- state: OptimizerState,
281
- reason: string,
282
- usage?: ProviderMutation["usage"],
283
- ): ProviderMutation {
284
- return {
285
- prompt: state.best.prompt,
286
- mutations: [],
287
- rationale: `claude-fallback: ${reason}`,
288
- ...(usage !== undefined ? { usage } : {}),
289
- };
290
- }
291
- }
292
-
293
- /** Convenience factory mirroring `createAnthropicAdapter` ergonomics. */
294
- export function createClaudeMutationProvider(
295
- opts: ClaudeMutationProviderOptions,
296
- ): ClaudeMutationProvider {
297
- return new ClaudeMutationProvider(opts);
298
- }