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