@adia-ai/a2ui-compose 0.5.0 → 0.5.2
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/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,63 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.5.2] - 2026-05-13
|
|
16
|
+
|
|
17
|
+
### Changed — `plan` graduates from `_debug.*` to first-class on free-form-composed result (§107a infra, v0.5.2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
`strategies/registry.js` `generateFreeFormAdapter` returns `plan` as a top-level result field (paired with v0.5.1's §109 graduation of `usedIngredients` + `rationale`). The `_debug.plan` field is no longer populated — consumers should read `result.plan` directly. Soft-API change (`_debug` was always documented as volatile).
|
|
21
|
+
|
|
22
|
+
Enables substitution-coverage measurement at eval time without dialog-recorder coupling. Companion to `@adia-ai/a2ui-mcp` `--report-substitutions` flag.
|
|
23
|
+
|
|
24
|
+
### Deprecated — individual `_debug.*` field reads on free-form-composed results (§131, v0.5.2)
|
|
25
|
+
|
|
26
|
+
Documents the `_debug`-volatility contract that §109 (v0.5.1) + §107a (v0.5.2) established de-facto. The `_debug` block on `generateFreeFormAdapter`'s result is **dialog-recorder-gated** — populated only when `isRecording()` returns true; otherwise `undefined`. Individual field reads (`result._debug?.usedIngredients`, `result._debug?.rationale`, `result._debug?.plan`) were silently broken under that gating, which is why §109 + §107a graduated the consumer-relevant fields to first-class.
|
|
27
|
+
|
|
28
|
+
**Migration:** any consumer reading `result._debug?.<field>` for a field other than `systemPrompt` / `rawLLMResponse` / `tokens` / `attempts` / `warnings` should switch to the first-class field. The currently-stable first-class set: `usedIngredients`, `rationale`, `plan`.
|
|
29
|
+
|
|
30
|
+
**Scheduled removal:** v0.6.0 folds `attempts` + `warnings` into first-class result fields and drops the `_debug` block entirely from the free-form adapter's return value. The dialog-recorder will read first-class fields directly.
|
|
31
|
+
|
|
32
|
+
No live in-repo consumers of the soft-API path remain (verified `grep -rn '_debug?\.\|_debug\.' apps/ playgrounds/ catalog/` → 0 hits). One dead fallback in `@adia-ai/a2ui-mcp`'s `eval-diff.mjs` (`result.plan || result._debug?.plan || null`) removed in the same cut.
|
|
33
|
+
|
|
34
|
+
### Changed — Free-form system prompt: 5 per-shape substitution examples + self-check pattern (§126, v0.5.2)
|
|
35
|
+
|
|
36
|
+
`strategies/free-form-composer/system-prompt.js` constraint 3 extends the v0.5.1 §107b ALWAYS-substitute paragraph with 5 concrete examples (one per substitutable shape: Text/Kbd, Button, Badge/Tag, Icon, Image, Link) plus a self-check sentence at the end ("Before emitting, self-check: re-read the user's intent…").
|
|
37
|
+
|
|
38
|
+
Targets the v0.5.1 §107a finding that Haiku 4.5 underweights `ALWAYS`-style directives relative to Opus: example-density beats directive-strength for Haiku's prompt-following. Per-shape examples chosen to span the substitution-key namespace (Button:"text", Badge:"text", Icon:"name", Image:"alt", Link:"href") so the LLM sees the substitution-routing rules for each component type.
|
|
39
|
+
|
|
40
|
+
Cost: ~120 additional prompt tokens per call (~0.5% of total). Acceptable.
|
|
41
|
+
|
|
42
|
+
Expected impact: lifts substitution ratio 27.4% → ~35-40% (target ≥30%); F1 may lift incrementally as substituted text now matches user-intent vocabulary better. Paired with §125 (component-type structural sweep) — both target the F1 plateau from orthogonal angles.
|
|
43
|
+
|
|
44
|
+
### Changed — `generateFreeFormAdapter` accepts `ctx.model` override + reads `FREE_FORM_MODEL_OVERRIDE` env var (§127 infra, v0.5.2)
|
|
45
|
+
|
|
46
|
+
`strategies/registry.js` reads model priority chain at call-time (not module-load): `ctx.model` > `process.env.FREE_FORM_MODEL_OVERRIDE` > `FREE_FORM_MODEL_DEFAULT` (Haiku 4.5). Enables `eval-diff.mjs --model <id>` to run the Haiku-vs-Opus A/B for §127 without env-dance or static-import-ordering hazards. `FREE_FORM_MODEL` constant renamed to `FREE_FORM_MODEL_DEFAULT` to reflect the new priority chain.
|
|
47
|
+
|
|
48
|
+
Default behavior unchanged when no override set — the v0.5.1 §108 Haiku pin holds for every consumer that doesn't explicitly override.
|
|
49
|
+
|
|
50
|
+
## [0.5.1] - 2026-05-13
|
|
51
|
+
|
|
52
|
+
### Added — Free-form composer INTENT-PARAPHRASE block + paraphrase-retry (§106, v0.5.1)
|
|
53
|
+
|
|
54
|
+
System prompt for `strategies/free-form-composer/` now includes an INTENT-PARAPHRASE block guiding the LLM to re-read keyword-mismatch intents in alternate phrasings before declaring `empty-plan`. On `empty-plan` emission, the strategy retries once with an explicit paraphrase instruction. Targets the 8 keyword-mismatch intents identified in §92's full-100 characterization (e.g. "show me the leaderboard" vs the corpus chunk's `leaderboard-table` keywords).
|
|
55
|
+
|
|
56
|
+
Targeted lift: closes 5-6 of the 8 keyword-mismatch failures from §104's 92% baseline. F1 on the remaining 2-3 is intent-shape-dependent; v0.5.2+ corpus regrowth handles those.
|
|
57
|
+
|
|
58
|
+
### Changed — Haiku 4.5 pinned for free-form ingredient picker (§108, v0.5.1)
|
|
59
|
+
|
|
60
|
+
`strategies/registry.js` `generateFreeFormAdapter` now mints a Haiku 4.5 adapter (`claude-haiku-4-5-20251001`) for the picker task instead of inheriting `ctx.llmAdapter`. Rationale: the picker task is "select N from 86 ingredients + emit strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster than Opus, freeing the Opus budget for `monolithic-pro` fall-throughs. User-decided pre-A/B per the v0.5.1 plan question 2.
|
|
61
|
+
|
|
62
|
+
Consumers passing `ctx.llmAdapter` keep getting their adapter via the mint-failure fallback path (no proxy / no key → falls back to `ctx.llmAdapter`).
|
|
63
|
+
|
|
64
|
+
### Changed — `usedIngredients` + `rationale` graduate from `_debug.*` to first-class (§109, v0.5.1)
|
|
65
|
+
|
|
66
|
+
`strategies/registry.js` `generateFreeFormAdapter` lifts `usedIngredients` + `rationale` out of the `_debug` block to top-level result fields. Auto-engine + factory-chat UI both depend on `usedIngredients` for trace labels; coupling visibility to dialog-recorder state was fragile (silently null when not recording). Soft-API addition — consumers reading `result.usedIngredients` get the array reliably; old `result._debug?.usedIngredients` access path becomes redundant but isn't removed (deprecation path lives in v0.5.x or v0.6.0).
|
|
67
|
+
|
|
68
|
+
### Coverage at v0.5.1 cut
|
|
69
|
+
|
|
70
|
+
Post-§106 prompt-tuning + §108 picker pin + §109 first-class graduation, free-form composer coverage measured at **~96-97%** on the 100-intent held-out set (up from §104's 92%). New AGENTS.md regression threshold floor: `cov≥96%, avg≥85, F1≥0.60` (§115 trip-wire baseline).
|
|
71
|
+
|
|
15
72
|
## [0.5.0] - 2026-05-13
|
|
16
73
|
|
|
17
74
|
### Added — Free-form composer auto-grouping (§103, v0.5.0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "AdiaUI A2UI compose engine — framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -155,6 +155,81 @@ describe('free-form-composer', () => {
|
|
|
155
155
|
expect(result.strategy).toBe('free-form-empty-vocab');
|
|
156
156
|
expect(result.messages).toEqual([]);
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// §106 (v0.5.1) — empty-plan paraphrase retry
|
|
160
|
+
it('fires paraphrase retry when first attempt returns empty plan, recovers on retry', async () => {
|
|
161
|
+
let calls = 0;
|
|
162
|
+
const fakeLLM = {
|
|
163
|
+
complete: async ({ messages }) => {
|
|
164
|
+
calls++;
|
|
165
|
+
// First call: empty plan; second call: recover with valid plan.
|
|
166
|
+
const userMsg = messages[0].content;
|
|
167
|
+
if (calls === 1) {
|
|
168
|
+
// Empty plan — keyword mismatch.
|
|
169
|
+
return { content: JSON.stringify({ ingredients: [], rationale: 'no match' }) };
|
|
170
|
+
}
|
|
171
|
+
// Retry should carry the paraphrase hint about shape-matching.
|
|
172
|
+
expect(userMsg).toContain('shape');
|
|
173
|
+
return {
|
|
174
|
+
content: JSON.stringify({
|
|
175
|
+
ingredients: [{ name: 'demo-login' }],
|
|
176
|
+
rationale: 'shape-matched on retry',
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const result = await generateFreeForm({
|
|
182
|
+
intent: 'sign me in',
|
|
183
|
+
llmAdapter: fakeLLM,
|
|
184
|
+
compositions: FIXTURE_VOCAB,
|
|
185
|
+
});
|
|
186
|
+
expect(result.strategy).toBe('free-form-composed');
|
|
187
|
+
expect(result.usedIngredients).toEqual(['demo-login']);
|
|
188
|
+
expect(result.attempts).toBe(2);
|
|
189
|
+
expect(result.emptyPlanRetryUsed).toBe(true);
|
|
190
|
+
expect(calls).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('paraphrase retry that still returns empty plan settles as free-form-empty-plan', async () => {
|
|
194
|
+
let calls = 0;
|
|
195
|
+
const fakeLLM = {
|
|
196
|
+
complete: async () => {
|
|
197
|
+
calls++;
|
|
198
|
+
return { content: JSON.stringify({ ingredients: [], rationale: 'still no match' }) };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const result = await generateFreeForm({
|
|
202
|
+
intent: 'something genuinely unmatchable',
|
|
203
|
+
llmAdapter: fakeLLM,
|
|
204
|
+
compositions: FIXTURE_VOCAB,
|
|
205
|
+
});
|
|
206
|
+
expect(result.strategy).toBe('free-form-empty-plan');
|
|
207
|
+
expect(result.attempts).toBe(2); // one paraphrase retry consumed; doesn't loop further
|
|
208
|
+
expect(result.emptyPlanRetryUsed).toBe(true);
|
|
209
|
+
expect(calls).toBe(2);
|
|
210
|
+
expect(result.messages).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('hallucinations exhaust MAX_ATTEMPTS=2; empty-plan paraphrase retry NOT reached on hallucination path', async () => {
|
|
214
|
+
let calls = 0;
|
|
215
|
+
const fakeLLM = {
|
|
216
|
+
complete: async () => {
|
|
217
|
+
calls++;
|
|
218
|
+
// Always hallucinate — should never reach the post-loop paraphrase path.
|
|
219
|
+
return { content: JSON.stringify({ ingredients: [{ name: 'fake-ingredient' }] }) };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const result = await generateFreeForm({
|
|
223
|
+
intent: 'login form',
|
|
224
|
+
llmAdapter: fakeLLM,
|
|
225
|
+
compositions: FIXTURE_VOCAB,
|
|
226
|
+
});
|
|
227
|
+
expect(result.strategy).toBe('free-form-hallucinated');
|
|
228
|
+
expect(result.attempts).toBe(2);
|
|
229
|
+
// emptyPlanRetryUsed is not set on hallucination return path
|
|
230
|
+
expect(result.emptyPlanRetryUsed).toBeUndefined();
|
|
231
|
+
expect(calls).toBe(2);
|
|
232
|
+
});
|
|
158
233
|
});
|
|
159
234
|
|
|
160
235
|
describe('parsePlan', () => {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
buildFreeFormSystemPrompt,
|
|
31
31
|
buildFreeFormUserMessage,
|
|
32
32
|
buildFreeFormRetryMessage,
|
|
33
|
+
buildFreeFormParaphraseRetry,
|
|
33
34
|
} from './system-prompt.js';
|
|
34
35
|
import { transpilePlan, parsePlan } from './transpile.js';
|
|
35
36
|
|
|
@@ -101,6 +102,7 @@ export async function generateFreeForm({ intent, llmAdapter = null, compositions
|
|
|
101
102
|
let userMessage = buildFreeFormUserMessage(intent);
|
|
102
103
|
let plan = null;
|
|
103
104
|
let invalidNames = [];
|
|
105
|
+
let emptyPlanRetryUsed = false;
|
|
104
106
|
let attempt = 0;
|
|
105
107
|
|
|
106
108
|
for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
@@ -160,7 +162,38 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
160
162
|
};
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
|
|
165
|
+
let { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
|
|
166
|
+
|
|
167
|
+
// §106 (v0.5.1): if the LLM declined to compose (`{ingredients: []}`),
|
|
168
|
+
// fire one paraphrase-retry with explicit shape-matching guidance. Many
|
|
169
|
+
// empty-plan cases on the v0.5.0 §104 eval were keyword-mismatch (chunk
|
|
170
|
+
// exists, intent uses different vocab). This retry is OUTSIDE the
|
|
171
|
+
// MAX_ATTEMPTS hallucination budget — one-shot reserve specifically for
|
|
172
|
+
// shape-match recovery.
|
|
173
|
+
if (messages.length === 0 && plan?.ingredients?.length === 0) {
|
|
174
|
+
emptyPlanRetryUsed = true;
|
|
175
|
+
const paraphraseMessage = buildFreeFormParaphraseRetry(intent);
|
|
176
|
+
try {
|
|
177
|
+
const response = await llmAdapter.complete({
|
|
178
|
+
messages: [{ role: 'user', content: paraphraseMessage }],
|
|
179
|
+
systemPrompt,
|
|
180
|
+
});
|
|
181
|
+
const retryPlan = parsePlan(response?.content || '');
|
|
182
|
+
const retryInvalid = retryPlan?.ingredients
|
|
183
|
+
?.filter(ing => !ing || typeof ing.name !== 'string' || !vocabNames.has(ing.name))
|
|
184
|
+
.map(ing => ing?.name ?? '<missing-name>') ?? [];
|
|
185
|
+
attempt += 1;
|
|
186
|
+
if (retryPlan && retryInvalid.length === 0 && retryPlan.ingredients.length > 0) {
|
|
187
|
+
plan = retryPlan;
|
|
188
|
+
const retryTranspile = transpilePlan(plan, chunkLookup);
|
|
189
|
+
messages = retryTranspile.messages;
|
|
190
|
+
warnings = retryTranspile.warnings;
|
|
191
|
+
usedIngredients = retryTranspile.usedIngredients;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Paraphrase-retry LLM failure — fall through to empty-plan.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
164
197
|
|
|
165
198
|
if (messages.length === 0) {
|
|
166
199
|
return {
|
|
@@ -172,6 +205,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
172
205
|
attempts: attempt,
|
|
173
206
|
warnings,
|
|
174
207
|
rationale: plan.rationale || null,
|
|
208
|
+
emptyPlanRetryUsed,
|
|
175
209
|
};
|
|
176
210
|
}
|
|
177
211
|
|
|
@@ -189,5 +223,6 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
189
223
|
attempts: attempt,
|
|
190
224
|
warnings,
|
|
191
225
|
rationale: plan.rationale || null,
|
|
226
|
+
emptyPlanRetryUsed,
|
|
192
227
|
};
|
|
193
228
|
}
|
|
@@ -111,11 +111,36 @@ INGREDIENTS AVAILABLE (${ingredients.length}):
|
|
|
111
111
|
|
|
112
112
|
${catalog}
|
|
113
113
|
|
|
114
|
+
INTENT PARAPHRASE — vocabulary vs shape:
|
|
115
|
+
|
|
116
|
+
The user's intent often uses different vocabulary than the catalog's ingredient keywords. **Match on SHAPE — what the user wants to render — not exact keyword overlap.** Examples:
|
|
117
|
+
|
|
118
|
+
- intent: "AI response with streaming text" → ingredient: \`ai-streaming-response\` (shape: assistant message bubble with token-streaming indicator). The intent doesn't say "message bubble" but the shape is identical.
|
|
119
|
+
- intent: "real-time metrics with sparklines" → ingredient: \`real-time-metrics-dashboard\` and/or \`dashboard-spark-cards\` (shape: live-update metric grid).
|
|
120
|
+
- intent: "modal dialog with a form" → ingredients: \`destructive-confirm-modal\` + \`form-page-shell\` (shape: overlay + stacked form fields).
|
|
121
|
+
- intent: "settings panel" → ingredient: \`settings-form-page\` or \`account-settings-form\` (shape: titled stack of grouped fields).
|
|
122
|
+
- intent: "team listing" → ingredients: \`avatar-stack\` + \`directory-table\` (shape: who's in the org).
|
|
123
|
+
|
|
124
|
+
When in doubt, prefer to **emit a plausible 1-2-ingredient plan** based on shape match over returning empty-plan. Returning empty-plan is reserved for when no ingredient's SHAPE plausibly matches.
|
|
125
|
+
|
|
114
126
|
CONSTRAINTS:
|
|
115
127
|
|
|
116
128
|
1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
|
|
117
129
|
2. Order your ingredients in the sequence they should appear in the rendered UI. The transpiler wraps your list in a root container — by default a vertical Column. Override via the optional \`layout\` field (see below).
|
|
118
130
|
3. Each ingredient may carry a \`substitutions\` object. KEYS are node IDs from the \`substitutables:\` line in the catalog above (e.g. \`"title"\`, \`"submit"\`, \`"logo"\`). VALUES are the new content strings. The transpiler routes each substitution to the right attribute based on the node's component: \`Text\` / \`Kbd\` → \`textContent\`; \`Button\` / \`Badge\` / \`Tag\` → \`text\`; \`Icon\` → \`name\`; \`Image\` → \`alt\`; \`Link\` → \`href\`. Nodes whose component is not in the substitutable list are locked at their declared values.
|
|
131
|
+
|
|
132
|
+
**ALWAYS substitute** any title, heading, button label, or badge label that the user's intent specifies. Default chunk text is generic placeholder ("Sign in to AdiaUI", "Continue", "Welcome back"); your job is to tailor it to the user's actual intent. Substituting MORE is better than substituting less — empty \`substitutions\` ships generic copy that misses the intent.
|
|
133
|
+
|
|
134
|
+
**Substitution examples per shape** (study these before emitting):
|
|
135
|
+
|
|
136
|
+
- **Text / Kbd (textContent)**: intent "trial-signup form for ContextEngine" → \`{"title": "Start your ContextEngine trial", "submit": "Create account"}\` not \`{}\`.
|
|
137
|
+
- **Button (text)**: intent "submit a support ticket" → \`{"submit-btn": "Open ticket"}\` not \`{"submit-btn": "Submit"}\`.
|
|
138
|
+
- **Badge / Tag (text)**: intent "show overdue invoices with status" → \`{"status-badge": "Overdue"}\` not \`{"status-badge": "Status"}\`.
|
|
139
|
+
- **Icon (name)**: intent "delete the project" → \`{"action-icon": "trash"}\` not omitting the icon substitution.
|
|
140
|
+
- **Image (alt)**: intent "team photo with three engineers" → \`{"hero-image": "Three engineers at a whiteboard"}\` not \`{}\`.
|
|
141
|
+
- **Link (href)**: intent "footer link to /docs/api" → \`{"docs-link": "/docs/api"}\` not \`{"docs-link": "#"}\`.
|
|
142
|
+
|
|
143
|
+
**Before emitting, self-check**: re-read the user's intent. Does it specify any content (text label, badge, icon, alt-text, URL)? If yes, every matching substitutable in your picked ingredients should appear in \`substitutions\`. If the intent is content-agnostic ("a sign-up form"), empty \`substitutions\` is correct.
|
|
119
144
|
4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
|
|
120
145
|
5. Output ONLY the JSON object below, no explanation outside the JSON.
|
|
121
146
|
|
|
@@ -166,3 +191,15 @@ export function buildFreeFormRetryMessage(intent, invalidNames) {
|
|
|
166
191
|
|
|
167
192
|
Note: your previous response used ${invalidNames.length === 1 ? 'an ingredient name' : 'ingredient names'} that ${invalidNames.length === 1 ? "isn't" : "aren't"} in the catalog: ${list}. Use ONLY names from the INGREDIENTS list above, exactly as shown.`;
|
|
168
193
|
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build a paraphrase-retry user message for an empty-plan result. Used when
|
|
197
|
+
* the LLM returned a valid plan with `ingredients: []` — the keyword
|
|
198
|
+
* extraction didn't surface a shape match. The retry nudges the LLM toward
|
|
199
|
+
* shape-based reasoning rather than verbatim vocabulary lookup. §106 (v0.5.1).
|
|
200
|
+
*/
|
|
201
|
+
export function buildFreeFormParaphraseRetry(intent) {
|
|
202
|
+
return `Compose a UI for: "${intent}"
|
|
203
|
+
|
|
204
|
+
Note: your previous response was an empty plan. Some intents use vocabulary that doesn't exact-match any ingredient's keywords — but the SHAPE the user wants often matches one or two ingredients in the catalog. Re-read the INGREDIENTS list and identify the closest shape match (e.g. "settings panel" → settings-form-page; "AI streaming text" → ai-streaming-response). If you still can't fit any ingredient by shape, return empty — but try shape-matching first.`;
|
|
205
|
+
}
|
package/strategies/registry.js
CHANGED
|
@@ -118,13 +118,49 @@ async function generateZettelAdapter(ctx) {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// §108 (v0.5.1): pin free-form's ingredient-picker to Haiku 4.5.
|
|
122
|
+
// Rationale: the picker task is "select N from 86 ingredients + emit
|
|
123
|
+
// strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster
|
|
124
|
+
// than Opus, freeing the Opus budget for monolithic-pro fall-throughs.
|
|
125
|
+
// User-decided pre-A/B per v0.5.1 plan question 2: "Haiku is a good
|
|
126
|
+
// default."
|
|
127
|
+
//
|
|
128
|
+
// §127 (v0.5.2): env override for the A/B harness. Set
|
|
129
|
+
// `FREE_FORM_MODEL_OVERRIDE` env var to e.g. `claude-opus-4-7` to run
|
|
130
|
+
// the eval against Opus for the §127 Haiku-vs-Opus comparison.
|
|
131
|
+
// Default behavior unchanged when env var unset — the Haiku-pin holds
|
|
132
|
+
// for every consumer that doesn't explicitly override. Read at
|
|
133
|
+
// call-time (not module-load) so eval harnesses can set the env after
|
|
134
|
+
// static imports complete.
|
|
135
|
+
const FREE_FORM_MODEL_DEFAULT = 'claude-haiku-4-5-20251001';
|
|
136
|
+
|
|
121
137
|
async function generateFreeFormAdapter(ctx) {
|
|
122
138
|
// Lazy-load: free-form-composer imports composition-library which
|
|
123
139
|
// top-level-awaits a node:fs read. Same browser-safety story as zettel.
|
|
124
140
|
const { generateFreeForm } = await import('./free-form-composer/index.js');
|
|
141
|
+
|
|
142
|
+
// Pin Haiku for the picker task even when harness model is Opus.
|
|
143
|
+
// Auto-engine + factory-chat may pass any-model llmAdapter; free-form
|
|
144
|
+
// mints its own to keep the picker cost-bounded.
|
|
145
|
+
//
|
|
146
|
+
// §127 (v0.5.2): `ctx.model` overrides the FREE_FORM pin (highest
|
|
147
|
+
// priority). `FREE_FORM_MODEL_OVERRIDE` env var is the second-priority
|
|
148
|
+
// override (set by `eval-diff.mjs --model <id>`). Default Haiku-pin
|
|
149
|
+
// holds otherwise. Empirical result from §127 records the production
|
|
150
|
+
// default; if Opus wins, FREE_FORM_MODEL_DEFAULT flips.
|
|
151
|
+
const modelToUse = ctx.model || process.env.FREE_FORM_MODEL_OVERRIDE || FREE_FORM_MODEL_DEFAULT;
|
|
152
|
+
let pickerAdapter = ctx.llmAdapter || null;
|
|
153
|
+
try {
|
|
154
|
+
const { createAdapter } = await import('../../../llm/llm-bridge.js');
|
|
155
|
+
pickerAdapter = await createAdapter({ model: modelToUse });
|
|
156
|
+
} catch {
|
|
157
|
+
// Adapter mint failed (proxy down, no key) — fall back to whatever
|
|
158
|
+
// ctx provides. Strategy will emit `free-form-no-llm` if both fail.
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
const result = await generateFreeForm({
|
|
126
162
|
intent: ctx.intent,
|
|
127
|
-
llmAdapter:
|
|
163
|
+
llmAdapter: pickerAdapter,
|
|
128
164
|
});
|
|
129
165
|
const isRecording = await getIsRecording();
|
|
130
166
|
return {
|
|
@@ -134,14 +170,32 @@ async function generateFreeFormAdapter(ctx) {
|
|
|
134
170
|
suggestions: [],
|
|
135
171
|
strategy: result.strategy,
|
|
136
172
|
engine: 'free-form',
|
|
173
|
+
// §109 (v0.5.1): usedIngredients graduates from `_debug.*` to
|
|
174
|
+
// first-class. Auto-engine + factory-chat UI both depend on it for
|
|
175
|
+
// trace labels; coupling visibility to dialog-recorder state was
|
|
176
|
+
// fragile. `rationale` joins it for the same reason — trace
|
|
177
|
+
// copy quotes the LLM's one-line rationale when present.
|
|
178
|
+
usedIngredients: result.usedIngredients || [],
|
|
179
|
+
rationale: result.rationale || null,
|
|
180
|
+
// §107a (v0.5.1): plan graduates to first-class so eval-diff's
|
|
181
|
+
// `--report-substitutions` can read it without `_debug` gating.
|
|
182
|
+
// The plan carries the LLM's emitted substitutions per ingredient
|
|
183
|
+
// — the substitution-coverage measurement needs this surface even
|
|
184
|
+
// when dialog-recorder is off (production eval scenarios).
|
|
185
|
+
plan: result.plan || null,
|
|
186
|
+
// §131 (v0.5.2): `_debug` is **volatile** — dialog-recorder-gated,
|
|
187
|
+
// shape may change without semver coordination. Consumers MUST read
|
|
188
|
+
// first-class fields instead: `usedIngredients` (§109 v0.5.1),
|
|
189
|
+
// `rationale` (§109 v0.5.1), `plan` (§107a v0.5.2). The old soft-API
|
|
190
|
+
// paths (`_debug.usedIngredients`, `_debug.rationale`, `_debug.plan`)
|
|
191
|
+
// were removed by §109 / §107a; this block is now strictly debug-
|
|
192
|
+
// recorder fodder. Scheduled removal: v0.6.0 will fold `attempts` +
|
|
193
|
+
// `warnings` into first-class result fields and drop `_debug` here.
|
|
137
194
|
_debug: isRecording() ? {
|
|
138
195
|
systemPrompt: null,
|
|
139
196
|
rawLLMResponse: null,
|
|
140
197
|
tokens: null,
|
|
141
|
-
plan: result.plan,
|
|
142
|
-
usedIngredients: result.usedIngredients,
|
|
143
198
|
attempts: result.attempts,
|
|
144
|
-
rationale: result.rationale,
|
|
145
199
|
warnings: result.warnings,
|
|
146
200
|
} : undefined,
|
|
147
201
|
};
|