@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.
- package/dist/index.d.ts +132 -0
- package/dist/index.js +233 -0
- package/package.json +12 -9
- package/src/index.test.ts +0 -324
- package/src/index.ts +0 -298
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
17
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
-
"@crewhaus/prompt-optimizer": "0.1.
|
|
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.
|
|
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": ["
|
|
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
|
-
}
|