@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.
- package/dist/agent-loop.d.ts +113 -0
- package/dist/agent-loop.js +128 -0
- package/dist/cairn-widget.js +14 -9
- package/dist/cursor-overlay.d.ts +19 -0
- package/dist/cursor-overlay.js +126 -0
- package/dist/element-ladder.d.ts +71 -0
- package/dist/element-ladder.js +168 -0
- package/dist/index.d.ts +79 -1
- package/dist/index.js +886 -96
- package/dist/key-rotator.d.ts +28 -0
- package/dist/key-rotator.js +57 -3
- package/dist/memory-sqlite.d.ts +86 -0
- package/dist/memory-sqlite.js +230 -0
- package/dist/realtime-cli.js +22 -1
- package/dist/realtime-server.d.ts +83 -2
- package/dist/realtime-server.js +561 -121
- package/dist/server.d.ts +266 -5
- package/dist/server.js +1013 -83
- package/dist/skill-store.d.ts +17 -0
- package/dist/skill-store.js +78 -0
- package/dist/tts-stream.d.ts +25 -0
- package/dist/tts-stream.js +32 -0
- package/dist/vad.d.ts +27 -0
- package/dist/vad.js +128 -0
- package/dist/verb-executor.d.ts +32 -11
- package/dist/verb-executor.js +315 -39
- package/dist/webmcp-client.d.ts +14 -1
- package/dist/webmcp-client.js +22 -1
- package/package.json +3 -1
- package/src/agent-loop.ts +222 -0
- package/src/cursor-overlay.ts +130 -0
- package/src/element-ladder.ts +170 -0
- package/src/index.tsx +935 -100
- package/src/key-rotator.ts +57 -2
- package/src/memory-sqlite.ts +283 -0
- package/src/realtime-cli.ts +24 -1
- package/src/realtime-server.ts +669 -123
- package/src/server.ts +1119 -83
- package/src/skill-store.ts +88 -0
- package/src/tts-stream.ts +30 -0
- package/src/vad.ts +153 -0
- package/src/verb-executor.ts +329 -42
- package/src/web-component.ts +97 -24
- 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, {
|
|
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
|
|
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
|
|
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({
|
|
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"
|
|
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
|
-
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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("
|
|
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:
|
|
276
|
-
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:
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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(
|
|
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:
|
|
359
|
-
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
472
|
-
|
|
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
|
|
533
|
-
liveElements, or
|
|
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
|
|
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
|
-
|
|
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
|
|
605
|
-
its own shape — no separate "text"),
|
|
606
|
-
this INSTEAD of separate single steps
|
|
607
|
-
you need and none of them depends on
|
|
608
|
-
first (e.g. filling three fields you
|
|
609
|
-
sequence of clicks). If a later step needs to
|
|
610
|
-
one turns up, or depends on something an earlier
|
|
611
|
-
newly reveal, use single steps instead — a batch only
|
|
612
|
-
it is right now, not as an earlier step in the same
|
|
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
|
+
}
|