@cairnvibe/sdk 0.2.13 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -5,19 +5,43 @@
5
5
 
6
6
  import Anthropic from "@anthropic-ai/sdk";
7
7
  import Groq from "groq-sdk";
8
+ import { z } from "zod";
8
9
  import {
10
+ classifyUiPattern,
9
11
  CopilotRequestSchema,
12
+ CriticVerdictSchema,
13
+ deriveStructureSignals,
14
+ isTerminalVerb,
15
+ PlannerOutputSchema,
16
+ renderPlaybookHint,
17
+ slugifySkillId,
18
+ TaskSchema,
19
+ UI_PATTERNS,
10
20
  VERBS,
11
21
  VerbResponseSchema,
22
+ type CriticVerdict,
12
23
  type HistoryTurn,
13
24
  type LiveElement,
14
25
  type Manifest,
26
+ type Plan,
27
+ type PlannerOutput,
28
+ type Skill,
29
+ type SkillSummary,
30
+ type Task,
31
+ type UiPatternId,
15
32
  type VerbResponse,
16
33
  type WebMcpTool,
17
34
  } from "@cairnvibe/core";
35
+ import { looksMultiStep, MAX_HISTORY_TURNS, summarizeVerbForHistory } from "./agent-loop";
36
+ import { formatArchivedFacts, formatRememberedFacts, seedHistoryFromMemory, type MemoryStore } from "./memory-sqlite";
18
37
  import { KeyRotator } from "./key-rotator";
38
+ import type { SkillStore } from "./skill-store";
19
39
 
20
40
  const VERB_TOOL_NAME = "respond_with_verb";
41
+ const PLAN_TOOL_NAME = "create_plan";
42
+ const PLAN_TOOL_DESCRIPTION = "Submit an ordered task plan for achieving the user's real end goal.";
43
+ const CRITIC_TOOL_NAME = "submit_verdict";
44
+ const CRITIC_TOOL_DESCRIPTION = "Submit your verdict on whether the current task is actually done, based on the real resulting state.";
21
45
 
22
46
  /**
23
47
  * What the agent is allowed to do, independent of which specific "do"
@@ -46,10 +70,50 @@ export interface CreateCopilotHandlerOptions {
46
70
  model?: string;
47
71
  /** Action ids this deployment actually supports. "do" is refused for anything else. */
48
72
  registeredActions?: string[];
73
+ /**
74
+ * Phase 4, layer 5 — real, human-written descriptions for `registeredActions`
75
+ * ids, e.g. `{ archiveInvoice: "Archives the invoice; cannot be undone." }`.
76
+ * Optional and purely additive: an id with no entry here still works
77
+ * exactly as before (rendered bare, no description) — this was the
78
+ * weakest-typed of Cairn's three action-invocation mechanisms (a
79
+ * registered action id carried literally zero server-visible metadata,
80
+ * unlike a WebMCP tool's own description or an element's `does` text);
81
+ * this closes that gap without changing what the model must echo back
82
+ * in "action" (still the bare id — see renderRegisteredActions).
83
+ */
84
+ actionDescriptions?: Record<string, string>;
49
85
  /** What the agent is allowed to do at all. Defaults to "act". See `CapabilityTier`. */
50
86
  capability?: CapabilityTier;
51
87
  /** Display name / identity for the agent, woven into its system prompt and shown in the widget. Defaults to "Cairn". */
52
88
  persona?: string;
89
+ /**
90
+ * Phase 5 step 4 — real cross-session memory for the typed/HTTP
91
+ * transport (packages/sdk/src/memory-sqlite.ts, or any store
92
+ * implementing the same interface). Optional — omitting it keeps
93
+ * every request exactly as memory-less as before this existed.
94
+ * Scoped by whatever `scopeId` string the request itself carries
95
+ * (`CopilotRequestSchema.scopeId`) — this SDK invents no identity of
96
+ * its own. Unlike the realtime relay (one persistent connection
97
+ * remembers a scopeId once), this transport is stateless per
98
+ * request: `resolveVerb`'s own callers seed from memory only when the
99
+ * REQUEST's own `history` arrives empty (a genuinely fresh session —
100
+ * see `createCopilotHandlerWithLLM`), never on every request, so a
101
+ * session already accumulating its own history client-side isn't
102
+ * re-seeded on top of itself.
103
+ */
104
+ memory?: MemoryStore;
105
+ /**
106
+ * Architecture Pillar 3 (Skill half) — real, per-deployment Skill
107
+ * storage (packages/sdk/src/skill-store.ts). A DIFFERENT scope axis
108
+ * than `memory` above — see skill-store.ts's own doc comment. Optional;
109
+ * omitting it keeps every request exactly as it was before this
110
+ * existed. Consumed by `createPlanHandler` (retrieval — a matching
111
+ * Skill's full instructions get surfaced to the Planner) and
112
+ * `createSkillSaveHandler` (the Formulator's own save side).
113
+ */
114
+ skills?: SkillStore;
115
+ /** The deployment-wide scope Skills are stored/looked up under when `skills` is configured. Defaults to "default" when omitted. */
116
+ skillsScopeId?: string;
53
117
  }
54
118
 
55
119
  export interface CopilotHandlerResult {
@@ -81,25 +145,71 @@ export function createCopilotHandler(manifest: Manifest, options: CreateCopilotH
81
145
  const registeredActions = options.registeredActions ?? [];
82
146
  const capability = options.capability ?? "act";
83
147
  const llm = createVerbLLM(options);
84
- return createCopilotHandlerWithLLM(manifest, llm, { registeredActions, capability, persona: options.persona });
148
+ return createCopilotHandlerWithLLM(manifest, llm, {
149
+ registeredActions,
150
+ capability,
151
+ persona: options.persona,
152
+ actionDescriptions: options.actionDescriptions,
153
+ memory: options.memory,
154
+ });
85
155
  }
86
156
 
87
157
  /** Same as `createCopilotHandler`, but with the LLM injected — used by tests to fake it. */
88
158
  export function createCopilotHandlerWithLLM(
89
159
  manifest: Manifest,
90
160
  llm: VerbLLM,
91
- options: { registeredActions?: string[]; capability?: CapabilityTier; persona?: string } = {},
161
+ options: { registeredActions?: string[]; capability?: CapabilityTier; persona?: string; actionDescriptions?: Record<string, string>; memory?: MemoryStore } = {},
92
162
  ): CopilotHandler {
93
163
  const registeredActions = options.registeredActions ?? [];
94
164
  const capability = options.capability ?? "act";
95
- const systemPrompt = buildSystemPrompt(manifest, registeredActions, options.persona);
165
+ const actionDescriptions = options.actionDescriptions ?? {};
166
+ const systemPrompt = buildSystemPrompt(manifest, registeredActions, options.persona, actionDescriptions);
96
167
 
97
168
  return async function handleCopilotRequest(body: unknown): Promise<CopilotHandlerResult> {
98
169
  const parsedRequest = CopilotRequestSchema.safeParse(body);
99
170
  if (!parsedRequest.success) {
100
171
  return { status: 400, body: { error: "invalid request body" } };
101
172
  }
102
- const verb = await resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, parsedRequest.data);
173
+ const input = parsedRequest.data;
174
+
175
+ // Phase 5 step 4 — real cross-session memory for the typed/HTTP
176
+ // transport. Unlike the realtime relay (one persistent connection,
177
+ // seeded once), this is stateless per request — seeded only when
178
+ // the CLIENT's own history arrives empty, the real signal for "this
179
+ // is a genuinely fresh session" (a session already accumulating its
180
+ // own history client-side is never re-seeded on top of itself; see
181
+ // CreateCopilotHandlerOptions.memory's own doc comment).
182
+ let effectiveHistory = input.history ?? [];
183
+ if (options.memory && input.scopeId && effectiveHistory.length === 0) {
184
+ const priorTurns = options.memory.recentTurns(input.scopeId);
185
+ effectiveHistory = seedHistoryFromMemory([], priorTurns, MAX_HISTORY_TURNS);
186
+ const factsSummary = formatRememberedFacts(options.memory.recallFacts(input.scopeId));
187
+ if (factsSummary) effectiveHistory = [{ role: "assistant", text: factsSummary }, ...effectiveHistory];
188
+ }
189
+
190
+ // Architecture Pillar 5 — the Archive tier, checked on EVERY request
191
+ // (not just a fresh session — unlike Core facts above, an archived
192
+ // fact is never always-injected, only surfaced when THIS question
193
+ // actually relates to it, which can happen at any point in an
194
+ // ongoing conversation, not only at its start).
195
+ if (options.memory && input.scopeId) {
196
+ const archivedMatch = options.memory.recallArchivedFacts(input.scopeId, input.question);
197
+ const archivedSummary = formatArchivedFacts(archivedMatch);
198
+ if (archivedSummary) effectiveHistory = [...effectiveHistory, { role: "assistant", text: archivedSummary }];
199
+ }
200
+
201
+ const verb = await resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, { ...input, history: effectiveHistory });
202
+
203
+ // Recorded only for a TERMINAL verb — matching the realtime relay's
204
+ // own discipline exactly: a continuing step (click/fill/read/
205
+ // call_tool/batch, or now a navigate marked continueAfter — see
206
+ // isTerminalVerb's own doc comment) is an internal implementation
207
+ // detail of one logical exchange, never its own remembered "turn".
208
+ if (options.memory && input.scopeId && isTerminalVerb(verb)) {
209
+ options.memory.recordTurn(input.scopeId, "user", input.question);
210
+ options.memory.recordTurn(input.scopeId, "assistant", summarizeVerbForHistory(verb));
211
+ }
212
+
103
213
  return { status: 200, body: verb };
104
214
  };
105
215
  }
