llm-agent.mjs

  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}