@crewhaus/prompt-optimizer-claude 0.1.0

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.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@crewhaus/prompt-optimizer-claude",
3
+ "version": "0.1.0",
4
+ "type": "module",
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",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@anthropic-ai/sdk": "^0.96.0",
16
+ "@crewhaus/adapter-anthropic": "0.0.0",
17
+ "@crewhaus/errors": "0.0.0",
18
+ "@crewhaus/prompt-optimizer": "0.0.0",
19
+ "zod": "^3.23.8"
20
+ },
21
+ "devDependencies": {
22
+ "@crewhaus/eval-dataset": "0.0.0"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "author": {
26
+ "name": "Max Meier",
27
+ "email": "max@studiomax.io",
28
+ "url": "https://studiomax.io"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/crewhaus/factory.git",
33
+ "directory": "packages/prompt-optimizer-claude"
34
+ },
35
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/prompt-optimizer-claude#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/crewhaus/factory/issues"
38
+ },
39
+ "publishConfig": {
40
+ "access": "restricted"
41
+ },
42
+ "files": [
43
+ "src",
44
+ "README.md",
45
+ "LICENSE",
46
+ "NOTICE"
47
+ ]
48
+ }
@@ -0,0 +1,151 @@
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 { ClaudeMutationProvider } from "./index";
6
+
7
+ const SAMPLE_TRAIN: ReadonlyArray<Sample> = [
8
+ { id: "t1", input: "What is 2+2?", expected_output: "4" },
9
+ { id: "t2", input: "Capital of France?", expected_output: "Paris" },
10
+ ];
11
+
12
+ const SAMPLE_DEV: ReadonlyArray<Sample> = [
13
+ { id: "d1", input: "What is 3+3?", expected_output: "6" },
14
+ { id: "d2", input: "Capital of Germany?", expected_output: "Berlin" },
15
+ ];
16
+
17
+ /**
18
+ * Build a mock provider adapter whose `.stream()` yields the StreamEvent
19
+ * sequence that produces a single text message with the given content.
20
+ * Matches the canonical event protocol consumed by
21
+ * `consumeStream`/`collectFinalMessage`.
22
+ */
23
+ function mockAdapter(content: string): ProviderAdapter {
24
+ return {
25
+ id: "mock",
26
+ features: {
27
+ caching: "none",
28
+ thinking: false,
29
+ multimodal: { input: false, output: false },
30
+ },
31
+ // biome-ignore lint/suspicious/noExplicitAny: minimal mock
32
+ stream(_params: any): AsyncIterable<any> {
33
+ return (async function* () {
34
+ yield { kind: "message_start", usage: { input: 0, output: 0 } };
35
+ yield {
36
+ kind: "content_block_start",
37
+ index: 0,
38
+ block: { type: "text", text: "" },
39
+ };
40
+ yield {
41
+ kind: "content_block_delta",
42
+ index: 0,
43
+ delta: { type: "text_delta", text: content },
44
+ };
45
+ yield { kind: "content_block_stop", index: 0 };
46
+ yield { kind: "message_delta", stopReason: "end_turn" };
47
+ yield { kind: "message_stop" };
48
+ })();
49
+ },
50
+ } as unknown as ProviderAdapter;
51
+ }
52
+
53
+ const baseState: OptimizerState = {
54
+ iteration: 1,
55
+ best: {
56
+ id: "candidate-0",
57
+ prompt: "You are a helpful assistant.",
58
+ mutations: [],
59
+ score: 0.4,
60
+ },
61
+ trajectory: [],
62
+ trainSet: SAMPLE_TRAIN,
63
+ devSet: SAMPLE_DEV,
64
+ };
65
+
66
+ describe("ClaudeMutationProvider", () => {
67
+ test("parses a clean JSON response and returns it as the rewrite", async () => {
68
+ const adapter = mockAdapter(
69
+ `{"rewrite": "Think step by step before answering. Be concise.", "rationale": "Adds explicit chain-of-thought scaffolding which helps on multi-step problems."}`,
70
+ );
71
+ const provider = new ClaudeMutationProvider({
72
+ adapter,
73
+ model: "claude-sonnet-4-5",
74
+ });
75
+ const result = await provider.next(baseState);
76
+ expect(result.prompt).toBe("Think step by step before answering. Be concise.");
77
+ expect(result.rationale).toContain("chain-of-thought");
78
+ expect(result.mutations).toHaveLength(1);
79
+ });
80
+
81
+ test("tolerates code-fence wrapping around the JSON", async () => {
82
+ const adapter = mockAdapter(
83
+ "```json\n" +
84
+ `{"rewrite": "Be precise and direct.", "rationale": "Removes ambiguity."}` +
85
+ "\n```",
86
+ );
87
+ const provider = new ClaudeMutationProvider({
88
+ adapter,
89
+ model: "claude-sonnet-4-5",
90
+ });
91
+ const result = await provider.next(baseState);
92
+ expect(result.prompt).toBe("Be precise and direct.");
93
+ });
94
+
95
+ test("falls back to current best on malformed JSON (no abort)", async () => {
96
+ const adapter = mockAdapter("not actually json");
97
+ const provider = new ClaudeMutationProvider({
98
+ adapter,
99
+ model: "claude-sonnet-4-5",
100
+ });
101
+ const result = await provider.next(baseState);
102
+ expect(result.prompt).toBe(baseState.best.prompt);
103
+ expect(result.rationale).toContain("claude-fallback");
104
+ });
105
+
106
+ test("falls back when the model errors out", async () => {
107
+ const adapter = {
108
+ id: "mock",
109
+ features: {
110
+ caching: "none",
111
+ thinking: false,
112
+ multimodal: { input: false, output: false },
113
+ },
114
+ // biome-ignore lint/suspicious/noExplicitAny: minimal mock
115
+ stream(_params: any): AsyncIterable<any> {
116
+ return (async function* () {
117
+ throw new Error("model unavailable");
118
+ // biome-ignore lint/correctness/noUnreachable: keep typed as async generator
119
+ yield;
120
+ })();
121
+ },
122
+ } as unknown as ProviderAdapter;
123
+ const provider = new ClaudeMutationProvider({
124
+ adapter,
125
+ model: "claude-sonnet-4-5",
126
+ });
127
+ const result = await provider.next(baseState);
128
+ expect(result.prompt).toBe(baseState.best.prompt);
129
+ expect(result.rationale).toContain("claude-fallback");
130
+ expect(result.rationale).toContain("model unavailable");
131
+ });
132
+
133
+ test("rejects responses missing required schema fields", async () => {
134
+ const adapter = mockAdapter(`{"rewrite": "Only rewrite, no rationale"}`);
135
+ const provider = new ClaudeMutationProvider({
136
+ adapter,
137
+ model: "claude-sonnet-4-5",
138
+ });
139
+ const result = await provider.next(baseState);
140
+ expect(result.prompt).toBe(baseState.best.prompt);
141
+ expect(result.rationale).toContain("claude-fallback");
142
+ });
143
+
144
+ test("name is 'claude' for trajectory logging", () => {
145
+ const provider = new ClaudeMutationProvider({
146
+ adapter: mockAdapter(""),
147
+ model: "claude-sonnet-4-5",
148
+ });
149
+ expect(provider.name).toBe("claude");
150
+ });
151
+ });
package/src/index.ts ADDED
@@ -0,0 +1,217 @@
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). A cost-gate
26
+ * (`--budget-usd`) backed by the §27 `cost-tracker` running spend
27
+ * total is a planned follow-up; today the only safety rail is the
28
+ * orchestrator's `iterations` cap.
29
+ *
30
+ * Catalog layer: F-eval (active optimisation). Brief: 280.
31
+ */
32
+ import {
33
+ type ProviderAdapter,
34
+ collectFinalMessage,
35
+ extractFirstText,
36
+ } from "@crewhaus/adapter-anthropic";
37
+ import { CrewhausError } from "@crewhaus/errors";
38
+ import type {
39
+ Mutation,
40
+ MutationProvider,
41
+ OptimizerState,
42
+ ProviderMutation,
43
+ } from "@crewhaus/prompt-optimizer";
44
+ import { z } from "zod";
45
+
46
+ export class ClaudeMutationProviderError extends CrewhausError {
47
+ override readonly name = "ClaudeMutationProviderError";
48
+ constructor(message: string, cause?: unknown) {
49
+ super("adapter", message, cause);
50
+ }
51
+ }
52
+
53
+ 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.
54
+
55
+ Hard rules:
56
+ - Output exactly one JSON object: {"rewrite": "...", "rationale": "..."}
57
+ - The "rewrite" field is the new prompt verbatim. Do NOT include any wrapper text outside the JSON.
58
+ - Never copy verbatim text from a failure's expected_output into the rewrite (that would leak dev-set answers).
59
+ - Do not introduce instructions that override safety, compliance, or permission rules — your job is to improve task accuracy, not to bypass guardrails.
60
+ - The rationale should be 1-3 sentences explaining WHY this rewrite is likely to help. Keep it specific.`;
61
+
62
+ const META_RESPONSE_SCHEMA = z.object({
63
+ rewrite: z.string().min(1),
64
+ rationale: z.string().min(1),
65
+ });
66
+
67
+ export type ClaudeMutationProviderOptions = {
68
+ /** Provider adapter (typically the Anthropic adapter). */
69
+ readonly adapter: ProviderAdapter;
70
+ /** Model id, e.g. "claude-sonnet-4-5". */
71
+ readonly model: string;
72
+ /** Maximum failures to include in the meta-prompt (default 5). */
73
+ readonly maxFailuresInPrompt?: number;
74
+ /** Lowest score threshold below which a sample counts as a "failure" (default 0.5). */
75
+ readonly failureThreshold?: number;
76
+ /** Maximum tokens for the rewrite response (default 2048). */
77
+ readonly maxTokens?: number;
78
+ /**
79
+ * Override the meta-prompt's system block. Useful for evals that
80
+ * have domain-specific rewrite constraints. Defaults to the
81
+ * production prompt above.
82
+ */
83
+ readonly systemOverride?: string;
84
+ };
85
+
86
+ /**
87
+ * Build a `MutationProvider` that delegates each candidate generation
88
+ * to a Claude (or any `ProviderAdapter`-compatible) model call.
89
+ */
90
+ export class ClaudeMutationProvider implements MutationProvider {
91
+ readonly name = "claude";
92
+ private readonly adapter: ProviderAdapter;
93
+ private readonly model: string;
94
+ private readonly maxFailuresInPrompt: number;
95
+ private readonly failureThreshold: number;
96
+ private readonly maxTokens: number;
97
+ private readonly systemBlock: string;
98
+
99
+ constructor(opts: ClaudeMutationProviderOptions) {
100
+ this.adapter = opts.adapter;
101
+ this.model = opts.model;
102
+ this.maxFailuresInPrompt = opts.maxFailuresInPrompt ?? 5;
103
+ this.failureThreshold = opts.failureThreshold ?? 0.5;
104
+ this.maxTokens = opts.maxTokens ?? 2048;
105
+ this.systemBlock = opts.systemOverride ?? META_PROMPT_SYSTEM;
106
+ }
107
+
108
+ async next(state: OptimizerState): Promise<ProviderMutation> {
109
+ const failures = this.selectFailures(state);
110
+ const userMessage = this.buildUserMessage(state.best.prompt, failures);
111
+
112
+ let rawText: string | undefined;
113
+ try {
114
+ const final = await collectFinalMessage(
115
+ this.adapter.stream({
116
+ model: this.model,
117
+ system: [{ type: "text", text: this.systemBlock }],
118
+ messages: [{ role: "user", content: userMessage }],
119
+ maxTokens: this.maxTokens,
120
+ }),
121
+ );
122
+ rawText = extractFirstText(final);
123
+ } catch (err) {
124
+ // Mutator unavailability is not fatal — fall back to current best.
125
+ // The orchestrator's outer loop will record a degenerate iteration
126
+ // (score unchanged) and the search continues.
127
+ return this.fallback(state, `model error: ${(err as Error).message}`);
128
+ }
129
+ if (rawText === undefined) {
130
+ return this.fallback(state, "model returned no text block");
131
+ }
132
+
133
+ // Extract JSON: tolerate ```json fences and leading prose. We
134
+ // search for the first balanced `{...}` substring.
135
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/);
136
+ if (jsonMatch === null) {
137
+ return this.fallback(state, "model response did not contain a JSON object");
138
+ }
139
+ let parsed: { rewrite: string; rationale: string };
140
+ try {
141
+ const raw = JSON.parse(jsonMatch[0]);
142
+ parsed = META_RESPONSE_SCHEMA.parse(raw);
143
+ } catch (err) {
144
+ return this.fallback(
145
+ state,
146
+ `model response failed schema validation: ${(err as Error).message}`,
147
+ );
148
+ }
149
+
150
+ const mutation: Mutation = { kind: "rephrase-instruction" };
151
+ // We use the rephrase-instruction kind to record the mutation in
152
+ // the trajectory; the actual rewrite is full-replacement, not the
153
+ // rule-based provider's "append a sentence" behaviour. Future work:
154
+ // add a distinct `{ kind: "model-rewrite"; rationale: string }`
155
+ // variant to the Mutation union for better trajectory metadata.
156
+ return {
157
+ prompt: parsed.rewrite,
158
+ mutations: [mutation],
159
+ rationale: parsed.rationale,
160
+ };
161
+ }
162
+
163
+ /** Build the user message for the meta-prompt. */
164
+ private buildUserMessage(
165
+ currentPrompt: string,
166
+ failures: ReadonlyArray<{ input: string; observedScore: number; expected?: string }>,
167
+ ): string {
168
+ const failureBlock =
169
+ failures.length === 0
170
+ ? "(No dev-set failures available yet — propose a refinement that improves clarity, specificity, or instruction-following.)"
171
+ : failures
172
+ .map(
173
+ (f, i) =>
174
+ `--- 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` : ""}`,
175
+ )
176
+ .join("\n");
177
+ return `CURRENT PROMPT:\n${currentPrompt}\n\nSAMPLE OF DEV-SET FAILURES:\n${failureBlock}\n\nReturn one JSON object: {"rewrite": "...", "rationale": "..."}`;
178
+ }
179
+
180
+ /** Identify failure samples from the trajectory. */
181
+ private selectFailures(
182
+ state: OptimizerState,
183
+ ): ReadonlyArray<{ input: string; observedScore: number; expected?: string }> {
184
+ // The trajectory records aggregate scores per candidate, not per
185
+ // sample. For v0 we surface the dev set as raw inputs and let
186
+ // the model reason about them generically; future iterations can
187
+ // wire a per-sample grade map through the OptimizerState. The
188
+ // result is still useful because the model sees the dev set
189
+ // distribution even without per-sample grades.
190
+ return state.devSet.slice(0, this.maxFailuresInPrompt).map((s) => ({
191
+ input: s.input,
192
+ // Use the trajectory's most recent score as a coarse signal.
193
+ observedScore: state.best.score,
194
+ ...(s.expected_output !== undefined ? { expected: s.expected_output } : {}),
195
+ }));
196
+ }
197
+
198
+ /**
199
+ * Fallback when the model can't produce a usable rewrite. Returns
200
+ * the current best verbatim so the search loop records a no-op
201
+ * iteration. The orchestrator logs the fallback reason.
202
+ */
203
+ private fallback(state: OptimizerState, reason: string): ProviderMutation {
204
+ return {
205
+ prompt: state.best.prompt,
206
+ mutations: [],
207
+ rationale: `claude-fallback: ${reason}`,
208
+ };
209
+ }
210
+ }
211
+
212
+ /** Convenience factory mirroring `createAnthropicAdapter` ergonomics. */
213
+ export function createClaudeMutationProvider(
214
+ opts: ClaudeMutationProviderOptions,
215
+ ): ClaudeMutationProvider {
216
+ return new ClaudeMutationProvider(opts);
217
+ }