1/**
2 * LLM-backed VariantAgent for the live-mode E2E suite.
3 *
4 * Implements the same one-method interface as createFakeAgent() in
5 * tests/live-e2e/agent.mjs: generateVariants(event, context) returns
6 * { scopedCss, variants[] }. The orchestrator handles wrap, write, accept,
7 * and carbonize cleanup deterministically, so this module's only job is
8 * producing variant content for the wrapper.
9 *
10 * Default model: Claude Haiku 4.5 — fast, cheap, smart enough for variant
11 * generation in test fixtures. Override via { model } when constructing,
12 * or via the IMPECCABLE_E2E_LLM_MODEL env var at the call site (test runner).
13 *
14 * Prompt caching: live.md (the live-mode skill spec) is the bulk of the
15 * system prompt and is stable across calls. We mark a cache_control breakpoint
16 * on the last system block so both the JSON-contract instructions and the
17 * spec are cached as one prefix. Subsequent calls in the same run pay only
18 * the cache-read rate (~0.1× input).
19 *
20 * Returns null from createLlmAgent() when ANTHROPIC_API_KEY is unset; the
21 * test runner reads that and skips the case rather than failing.
22 */
23
24import fs from 'node:fs/promises';
25import path from 'node:path';
26import { fileURLToPath } from 'node:url';
27import Anthropic from '@anthropic-ai/sdk';
28
29const __dirname = path.dirname(fileURLToPath(import.meta.url));
30const REPO_ROOT = path.join(__dirname, '..', '..', '..');
31const LIVE_MD_PATH = path.join(REPO_ROOT, 'skill', 'reference', 'live.md');
32
33const DEFAULT_MODEL = 'claude-haiku-4-5';
34
35const SYSTEM_INSTRUCTIONS = [
36 'You are an automated subagent inside Impeccable\'s live-mode test harness.',
37 'Given an element the user picked, an action, and a count, you produce variant DOM content in a strict JSON shape.',
38 '',
39 'OUTPUT CONTRACT — return ONLY a JSON object with this exact shape. No prose, no code fences, no commentary:',
40 '',
41 '{',
42 ' "scopedCss": "string — contents of the preview CSS block, authored according to wrapInfo.cssAuthoring",',
43 ' "variants": [',
44 ' {',
45 ' "innerHtml": "string — single top-level HTML element matching the picked element\'s tag, e.g. <h1 class=\\"hero-title\\">Title</h1>",',
46 ' "params": [/* optional 0-4 ParamSpec entries */]',
47 ' }',
48 ' ]',
49 '}',
50 '',
51 'ParamSpec is one of:',
52 ' { "id": "string", "kind": "range", "min": number, "max": number, "step": number, "default": number, "label": "string" }',
53 ' { "id": "string", "kind": "steps", "default": "string", "label": "string", "options": [{ "value": "string", "label": "string" }, ...] }',
54 ' { "id": "string", "kind": "toggle", "default": boolean, "label": "string" }',
55 '',
56 'REQUIREMENTS',
57 '- Each variant.innerHtml must be a single top-level HTML element. Use the EXACT same tag as the picked element.',
58 '- PRESERVE the original element\'s className verbatim. If the picked element\'s outerHTML contains class="hero-title", every variant\'s innerHtml MUST contain the same class="hero-title" string (you may add additional class names alongside, never remove or rename the original). This is a hard requirement — automated harnesses verify the original class survives across the variant set.',
59 '- Generate exactly event.count variants — no more, no fewer.',
60 '- Mix the param kinds across the variant set: include at least one range, one steps, and one toggle when count >= 3.',
61 '- The scopedCss must follow wrapInfo.cssAuthoring exactly: use its selector strategy, rulePattern, requirements, and forbidden patterns.',
62 '- Wire scopedCss rules against the params you emit (CSS vars for range/toggle, attribute selectors for steps/toggle).',
63 '- Use HTML attribute syntax in innerHtml (class=, not className=). The orchestrator translates per file syntax.',
64 '- Do NOT emit the wrapping <div data-impeccable-variant="N">. The orchestrator wraps your content.',
65 '- Do NOT emit the outer <style data-impeccable-css> tag. Only its contents go in scopedCss.',
66 '- Do NOT include any <!-- comments --> in scopedCss; CSS comments use /* */.',
67 '',
68 'CONTEXT — full live-mode skill spec follows. Use it as the source of truth for any nuance in the variant format.',
69].join('\n');
70
71/**
72 * @typedef {object} LlmAgentOptions
73 * @property {string=} apiKey Override ANTHROPIC_API_KEY env var.
74 * @property {string=} model Default 'claude-haiku-4-5'. Override to 'claude-sonnet-4-6' if Haiku produces unreliable JSON.
75 * @property {(msg: string) => void=} log Optional logger for debug output.
76 */
77
78/**
79 * @param {LlmAgentOptions} [opts]
80 * @returns {Promise<{generateVariants: (event: object, context: object) => Promise<{scopedCss: string, variants: object[]}>} | null>}
81 */
82export async function createLlmAgent(opts = {}) {
83 const apiKey = opts.apiKey || process.env.ANTHROPIC_API_KEY;
84 if (!apiKey) return null;
85
86 const model = opts.model || DEFAULT_MODEL;
87 const log = opts.log || (() => {});
88
89 const liveMd = await fs.readFile(LIVE_MD_PATH, 'utf-8');
90 const client = new Anthropic({ apiKey });
91
92 return {
93 async generateVariants(event, context = {}) {
94 const userMessage = [
95 'Produce variants for the following pick. Reply with the JSON object only — no prose.',
96 '',
97 '```json',
98 JSON.stringify(
99 {
100 id: event.id,
101 action: event.action,
102 count: event.count,
103 element: {
104 outerHTML: event.element?.outerHTML,
105 tagName: event.element?.tagName,
106 className: event.element?.className,
107 textContent: event.element?.textContent?.slice(0, 200),
108 },
109 wrapInfo: {
110 styleMode: context.wrapInfo?.styleMode,
111 styleTag: context.wrapInfo?.styleTag,
112 cssAuthoring: context.wrapInfo?.cssAuthoring,
113 },
114 },
115 null,
116 2,
117 ),
118 '```',
119 ].join('\n');
120
121 const response = await client.messages.create({
122 model,
123 max_tokens: 16000,
124 system: [
125 { type: 'text', text: SYSTEM_INSTRUCTIONS },
126 // Cacheable: the entire stable prefix (instructions + spec) is
127 // cached up to this breakpoint. The user message holds all the
128 // per-call volatile content.
129 { type: 'text', text: liveMd, cache_control: { type: 'ephemeral' } },
130 ],
131 messages: [{ role: 'user', content: userMessage }],
132 });
133
134 const cacheRead = response.usage?.cache_read_input_tokens ?? 0;
135 const cacheWrite = response.usage?.cache_creation_input_tokens ?? 0;
136 const inputTokens = response.usage?.input_tokens ?? 0;
137 const outputTokens = response.usage?.output_tokens ?? 0;
138 log(
139 `model=${model} input=${inputTokens} output=${outputTokens} cache_read=${cacheRead} cache_write=${cacheWrite}`,
140 );
141
142 const text = response.content
143 .filter((b) => b.type === 'text')
144 .map((b) => b.text)
145 .join('');
146
147 const cleaned = stripCodeFence(text.trim());
148 let parsed;
149 try {
150 parsed = JSON.parse(cleaned);
151 } catch (err) {
152 throw new Error(
153 `LLM agent: response was not valid JSON (${err.message}). First 500 chars:\n${cleaned.slice(0, 500)}`,
154 );
155 }
156
157 if (typeof parsed.scopedCss !== 'string') {
158 throw new Error(`LLM agent: missing or non-string scopedCss in response`);
159 }
160 if (!Array.isArray(parsed.variants) || parsed.variants.length === 0) {
161 throw new Error(`LLM agent: variants must be a non-empty array`);
162 }
163 for (const [i, v] of parsed.variants.entries()) {
164 if (typeof v.innerHtml !== 'string' || !v.innerHtml.trim()) {
165 throw new Error(`LLM agent: variants[${i}].innerHtml missing or empty`);
166 }
167 if (v.params !== undefined && !Array.isArray(v.params)) {
168 throw new Error(`LLM agent: variants[${i}].params must be an array if present`);
169 }
170 }
171
172 return parsed;
173 },
174 };
175}
176
177/**
178 * Some models wrap JSON in ```json … ``` fences despite the instruction not to.
179 * Strip a single optional fence, leave anything else alone.
180 */
181function stripCodeFence(s) {
182 return s
183 .replace(/^```(?:json)?\s*\n/, '')
184 .replace(/\n```\s*$/, '');
185}