@@ -127,13 +237,25 @@ export async function resolveVerb(
127
237
  ): Promise<VerbResponse> {
128
238
  let candidate: unknown;
129
239
  try {
240
+ // Architecture Pillar 2 — classified from the SAME liveElements this
241
+ // request already carries for element resolution, no new client
242
+ // wiring or payload field needed. Real, checkable evidence (which
243
+ // labels/roles matched), never a bare guess — see ui-patterns.ts's own
244
+ // doc comment. Absent entirely when nothing matched (a page that's
245
+ // none of the known patterns), rather than forcing a hint that isn't real.
246
+ const patternMatches = input.liveElements?.length ? classifyUiPattern(deriveStructureSignals(input.liveElements)) : [];
130
247
  // Element-level detail for the current page only, attached here rather
131
248
  // than baked into the (static, cached) system prompt — see
132
249
  // buildSystemPrompt's comment for why. This payload is already
133
250
  // per-request and was never cached, so there's nothing to lose by
134
251
  // making it bigger; the system prompt is what has to stay small and
135
252
  // route-independent.
136
- const userMessage = JSON.stringify({ ...input, currentPageElements: buildPageElements(manifest, input.route) });
253
+ const userMessage = JSON.stringify({
254
+ ...input,
255
+ currentPageElements: buildPageElements(manifest, input.route),
256
+ currentPageDataShapes: buildPageDataShapes(manifest, input.route),
257
+ ...(patternMatches.length ? { suggestedApproach: renderPlaybookHint(patternMatches[0].pattern) } : {}),
258
+ });
137
259
  candidate = await llm.respond(systemPrompt, userMessage);
138
260
  } catch (err) {
139
261
  console.error("[cairn] copilot LLM call failed:", err);
@@ -189,11 +311,22 @@ export async function resolveVerb(
189
311
  const isKnownTarget = (target: string) => pageElements.some((e) => e.id === target) || (input.liveElements ?? []).some((e) => e.id === target);
190
312
  const isKnownTool = (name: string) => (input.webMcpTools ?? []).some((t) => t.name === name);
191
313
 
192
- if (parsedVerb.data.verb === "click" || parsedVerb.data.verb === "fill" || parsedVerb.data.verb === "read") {
314
+ if (parsedVerb.data.verb === "click" || parsedVerb.data.verb === "fill" || parsedVerb.data.verb === "read" || parsedVerb.data.verb === "select" || parsedVerb.data.verb === "scroll" || parsedVerb.data.verb === "wait_for") {
193
315
  if (!isKnownTarget(parsedVerb.data.target)) {
194
316
  return { verb: "explain", text: "I don't see that on this page right now." };
195
317
  }
196
318
  }
319
+ if (parsedVerb.data.verb === "drag") {
320
+ if (!isKnownTarget(parsedVerb.data.target) || !isKnownTarget(parsedVerb.data.to)) {
321
+ return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
322
+ }
323
+ }
324
+ // key's target is optional (omitted means "whatever's currently
325
+ // focused") — only check it against real state when the model actually
326
+ // named one, same "never invented" invariant as every other target.
327
+ if (parsedVerb.data.verb === "key" && parsedVerb.data.target && !isKnownTarget(parsedVerb.data.target)) {
328
+ return { verb: "explain", text: "I don't see that on this page right now." };
329
+ }
197
330
  if (parsedVerb.data.verb === "call_tool") {
198
331
  if (!isKnownTool(parsedVerb.data.name)) {
199
332
  return { verb: "explain", text: "That isn't something I can do here." };
@@ -204,9 +337,12 @@ export async function resolveVerb(
204
337
  // partially execute a batch whose later step names something the
205
338
  // model invented; refuse the whole turn instead of guessing which
206
339
  // steps were "safe enough" to run.
207
- const allKnown = parsedVerb.data.actions.every((action) =>
208
- action.verb === "call_tool" ? isKnownTool(action.name) : isKnownTarget(action.target),
209
- );
340
+ const allKnown = parsedVerb.data.actions.every((action) => {
341
+ if (action.verb === "call_tool") return isKnownTool(action.name);
342
+ if (action.verb === "drag") return isKnownTarget(action.target) && isKnownTarget(action.to);
343
+ if (action.verb === "key") return !action.target || isKnownTarget(action.target);
344
+ return isKnownTarget(action.target);
345
+ });
210
346
  if (!allKnown) {
211
347
  return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
212
348
  }
@@ -230,10 +366,135 @@ export async function resolveVerb(
230
366
  return parsedVerb.data;
231
367
  }
232
368
 
233
- /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
234
- export function createVerbLLM(options: CreateCopilotHandlerOptions = {}): VerbLLM {
235
- const registeredActions = options.registeredActions ?? [];
236
- const toolSchema = buildVerbToolSchema(registeredActions);
369
+ /**
370
+ * Phase 3, step 2 (see DEVELOPMENT.md/the plan file) the Planner half
371
+ * of the Planner/Executor/Critic/Talker redesign. Decomposes a real end
372
+ * goal into an ordered task list BEFORE any execution happens, mirroring
373
+ * resolveVerb's own resilience discipline: never throws to the caller,
374
+ * degrades to a real, usable single-task fallback plan on any failure
375
+ * (a bad LLM response, a schema mismatch, a network error) rather than
376
+ * blocking the turn on a Planner hiccup. `version`/each task's `status`
377
+ * are harness-owned, not asked of the model (PlannerOutputSchema's own
378
+ * doc comment) — assembled here around the model's raw output.
379
+ *
380
+ * Deliberately does NOT yet change what the loop actually does with the
381
+ * result — step 2's own scope is observability only (see the doc comment
382
+ * on this function's call site in realtime-server.ts). The Critic (step
383
+ * 3) is what makes a Plan's tasks/doneContracts actually drive behavior.
384
+ */
385
+ export async function resolvePlan(llm: VerbLLM, goal: string, version = 1, manifest?: Manifest, actionsText?: string, skills?: { summariesText?: string; suggestedInstructions?: string }): Promise<Plan> {
386
+ let candidate: unknown;
387
+ try {
388
+ // manifest/actionsText are appended, optional, and default to absent —
389
+ // additive on purpose (see this function's own exported-API note
390
+ // above): an existing 2- or 3-arg call site (own or a published
391
+ // consumer's) keeps building the exact same {goal} userMessage it
392
+ // always has. Real page/data grounding (Phase 4 step 3) only applies
393
+ // when a caller has a manifest to pass — see buildPlannerPageDirectory's
394
+ // own doc comment for the token-budget discipline behind what it
395
+ // includes. actionsText (Phase 4 step 4) is the SAME rendering
396
+ // buildSystemPrompt/buildVerbToolSchema use for registered actions —
397
+ // pass renderRegisteredActions(...)'s own output, not a hand-rolled
398
+ // string, so the Planner and Executor never describe the same
399
+ // capability two different ways. skills (Architecture Pillar 3) is
400
+ // the same additive shape: `summariesText` (renderSkillSummaries'
401
+ // output — every this-deployment Skill's name+description, cheap to
402
+ // always include) and `suggestedInstructions` (matchSkillByGoal's own
403
+ // match, full instructions, only when one genuinely matched this
404
+ // goal) — both optional, both absent by default for a caller with no
405
+ // SkillStore configured.
406
+ const payload: Record<string, unknown> = { goal };
407
+ if (manifest) payload.pages = buildPlannerPageDirectory(manifest);
408
+ if (actionsText) payload.actions = actionsText;
409
+ if (skills?.summariesText) payload.skills = skills.summariesText;
410
+ if (skills?.suggestedInstructions) payload.suggestedSkill = skills.suggestedInstructions;
411
+ const userMessage = JSON.stringify(payload);
412
+ candidate = await llm.respond(buildPlannerSystemPrompt(), userMessage);
413
+ } catch (err) {
414
+ console.error("[cairn] planner LLM call failed:", err);
415
+ return fallbackPlan(goal, version);
416
+ }
417
+
418
+ const parsed = PlannerOutputSchema.safeParse(candidate);
419
+ if (!parsed.success) return fallbackPlan(goal, version);
420
+ return assemblePlan(parsed.data, version);
421
+ }
422
+
423
+ function assemblePlan(output: PlannerOutput, version: number): Plan {
424
+ return {
425
+ version,
426
+ goal: output.goal,
427
+ facts: output.facts,
428
+ tasks: output.tasks.map((task, i) => ({ ...task, status: i === 0 ? "in_progress" : "pending" })),
429
+ };
430
+ }
431
+
432
+ /** The real, single-task plan used when the Planner call itself fails —
433
+ * "do the whole goal as one task" is always a valid (if unstructured)
434
+ * plan, so a Planner hiccup degrades the redesign back to today's
435
+ * behavior instead of blocking the turn. Exported so every caller that
436
+ * needs "a plan, even a trivial one, right now" (e.g. a Critic call that
437
+ * fires before a real Planner result has come back) builds the exact
438
+ * same shape instead of hand-rolling a duplicate literal — realtime-
439
+ * server.ts's own finalizeTurn and index.tsx's runTypedAgentLoop both do
440
+ * this, for the same reason. */
441
+ export function fallbackPlan(goal: string, version: number): Plan {
442
+ return {
443
+ version,
444
+ goal,
445
+ facts: [],
446
+ tasks: [{ id: "t1", description: goal, doneContract: "The stated goal has been achieved.", status: "in_progress" }],
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Phase 3, step 3 — the Critic. A genuinely SEPARATE pass over the
452
+ * step's real observation, decoupled from the Executor/model's own
453
+ * self-report — this is the direct fix for the diagnosed bug (a batch
454
+ * of 2 clicks succeeded, and the model kept looping 4 more iterations
455
+ * before giving up, never recognizing its own success). Mirrors
456
+ * packages/evals/src/judge.ts's own judgeScenario shape on purpose (a
457
+ * separate model looking at real state, forced tool call, structured
458
+ * verdict) — same real precedent already proven and tested in this repo,
459
+ * not a new pattern invented for this. Same resilience discipline as
460
+ * resolveVerb/resolvePlan: never throws, degrades to a real "continue"
461
+ * verdict (harmless — the loop just behaves as if the Critic weren't
462
+ * there for this one step) on any failure.
463
+ */
464
+ export async function resolveCritic(llm: VerbLLM, task: Task, goal: string, verb: VerbResponse, observation: string | null | undefined): Promise<CriticVerdict> {
465
+ let candidate: unknown;
466
+ try {
467
+ candidate = await llm.respond(
468
+ buildCriticSystemPrompt(),
469
+ JSON.stringify({
470
+ goal,
471
+ taskDescription: task.description,
472
+ doneContract: task.doneContract,
473
+ action: summarizeVerbForHistory(verb),
474
+ observation: observation ?? "no result",
475
+ }),
476
+ );
477
+ } catch (err) {
478
+ console.error("[cairn] critic LLM call failed:", err);
479
+ return { verdict: "continue", reasoning: "Critic call failed — defaulting to continue rather than blocking the turn." };
480
+ }
481
+
482
+ const parsed = CriticVerdictSchema.safeParse(candidate);
483
+ if (!parsed.success) return { verdict: "continue", reasoning: "Critic response failed validation — defaulting to continue rather than blocking the turn." };
484
+ return parsed.data;
485
+ }
486
+
487
+ /** Same real rotation/model-selection logic as createVerbLLM/createPlanLLM,
488
+ * configured for the Critic's own tool instead — see resolveCritic. */
489
+ export function createCriticLLM(options: CreateCopilotHandlerOptions = {}): VerbLLM {
490
+ return createToolLLM(options, buildCriticToolSchema(), CRITIC_TOOL_NAME, CRITIC_TOOL_DESCRIPTION);
491
+ }
492
+
493
+ /** Builds a provider-appropriate forced-single-tool-call LLM for ANY tool
494
+ * shape (verb resolution, planning, ...) — the real rotation/model-
495
+ * selection logic every such caller needs, factored out once so
496
+ * createVerbLLM/createPlanLLM stay thin, tool-specific wrappers around it. */
497
+ function createToolLLM(options: CreateCopilotHandlerOptions, toolSchema: Record<string, unknown>, toolName: string, toolDescription: string): VerbLLM {
237
498
  const provider = options.provider ?? "anthropic";
238
499
 
239
500
  if (provider === "groq") {
@@ -243,15 +504,240 @@ export function createVerbLLM(options: CreateCopilotHandlerOptions = {}): VerbLL
243
504
  ? new KeyRotator([options.apiKey])
244
505
  : KeyRotator.fromEnvList(process.env.GROQ_API_KEYS);
245
506
  if (!rotator) {
246
- throw new Error("createVerbLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
507
+ throw new Error("createToolLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
247
508
  }
248
509
  const model = options.model ?? process.env.GROQ_MODEL ?? GROQ_DEFAULT_MODEL;
249
- return new GroqVerbLLM(rotator, model, toolSchema);
510
+ return new GroqVerbLLM(rotator, model, toolSchema, undefined, toolName, toolDescription);
250
511
  }
251
512
 
252
513
  const client = new Anthropic({ apiKey: options.apiKey });
253
514
  const model = options.model ?? process.env.CAIRN_RUNTIME_MODEL ?? "claude-opus-5";
254
- return new AnthropicVerbLLM(client, model, toolSchema);
515
+ return new AnthropicVerbLLM(client, model, toolSchema, toolName, toolDescription);
516
+ }
517
+
518
+ /** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
519
+ export function createVerbLLM(options: CreateCopilotHandlerOptions = {}): VerbLLM {
520
+ const registeredActions = options.registeredActions ?? [];
521
+ return createToolLLM(options, buildVerbToolSchema(registeredActions, options.actionDescriptions ?? {}), VERB_TOOL_NAME, VERB_TOOL_DESCRIPTION);
522
+ }
523
+
524
+ /** Same real rotation/model-selection logic as createVerbLLM, configured
525
+ * for the Planner's own tool instead — see resolvePlan. */
526
+ export function createPlanLLM(options: CreateCopilotHandlerOptions = {}): VerbLLM {
527
+ return createToolLLM(options, buildPlanToolSchema(), PLAN_TOOL_NAME, PLAN_TOOL_DESCRIPTION);
528
+ }
529
+
530
+ /**
531
+ * Architecture Pillar 3 (Skill half) — the Formulator. Runs once a task
532
+ * genuinely completes (not per-step — cheap on purpose, matching the plan
533
+ * file's own framing), compiling whatever real, Critic-verified
534
+ * `learnedFact`s were collected along the way (CriticVerdictSchema's own
535
+ * doc comment is the enforcement point for "never user data") into one
536
+ * Skill. Deliberately DETERMINISTIC, not a fourth kind of real LLM call —
537
+ * every fact it compiles already passed through the Critic's own
538
+ * verification, so there's nothing left to "figure out" that would
539
+ * justify the added cost/latency/failure surface of another model round
540
+ * trip; see DEVELOPMENT.md's own entry for the real cost reasoning
541
+ * (this session already hit genuine Groq quota exhaustion more than once
542
+ * from cumulative call volume). Returns null when nothing was learned —
543
+ * the common case, not an error; a caller should simply not save anything.
544
+ */
545
+ export function compileSkill(goal: string, learnedFacts: string[], pattern?: UiPatternId): Skill | null {
546
+ if (learnedFacts.length === 0) return null;
547
+ const name = goal.length > 80 ? `${goal.slice(0, 79)}…` : goal;
548
+ const firstFact = learnedFacts[0];
549
+ return {
550
+ id: slugifySkillId(name),
551
+ name,
552
+ description: firstFact.length > 120 ? `${firstFact.slice(0, 119)}…` : firstFact,
553
+ instructions: learnedFacts.join(" "),
554
+ pattern,
555
+ createdAt: new Date().toISOString(),
556
+ };
557
+ }
558
+
559
+ const SIGNIFICANT_WORD_MIN_LENGTH = 4;
560
+ // Real, common phrasing variance between a Skill's own name (usually a
561
+ // gerund, "Connecting nodes...") and a later goal restating the same idea
562
+ // ("connect the node...") means exact word equality misses obvious
563
+ // matches ("connecting" vs "connect", "nodes" vs "node"). A crude 4-
564
+ // character-prefix "stem" — not a real stemming library, deliberately —
565
+ // catches this common case without a new dependency, at the cost of
566
+ // occasional false-positive stems on short unrelated words; the min
567
+ // significant-word length above already screens out the shortest, most
568
+ // collision-prone words.
569
+ const STEM_LENGTH = 4;
570
+
571
+ function significantWordStems(text: string): Set<string> {
572
+ return new Set(
573
+ text
574
+ .toLowerCase()
575
+ .split(/[^a-z0-9]+/)
576
+ .filter((w) => w.length >= SIGNIFICANT_WORD_MIN_LENGTH)
577
+ .map((w) => w.slice(0, STEM_LENGTH)),
578
+ );
579
+ }
580
+
581
+ /**
582
+ * Architecture Pillar 3 (Skill half) — the retrieval side. A cheap,
583
+ * deterministic keyword-overlap match against a NEW goal (never another
584
+ * real LLM call, same reasoning as compileSkill above) — real progressive
585
+ * disclosure: every Skill's summary is cheap enough to always list (see
586
+ * SkillStore's own doc comment), but only the ONE Skill whose own name
587
+ * shares real, significant words with the current goal gets its full
588
+ * instructions loaded. A caller still needs its own SkillStore.getSkill
589
+ * call to fetch those full instructions for whatever this returns — this
590
+ * function only ever sees cheap summaries, never a full Skill.
591
+ */
592
+ export function matchSkillByGoal(summaries: SkillSummary[], goal: string): SkillSummary | null {
593
+ const goalStems = significantWordStems(goal);
594
+ if (goalStems.size === 0) return null;
595
+
596
+ let best: SkillSummary | null = null;
597
+ let bestScore = 0;
598
+ for (const summary of summaries) {
599
+ const score = Array.from(significantWordStems(summary.name)).filter((stem) => goalStems.has(stem)).length;
600
+ if (score > bestScore) {
601
+ bestScore = score;
602
+ best = summary;
603
+ }
604
+ }
605
+ return best;
606
+ }
607
+
608
+ /** Same rendering discipline as renderRegisteredActions — "id (description)" per Skill, for the Planner's own userMessage. */
609
+ export function renderSkillSummaries(summaries: SkillSummary[]): string {
610
+ return summaries.map((s) => `${s.name} (${s.description})`).join("; ");
611
+ }
612
+
613
+ const PlanRequestSchema = z
614
+ .object({
615
+ goal: z.string().min(1),
616
+ version: z.number().int().min(1).optional(),
617
+ })
618
+ .strict();
619
+
620
+ const CriticRequestSchema = z
621
+ .object({
622
+ task: TaskSchema,
623
+ goal: z.string().min(1),
624
+ verb: VerbResponseSchema,
625
+ observation: z.string().nullable().optional(),
626
+ })
627
+ .strict();
628
+
629
+ export type PlanHandler = (body: unknown) => Promise<{ status: number; body: Plan | { error: string } }>;
630
+ export type CriticHandler = (body: unknown) => Promise<{ status: number; body: CriticVerdict | { error: string } }>;
631
+
632
+ /**
633
+ * Architecture Pillar 4 — the typed/HTTP transport's own real Planner
634
+ * endpoint, closing the gap the plan file names directly: "the typed/
635
+ * HTTP path (index.tsx's runTypedAgentLoop) has zero Planner/Critic
636
+ * wiring at all... today explicitly realtime-only by deferral, not by
637
+ * decision." A thin HTTP wrapper around the exact same resolvePlan the
638
+ * realtime relay already calls in-process — the LLM call itself only
639
+ * ever needs to happen server-side (it holds the real API key), so a
640
+ * client-side caller (index.tsx) reaches it over a real request instead
641
+ * of importing resolvePlan directly, same reasoning as createCopilotHandler
642
+ * itself.
643
+ */
644
+ export function createPlanHandler(manifest: Manifest, options: CreateCopilotHandlerOptions = {}): PlanHandler {
645
+ return createPlanHandlerWithLLM(manifest, createPlanLLM(options), options);
646
+ }
647
+
648
+ /** Same as createPlanHandler, but with the LLM injected — used by tests to fake it, same pattern as createCopilotHandlerWithLLM. */
649
+ export function createPlanHandlerWithLLM(
650
+ manifest: Manifest,
651
+ planLLM: VerbLLM,
652
+ options: { registeredActions?: string[]; actionDescriptions?: Record<string, string>; skills?: SkillStore; skillsScopeId?: string } = {},
653
+ ): PlanHandler {
654
+ const actionsText = renderRegisteredActions(options.registeredActions ?? [], options.actionDescriptions ?? {});
655
+ const skillsScopeId = options.skillsScopeId ?? "default";
656
+ return async function handlePlanRequest(body: unknown) {
657
+ const parsed = PlanRequestSchema.safeParse(body);
658
+ if (!parsed.success) return { status: 400, body: { error: "invalid request body" } };
659
+
660
+ // Architecture Pillar 3 (Skill half) — the typed transport's own
661
+ // retrieval side, same shape realtime-server.ts's finalizeTurn
662
+ // already computes in-process. Absent `options.skills` (the
663
+ // overwhelming majority of deployments today) means zero overhead —
664
+ // this whole block is skipped entirely.
665
+ const skillSummaries = options.skills ? options.skills.listSkillSummaries(skillsScopeId) : [];
666
+ const matchedSkillSummary = skillSummaries.length ? matchSkillByGoal(skillSummaries, parsed.data.goal) : null;
667
+ const skillsPayload = skillSummaries.length
668
+ ? {
669
+ summariesText: renderSkillSummaries(skillSummaries) || undefined,
670
+ suggestedInstructions: matchedSkillSummary ? (options.skills!.getSkill(skillsScopeId, matchedSkillSummary.id)?.instructions ?? undefined) : undefined,
671
+ }
672
+ : undefined;
673
+
674
+ const plan = await resolvePlan(planLLM, parsed.data.goal, parsed.data.version ?? 1, manifest, actionsText || undefined, skillsPayload);
675
+ return { status: 200, body: plan };
676
+ };
677
+ }
678
+
679
+ /** Architecture Pillar 4's Critic counterpart to createPlanHandler — see
680
+ * its own doc comment. A thin HTTP wrapper around the same resolveCritic
681
+ * the realtime relay already calls in-process. */
682
+ export function createCriticHandler(options: CreateCopilotHandlerOptions = {}): CriticHandler {
683
+ return createCriticHandlerWithLLM(createCriticLLM(options));
684
+ }
685
+
686
+ /** Same as createCriticHandler, but with the LLM injected — used by tests to fake it. */
687
+ export function createCriticHandlerWithLLM(criticLLM: VerbLLM): CriticHandler {
688
+ return async function handleCriticRequest(body: unknown) {
689
+ const parsed = CriticRequestSchema.safeParse(body);
690
+ if (!parsed.success) return { status: 400, body: { error: "invalid request body" } };
691
+ const verdict = await resolveCritic(criticLLM, parsed.data.task, parsed.data.goal, parsed.data.verb, parsed.data.observation);
692
+ return { status: 200, body: verdict };
693
+ };
694
+ }
695
+
696
+ const SkillSaveRequestSchema = z
697
+ .object({
698
+ goal: z.string().min(1),
699
+ learnedFacts: z.array(z.string().min(1)),
700
+ pattern: z.enum(UI_PATTERNS).optional(),
701
+ })
702
+ .strict();
703
+
704
+ export type SkillSaveHandler = (body: unknown) => Promise<{ status: number; body: { saved: boolean } | { error: string } }>;
705
+
706
+ /**
707
+ * Architecture Pillar 3 (Skill half) — the typed transport's own save
708
+ * side (the Formulator's HTTP counterpart to realtime-server.ts's own
709
+ * in-process `compileSkill`+`saveSkill` call at the end of `finalizeTurn`).
710
+ * No LLM involved — `compileSkill` is deterministic (see its own doc
711
+ * comment for why) — so this needs no `-WithLLM` variant; it's real
712
+ * client-callable storage access, nothing more. The caller (index.tsx's
713
+ * runTypedAgentLoop) accumulates `learnedFacts` from its own Critic calls
714
+ * across one whole turn and posts here exactly once, after the turn
715
+ * concludes — never per-step, matching the Formulator's own "cheap on
716
+ * purpose" framing.
717
+ */
718
+ export function createSkillSaveHandler(skills: SkillStore, skillsScopeId = "default"): SkillSaveHandler {
719
+ return async function handleSkillSaveRequest(body: unknown) {
720
+ const parsed = SkillSaveRequestSchema.safeParse(body);
721
+ if (!parsed.success) return { status: 400, body: { error: "invalid request body" } };
722
+ const skill = compileSkill(parsed.data.goal, parsed.data.learnedFacts, parsed.data.pattern);
723
+ if (skill) skills.saveSkill(skillsScopeId, skill);
724
+ return { status: 200, body: { saved: skill !== null } };
725
+ };
726
+ }
727
+
728
+ /**
729
+ * Phase 2 step 1 — a genuinely UNSTRUCTURED, streamed call: no tools, no
730
+ * forced choice, just the model's plain spoken answer to the user's
731
+ * question, delivered incrementally. Exists because a real, live spike
732
+ * against Groq's actual API (see DEVELOPMENT.md/the plan file's Phase 2
733
+ * entry) found that a FORCED tool call never streams at the field level
734
+ * even with stream:true — the whole structured object arrives in one
735
+ * chunk. Plain, unforced generation genuinely streams token-by-token on
736
+ * both providers, and finishes faster besides — this is what makes "LLM
737
+ * tokens streamed straight into TTS" possible at all.
738
+ */
739
+ export interface StreamingTextLLM {
740
+ respondStreamed(systemPrompt: string, userMessage: string, onChunk: (delta: string) => void): Promise<string>;
255
741
  }
256
742
 
257
743
  // ---------------------------------------------------------------------------
@@ -263,6 +749,8 @@ export class AnthropicVerbLLM implements VerbLLM {
263
749
  private client: MessagesClient,
264
750
  private model: string,
265
751
  private toolSchema: Record<string, unknown>,
752
+ private toolName: string = VERB_TOOL_NAME,
753
+ private toolDescription: string = VERB_TOOL_DESCRIPTION,
266
754
  ) {}
267
755
 
268
756
  async respond(systemPrompt: string, userMessage: string): Promise<unknown> {
@@ -272,23 +760,58 @@ export class AnthropicVerbLLM implements VerbLLM {
272
760
  system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
273
761
  tools: [
274
762
  {
275
- name: VERB_TOOL_NAME,
276
- description: VERB_TOOL_DESCRIPTION,
763
+ name: this.toolName,
764
+ description: this.toolDescription,
277
765
  input_schema: this.toolSchema,
278
766
  strict: true,
279
767
  },
280
768
  ],
281
- tool_choice: { type: "tool", name: VERB_TOOL_NAME },
769
+ tool_choice: { type: "tool", name: this.toolName },
282
770
  messages: [{ role: "user", content: userMessage }],
283
771
  });
284
772
 
285
773
  const toolUse = response.content.find(
286
- (block: any): block is Anthropic.ToolUseBlock => block?.type === "tool_use" && block?.name === VERB_TOOL_NAME,
774
+ (block: any): block is Anthropic.ToolUseBlock => block?.type === "tool_use" && block?.name === this.toolName,
287
775
  );
288
776
  return toolUse?.input;
289
777
  }
290
778
  }
291
779
 
780
+ /** Minimal shape AnthropicStreamingTextLLM needs — narrow enough to fake in tests (a plain async generator, no real SDK stream class). */
781
+ export interface StreamingMessagesClient {
782
+ messages: {
783
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
784
+ create: (params: any) => Promise<AsyncIterable<any>>;
785
+ };
786
+ }
787
+
788
+ /** No tools, no tool_choice — see StreamingTextLLM's own doc comment for why plain, unforced generation is what streams. */
789
+ export class AnthropicStreamingTextLLM implements StreamingTextLLM {
790
+ constructor(
791
+ private client: StreamingMessagesClient,
792
+ private model: string,
793
+ ) {}
794
+
795
+ async respondStreamed(systemPrompt: string, userMessage: string, onChunk: (delta: string) => void): Promise<string> {
796
+ const stream = await this.client.messages.create({
797
+ model: this.model,
798
+ max_tokens: 1024,
799
+ stream: true,
800
+ system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
801
+ messages: [{ role: "user", content: userMessage }],
802
+ });
803
+
804
+ let full = "";
805
+ for await (const event of stream) {
806
+ if (event?.type === "content_block_delta" && event?.delta?.type === "text_delta" && typeof event.delta.text === "string") {
807
+ full += event.delta.text;
808
+ onChunk(event.delta.text);
809
+ }
810
+ }
811
+ return full;
812
+ }
813
+ }
814
+
292
815
  // Groq's chat-completions API is OpenAI-compatible: function-calling tools
293
816
  // instead of Anthropic's native tool_use blocks, arguments come back as a
294
817
  // JSON *string* to parse. Model list verified live against
@@ -311,40 +834,101 @@ export class GroqVerbLLM implements VerbLLM {
311
834
  private model: string,
312
835
  private toolSchema: Record<string, unknown>,
313
836
  private clientFactory: (apiKey: string) => GroqLikeClient = (apiKey) => new Groq({ apiKey }),
837
+ private toolName: string = VERB_TOOL_NAME,
838
+ private toolDescription: string = VERB_TOOL_DESCRIPTION,
314
839
  ) {}
315
840
 
316
841
  async respond(systemPrompt: string, userMessage: string): Promise<unknown> {
317
- try {
318
- return await this.attemptRespond(systemPrompt, userMessage);
319
- } catch (err) {
320
- // Real, live bugs, not theoretical two distinct non-deterministic
321
- // failure modes from openai/gpt-oss-120b (a reasoning-capable open
322
- // model), both rejected by Groq's own server-side validation before
323
- // this code ever sees a real response to work with, and both found
324
- // to recover cleanly on an identical retry a moment later:
325
- // - "output_parse_failed": the model "thinks out loud" in plain
326
- // prose instead of emitting the forced tool call.
327
- // - "tool_use_failed": the model hallucinates a slightly-wrong tool
328
- // name ("json", "response_with_verb" seen live, both against
329
- // the real, correctly-configured VERB_TOOL_NAME) instead of the
330
- // one forced tool it was actually given. This one was the actual
331
- // cause behind a real "voice keeps breaking" report — found live
332
- // running the new eval harness's synthetic-voice scenario, where
333
- // it surfaced as "Something went wrong on my end" with no other
334
- // symptom, exactly matching what got reported.
335
- // One retry — not exponential backoff, this is a latency-sensitive
336
- // voice/chat path genuinely helps rather than just delaying the
337
- // same failure. Anything else still propagates to resolveVerb's own
338
- // catch, unchanged.
339
- if (isRetryableToolCallFailure(err)) {
340
- return await this.attemptRespond(systemPrompt, userMessage);
842
+ // Three independent, real retry policies, combined in one loop:
843
+ // - Invalid key (401): the key itself is confirmed dead (see
844
+ // isInvalidKeyError/KeyRotator.markDead) excluded from rotation
845
+ // for the rest of this process's life, then retried on a
846
+ // DIFFERENT configured key, same bound as rate-limit retries
847
+ // below. Real, live-found gap this closes: before this existed, a
848
+ // single expired/invalid key in the rotation silently sabotaged
849
+ // roughly (dead keys / total keys) of every real request forever
850
+ // GroqVerbLLM only ever retried a 429, never a 401, so hitting a
851
+ // dead key on the rotation just failed the whole turn outright
852
+ // even when other, genuinely working keys were configured.
853
+ // - Rate-limit (429): retried on a DIFFERENT configured key, up to
854
+ // once per distinct key. Found live — a Groq account's own daily
855
+ // token quota exhausting mid-session doesn't mean every OTHER
856
+ // configured account/key is also exhausted; KeyRotator.take()
857
+ // already advances on every call, so simply retrying reaches a
858
+ // different key automatically. With only one key configured this
859
+ // never fires (nothing else to fall back to) — same behavior as
860
+ // before this existed.
861
+ // - Tool-call failure (see below): exactly one retry, regardless of
862
+ // key count unrelated to which key was used.
863
+ const maxKeyAttempts = Math.max(this.keys.size, 1);
864
+ let keyAttempts = 0;
865
+ let usedToolCallRetry = false;
866
+
867
+ for (;;) {
868
+ const key = this.keys.take();
869
+ try {
870
+ return await this.attemptRespond(key, systemPrompt, userMessage);
871
+ } catch (err) {
872
+ if (isInvalidKeyError(err)) {
873
+ this.keys.markDead(key);
874
+ if (keyAttempts < maxKeyAttempts - 1) {
875
+ keyAttempts++;
876
+ continue;
877
+ }
878
+ throw err;
879
+ }
880
+ if (isRateLimitError(err) && keyAttempts < maxKeyAttempts - 1) {
881
+ keyAttempts++;
882
+ continue;
883
+ }
884
+ // Real, live bugs, not theoretical — two distinct non-deterministic
885
+ // failure modes from openai/gpt-oss-120b (a reasoning-capable open
886
+ // model), both rejected by Groq's own server-side validation before
887
+ // this code ever sees a real response to work with, and both found
888
+ // to recover cleanly on an identical retry a moment later:
889
+ // - "output_parse_failed": the model "thinks out loud" in plain
890
+ // prose instead of emitting the forced tool call.
891
+ // - "tool_use_failed": the model hallucinates a slightly-wrong tool
892
+ // name ("json", "response_with_verb" — seen live, both against
893
+ // the real, correctly-configured VERB_TOOL_NAME) instead of the
894
+ // one forced tool it was actually given. This one was the actual
895
+ // cause behind a real "voice keeps breaking" report — found live
896
+ // running the new eval harness's synthetic-voice scenario, where
897
+ // it surfaced as "Something went wrong on my end" with no other
898
+ // symptom, exactly matching what got reported.
899
+ // Real, live bug found AFTER the single-retry mitigation above had
900
+ // already shipped: the SAME hallucinated-tool-name failure can hit
901
+ // twice in a row (seen live, back to back, on one otherwise-normal
902
+ // question), exhausting the one retry and still falling through to
903
+ // the generic fallback — even though Groq's own error response
904
+ // carries the model's complete, correctly-shaped answer right there
905
+ // in `failed_generation` (it parsed the arguments fine; it only
906
+ // picked the wrong TOOL NAME to wrap them in). Recovering that
907
+ // directly costs nothing (no extra round trip) and can't make
908
+ // things worse than today — resolveVerb's own VerbResponseSchema
909
+ // check right after this returns is the same safety net a normal,
910
+ // successful response already goes through, so a malformed
911
+ // extraction just falls to its existing "I'm not sure" fallback.
912
+ // Tried before spending the one real retry on output_parse_failed's
913
+ // case, where there's genuinely nothing to extract (no forced tool
914
+ // call was even produced).
915
+ const recovered = extractFailedGenerationArguments(err);
916
+ if (recovered !== undefined) return recovered;
917
+ // One retry — not exponential backoff, this is a latency-sensitive
918
+ // voice/chat path — genuinely helps rather than just delaying the
919
+ // same failure. Anything else still propagates to resolveVerb's own
920
+ // catch, unchanged.
921
+ if (isRetryableToolCallFailure(err) && !usedToolCallRetry) {
922
+ usedToolCallRetry = true;
923
+ continue;
924
+ }
925
+ throw err;
341
926
  }
342
- throw err;
343
927
  }
344
928
  }
345
929
 
346
- private async attemptRespond(systemPrompt: string, userMessage: string): Promise<unknown> {
347
- const client = this.clientFactory(this.keys.take());
930
+ private async attemptRespond(apiKey: string, systemPrompt: string, userMessage: string): Promise<unknown> {
931
+ const client = this.clientFactory(apiKey);
348
932
  const completion = await client.chat.completions.create({
349
933
  model: this.model,
350
934
  messages: [
@@ -355,13 +939,13 @@ export class GroqVerbLLM implements VerbLLM {
355
939
  {
356
940
  type: "function",
357
941
  function: {
358
- name: VERB_TOOL_NAME,
359
- description: VERB_TOOL_DESCRIPTION,
942
+ name: this.toolName,
943
+ description: this.toolDescription,
360
944
  parameters: this.toolSchema,
361
945
  },
362
946
  },
363
947
  ],
364
- tool_choice: { type: "function", function: { name: VERB_TOOL_NAME } },
948
+ tool_choice: { type: "function", function: { name: this.toolName } },
365
949
  });
366
950
 
367
951
  const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
@@ -374,15 +958,97 @@ export class GroqVerbLLM implements VerbLLM {
374
958
  }
375
959
  }
376
960
 
961
+ /** Minimal shape GroqStreamingTextLLM needs — narrow enough to fake in tests (a plain async generator, no real SDK stream class). */
962
+ export interface GroqLikeStreamingClient {
963
+ chat: {
964
+ completions: {
965
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
966
+ create: (params: any) => Promise<AsyncIterable<any>>;
967
+ };
968
+ };
969
+ }
970
+
971
+ /** No tools, no tool_choice — see StreamingTextLLM's own doc comment for why plain, unforced generation is what streams. No retry-on-hallucinated-tool-name logic here (GroqVerbLLM's own real, live-found bug) — there's no tool to hallucinate the name of. */
972
+ export class GroqStreamingTextLLM implements StreamingTextLLM {
973
+ constructor(
974
+ private keys: KeyRotator,
975
+ private model: string,
976
+ private clientFactory: (apiKey: string) => GroqLikeStreamingClient = (apiKey) => new Groq({ apiKey }),
977
+ ) {}
978
+
979
+ async respondStreamed(systemPrompt: string, userMessage: string, onChunk: (delta: string) => void): Promise<string> {
980
+ // Same rate-limit/invalid-key-retries-on-a-different-key policy as
981
+ // GroqVerbLLM.respond (see its own doc comment, including the real,
982
+ // live-found "a dead key sabotages roughly 1/N of every request"
983
+ // gap markDead closes). The one thing this path has to guard against
984
+ // that the non-streaming call doesn't: a real chunk already having
985
+ // reached the caller via onChunk before something fails mid-stream —
986
+ // both a 429 and a 401 always arrive on the initial request, before
987
+ // any chunk streams, so retrying is only ever attempted when nothing
988
+ // has been emitted yet; a genuinely different mid-stream failure is
989
+ // never retried, since doing so would duplicate output already sent.
990
+ const maxAttempts = Math.max(this.keys.size, 1);
991
+ let lastErr: unknown;
992
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
993
+ let emittedAnyChunk = false;
994
+ const key = this.keys.take();
995
+ try {
996
+ const client = this.clientFactory(key);
997
+ const stream = await client.chat.completions.create({
998
+ model: this.model,
999
+ stream: true,
1000
+ messages: [
1001
+ { role: "system", content: systemPrompt },
1002
+ { role: "user", content: userMessage },
1003
+ ],
1004
+ });
1005
+
1006
+ let full = "";
1007
+ for await (const chunk of stream) {
1008
+ const delta = chunk?.choices?.[0]?.delta?.content;
1009
+ if (typeof delta === "string" && delta) {
1010
+ full += delta;
1011
+ emittedAnyChunk = true;
1012
+ onChunk(delta);
1013
+ }
1014
+ }
1015
+ return full;
1016
+ } catch (err) {
1017
+ lastErr = err;
1018
+ if (emittedAnyChunk) throw err;
1019
+ if (isInvalidKeyError(err)) this.keys.markDead(key);
1020
+ if ((isInvalidKeyError(err) || isRateLimitError(err)) && attempt < maxAttempts - 1) continue;
1021
+ throw err;
1022
+ }
1023
+ }
1024
+ throw lastErr;
1025
+ }
1026
+ }
1027
+
377
1028
  /** Groq's SDK doesn't export a stable error shape to import and check
378
1029
  * against, so this checks defensively across the ways the real error has
379
1030
  * actually been observed to surface — a thrown APIError with a nested
380
1031
  * `.error.code`, a plain `.code`, or just the code string showing up
381
- * somewhere in the message — rather than relying on exactly one of them. */
1032
+ * somewhere in the message — rather than relying on exactly one of them.
1033
+ *
1034
+ * Real, live bug found AFTER this function had already shipped and been
1035
+ * unit-tested: the actual Groq SDK error is doubly-nested
1036
+ * (`err.error.error.code`, matching isRateLimitError's own real-shape
1037
+ * fix below) but this only ever checked ONE level (`err.error?.code`) —
1038
+ * so `code` always came back `undefined` against the real API surface,
1039
+ * and the retry this function exists for NEVER actually fired, despite
1040
+ * every unit test passing (they all hand-built a shallow, one-level
1041
+ * mock that doesn't match what Groq really throws). Caught live: a
1042
+ * `tool_use_failed`/hallucinated-tool-name error with a perfectly good
1043
+ * answer sitting right there in `failed_generation` fell straight to
1044
+ * the generic "Something went wrong on my end" fallback instead of
1045
+ * retrying — exactly the failure mode this function's own doc comment
1046
+ * already claimed to fix. Now checks the same depth `isRateLimitError`
1047
+ * does. */
382
1048
  function isRetryableToolCallFailure(err: unknown): boolean {
383
1049
  if (!err || typeof err !== "object") return false;
384
- const e = err as { code?: unknown; error?: { code?: unknown }; message?: unknown };
385
- const code = e.code ?? e.error?.code;
1050
+ const e = err as { code?: unknown; error?: { code?: unknown; error?: { code?: unknown } }; message?: unknown };
1051
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
386
1052
  const message = typeof e.message === "string" ? e.message : "";
387
1053
  if (code === "output_parse_failed" || message.includes("output_parse_failed")) return true;
388
1054
  // "attempted to call tool 'json' which was not in request.tools" — the
@@ -393,13 +1059,94 @@ function isRetryableToolCallFailure(err: unknown): boolean {
393
1059
  return false;
394
1060
  }
395
1061
 
1062
+ /**
1063
+ * Recovers the model's real, already-generated answer straight out of a
1064
+ * `tool_use_failed` error, instead of spending a retry re-asking for
1065
+ * something Groq already has. Same doubly-nested shape
1066
+ * isRetryableToolCallFailure/isRateLimitError check against
1067
+ * (`err.error.error.*`) plus the one extra field this specific error
1068
+ * carries: `failed_generation`, a JSON-encoded string of exactly what the
1069
+ * model produced — `{"name": "<hallucinated tool name>", "arguments":
1070
+ * {...the real verb payload...}}`. Deliberately narrow: only fires for
1071
+ * `tool_use_failed` specifically (never `output_parse_failed`, where the
1072
+ * model didn't produce a forced tool call at all, so there's nothing real
1073
+ * to recover here), and only returns something when `arguments` actually
1074
+ * parses as an object — anything else (a missing field, a non-JSON
1075
+ * string, a differently-shaped error) returns undefined and the caller
1076
+ * falls through to its existing retry/throw path, unchanged.
1077
+ */
1078
+ function extractFailedGenerationArguments(err: unknown): unknown {
1079
+ if (!err || typeof err !== "object") return undefined;
1080
+ const e = err as { error?: { error?: { code?: unknown; failed_generation?: unknown } } };
1081
+ if (e.error?.error?.code !== "tool_use_failed") return undefined;
1082
+ const raw = e.error.error.failed_generation;
1083
+ if (typeof raw !== "string") return undefined;
1084
+ try {
1085
+ const parsed = JSON.parse(raw) as { arguments?: unknown };
1086
+ return parsed && typeof parsed === "object" && parsed.arguments && typeof parsed.arguments === "object" ? parsed.arguments : undefined;
1087
+ } catch {
1088
+ return undefined;
1089
+ }
1090
+ }
1091
+
1092
+ /** Same defensive-shape-checking approach as isRetryableToolCallFailure —
1093
+ * the real error observed live (see DEVELOPMENT.md) is a thrown APIError
1094
+ * with `.status === 429` and a doubly-nested `.error.error.code ===
1095
+ * "rate_limit_exceeded"`, but checks a couple of shallower shapes too
1096
+ * rather than depending on exactly that nesting. */
1097
+ function isRateLimitError(err: unknown): boolean {
1098
+ if (!err || typeof err !== "object") return false;
1099
+ const e = err as { status?: unknown; code?: unknown; error?: { code?: unknown; error?: { code?: unknown } }; message?: unknown };
1100
+ if (e.status === 429) return true;
1101
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
1102
+ if (code === "rate_limit_exceeded") return true;
1103
+ const message = typeof e.message === "string" ? e.message : "";
1104
+ return message.includes("rate_limit_exceeded") || message.includes("Rate limit reached");
1105
+ }
1106
+
1107
+ /**
1108
+ * Same defensive-shape-checking approach as isRateLimitError — the real
1109
+ * error was checked directly against the live Groq API before writing
1110
+ * this (not guessed): a real 401 from an invalid/expired key throws with
1111
+ * `.status === 401` and the SAME doubly-nested `.error.error.code ===
1112
+ * "invalid_api_key"` shape isRateLimitError already has to check for its
1113
+ * own 429 case, confirmed via `Groq({apiKey}).chat.completions.create(...)`
1114
+ * against a genuinely dead key and inspecting the thrown error's own
1115
+ * `.status`/`.error`/`.message` fields directly.
1116
+ */
1117
+ function isInvalidKeyError(err: unknown): boolean {
1118
+ if (!err || typeof err !== "object") return false;
1119
+ const e = err as { status?: unknown; code?: unknown; error?: { code?: unknown; error?: { code?: unknown } }; message?: unknown };
1120
+ if (e.status === 401) return true;
1121
+ const code = e.code ?? e.error?.code ?? e.error?.error?.code;
1122
+ if (code === "invalid_api_key") return true;
1123
+ const message = typeof e.message === "string" ? e.message : "";
1124
+ return message.includes("invalid_api_key") || message.includes("Invalid API Key");
1125
+ }
1126
+
396
1127
  // ---------------------------------------------------------------------------
397
1128
  // Shared tool schema / system prompt
398
1129
  // ---------------------------------------------------------------------------
399
1130
 
400
1131
  const VERB_TOOL_DESCRIPTION = "Respond with exactly one action for the UI to take. Never invent selectors, routes, or code.";
401
1132
 
402
- export function buildVerbToolSchema(registeredActions: string[]): Record<string, unknown> {
1133
+ /**
1134
+ * Phase 4, layer 5 — the ONE place a registered action id is rendered
1135
+ * with its (optional) real description, shared by buildVerbToolSchema,
1136
+ * buildSystemPrompt's own do-verb text, and resolvePlan's userMessage —
1137
+ * so the Executor and the Planner describe the exact same capability the
1138
+ * exact same way, and there's no risk of the three drifting out of sync.
1139
+ * Deliberately renders "id (description)" rather than baking the
1140
+ * description into what the model must echo back — resolveVerb's own
1141
+ * `registeredActions.includes(parsedVerb.data.action)` check (server.ts)
1142
+ * needs the RAW id back, verbatim, or a real registered action would
1143
+ * silently stop being recognized.
1144
+ */
1145
+ export function renderRegisteredActions(registeredActions: string[], actionDescriptions: Record<string, string> = {}): string {
1146
+ return registeredActions.map((id) => (actionDescriptions[id] ? `${id} (${actionDescriptions[id]})` : id)).join(", ");
1147
+ }
1148
+
1149
+ export function buildVerbToolSchema(registeredActions: string[], actionDescriptions: Record<string, string> = {}): Record<string, unknown> {
403
1150
  // Every genuinely-optional field allows `null` as well as its real type
404
1151
  // (`["string", "null"]`, not just `"string"`) — found live, not
405
1152
  // theoretical: real models (verified against Groq's openai/gpt-oss-120b)
@@ -420,17 +1167,24 @@ export function buildVerbToolSchema(registeredActions: string[]): Record<string,
420
1167
  verb: { type: "string", enum: [...VERBS] },
421
1168
  text: nullableString("Shown to the user. Required for explain. null (or omitted) if not applicable."),
422
1169
  target: nullableString(
423
- "An id from currentPageElements or liveElements. Required for highlight/open/click/fill/read. For do, the id of what the action applies to, if it needs one — prefer a liveElements id when the user means one specific item among several. Not used for batch — each of its own actions carries its own target instead. null (or omitted) if not applicable.",
1170
+ "An id from currentPageElements or liveElements. Required for highlight/open/click/fill/read/select/scroll/wait_for, and for drag (the thing being dragged). For do, the id of what the action applies to, if it needs one — prefer a liveElements id when the user means one specific item among several. For key, the element to press the key on — omit to press it on whatever's currently focused. Not used for batch — each of its own actions carries its own target instead. null (or omitted) if not applicable.",
424
1171
  ),
1172
+ to: nullableString("Required for drag — the id (from currentPageElements or liveElements) of where to drop it. null (or omitted) if not applicable."),
1173
+ key: nullableString('Required for key — one real key name: Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, or ArrowRight. null (or omitted) if not applicable.'),
425
1174
  route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
1175
+ continueAfter: {
1176
+ type: ["boolean", "null"],
1177
+ description:
1178
+ 'Only for navigate. Set to true when the user\'s real goal needs MORE than just arriving at the new page — e.g. "buy earbuds" means navigate there, then search, then report back what you found, not just navigate. When true, you\'ll be asked again once you\'ve arrived, with that page\'s own real elements, so you can decide the next real step (or say you\'re done). Leave false/null for a plain "take me to X" request, where arriving IS the whole answer — setting this needlessly costs an extra real turn for no reason.',
1179
+ },
426
1180
  action: nullableString(
427
1181
  "Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
428
1182
  (registeredActions.length
429
- ? `— either one of this deployment's registered actions [${registeredActions.join(", ")}], or, for any other element from currentPageElements or liveElements whose own description/label says it performs a real action, any short label describing it.`
1183
+ ? `— either the exact id (never its description in parens) of one of this deployment's registered actions [${renderRegisteredActions(registeredActions, actionDescriptions)}], or, for any other element from currentPageElements or liveElements whose own description/label says it performs a real action, any short label describing it.`
430
1184
  : "for any element from currentPageElements or liveElements whose own description/label says it performs a real action — no actions are separately registered in this deployment, but that path still works.") +
431
1185
  " null (or omitted) if not applicable.",
432
1186
  ),
433
- value: nullableString('Required for fill — the exact text to type into "target". null (or omitted) if not applicable.'),
1187
+ value: nullableString('Required for fill — the exact text to type into "target". Required for select — the option\'s visible text, never its raw internal value. null (or omitted) if not applicable.'),
434
1188
  name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list, exactly as given. null (or omitted) if not applicable."),
435
1189
  args: {
436
1190
  type: ["object", "null"],
@@ -463,14 +1217,18 @@ export function buildVerbToolSchema(registeredActions: string[]): Record<string,
463
1217
  actions: {
464
1218
  type: ["array", "null"],
465
1219
  description:
466
- "Required for batch, 2-5 items. Several click/fill/read/call_tool steps executed in order in ONE round trip, instead of one round trip each — use this when you already know several steps are needed and don't need to see one step's real result before choosing the next (e.g. filling three known fields, or clicking through a sequence you're already sure about). If a later step genuinely depends on what an earlier one turns up, use a single step instead and decide the next one once you see its real result. text (if any) is spoken once for the whole batch, not per step. null (or omitted) if not applicable.",
1220
+ "Required for batch, 2-5 items. Several click/fill/read/call_tool/drag/select/key/scroll/wait_for steps executed in order in ONE round trip, instead of one round trip each — use this when you already know several steps are needed and don't need to see one step's real result before choosing the next (e.g. filling three known fields, or clicking through a sequence you're already sure about). If a later step genuinely depends on what an earlier one turns up, use a single step instead and decide the next one once you see its real result. text (if any) is spoken once for the whole batch, not per step. null (or omitted) if not applicable.",
467
1221
  items: {
468
1222
  type: "object",
469
1223
  properties: {
470
- verb: { type: "string", enum: ["click", "fill", "read", "call_tool"] },
471
- target: nullableString("An id from currentPageElements or liveElements. Required for click/fill/read. null (or omitted) if not applicable."),
472
- value: nullableString('Required for fill the exact text to type into "target". null (or omitted) if not applicable.'),
1224
+ verb: { type: "string", enum: ["click", "fill", "read", "call_tool", "drag", "select", "key", "scroll", "wait_for"] },
1225
+ target: nullableString(
1226
+ "An id from currentPageElements or liveElements. Required for click/fill/read/select/scroll/wait_for/drag (the thing being dragged). For key, omit to press it on whatever's currently focused. null (or omitted) if not applicable.",
1227
+ ),
1228
+ value: nullableString('Required for fill — the exact text to type into "target". Required for select — the option\'s visible text. null (or omitted) if not applicable.'),
473
1229
  name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list. null (or omitted) if not applicable."),
1230
+ to: nullableString("Required for drag — the id of where to drop it. null (or omitted) if not applicable."),
1231
+ key: nullableString("Required for key — one real key name (Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight). null (or omitted) if not applicable."),
474
1232
  args: {
475
1233
  type: ["object", "null"],
476
1234
  description: "For call_tool — the arguments object, matching that tool's own inputSchema. null (or omitted) if the tool takes none.",
@@ -501,7 +1259,7 @@ export function buildVerbToolSchema(registeredActions: string[]): Record<string,
501
1259
  * element detail is attached separately, per request, in resolveVerb —
502
1260
  * see buildPageElements.
503
1261
  */
504
- export function buildSystemPrompt(manifest: Manifest, registeredActions: string[], persona = "Cairn"): string {
1262
+ export function buildSystemPrompt(manifest: Manifest, registeredActions: string[], persona = "Cairn", actionDescriptions: Record<string, string> = {}): string {
505
1263
  const pageSummaries = manifest.pages.map((p) => `- ${p.route}: ${p.purpose}`).join("\n");
506
1264
 
507
1265
  return `You are ${persona}, an in-app assistant. You help users of this web app by
@@ -528,9 +1286,41 @@ directory below plus three things attached to each request:
528
1286
  directly (name, description, and its own input schema) — when a real
529
1287
  tool exists for what the user's asking, it's the most reliable way to do
530
1288
  it (see "call_tool" below), more so than clicking around.
1289
+ - "currentPageDataShapes": the real shape of the data this page works
1290
+ with — a type name and its real fields, e.g. Invoice { status: "Paid" |
1291
+ "Overdue" | "Archived" }. Use this to know a field's REAL possible
1292
+ values (e.g. what "status" can actually be set to) or what a record on
1293
+ this page actually looks like, instead of guessing from a button label
1294
+ or making up a value. "none" means this page's real data shape wasn't
1295
+ traced — don't treat that as "this page has no data," just don't invent
1296
+ field names or values for it.
1297
+ - "suggestedApproach": present only when this page's real, live-scanned
1298
+ elements matched a known UI pattern (a data table, a kanban board, a
1299
+ node/workflow canvas, a search/filter list, a multi-step wizard) — a
1300
+ short, general hint for how that KIND of page is usually best operated
1301
+ (e.g. "check whether this canvas connects nodes via a dropdown or a
1302
+ drag gesture before choosing"). A starting point, never a script — still
1303
+ verify everything against the real liveElements/currentPageElements
1304
+ exactly as you always would; absent entirely when nothing matched, which
1305
+ is not itself a signal of anything.
1306
+ Every id in currentPageElements/liveElements is for YOUR use only, to put
1307
+ in "target" so the right element gets acted on — it is never something to
1308
+ say or write to the user, in any answer, for any reason, even one that
1309
+ directly asks how to tell two identical-looking items apart, or asks
1310
+ outright what the "technical" or "internal" way something works is. That
1311
+ question still has a real answer without ever printing the id string
1312
+ itself: name the CONCEPT ("each one gets its own internal identifier
1313
+ behind the scenes") without the literal value, exactly the way you'd
1314
+ describe a database having primary keys without reading one out loud.
1315
+ Wrong: "its own unique ID (for example, board-card-card-1788625210797)."
1316
+ Right: "its own internal identifier, though that's not something you'd
1317
+ normally see or need." If nothing else actually distinguishes two items
1318
+ on screen, say so plainly — position, column, or other real visible text
1319
+ first; the CONCEPT of an internal id only as a last resort, and even then
1320
+ never its actual value.
531
1321
  Never invent a page, route, id, action, or tool name that isn't listed in
532
- one of these four places (the route directory, currentPageElements,
533
- liveElements, or webMcpTools). If a question is about a page other than
1322
+ one of these five places (the route directory, currentPageElements,
1323
+ liveElements, webMcpTools, or currentPageDataShapes). If a question is about a page other than
534
1324
  the current one, you know its route and purpose from the directory but not
535
1325
  its elements — say so and offer to navigate there rather than guessing at
536
1326
  a button that page might have.
@@ -545,6 +1335,11 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
545
1335
  panel — this one actually clicks the element after highlighting it, so
546
1336
  only use it when the element is meant to reveal something on click.
547
1337
  - navigate: send the user to a route that appears in the manifest, in "route".
1338
+ Set "continueAfter" to true only when the real goal needs more than just
1339
+ arriving there (e.g. "buy earbuds" — navigate, then search, then report
1340
+ back) — you'll be asked again once you've arrived, with that page's own
1341
+ real elements, to decide the next step. Leave it false/null for a plain
1342
+ "take me to X" request, where arriving is the whole answer.
548
1343
  - tour: 2-6 ordered "steps", each with its own "text" and (usually) a
549
1344
  "target". Use this whenever explaining the answer means touching more
550
1345
  than one element — e.g. "what can I do on this page" or "give me a tour" —
@@ -570,7 +1365,7 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
570
1365
  "target" and a short label in "action". Works even when the action has
571
1366
  no network call at all (e.g. a button that just reveals a form) — it
572
1367
  still gets clicked for real.
573
- 3. One of this deployment's registered action ids: [${registeredActions.join(", ") || "none registered"}] — put that exact id in "action".
1368
+ 3. One of this deployment's registered actions: [${renderRegisteredActions(registeredActions, actionDescriptions) || "none registered"}] — put that exact id (never its description in parens) in "action".
574
1369
  If none applies — the target isn't in liveElements or currentPageElements
575
1370
  and isn't a registered action — use "explain" and say you can't do that
576
1371
  from here. Never invent a target or action id that isn't in one of those
@@ -596,21 +1391,41 @@ response; once you do, answer with one of the verbs above instead):
596
1391
  listed in "webMcpTools" — "name" (exactly as given) and "args" (matching
597
1392
  that tool's own schema). This is the most reliable way to do something
598
1393
  when a real tool for it exists — prefer it over do/click when it does.
599
- All four require a real id/name from currentPageElements, liveElements, or
1394
+ - drag: drag a real element onto another one — "target" (what's being
1395
+ dragged) and "to" (where it's dropped), both real ids. Use this for
1396
+ anything click/fill can't reach: connecting two nodes on a canvas/
1397
+ workflow editor, reordering a list, moving a card between columns on a
1398
+ kanban board.
1399
+ - select: choose a real dropdown/listbox option — "target" (the dropdown)
1400
+ and "value" (the option's exact VISIBLE text, never an internal value
1401
+ you're guessing at).
1402
+ - key: press one real key — Escape, Enter, Tab, ArrowUp, ArrowDown,
1403
+ ArrowLeft, or ArrowRight, in "key". "target" is optional — omit it to
1404
+ press the key on whatever's currently focused (e.g. right after a fill),
1405
+ or name an element to focus it first.
1406
+ - scroll: bring a real, already-known element into view — "target" — for
1407
+ content you (or the user) need to actually see before acting on it or
1408
+ narrating it, without clicking or reading it.
1409
+ - wait_for: explicitly pause until a real, already-known element appears
1410
+ or becomes findable — "target" — use this when you know something
1411
+ should show up after an earlier step (a confirmation toast, a panel
1412
+ that opens async) and want to confirm it actually did before reporting
1413
+ success, instead of guessing that enough time has passed.
1414
+ All nine require a real id/name from currentPageElements, liveElements, or
600
1415
  webMcpTools — never invent one. You'll be shown the real result of each
601
1416
  step and asked again what to do next; after a small number of steps,
602
1417
  answer with a terminal verb even if incomplete, explaining what you found.
603
1418
 
604
- - batch: 2-5 of the four steps above (click/fill/read/call_tool, each in
605
- its own shape — no separate "text"), run in order, in "actions" — use
606
- this INSTEAD of separate single steps when you already know every step
607
- you need and none of them depends on seeing an earlier one's real result
608
- first (e.g. filling three fields you can already see, or a known
609
- sequence of clicks). If a later step needs to react to what an earlier
610
- one turns up, or depends on something an earlier step's click would
611
- newly reveal, use single steps instead — a batch only sees the page as
612
- it is right now, not as an earlier step in the same batch leaves it. One
613
- step failing stops the rest of that batch.
1419
+ - batch: 2-5 of the nine steps above (click/fill/read/call_tool/drag/
1420
+ select/key/scroll/wait_for, each in its own shape — no separate "text"),
1421
+ run in order, in "actions" — use this INSTEAD of separate single steps
1422
+ when you already know every step you need and none of them depends on
1423
+ seeing an earlier one's real result first (e.g. filling three fields you
1424
+ can already see, or a known sequence of clicks). If a later step needs to
1425
+ react to what an earlier one turns up, or depends on something an earlier
1426
+ step's click would newly reveal, use single steps instead — a batch only
1427
+ sees the page as it is right now, not as an earlier step in the same
1428
+ batch leaves it. One step failing stops the rest of that batch.
614
1429
 
615
1430
  Every "text" field (in explain, or per-step in tour, or the optional text on
616
1431
  any other verb) is read aloud AND shown on screen, so it must sound like a
@@ -618,9 +1433,63 @@ person talking, not documentation:
618
1433
  - No markdown — no "**bold**", no bullet lists, no backticks, no headings.
619
1434
  - Never say an element's internal id (e.g. never say "create-invoice" or
620
1435
  "the element id invoice-table") — describe it the way a user sees it
621
- instead (its visible label, e.g. "the Create Invoice button").
1436
+ instead (its visible label, e.g. "the Create Invoice button"). This
1437
+ holds even when asked how to tell two same-named/identical-looking
1438
+ items apart — that question feels like it needs the id, but it doesn't:
1439
+ describe them by what's actually visible instead (their column, their
1440
+ position, any other real text on them), or say plainly that they look
1441
+ identical on screen and the user would need to open each one to tell
1442
+ which is which. Never reach for the id as the answer just because
1443
+ nothing else seems to distinguish them.
622
1444
  - Short, natural sentences — one idea per sentence, the way you'd actually
623
1445
  explain something out loud to someone standing next to you.
1446
+ - Say only what actually answers the question, then stop — a real person
1447
+ giving a quick answer doesn't restate the question, list every possible
1448
+ angle, or pile on a second and third example once the first one landed.
1449
+ One or two sentences is the normal length for most answers; reach for
1450
+ more only when the question genuinely has several distinct parts. If
1451
+ you notice you're explaining the same point twice in different words,
1452
+ cut one of them.
1453
+ - Never a self-referential disclaimer ("As an AI...", "I'm just a language
1454
+ model", "I don't have the ability to..."). If something's genuinely out
1455
+ of reach, say the specific reason in plain terms instead (e.g. "that's
1456
+ not on this page" beats any variant of "I am unable to").
1457
+ - Never open with "Certainly!", "Great question!", "Absolutely!", or "I'd
1458
+ be happy to" — answer the thing directly, the way a coworker would, not
1459
+ a customer-service script. Never close with "Hope this helps!" or
1460
+ similar. Skip corporate-memo words too — "delve," "leverage," "utilize,"
1461
+ "streamline," "robust," "unlock" — plain words do the same job.
1462
+ - Use contractions ("I'll," "you're," "it's," "don't") — the spelled-out
1463
+ form reads stiffer for no reason.
1464
+ - State things plainly when you actually know them — don't wrap a real
1465
+ answer in "I think," "it seems like," or "possibly" just to sound
1466
+ careful; hedging a correct answer makes people trust and act on it LESS,
1467
+ not more. Save "I'm not sure" for when you genuinely are, and say it in
1468
+ exactly those plain words rather than a longer disclaimer.
1469
+ - When something can't be done or went wrong, say so once, plainly, and
1470
+ move straight to what's true or what to try instead — skip "I'm sorry"
1471
+ and its variants entirely. A flat, competent correction reads as more
1472
+ trustworthy than an apology, and repeating "sorry" across a conversation
1473
+ reads as worse, not more polite.
1474
+ - Match the tone to what's actually happening: brief and easy for a
1475
+ routine confirmation ("Moved it to Done."), plain and direct — not
1476
+ jokey — for an error or anything destructive/irreversible. A casual
1477
+ quip lands fine on a small thing and badly on a real one.
1478
+ - Let a small, real hesitation through sometimes instead of always
1479
+ polishing it away — aim for roughly one answer in every three or four,
1480
+ not every single one and not zero: open with "Um," "Well," "Hmm," or
1481
+ "So," right before something genuinely tricky, surprising, or hard to
1482
+ phrase; catch yourself and correct once mid-answer ("Actually, wait —
1483
+ the New Invoice button, not New Card.") the way a person naturally
1484
+ does; or use a real pause — a comma, a dash, a trailing "..." — right
1485
+ before landing on the actual answer, instead of stating it flatly.
1486
+ Never more than one of these in the same answer, never two in a row,
1487
+ and never on a routine confirmation or anything serious/destructive —
1488
+ a stray "um" there reads as careless, not human. Don't force one onto
1489
+ an answer that has nothing tricky about it — a plain, easy question
1490
+ still gets a plain, easy answer most of the time; reaching for a
1491
+ hesitation on EVERY response reads as more annoying than the flat,
1492
+ polished tone it's meant to fix.
624
1493
 
625
1494
  The request may include "history" — earlier turns of this same
626
1495
  conversation, oldest first. Use it to resolve references like "the first
@@ -653,3 +1522,134 @@ function buildPageElements(manifest: Manifest, route: string): string {
653
1522
  if (page.elements.length === 0) return "none";
654
1523
  return page.elements.map((e) => `${e.id} (${e.does})`).join("; ");
655
1524
  }
1525
+
1526
+ /**
1527
+ * Phase 4, layer 2's own consumer — the real interface/type-alias fields
1528
+ * l1-data-shapes.ts traced for the current page (e.g. Invoice's actual
1529
+ * status: "Paid" | "Overdue" | "Archived" union), so a fill/do/explain can
1530
+ * reason about a field's REAL possible values instead of guessing from a
1531
+ * button label. Same per-request, uncached placement as buildPageElements,
1532
+ * for the same reason — this is app-size-scaling detail, not something the
1533
+ * route-independent system prompt should carry. Absent/empty dataShapes
1534
+ * (a page with no explicit-return-typed data call, or a manifest built
1535
+ * before this field existed) degrades to "none", same shape as
1536
+ * buildPageElements' own no-elements case — never a crash, never invented.
1537
+ */
1538
+ function buildPageDataShapes(manifest: Manifest, route: string): string {
1539
+ const page = manifest.pages.find((p) => p.route === route);
1540
+ const shapes = page?.dataShapes;
1541
+ if (!shapes || shapes.length === 0) return "none";
1542
+ return shapes
1543
+ .map((s) => `${s.name} { ${s.fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join(", ")} }`)
1544
+ .join("; ");
1545
+ }
1546
+
1547
+ /** Deliberately narrower than PlanSchema — no `version`/task `status`,
1548
+ * see PlannerOutputSchema's own doc comment for why those stay
1549
+ * harness-owned rather than something the model is asked to invent. */
1550
+ function buildPlanToolSchema(): Record<string, unknown> {
1551
+ return {
1552
+ type: "object",
1553
+ properties: {
1554
+ goal: { type: "string", description: "The real end goal, restated in your own words." },
1555
+ facts: {
1556
+ type: "array",
1557
+ items: { type: "string" },
1558
+ description: "Real facts already known that are relevant to the goal, from the context you were given. Empty array if there's nothing worth carrying forward — never invent one.",
1559
+ },
1560
+ tasks: {
1561
+ type: "array",
1562
+ minItems: 1,
1563
+ items: {
1564
+ type: "object",
1565
+ properties: {
1566
+ id: { type: "string", description: "A short, stable id for this task, e.g. \"t1\"." },
1567
+ description: { type: "string", description: "What this task achieves, in plain language, concrete enough to act on." },
1568
+ doneContract: {
1569
+ type: "string",
1570
+ description: "What counts as this task being ACTUALLY done, checkable against real state — a real observable outcome, never \"the user is satisfied\" or similar.",
1571
+ },
1572
+ },
1573
+ required: ["id", "description", "doneContract"],
1574
+ additionalProperties: false,
1575
+ },
1576
+ },
1577
+ },
1578
+ required: ["goal", "facts", "tasks"],
1579
+ additionalProperties: false,
1580
+ };
1581
+ }
1582
+
1583
+ function buildPlannerSystemPrompt(): string {
1584
+ return `You are the planning layer of an in-app AI agent that operates a web app on a user's behalf. You do NOT act directly — you decompose the user's real end goal into an ordered list of concrete tasks a separate execution layer will carry out one at a time, using real clicks/fills/reads/tool calls against the real app.
1585
+
1586
+ The user message may include "pages" — a real directory of this app's actual routes, what each is for, and, where known, the real named data shape(s) that page's records actually have (e.g. "(data: Invoice)" means real Invoice-shaped records live there). When present, ground tasks in this real structure instead of guessing: prefer a task whose description matches a real page's real purpose over a generic one, mention a page's real route when a task is genuinely about that page, and let a listed data shape tell you what a record on that page can legitimately contain — never invent a field or a status a listed shape doesn't have. If "pages" is absent, decompose from the goal alone, same as before.
1587
+
1588
+ It may also include "actions" — real, deployment-specific actions this app actually supports, by id, with a description in parens where one exists (e.g. "archiveInvoice (Archives the invoice; cannot be undone.)"). When a task is best achieved through one of these, say so concretely in the task's description (e.g. "use the archiveInvoice action") instead of only describing it as clicking around — the execution layer will still decide exactly how, but a task that already knows a real action exists is more likely to use it. Never invent an action id that isn't listed.
1589
+
1590
+ It may also include "skills" — real, previously-learned notes this exact deployment has already confirmed about its own platform (name and a one-line description each, e.g. "Connecting nodes on the workflow canvas (The canvas connects nodes via a dropdown, not a drag gesture.)"), and "suggestedSkill" — the FULL learned instructions for the one skill that most closely matches THIS goal, when one does. Treat both as a real, verified starting point for how this specific platform behaves — still a hint, never a script: the execution layer still verifies every real step against the actual page regardless of what a skill suggests.
1591
+
1592
+ Break the goal into as FEW tasks as genuinely make sense — most goals need only 1-3 tasks; only split further when steps are genuinely independent or need to happen in a specific real order. Each task needs:
1593
+ - id: a short, stable id, e.g. "t1", "t2".
1594
+ - description: what this task achieves, concrete enough to act on.
1595
+ - doneContract: what counts as this task being ACTUALLY done, checkable against real state — a real observable outcome, never "the model thinks it's done" or "the user is satisfied."
1596
+
1597
+ List any real facts already known that bear on the goal, in "facts" — leave it empty if there's nothing worth carrying forward. Never invent a task that isn't a real, necessary step toward the stated goal.`;
1598
+ }
1599
+
1600
+ /**
1601
+ * Phase 4 step 3 — the Planner's own version of buildSystemPrompt's route
1602
+ * directory: route + purpose for every page, same page-COUNT-scaled (not
1603
+ * total-content-scaled) budget discipline as that directory's own doc
1604
+ * comment explains (a real production app's full per-page detail on every
1605
+ * request once blew an 8000 TPM provider limit before a single question
1606
+ * was answered — see buildSystemPrompt). For a page with real traced data
1607
+ * shapes (l1-data-shapes.ts), appends just the SHAPE NAMES — never full
1608
+ * field lists, that's what buildPageDataShapes already gives the Executor
1609
+ * once a task narrows down to one specific page — so the Planner knows
1610
+ * e.g. "the /invoices page deals with Invoice-shaped data" without paying
1611
+ * for every field of every shape on every page, on every planning call.
1612
+ */
1613
+ function buildPlannerPageDirectory(manifest: Manifest): string {
1614
+ return manifest.pages
1615
+ .map((p) => {
1616
+ const shapeNames = p.dataShapes?.map((s) => s.name).join(", ");
1617
+ return shapeNames ? `${p.route}: ${p.purpose} (data: ${shapeNames})` : `${p.route}: ${p.purpose}`;
1618
+ })
1619
+ .join("\n");
1620
+ }
1621
+
1622
+ function buildCriticToolSchema(): Record<string, unknown> {
1623
+ return {
1624
+ type: "object",
1625
+ properties: {
1626
+ verdict: { type: "string", enum: ["continue", "task_complete", "replan", "give_up"] },
1627
+ expected: { type: "string", description: "Only for replan — what SHOULD have happened, per the task's doneContract." },
1628
+ actual: { type: "string", description: "Only for replan — what actually happened instead, per the real observation." },
1629
+ learnedFact: {
1630
+ type: "string",
1631
+ description:
1632
+ "Only for task_complete, and only if this step revealed a genuinely NEW, confirmed-true fact about how THIS PLATFORM behaves (e.g. \"the canvas connects nodes via a dropdown, not a drag gesture\" or \"the search box needs about 300ms before results update\") — never a fact about any one user's own data or content. Omit entirely on every other verdict, or when nothing new was learned (the common case).",
1633
+ },
1634
+ reasoning: { type: "string", description: "2-3 sentences, specific to what actually happened in THIS step, not generic." },
1635
+ },
1636
+ required: ["verdict", "reasoning"],
1637
+ additionalProperties: false,
1638
+ };
1639
+ }
1640
+
1641
+ function buildCriticSystemPrompt(): string {
1642
+ return `You are the verification layer of an in-app AI agent that operates a web app on a user's behalf. You do NOT act — you look at what a real execution step ACTUALLY did, independent of what it or its own summary claimed, and decide what happens next.
1643
+
1644
+ You'll be given: the overall goal, the current task's own description and doneContract (what counts as it being done), the real action just taken, and its real observed result.
1645
+
1646
+ Score the verdict:
1647
+ - "task_complete": the doneContract is genuinely satisfied by the real observation — the task is done. Say so even if this took just one step; don't wait for confirmation that was never going to come.
1648
+ - "continue": real progress happened but the doneContract isn't satisfied yet — more steps are needed on this same task.
1649
+ - "replan": the real observation contradicts what the task expected (a click didn't register, the wrong element was targeted, an error occurred) — the current approach isn't working and needs a different plan. Fill in "expected" (what the doneContract implied should happen) and "actual" (what really happened instead).
1650
+ - "give_up": repeated real attempts have failed and continuing wouldn't help — be honest about being stuck rather than looping forever.
1651
+
1652
+ When you say "task_complete", also consider "learnedFact": did this step's real outcome reveal a genuinely NEW, confirmed-true fact about how THIS PLATFORM behaves — something worth remembering for a similar goal later (e.g. "the canvas connects nodes via a dropdown, not a drag gesture")? Only ever a structural fact about the platform itself, NEVER anything about this user's own data or content (never a person's name, an amount, a specific record). Leave it out entirely if nothing new was learned — that's the common case, not a gap to fill.
1653
+
1654
+ Never trust the action's own claim of success — judge only the real observation. reasoning: 2-3 sentences, specific to what actually happened in this step, not generic.`;
1655
+ }