@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/dist/server.js
CHANGED
|
@@ -7,18 +7,41 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
7
7
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
8
|
};
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.GroqVerbLLM = exports.AnthropicVerbLLM = void 0;
|
|
10
|
+
exports.GroqStreamingTextLLM = exports.GroqVerbLLM = exports.AnthropicStreamingTextLLM = exports.AnthropicVerbLLM = exports.KeyRotator = void 0;
|
|
11
11
|
exports.createCopilotHandler = createCopilotHandler;
|
|
12
12
|
exports.createCopilotHandlerWithLLM = createCopilotHandlerWithLLM;
|
|
13
13
|
exports.resolveVerb = resolveVerb;
|
|
14
|
+
exports.resolvePlan = resolvePlan;
|
|
15
|
+
exports.fallbackPlan = fallbackPlan;
|
|
16
|
+
exports.resolveCritic = resolveCritic;
|
|
17
|
+
exports.createCriticLLM = createCriticLLM;
|
|
14
18
|
exports.createVerbLLM = createVerbLLM;
|
|
19
|
+
exports.createPlanLLM = createPlanLLM;
|
|
20
|
+
exports.compileSkill = compileSkill;
|
|
21
|
+
exports.matchSkillByGoal = matchSkillByGoal;
|
|
22
|
+
exports.renderSkillSummaries = renderSkillSummaries;
|
|
23
|
+
exports.createPlanHandler = createPlanHandler;
|
|
24
|
+
exports.createPlanHandlerWithLLM = createPlanHandlerWithLLM;
|
|
25
|
+
exports.createCriticHandler = createCriticHandler;
|
|
26
|
+
exports.createCriticHandlerWithLLM = createCriticHandlerWithLLM;
|
|
27
|
+
exports.createSkillSaveHandler = createSkillSaveHandler;
|
|
28
|
+
exports.renderRegisteredActions = renderRegisteredActions;
|
|
15
29
|
exports.buildVerbToolSchema = buildVerbToolSchema;
|
|
16
30
|
exports.buildSystemPrompt = buildSystemPrompt;
|
|
17
31
|
const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
|
|
18
32
|
const groq_sdk_1 = __importDefault(require("groq-sdk"));
|
|
33
|
+
const zod_1 = require("zod");
|
|
19
34
|
const core_1 = require("@cairnvibe/core");
|
|
20
|
-
const
|
|
35
|
+
const agent_loop_1 = require("./agent-loop");
|
|
36
|
+
const memory_sqlite_1 = require("./memory-sqlite");
|
|
37
|
+
var key_rotator_1 = require("./key-rotator");
|
|
38
|
+
Object.defineProperty(exports, "KeyRotator", { enumerable: true, get: function () { return key_rotator_1.KeyRotator; } });
|
|
39
|
+
const key_rotator_2 = require("./key-rotator");
|
|
21
40
|
const VERB_TOOL_NAME = "respond_with_verb";
|
|
41
|
+
const PLAN_TOOL_NAME = "create_plan";
|
|
42
|
+
const PLAN_TOOL_DESCRIPTION = "Submit an ordered task plan for achieving the user's real end goal.";
|
|
43
|
+
const CRITIC_TOOL_NAME = "submit_verdict";
|
|
44
|
+
const CRITIC_TOOL_DESCRIPTION = "Submit your verdict on whether the current task is actually done, based on the real resulting state.";
|
|
22
45
|
const TIER_ALLOWED_VERBS = {
|
|
23
46
|
// "read" is non-mutating (pure observation, like highlight) so it's
|
|
24
47
|
// available at every tier — a turn that only ever reads is exactly as
|
|
@@ -31,19 +54,62 @@ function createCopilotHandler(manifest, options = {}) {
|
|
|
31
54
|
const registeredActions = options.registeredActions ?? [];
|
|
32
55
|
const capability = options.capability ?? "act";
|
|
33
56
|
const llm = createVerbLLM(options);
|
|
34
|
-
return createCopilotHandlerWithLLM(manifest, llm, {
|
|
57
|
+
return createCopilotHandlerWithLLM(manifest, llm, {
|
|
58
|
+
registeredActions,
|
|
59
|
+
capability,
|
|
60
|
+
persona: options.persona,
|
|
61
|
+
actionDescriptions: options.actionDescriptions,
|
|
62
|
+
memory: options.memory,
|
|
63
|
+
});
|
|
35
64
|
}
|
|
36
65
|
/** Same as `createCopilotHandler`, but with the LLM injected — used by tests to fake it. */
|
|
37
66
|
function createCopilotHandlerWithLLM(manifest, llm, options = {}) {
|
|
38
67
|
const registeredActions = options.registeredActions ?? [];
|
|
39
68
|
const capability = options.capability ?? "act";
|
|
40
|
-
const
|
|
69
|
+
const actionDescriptions = options.actionDescriptions ?? {};
|
|
70
|
+
const systemPrompt = buildSystemPrompt(manifest, registeredActions, options.persona, actionDescriptions);
|
|
41
71
|
return async function handleCopilotRequest(body) {
|
|
42
72
|
const parsedRequest = core_1.CopilotRequestSchema.safeParse(body);
|
|
43
73
|
if (!parsedRequest.success) {
|
|
44
74
|
return { status: 400, body: { error: "invalid request body" } };
|
|
45
75
|
}
|
|
46
|
-
const
|
|
76
|
+
const input = parsedRequest.data;
|
|
77
|
+
// Phase 5 step 4 — real cross-session memory for the typed/HTTP
|
|
78
|
+
// transport. Unlike the realtime relay (one persistent connection,
|
|
79
|
+
// seeded once), this is stateless per request — seeded only when
|
|
80
|
+
// the CLIENT's own history arrives empty, the real signal for "this
|
|
81
|
+
// is a genuinely fresh session" (a session already accumulating its
|
|
82
|
+
// own history client-side is never re-seeded on top of itself; see
|
|
83
|
+
// CreateCopilotHandlerOptions.memory's own doc comment).
|
|
84
|
+
let effectiveHistory = input.history ?? [];
|
|
85
|
+
if (options.memory && input.scopeId && effectiveHistory.length === 0) {
|
|
86
|
+
const priorTurns = options.memory.recentTurns(input.scopeId);
|
|
87
|
+
effectiveHistory = (0, memory_sqlite_1.seedHistoryFromMemory)([], priorTurns, agent_loop_1.MAX_HISTORY_TURNS);
|
|
88
|
+
const factsSummary = (0, memory_sqlite_1.formatRememberedFacts)(options.memory.recallFacts(input.scopeId));
|
|
89
|
+
if (factsSummary)
|
|
90
|
+
effectiveHistory = [{ role: "assistant", text: factsSummary }, ...effectiveHistory];
|
|
91
|
+
}
|
|
92
|
+
// Architecture Pillar 5 — the Archive tier, checked on EVERY request
|
|
93
|
+
// (not just a fresh session — unlike Core facts above, an archived
|
|
94
|
+
// fact is never always-injected, only surfaced when THIS question
|
|
95
|
+
// actually relates to it, which can happen at any point in an
|
|
96
|
+
// ongoing conversation, not only at its start).
|
|
97
|
+
if (options.memory && input.scopeId) {
|
|
98
|
+
const archivedMatch = options.memory.recallArchivedFacts(input.scopeId, input.question);
|
|
99
|
+
const archivedSummary = (0, memory_sqlite_1.formatArchivedFacts)(archivedMatch);
|
|
100
|
+
if (archivedSummary)
|
|
101
|
+
effectiveHistory = [...effectiveHistory, { role: "assistant", text: archivedSummary }];
|
|
102
|
+
}
|
|
103
|
+
const verb = await resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, { ...input, history: effectiveHistory });
|
|
104
|
+
// Recorded only for a TERMINAL verb — matching the realtime relay's
|
|
105
|
+
// own discipline exactly: a continuing step (click/fill/read/
|
|
106
|
+
// call_tool/batch, or now a navigate marked continueAfter — see
|
|
107
|
+
// isTerminalVerb's own doc comment) is an internal implementation
|
|
108
|
+
// detail of one logical exchange, never its own remembered "turn".
|
|
109
|
+
if (options.memory && input.scopeId && (0, core_1.isTerminalVerb)(verb)) {
|
|
110
|
+
options.memory.recordTurn(input.scopeId, "user", input.question);
|
|
111
|
+
options.memory.recordTurn(input.scopeId, "assistant", (0, agent_loop_1.summarizeVerbForHistory)(verb));
|
|
112
|
+
}
|
|
47
113
|
return { status: 200, body: verb };
|
|
48
114
|
};
|
|
49
115
|
}
|
|
@@ -56,13 +122,25 @@ function createCopilotHandlerWithLLM(manifest, llm, options = {}) {
|
|
|
56
122
|
async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capability, input) {
|
|
57
123
|
let candidate;
|
|
58
124
|
try {
|
|
125
|
+
// Architecture Pillar 2 — classified from the SAME liveElements this
|
|
126
|
+
// request already carries for element resolution, no new client
|
|
127
|
+
// wiring or payload field needed. Real, checkable evidence (which
|
|
128
|
+
// labels/roles matched), never a bare guess — see ui-patterns.ts's own
|
|
129
|
+
// doc comment. Absent entirely when nothing matched (a page that's
|
|
130
|
+
// none of the known patterns), rather than forcing a hint that isn't real.
|
|
131
|
+
const patternMatches = input.liveElements?.length ? (0, core_1.classifyUiPattern)((0, core_1.deriveStructureSignals)(input.liveElements)) : [];
|
|
59
132
|
// Element-level detail for the current page only, attached here rather
|
|
60
133
|
// than baked into the (static, cached) system prompt — see
|
|
61
134
|
// buildSystemPrompt's comment for why. This payload is already
|
|
62
135
|
// per-request and was never cached, so there's nothing to lose by
|
|
63
136
|
// making it bigger; the system prompt is what has to stay small and
|
|
64
137
|
// route-independent.
|
|
65
|
-
const userMessage = JSON.stringify({
|
|
138
|
+
const userMessage = JSON.stringify({
|
|
139
|
+
...input,
|
|
140
|
+
currentPageElements: buildPageElements(manifest, input.route),
|
|
141
|
+
currentPageDataShapes: buildPageDataShapes(manifest, input.route),
|
|
142
|
+
...(patternMatches.length ? { suggestedApproach: (0, core_1.renderPlaybookHint)(patternMatches[0].pattern) } : {}),
|
|
143
|
+
});
|
|
66
144
|
candidate = await llm.respond(systemPrompt, userMessage);
|
|
67
145
|
}
|
|
68
146
|
catch (err) {
|
|
@@ -114,11 +192,22 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
|
|
|
114
192
|
const pageElements = manifest.pages.find((p) => p.route === input.route)?.elements ?? [];
|
|
115
193
|
const isKnownTarget = (target) => pageElements.some((e) => e.id === target) || (input.liveElements ?? []).some((e) => e.id === target);
|
|
116
194
|
const isKnownTool = (name) => (input.webMcpTools ?? []).some((t) => t.name === name);
|
|
117
|
-
if (parsedVerb.data.verb === "click" || parsedVerb.data.verb === "fill" || parsedVerb.data.verb === "read") {
|
|
195
|
+
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") {
|
|
118
196
|
if (!isKnownTarget(parsedVerb.data.target)) {
|
|
119
197
|
return { verb: "explain", text: "I don't see that on this page right now." };
|
|
120
198
|
}
|
|
121
199
|
}
|
|
200
|
+
if (parsedVerb.data.verb === "drag") {
|
|
201
|
+
if (!isKnownTarget(parsedVerb.data.target) || !isKnownTarget(parsedVerb.data.to)) {
|
|
202
|
+
return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// key's target is optional (omitted means "whatever's currently
|
|
206
|
+
// focused") — only check it against real state when the model actually
|
|
207
|
+
// named one, same "never invented" invariant as every other target.
|
|
208
|
+
if (parsedVerb.data.verb === "key" && parsedVerb.data.target && !isKnownTarget(parsedVerb.data.target)) {
|
|
209
|
+
return { verb: "explain", text: "I don't see that on this page right now." };
|
|
210
|
+
}
|
|
122
211
|
if (parsedVerb.data.verb === "call_tool") {
|
|
123
212
|
if (!isKnownTool(parsedVerb.data.name)) {
|
|
124
213
|
return { verb: "explain", text: "That isn't something I can do here." };
|
|
@@ -129,7 +218,15 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
|
|
|
129
218
|
// partially execute a batch whose later step names something the
|
|
130
219
|
// model invented; refuse the whole turn instead of guessing which
|
|
131
220
|
// steps were "safe enough" to run.
|
|
132
|
-
const allKnown = parsedVerb.data.actions.every((action) =>
|
|
221
|
+
const allKnown = parsedVerb.data.actions.every((action) => {
|
|
222
|
+
if (action.verb === "call_tool")
|
|
223
|
+
return isKnownTool(action.name);
|
|
224
|
+
if (action.verb === "drag")
|
|
225
|
+
return isKnownTarget(action.target) && isKnownTarget(action.to);
|
|
226
|
+
if (action.verb === "key")
|
|
227
|
+
return !action.target || isKnownTarget(action.target);
|
|
228
|
+
return isKnownTarget(action.target);
|
|
229
|
+
});
|
|
133
230
|
if (!allKnown) {
|
|
134
231
|
return { verb: "explain", text: "I don't see everything I'd need for that on this page right now." };
|
|
135
232
|
}
|
|
@@ -148,26 +245,337 @@ async function resolveVerb(llm, systemPrompt, manifest, registeredActions, capab
|
|
|
148
245
|
}
|
|
149
246
|
return parsedVerb.data;
|
|
150
247
|
}
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
248
|
+
/**
|
|
249
|
+
* Phase 3, step 2 (see DEVELOPMENT.md/the plan file) — the Planner half
|
|
250
|
+
* of the Planner/Executor/Critic/Talker redesign. Decomposes a real end
|
|
251
|
+
* goal into an ordered task list BEFORE any execution happens, mirroring
|
|
252
|
+
* resolveVerb's own resilience discipline: never throws to the caller,
|
|
253
|
+
* degrades to a real, usable single-task fallback plan on any failure
|
|
254
|
+
* (a bad LLM response, a schema mismatch, a network error) rather than
|
|
255
|
+
* blocking the turn on a Planner hiccup. `version`/each task's `status`
|
|
256
|
+
* are harness-owned, not asked of the model (PlannerOutputSchema's own
|
|
257
|
+
* doc comment) — assembled here around the model's raw output.
|
|
258
|
+
*
|
|
259
|
+
* Deliberately does NOT yet change what the loop actually does with the
|
|
260
|
+
* result — step 2's own scope is observability only (see the doc comment
|
|
261
|
+
* on this function's call site in realtime-server.ts). The Critic (step
|
|
262
|
+
* 3) is what makes a Plan's tasks/doneContracts actually drive behavior.
|
|
263
|
+
*/
|
|
264
|
+
async function resolvePlan(llm, goal, version = 1, manifest, actionsText, skills) {
|
|
265
|
+
let candidate;
|
|
266
|
+
try {
|
|
267
|
+
// manifest/actionsText are appended, optional, and default to absent —
|
|
268
|
+
// additive on purpose (see this function's own exported-API note
|
|
269
|
+
// above): an existing 2- or 3-arg call site (own or a published
|
|
270
|
+
// consumer's) keeps building the exact same {goal} userMessage it
|
|
271
|
+
// always has. Real page/data grounding (Phase 4 step 3) only applies
|
|
272
|
+
// when a caller has a manifest to pass — see buildPlannerPageDirectory's
|
|
273
|
+
// own doc comment for the token-budget discipline behind what it
|
|
274
|
+
// includes. actionsText (Phase 4 step 4) is the SAME rendering
|
|
275
|
+
// buildSystemPrompt/buildVerbToolSchema use for registered actions —
|
|
276
|
+
// pass renderRegisteredActions(...)'s own output, not a hand-rolled
|
|
277
|
+
// string, so the Planner and Executor never describe the same
|
|
278
|
+
// capability two different ways. skills (Architecture Pillar 3) is
|
|
279
|
+
// the same additive shape: `summariesText` (renderSkillSummaries'
|
|
280
|
+
// output — every this-deployment Skill's name+description, cheap to
|
|
281
|
+
// always include) and `suggestedInstructions` (matchSkillByGoal's own
|
|
282
|
+
// match, full instructions, only when one genuinely matched this
|
|
283
|
+
// goal) — both optional, both absent by default for a caller with no
|
|
284
|
+
// SkillStore configured.
|
|
285
|
+
const payload = { goal };
|
|
286
|
+
if (manifest)
|
|
287
|
+
payload.pages = buildPlannerPageDirectory(manifest);
|
|
288
|
+
if (actionsText)
|
|
289
|
+
payload.actions = actionsText;
|
|
290
|
+
if (skills?.summariesText)
|
|
291
|
+
payload.skills = skills.summariesText;
|
|
292
|
+
if (skills?.suggestedInstructions)
|
|
293
|
+
payload.suggestedSkill = skills.suggestedInstructions;
|
|
294
|
+
const userMessage = JSON.stringify(payload);
|
|
295
|
+
candidate = await llm.respond(buildPlannerSystemPrompt(), userMessage);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
console.error("[cairn] planner LLM call failed:", err);
|
|
299
|
+
return fallbackPlan(goal, version);
|
|
300
|
+
}
|
|
301
|
+
const parsed = core_1.PlannerOutputSchema.safeParse(candidate);
|
|
302
|
+
if (!parsed.success)
|
|
303
|
+
return fallbackPlan(goal, version);
|
|
304
|
+
return assemblePlan(parsed.data, version);
|
|
305
|
+
}
|
|
306
|
+
function assemblePlan(output, version) {
|
|
307
|
+
return {
|
|
308
|
+
version,
|
|
309
|
+
goal: output.goal,
|
|
310
|
+
facts: output.facts,
|
|
311
|
+
tasks: output.tasks.map((task, i) => ({ ...task, status: i === 0 ? "in_progress" : "pending" })),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/** The real, single-task plan used when the Planner call itself fails —
|
|
315
|
+
* "do the whole goal as one task" is always a valid (if unstructured)
|
|
316
|
+
* plan, so a Planner hiccup degrades the redesign back to today's
|
|
317
|
+
* behavior instead of blocking the turn. Exported so every caller that
|
|
318
|
+
* needs "a plan, even a trivial one, right now" (e.g. a Critic call that
|
|
319
|
+
* fires before a real Planner result has come back) builds the exact
|
|
320
|
+
* same shape instead of hand-rolling a duplicate literal — realtime-
|
|
321
|
+
* server.ts's own finalizeTurn and index.tsx's runTypedAgentLoop both do
|
|
322
|
+
* this, for the same reason. */
|
|
323
|
+
function fallbackPlan(goal, version) {
|
|
324
|
+
return {
|
|
325
|
+
version,
|
|
326
|
+
goal,
|
|
327
|
+
facts: [],
|
|
328
|
+
tasks: [{ id: "t1", description: goal, doneContract: "The stated goal has been achieved.", status: "in_progress" }],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Phase 3, step 3 — the Critic. A genuinely SEPARATE pass over the
|
|
333
|
+
* step's real observation, decoupled from the Executor/model's own
|
|
334
|
+
* self-report — this is the direct fix for the diagnosed bug (a batch
|
|
335
|
+
* of 2 clicks succeeded, and the model kept looping 4 more iterations
|
|
336
|
+
* before giving up, never recognizing its own success). Mirrors
|
|
337
|
+
* packages/evals/src/judge.ts's own judgeScenario shape on purpose (a
|
|
338
|
+
* separate model looking at real state, forced tool call, structured
|
|
339
|
+
* verdict) — same real precedent already proven and tested in this repo,
|
|
340
|
+
* not a new pattern invented for this. Same resilience discipline as
|
|
341
|
+
* resolveVerb/resolvePlan: never throws, degrades to a real "continue"
|
|
342
|
+
* verdict (harmless — the loop just behaves as if the Critic weren't
|
|
343
|
+
* there for this one step) on any failure.
|
|
344
|
+
*/
|
|
345
|
+
async function resolveCritic(llm, task, goal, verb, observation) {
|
|
346
|
+
let candidate;
|
|
347
|
+
try {
|
|
348
|
+
candidate = await llm.respond(buildCriticSystemPrompt(), JSON.stringify({
|
|
349
|
+
goal,
|
|
350
|
+
taskDescription: task.description,
|
|
351
|
+
doneContract: task.doneContract,
|
|
352
|
+
action: (0, agent_loop_1.summarizeVerbForHistory)(verb),
|
|
353
|
+
observation: observation ?? "no result",
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
console.error("[cairn] critic LLM call failed:", err);
|
|
358
|
+
return { verdict: "continue", reasoning: "Critic call failed — defaulting to continue rather than blocking the turn." };
|
|
359
|
+
}
|
|
360
|
+
const parsed = core_1.CriticVerdictSchema.safeParse(candidate);
|
|
361
|
+
if (!parsed.success)
|
|
362
|
+
return { verdict: "continue", reasoning: "Critic response failed validation — defaulting to continue rather than blocking the turn." };
|
|
363
|
+
return parsed.data;
|
|
364
|
+
}
|
|
365
|
+
/** Same real rotation/model-selection logic as createVerbLLM/createPlanLLM,
|
|
366
|
+
* configured for the Critic's own tool instead — see resolveCritic. */
|
|
367
|
+
function createCriticLLM(options = {}) {
|
|
368
|
+
return createToolLLM(options, buildCriticToolSchema(), CRITIC_TOOL_NAME, CRITIC_TOOL_DESCRIPTION);
|
|
369
|
+
}
|
|
370
|
+
/** Builds a provider-appropriate forced-single-tool-call LLM for ANY tool
|
|
371
|
+
* shape (verb resolution, planning, ...) — the real rotation/model-
|
|
372
|
+
* selection logic every such caller needs, factored out once so
|
|
373
|
+
* createVerbLLM/createPlanLLM stay thin, tool-specific wrappers around it. */
|
|
374
|
+
function createToolLLM(options, toolSchema, toolName, toolDescription) {
|
|
155
375
|
const provider = options.provider ?? "anthropic";
|
|
156
376
|
if (provider === "groq") {
|
|
157
|
-
const rotator = options.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
377
|
+
const rotator = options.keyRotator
|
|
378
|
+
?? (options.apiKeys
|
|
379
|
+
? new key_rotator_2.KeyRotator(options.apiKeys)
|
|
380
|
+
: options.apiKey
|
|
381
|
+
? new key_rotator_2.KeyRotator([options.apiKey])
|
|
382
|
+
: key_rotator_2.KeyRotator.fromEnvList(process.env.GROQ_API_KEYS));
|
|
162
383
|
if (!rotator) {
|
|
163
|
-
throw new Error("
|
|
384
|
+
throw new Error("createToolLLM: provider 'groq' needs apiKey(s), or GROQ_API_KEYS in env");
|
|
164
385
|
}
|
|
165
386
|
const model = options.model ?? process.env.GROQ_MODEL ?? GROQ_DEFAULT_MODEL;
|
|
166
|
-
return new GroqVerbLLM(rotator, model, toolSchema);
|
|
387
|
+
return new GroqVerbLLM(rotator, model, toolSchema, undefined, toolName, toolDescription);
|
|
167
388
|
}
|
|
168
389
|
const client = new sdk_1.default({ apiKey: options.apiKey });
|
|
169
390
|
const model = options.model ?? process.env.CAIRN_RUNTIME_MODEL ?? "claude-opus-5";
|
|
170
|
-
return new AnthropicVerbLLM(client, model, toolSchema);
|
|
391
|
+
return new AnthropicVerbLLM(client, model, toolSchema, toolName, toolDescription);
|
|
392
|
+
}
|
|
393
|
+
/** Builds the provider-appropriate VerbLLM from the same options createCopilotHandler accepts — reused by the realtime relay. */
|
|
394
|
+
function createVerbLLM(options = {}) {
|
|
395
|
+
const registeredActions = options.registeredActions ?? [];
|
|
396
|
+
return createToolLLM(options, buildVerbToolSchema(registeredActions, options.actionDescriptions ?? {}), VERB_TOOL_NAME, VERB_TOOL_DESCRIPTION);
|
|
397
|
+
}
|
|
398
|
+
/** Same real rotation/model-selection logic as createVerbLLM, configured
|
|
399
|
+
* for the Planner's own tool instead — see resolvePlan. */
|
|
400
|
+
function createPlanLLM(options = {}) {
|
|
401
|
+
return createToolLLM(options, buildPlanToolSchema(), PLAN_TOOL_NAME, PLAN_TOOL_DESCRIPTION);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Architecture Pillar 3 (Skill half) — the Formulator. Runs once a task
|
|
405
|
+
* genuinely completes (not per-step — cheap on purpose, matching the plan
|
|
406
|
+
* file's own framing), compiling whatever real, Critic-verified
|
|
407
|
+
* `learnedFact`s were collected along the way (CriticVerdictSchema's own
|
|
408
|
+
* doc comment is the enforcement point for "never user data") into one
|
|
409
|
+
* Skill. Deliberately DETERMINISTIC, not a fourth kind of real LLM call —
|
|
410
|
+
* every fact it compiles already passed through the Critic's own
|
|
411
|
+
* verification, so there's nothing left to "figure out" that would
|
|
412
|
+
* justify the added cost/latency/failure surface of another model round
|
|
413
|
+
* trip; see DEVELOPMENT.md's own entry for the real cost reasoning
|
|
414
|
+
* (this session already hit genuine Groq quota exhaustion more than once
|
|
415
|
+
* from cumulative call volume). Returns null when nothing was learned —
|
|
416
|
+
* the common case, not an error; a caller should simply not save anything.
|
|
417
|
+
*/
|
|
418
|
+
function compileSkill(goal, learnedFacts, pattern) {
|
|
419
|
+
if (learnedFacts.length === 0)
|
|
420
|
+
return null;
|
|
421
|
+
const name = goal.length > 80 ? `${goal.slice(0, 79)}…` : goal;
|
|
422
|
+
const firstFact = learnedFacts[0];
|
|
423
|
+
return {
|
|
424
|
+
id: (0, core_1.slugifySkillId)(name),
|
|
425
|
+
name,
|
|
426
|
+
description: firstFact.length > 120 ? `${firstFact.slice(0, 119)}…` : firstFact,
|
|
427
|
+
instructions: learnedFacts.join(" "),
|
|
428
|
+
pattern,
|
|
429
|
+
createdAt: new Date().toISOString(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const SIGNIFICANT_WORD_MIN_LENGTH = 4;
|
|
433
|
+
// Real, common phrasing variance between a Skill's own name (usually a
|
|
434
|
+
// gerund, "Connecting nodes...") and a later goal restating the same idea
|
|
435
|
+
// ("connect the node...") means exact word equality misses obvious
|
|
436
|
+
// matches ("connecting" vs "connect", "nodes" vs "node"). A crude 4-
|
|
437
|
+
// character-prefix "stem" — not a real stemming library, deliberately —
|
|
438
|
+
// catches this common case without a new dependency, at the cost of
|
|
439
|
+
// occasional false-positive stems on short unrelated words; the min
|
|
440
|
+
// significant-word length above already screens out the shortest, most
|
|
441
|
+
// collision-prone words.
|
|
442
|
+
const STEM_LENGTH = 4;
|
|
443
|
+
function significantWordStems(text) {
|
|
444
|
+
return new Set(text
|
|
445
|
+
.toLowerCase()
|
|
446
|
+
.split(/[^a-z0-9]+/)
|
|
447
|
+
.filter((w) => w.length >= SIGNIFICANT_WORD_MIN_LENGTH)
|
|
448
|
+
.map((w) => w.slice(0, STEM_LENGTH)));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Architecture Pillar 3 (Skill half) — the retrieval side. A cheap,
|
|
452
|
+
* deterministic keyword-overlap match against a NEW goal (never another
|
|
453
|
+
* real LLM call, same reasoning as compileSkill above) — real progressive
|
|
454
|
+
* disclosure: every Skill's summary is cheap enough to always list (see
|
|
455
|
+
* SkillStore's own doc comment), but only the ONE Skill whose own name
|
|
456
|
+
* shares real, significant words with the current goal gets its full
|
|
457
|
+
* instructions loaded. A caller still needs its own SkillStore.getSkill
|
|
458
|
+
* call to fetch those full instructions for whatever this returns — this
|
|
459
|
+
* function only ever sees cheap summaries, never a full Skill.
|
|
460
|
+
*/
|
|
461
|
+
function matchSkillByGoal(summaries, goal) {
|
|
462
|
+
const goalStems = significantWordStems(goal);
|
|
463
|
+
if (goalStems.size === 0)
|
|
464
|
+
return null;
|
|
465
|
+
let best = null;
|
|
466
|
+
let bestScore = 0;
|
|
467
|
+
for (const summary of summaries) {
|
|
468
|
+
const score = Array.from(significantWordStems(summary.name)).filter((stem) => goalStems.has(stem)).length;
|
|
469
|
+
if (score > bestScore) {
|
|
470
|
+
bestScore = score;
|
|
471
|
+
best = summary;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return best;
|
|
475
|
+
}
|
|
476
|
+
/** Same rendering discipline as renderRegisteredActions — "id (description)" per Skill, for the Planner's own userMessage. */
|
|
477
|
+
function renderSkillSummaries(summaries) {
|
|
478
|
+
return summaries.map((s) => `${s.name} (${s.description})`).join("; ");
|
|
479
|
+
}
|
|
480
|
+
const PlanRequestSchema = zod_1.z
|
|
481
|
+
.object({
|
|
482
|
+
goal: zod_1.z.string().min(1),
|
|
483
|
+
version: zod_1.z.number().int().min(1).optional(),
|
|
484
|
+
})
|
|
485
|
+
.strict();
|
|
486
|
+
const CriticRequestSchema = zod_1.z
|
|
487
|
+
.object({
|
|
488
|
+
task: core_1.TaskSchema,
|
|
489
|
+
goal: zod_1.z.string().min(1),
|
|
490
|
+
verb: core_1.VerbResponseSchema,
|
|
491
|
+
observation: zod_1.z.string().nullable().optional(),
|
|
492
|
+
})
|
|
493
|
+
.strict();
|
|
494
|
+
/**
|
|
495
|
+
* Architecture Pillar 4 — the typed/HTTP transport's own real Planner
|
|
496
|
+
* endpoint, closing the gap the plan file names directly: "the typed/
|
|
497
|
+
* HTTP path (index.tsx's runTypedAgentLoop) has zero Planner/Critic
|
|
498
|
+
* wiring at all... today explicitly realtime-only by deferral, not by
|
|
499
|
+
* decision." A thin HTTP wrapper around the exact same resolvePlan the
|
|
500
|
+
* realtime relay already calls in-process — the LLM call itself only
|
|
501
|
+
* ever needs to happen server-side (it holds the real API key), so a
|
|
502
|
+
* client-side caller (index.tsx) reaches it over a real request instead
|
|
503
|
+
* of importing resolvePlan directly, same reasoning as createCopilotHandler
|
|
504
|
+
* itself.
|
|
505
|
+
*/
|
|
506
|
+
function createPlanHandler(manifest, options = {}) {
|
|
507
|
+
return createPlanHandlerWithLLM(manifest, createPlanLLM(options), options);
|
|
508
|
+
}
|
|
509
|
+
/** Same as createPlanHandler, but with the LLM injected — used by tests to fake it, same pattern as createCopilotHandlerWithLLM. */
|
|
510
|
+
function createPlanHandlerWithLLM(manifest, planLLM, options = {}) {
|
|
511
|
+
const actionsText = renderRegisteredActions(options.registeredActions ?? [], options.actionDescriptions ?? {});
|
|
512
|
+
const skillsScopeId = options.skillsScopeId ?? "default";
|
|
513
|
+
return async function handlePlanRequest(body) {
|
|
514
|
+
const parsed = PlanRequestSchema.safeParse(body);
|
|
515
|
+
if (!parsed.success)
|
|
516
|
+
return { status: 400, body: { error: "invalid request body" } };
|
|
517
|
+
// Architecture Pillar 3 (Skill half) — the typed transport's own
|
|
518
|
+
// retrieval side, same shape realtime-server.ts's finalizeTurn
|
|
519
|
+
// already computes in-process. Absent `options.skills` (the
|
|
520
|
+
// overwhelming majority of deployments today) means zero overhead —
|
|
521
|
+
// this whole block is skipped entirely.
|
|
522
|
+
const skillSummaries = options.skills ? options.skills.listSkillSummaries(skillsScopeId) : [];
|
|
523
|
+
const matchedSkillSummary = skillSummaries.length ? matchSkillByGoal(skillSummaries, parsed.data.goal) : null;
|
|
524
|
+
const skillsPayload = skillSummaries.length
|
|
525
|
+
? {
|
|
526
|
+
summariesText: renderSkillSummaries(skillSummaries) || undefined,
|
|
527
|
+
suggestedInstructions: matchedSkillSummary ? (options.skills.getSkill(skillsScopeId, matchedSkillSummary.id)?.instructions ?? undefined) : undefined,
|
|
528
|
+
}
|
|
529
|
+
: undefined;
|
|
530
|
+
const plan = await resolvePlan(planLLM, parsed.data.goal, parsed.data.version ?? 1, manifest, actionsText || undefined, skillsPayload);
|
|
531
|
+
return { status: 200, body: plan };
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
/** Architecture Pillar 4's Critic counterpart to createPlanHandler — see
|
|
535
|
+
* its own doc comment. A thin HTTP wrapper around the same resolveCritic
|
|
536
|
+
* the realtime relay already calls in-process. */
|
|
537
|
+
function createCriticHandler(options = {}) {
|
|
538
|
+
return createCriticHandlerWithLLM(createCriticLLM(options));
|
|
539
|
+
}
|
|
540
|
+
/** Same as createCriticHandler, but with the LLM injected — used by tests to fake it. */
|
|
541
|
+
function createCriticHandlerWithLLM(criticLLM) {
|
|
542
|
+
return async function handleCriticRequest(body) {
|
|
543
|
+
const parsed = CriticRequestSchema.safeParse(body);
|
|
544
|
+
if (!parsed.success)
|
|
545
|
+
return { status: 400, body: { error: "invalid request body" } };
|
|
546
|
+
const verdict = await resolveCritic(criticLLM, parsed.data.task, parsed.data.goal, parsed.data.verb, parsed.data.observation);
|
|
547
|
+
return { status: 200, body: verdict };
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const SkillSaveRequestSchema = zod_1.z
|
|
551
|
+
.object({
|
|
552
|
+
goal: zod_1.z.string().min(1),
|
|
553
|
+
learnedFacts: zod_1.z.array(zod_1.z.string().min(1)),
|
|
554
|
+
pattern: zod_1.z.enum(core_1.UI_PATTERNS).optional(),
|
|
555
|
+
})
|
|
556
|
+
.strict();
|
|
557
|
+
/**
|
|
558
|
+
* Architecture Pillar 3 (Skill half) — the typed transport's own save
|
|
559
|
+
* side (the Formulator's HTTP counterpart to realtime-server.ts's own
|
|
560
|
+
* in-process `compileSkill`+`saveSkill` call at the end of `finalizeTurn`).
|
|
561
|
+
* No LLM involved — `compileSkill` is deterministic (see its own doc
|
|
562
|
+
* comment for why) — so this needs no `-WithLLM` variant; it's real
|
|
563
|
+
* client-callable storage access, nothing more. The caller (index.tsx's
|
|
564
|
+
* runTypedAgentLoop) accumulates `learnedFacts` from its own Critic calls
|
|
565
|
+
* across one whole turn and posts here exactly once, after the turn
|
|
566
|
+
* concludes — never per-step, matching the Formulator's own "cheap on
|
|
567
|
+
* purpose" framing.
|
|
568
|
+
*/
|
|
569
|
+
function createSkillSaveHandler(skills, skillsScopeId = "default") {
|
|
570
|
+
return async function handleSkillSaveRequest(body) {
|
|
571
|
+
const parsed = SkillSaveRequestSchema.safeParse(body);
|
|
572
|
+
if (!parsed.success)
|
|
573
|
+
return { status: 400, body: { error: "invalid request body" } };
|
|
574
|
+
const skill = compileSkill(parsed.data.goal, parsed.data.learnedFacts, parsed.data.pattern);
|
|
575
|
+
if (skill)
|
|
576
|
+
skills.saveSkill(skillsScopeId, skill);
|
|
577
|
+
return { status: 200, body: { saved: skill !== null } };
|
|
578
|
+
};
|
|
171
579
|
}
|
|
172
580
|
// ---------------------------------------------------------------------------
|
|
173
581
|
// Providers
|
|
@@ -176,10 +584,14 @@ class AnthropicVerbLLM {
|
|
|
176
584
|
client;
|
|
177
585
|
model;
|
|
178
586
|
toolSchema;
|
|
179
|
-
|
|
587
|
+
toolName;
|
|
588
|
+
toolDescription;
|
|
589
|
+
constructor(client, model, toolSchema, toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
|
|
180
590
|
this.client = client;
|
|
181
591
|
this.model = model;
|
|
182
592
|
this.toolSchema = toolSchema;
|
|
593
|
+
this.toolName = toolName;
|
|
594
|
+
this.toolDescription = toolDescription;
|
|
183
595
|
}
|
|
184
596
|
async respond(systemPrompt, userMessage) {
|
|
185
597
|
const response = await this.client.messages.create({
|
|
@@ -188,20 +600,47 @@ class AnthropicVerbLLM {
|
|
|
188
600
|
system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
|
|
189
601
|
tools: [
|
|
190
602
|
{
|
|
191
|
-
name:
|
|
192
|
-
description:
|
|
603
|
+
name: this.toolName,
|
|
604
|
+
description: this.toolDescription,
|
|
193
605
|
input_schema: this.toolSchema,
|
|
194
606
|
strict: true,
|
|
195
607
|
},
|
|
196
608
|
],
|
|
197
|
-
tool_choice: { type: "tool", name:
|
|
609
|
+
tool_choice: { type: "tool", name: this.toolName },
|
|
198
610
|
messages: [{ role: "user", content: userMessage }],
|
|
199
611
|
});
|
|
200
|
-
const toolUse = response.content.find((block) => block?.type === "tool_use" && block?.name ===
|
|
612
|
+
const toolUse = response.content.find((block) => block?.type === "tool_use" && block?.name === this.toolName);
|
|
201
613
|
return toolUse?.input;
|
|
202
614
|
}
|
|
203
615
|
}
|
|
204
616
|
exports.AnthropicVerbLLM = AnthropicVerbLLM;
|
|
617
|
+
/** No tools, no tool_choice — see StreamingTextLLM's own doc comment for why plain, unforced generation is what streams. */
|
|
618
|
+
class AnthropicStreamingTextLLM {
|
|
619
|
+
client;
|
|
620
|
+
model;
|
|
621
|
+
constructor(client, model) {
|
|
622
|
+
this.client = client;
|
|
623
|
+
this.model = model;
|
|
624
|
+
}
|
|
625
|
+
async respondStreamed(systemPrompt, userMessage, onChunk) {
|
|
626
|
+
const stream = await this.client.messages.create({
|
|
627
|
+
model: this.model,
|
|
628
|
+
max_tokens: 1024,
|
|
629
|
+
stream: true,
|
|
630
|
+
system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
|
|
631
|
+
messages: [{ role: "user", content: userMessage }],
|
|
632
|
+
});
|
|
633
|
+
let full = "";
|
|
634
|
+
for await (const event of stream) {
|
|
635
|
+
if (event?.type === "content_block_delta" && event?.delta?.type === "text_delta" && typeof event.delta.text === "string") {
|
|
636
|
+
full += event.delta.text;
|
|
637
|
+
onChunk(event.delta.text);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return full;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
exports.AnthropicStreamingTextLLM = AnthropicStreamingTextLLM;
|
|
205
644
|
// Groq's chat-completions API is OpenAI-compatible: function-calling tools
|
|
206
645
|
// instead of Anthropic's native tool_use blocks, arguments come back as a
|
|
207
646
|
// JSON *string* to parse. Model list verified live against
|
|
@@ -212,44 +651,120 @@ class GroqVerbLLM {
|
|
|
212
651
|
model;
|
|
213
652
|
toolSchema;
|
|
214
653
|
clientFactory;
|
|
215
|
-
|
|
654
|
+
toolName;
|
|
655
|
+
toolDescription;
|
|
656
|
+
constructor(keys, model, toolSchema,
|
|
657
|
+
// maxRetries: 0 — real, live-found latency bug this closes: the Groq
|
|
658
|
+
// SDK's own default (2 automatic retries with exponential backoff) ran
|
|
659
|
+
// UNDERNEATH respond()'s own key-rotation retry loop, so a single 429
|
|
660
|
+
// key attempt could silently eat several real seconds of SDK-internal
|
|
661
|
+
// backoff before respond() ever saw the rejection and moved on to a
|
|
662
|
+
// DIFFERENT key. With several keys in rotation genuinely rate-limited
|
|
663
|
+
// at once (the common case this closes for), that compounded into a
|
|
664
|
+
// real, live-reported multi-second-to-a-minute hang with no visible
|
|
665
|
+
// progress — worse than useless, since respond()'s own retry already
|
|
666
|
+
// tries a different key/quota entirely, which the SDK's blind same-key
|
|
667
|
+
// backoff can never fix. respond() is the sole source of retry policy
|
|
668
|
+
// here now.
|
|
669
|
+
clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 }), toolName = VERB_TOOL_NAME, toolDescription = VERB_TOOL_DESCRIPTION) {
|
|
216
670
|
this.keys = keys;
|
|
217
671
|
this.model = model;
|
|
218
672
|
this.toolSchema = toolSchema;
|
|
219
673
|
this.clientFactory = clientFactory;
|
|
674
|
+
this.toolName = toolName;
|
|
675
|
+
this.toolDescription = toolDescription;
|
|
220
676
|
}
|
|
221
677
|
async respond(systemPrompt, userMessage) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
678
|
+
// Three independent, real retry policies, combined in one loop:
|
|
679
|
+
// - Invalid key (401): the key itself is confirmed dead (see
|
|
680
|
+
// isInvalidKeyError/KeyRotator.markDead) — excluded from rotation
|
|
681
|
+
// for the rest of this process's life, then retried on a
|
|
682
|
+
// DIFFERENT configured key, same bound as rate-limit retries
|
|
683
|
+
// below. Real, live-found gap this closes: before this existed, a
|
|
684
|
+
// single expired/invalid key in the rotation silently sabotaged
|
|
685
|
+
// roughly (dead keys / total keys) of every real request forever
|
|
686
|
+
// — GroqVerbLLM only ever retried a 429, never a 401, so hitting a
|
|
687
|
+
// dead key on the rotation just failed the whole turn outright
|
|
688
|
+
// even when other, genuinely working keys were configured.
|
|
689
|
+
// - Rate-limit (429): retried on a DIFFERENT configured key, up to
|
|
690
|
+
// once per distinct key. Found live — a Groq account's own daily
|
|
691
|
+
// token quota exhausting mid-session doesn't mean every OTHER
|
|
692
|
+
// configured account/key is also exhausted; KeyRotator.take()
|
|
693
|
+
// already advances on every call, so simply retrying reaches a
|
|
694
|
+
// different key automatically. With only one key configured this
|
|
695
|
+
// never fires (nothing else to fall back to) — same behavior as
|
|
696
|
+
// before this existed.
|
|
697
|
+
// - Tool-call failure (see below): exactly one retry, regardless of
|
|
698
|
+
// key count — unrelated to which key was used.
|
|
699
|
+
const maxKeyAttempts = Math.max(this.keys.size, 1);
|
|
700
|
+
let keyAttempts = 0;
|
|
701
|
+
let usedToolCallRetry = false;
|
|
702
|
+
for (;;) {
|
|
703
|
+
const key = this.keys.take();
|
|
704
|
+
try {
|
|
705
|
+
return await this.attemptRespond(key, systemPrompt, userMessage);
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (isInvalidKeyError(err)) {
|
|
709
|
+
this.keys.markDead(key);
|
|
710
|
+
if (keyAttempts < maxKeyAttempts - 1) {
|
|
711
|
+
keyAttempts++;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
throw err;
|
|
715
|
+
}
|
|
716
|
+
if (isRateLimitError(err) && keyAttempts < maxKeyAttempts - 1) {
|
|
717
|
+
keyAttempts++;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
// Real, live bugs, not theoretical — two distinct non-deterministic
|
|
721
|
+
// failure modes from openai/gpt-oss-120b (a reasoning-capable open
|
|
722
|
+
// model), both rejected by Groq's own server-side validation before
|
|
723
|
+
// this code ever sees a real response to work with, and both found
|
|
724
|
+
// to recover cleanly on an identical retry a moment later:
|
|
725
|
+
// - "output_parse_failed": the model "thinks out loud" in plain
|
|
726
|
+
// prose instead of emitting the forced tool call.
|
|
727
|
+
// - "tool_use_failed": the model hallucinates a slightly-wrong tool
|
|
728
|
+
// name ("json", "response_with_verb" — seen live, both against
|
|
729
|
+
// the real, correctly-configured VERB_TOOL_NAME) instead of the
|
|
730
|
+
// one forced tool it was actually given. This one was the actual
|
|
731
|
+
// cause behind a real "voice keeps breaking" report — found live
|
|
732
|
+
// running the new eval harness's synthetic-voice scenario, where
|
|
733
|
+
// it surfaced as "Something went wrong on my end" with no other
|
|
734
|
+
// symptom, exactly matching what got reported.
|
|
735
|
+
// Real, live bug found AFTER the single-retry mitigation above had
|
|
736
|
+
// already shipped: the SAME hallucinated-tool-name failure can hit
|
|
737
|
+
// twice in a row (seen live, back to back, on one otherwise-normal
|
|
738
|
+
// question), exhausting the one retry and still falling through to
|
|
739
|
+
// the generic fallback — even though Groq's own error response
|
|
740
|
+
// carries the model's complete, correctly-shaped answer right there
|
|
741
|
+
// in `failed_generation` (it parsed the arguments fine; it only
|
|
742
|
+
// picked the wrong TOOL NAME to wrap them in). Recovering that
|
|
743
|
+
// directly costs nothing (no extra round trip) and can't make
|
|
744
|
+
// things worse than today — resolveVerb's own VerbResponseSchema
|
|
745
|
+
// check right after this returns is the same safety net a normal,
|
|
746
|
+
// successful response already goes through, so a malformed
|
|
747
|
+
// extraction just falls to its existing "I'm not sure" fallback.
|
|
748
|
+
// Tried before spending the one real retry on output_parse_failed's
|
|
749
|
+
// case, where there's genuinely nothing to extract (no forced tool
|
|
750
|
+
// call was even produced).
|
|
751
|
+
const recovered = extractFailedGenerationArguments(err);
|
|
752
|
+
if (recovered !== undefined)
|
|
753
|
+
return recovered;
|
|
754
|
+
// One retry — not exponential backoff, this is a latency-sensitive
|
|
755
|
+
// voice/chat path — genuinely helps rather than just delaying the
|
|
756
|
+
// same failure. Anything else still propagates to resolveVerb's own
|
|
757
|
+
// catch, unchanged.
|
|
758
|
+
if (isRetryableToolCallFailure(err) && !usedToolCallRetry) {
|
|
759
|
+
usedToolCallRetry = true;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
throw err;
|
|
247
763
|
}
|
|
248
|
-
throw err;
|
|
249
764
|
}
|
|
250
765
|
}
|
|
251
|
-
async attemptRespond(systemPrompt, userMessage) {
|
|
252
|
-
const client = this.clientFactory(
|
|
766
|
+
async attemptRespond(apiKey, systemPrompt, userMessage) {
|
|
767
|
+
const client = this.clientFactory(apiKey);
|
|
253
768
|
const completion = await client.chat.completions.create({
|
|
254
769
|
model: this.model,
|
|
255
770
|
messages: [
|
|
@@ -260,13 +775,13 @@ class GroqVerbLLM {
|
|
|
260
775
|
{
|
|
261
776
|
type: "function",
|
|
262
777
|
function: {
|
|
263
|
-
name:
|
|
264
|
-
description:
|
|
778
|
+
name: this.toolName,
|
|
779
|
+
description: this.toolDescription,
|
|
265
780
|
parameters: this.toolSchema,
|
|
266
781
|
},
|
|
267
782
|
},
|
|
268
783
|
],
|
|
269
|
-
tool_choice: { type: "function", function: { name:
|
|
784
|
+
tool_choice: { type: "function", function: { name: this.toolName } },
|
|
270
785
|
});
|
|
271
786
|
const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
|
|
272
787
|
if (!toolCall)
|
|
@@ -280,16 +795,100 @@ class GroqVerbLLM {
|
|
|
280
795
|
}
|
|
281
796
|
}
|
|
282
797
|
exports.GroqVerbLLM = GroqVerbLLM;
|
|
798
|
+
/** 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. */
|
|
799
|
+
class GroqStreamingTextLLM {
|
|
800
|
+
keys;
|
|
801
|
+
model;
|
|
802
|
+
clientFactory;
|
|
803
|
+
constructor(keys, model,
|
|
804
|
+
// maxRetries: 0 — same real latency bug as GroqVerbLLM's own
|
|
805
|
+
// clientFactory default; see its doc comment for the full reasoning.
|
|
806
|
+
// respondStreamed below already has its own key-rotation retry loop
|
|
807
|
+
// (maxAttempts bounded by keys.size), which makes the SDK's blind
|
|
808
|
+
// same-key backoff redundant AND a source of silent multi-second
|
|
809
|
+
// delay stacked underneath it.
|
|
810
|
+
clientFactory = (apiKey) => new groq_sdk_1.default({ apiKey, maxRetries: 0 })) {
|
|
811
|
+
this.keys = keys;
|
|
812
|
+
this.model = model;
|
|
813
|
+
this.clientFactory = clientFactory;
|
|
814
|
+
}
|
|
815
|
+
async respondStreamed(systemPrompt, userMessage, onChunk) {
|
|
816
|
+
// Same rate-limit/invalid-key-retries-on-a-different-key policy as
|
|
817
|
+
// GroqVerbLLM.respond (see its own doc comment, including the real,
|
|
818
|
+
// live-found "a dead key sabotages roughly 1/N of every request"
|
|
819
|
+
// gap markDead closes). The one thing this path has to guard against
|
|
820
|
+
// that the non-streaming call doesn't: a real chunk already having
|
|
821
|
+
// reached the caller via onChunk before something fails mid-stream —
|
|
822
|
+
// both a 429 and a 401 always arrive on the initial request, before
|
|
823
|
+
// any chunk streams, so retrying is only ever attempted when nothing
|
|
824
|
+
// has been emitted yet; a genuinely different mid-stream failure is
|
|
825
|
+
// never retried, since doing so would duplicate output already sent.
|
|
826
|
+
const maxAttempts = Math.max(this.keys.size, 1);
|
|
827
|
+
let lastErr;
|
|
828
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
829
|
+
let emittedAnyChunk = false;
|
|
830
|
+
const key = this.keys.take();
|
|
831
|
+
try {
|
|
832
|
+
const client = this.clientFactory(key);
|
|
833
|
+
const stream = await client.chat.completions.create({
|
|
834
|
+
model: this.model,
|
|
835
|
+
stream: true,
|
|
836
|
+
messages: [
|
|
837
|
+
{ role: "system", content: systemPrompt },
|
|
838
|
+
{ role: "user", content: userMessage },
|
|
839
|
+
],
|
|
840
|
+
});
|
|
841
|
+
let full = "";
|
|
842
|
+
for await (const chunk of stream) {
|
|
843
|
+
const delta = chunk?.choices?.[0]?.delta?.content;
|
|
844
|
+
if (typeof delta === "string" && delta) {
|
|
845
|
+
full += delta;
|
|
846
|
+
emittedAnyChunk = true;
|
|
847
|
+
onChunk(delta);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return full;
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
lastErr = err;
|
|
854
|
+
if (emittedAnyChunk)
|
|
855
|
+
throw err;
|
|
856
|
+
if (isInvalidKeyError(err))
|
|
857
|
+
this.keys.markDead(key);
|
|
858
|
+
if ((isInvalidKeyError(err) || isRateLimitError(err)) && attempt < maxAttempts - 1)
|
|
859
|
+
continue;
|
|
860
|
+
throw err;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
throw lastErr;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
exports.GroqStreamingTextLLM = GroqStreamingTextLLM;
|
|
283
867
|
/** Groq's SDK doesn't export a stable error shape to import and check
|
|
284
868
|
* against, so this checks defensively across the ways the real error has
|
|
285
869
|
* actually been observed to surface — a thrown APIError with a nested
|
|
286
870
|
* `.error.code`, a plain `.code`, or just the code string showing up
|
|
287
|
-
* somewhere in the message — rather than relying on exactly one of them.
|
|
871
|
+
* somewhere in the message — rather than relying on exactly one of them.
|
|
872
|
+
*
|
|
873
|
+
* Real, live bug found AFTER this function had already shipped and been
|
|
874
|
+
* unit-tested: the actual Groq SDK error is doubly-nested
|
|
875
|
+
* (`err.error.error.code`, matching isRateLimitError's own real-shape
|
|
876
|
+
* fix below) but this only ever checked ONE level (`err.error?.code`) —
|
|
877
|
+
* so `code` always came back `undefined` against the real API surface,
|
|
878
|
+
* and the retry this function exists for NEVER actually fired, despite
|
|
879
|
+
* every unit test passing (they all hand-built a shallow, one-level
|
|
880
|
+
* mock that doesn't match what Groq really throws). Caught live: a
|
|
881
|
+
* `tool_use_failed`/hallucinated-tool-name error with a perfectly good
|
|
882
|
+
* answer sitting right there in `failed_generation` fell straight to
|
|
883
|
+
* the generic "Something went wrong on my end" fallback instead of
|
|
884
|
+
* retrying — exactly the failure mode this function's own doc comment
|
|
885
|
+
* already claimed to fix. Now checks the same depth `isRateLimitError`
|
|
886
|
+
* does. */
|
|
288
887
|
function isRetryableToolCallFailure(err) {
|
|
289
888
|
if (!err || typeof err !== "object")
|
|
290
889
|
return false;
|
|
291
890
|
const e = err;
|
|
292
|
-
const code = e.code ?? e.error?.code;
|
|
891
|
+
const code = e.code ?? e.error?.code ?? e.error?.error?.code;
|
|
293
892
|
const message = typeof e.message === "string" ? e.message : "";
|
|
294
893
|
if (code === "output_parse_failed" || message.includes("output_parse_failed"))
|
|
295
894
|
return true;
|
|
@@ -301,11 +900,98 @@ function isRetryableToolCallFailure(err) {
|
|
|
301
900
|
return true;
|
|
302
901
|
return false;
|
|
303
902
|
}
|
|
903
|
+
/**
|
|
904
|
+
* Recovers the model's real, already-generated answer straight out of a
|
|
905
|
+
* `tool_use_failed` error, instead of spending a retry re-asking for
|
|
906
|
+
* something Groq already has. Same doubly-nested shape
|
|
907
|
+
* isRetryableToolCallFailure/isRateLimitError check against
|
|
908
|
+
* (`err.error.error.*`) plus the one extra field this specific error
|
|
909
|
+
* carries: `failed_generation`, a JSON-encoded string of exactly what the
|
|
910
|
+
* model produced — `{"name": "<hallucinated tool name>", "arguments":
|
|
911
|
+
* {...the real verb payload...}}`. Deliberately narrow: only fires for
|
|
912
|
+
* `tool_use_failed` specifically (never `output_parse_failed`, where the
|
|
913
|
+
* model didn't produce a forced tool call at all, so there's nothing real
|
|
914
|
+
* to recover here), and only returns something when `arguments` actually
|
|
915
|
+
* parses as an object — anything else (a missing field, a non-JSON
|
|
916
|
+
* string, a differently-shaped error) returns undefined and the caller
|
|
917
|
+
* falls through to its existing retry/throw path, unchanged.
|
|
918
|
+
*/
|
|
919
|
+
function extractFailedGenerationArguments(err) {
|
|
920
|
+
if (!err || typeof err !== "object")
|
|
921
|
+
return undefined;
|
|
922
|
+
const e = err;
|
|
923
|
+
if (e.error?.error?.code !== "tool_use_failed")
|
|
924
|
+
return undefined;
|
|
925
|
+
const raw = e.error.error.failed_generation;
|
|
926
|
+
if (typeof raw !== "string")
|
|
927
|
+
return undefined;
|
|
928
|
+
try {
|
|
929
|
+
const parsed = JSON.parse(raw);
|
|
930
|
+
return parsed && typeof parsed === "object" && parsed.arguments && typeof parsed.arguments === "object" ? parsed.arguments : undefined;
|
|
931
|
+
}
|
|
932
|
+
catch {
|
|
933
|
+
return undefined;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/** Same defensive-shape-checking approach as isRetryableToolCallFailure —
|
|
937
|
+
* the real error observed live (see DEVELOPMENT.md) is a thrown APIError
|
|
938
|
+
* with `.status === 429` and a doubly-nested `.error.error.code ===
|
|
939
|
+
* "rate_limit_exceeded"`, but checks a couple of shallower shapes too
|
|
940
|
+
* rather than depending on exactly that nesting. */
|
|
941
|
+
function isRateLimitError(err) {
|
|
942
|
+
if (!err || typeof err !== "object")
|
|
943
|
+
return false;
|
|
944
|
+
const e = err;
|
|
945
|
+
if (e.status === 429)
|
|
946
|
+
return true;
|
|
947
|
+
const code = e.code ?? e.error?.code ?? e.error?.error?.code;
|
|
948
|
+
if (code === "rate_limit_exceeded")
|
|
949
|
+
return true;
|
|
950
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
951
|
+
return message.includes("rate_limit_exceeded") || message.includes("Rate limit reached");
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Same defensive-shape-checking approach as isRateLimitError — the real
|
|
955
|
+
* error was checked directly against the live Groq API before writing
|
|
956
|
+
* this (not guessed): a real 401 from an invalid/expired key throws with
|
|
957
|
+
* `.status === 401` and the SAME doubly-nested `.error.error.code ===
|
|
958
|
+
* "invalid_api_key"` shape isRateLimitError already has to check for its
|
|
959
|
+
* own 429 case, confirmed via `Groq({apiKey}).chat.completions.create(...)`
|
|
960
|
+
* against a genuinely dead key and inspecting the thrown error's own
|
|
961
|
+
* `.status`/`.error`/`.message` fields directly.
|
|
962
|
+
*/
|
|
963
|
+
function isInvalidKeyError(err) {
|
|
964
|
+
if (!err || typeof err !== "object")
|
|
965
|
+
return false;
|
|
966
|
+
const e = err;
|
|
967
|
+
if (e.status === 401)
|
|
968
|
+
return true;
|
|
969
|
+
const code = e.code ?? e.error?.code ?? e.error?.error?.code;
|
|
970
|
+
if (code === "invalid_api_key")
|
|
971
|
+
return true;
|
|
972
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
973
|
+
return message.includes("invalid_api_key") || message.includes("Invalid API Key");
|
|
974
|
+
}
|
|
304
975
|
// ---------------------------------------------------------------------------
|
|
305
976
|
// Shared tool schema / system prompt
|
|
306
977
|
// ---------------------------------------------------------------------------
|
|
307
978
|
const VERB_TOOL_DESCRIPTION = "Respond with exactly one action for the UI to take. Never invent selectors, routes, or code.";
|
|
308
|
-
|
|
979
|
+
/**
|
|
980
|
+
* Phase 4, layer 5 — the ONE place a registered action id is rendered
|
|
981
|
+
* with its (optional) real description, shared by buildVerbToolSchema,
|
|
982
|
+
* buildSystemPrompt's own do-verb text, and resolvePlan's userMessage —
|
|
983
|
+
* so the Executor and the Planner describe the exact same capability the
|
|
984
|
+
* exact same way, and there's no risk of the three drifting out of sync.
|
|
985
|
+
* Deliberately renders "id (description)" rather than baking the
|
|
986
|
+
* description into what the model must echo back — resolveVerb's own
|
|
987
|
+
* `registeredActions.includes(parsedVerb.data.action)` check (server.ts)
|
|
988
|
+
* needs the RAW id back, verbatim, or a real registered action would
|
|
989
|
+
* silently stop being recognized.
|
|
990
|
+
*/
|
|
991
|
+
function renderRegisteredActions(registeredActions, actionDescriptions = {}) {
|
|
992
|
+
return registeredActions.map((id) => (actionDescriptions[id] ? `${id} (${actionDescriptions[id]})` : id)).join(", ");
|
|
993
|
+
}
|
|
994
|
+
function buildVerbToolSchema(registeredActions, actionDescriptions = {}) {
|
|
309
995
|
// Every genuinely-optional field allows `null` as well as its real type
|
|
310
996
|
// (`["string", "null"]`, not just `"string"`) — found live, not
|
|
311
997
|
// theoretical: real models (verified against Groq's openai/gpt-oss-120b)
|
|
@@ -325,14 +1011,20 @@ function buildVerbToolSchema(registeredActions) {
|
|
|
325
1011
|
properties: {
|
|
326
1012
|
verb: { type: "string", enum: [...core_1.VERBS] },
|
|
327
1013
|
text: nullableString("Shown to the user. Required for explain. null (or omitted) if not applicable."),
|
|
328
|
-
target: nullableString("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."),
|
|
1014
|
+
target: nullableString("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."),
|
|
1015
|
+
to: nullableString("Required for drag — the id (from currentPageElements or liveElements) of where to drop it. null (or omitted) if not applicable."),
|
|
1016
|
+
key: nullableString('Required for key — one real key name: Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, or ArrowRight. null (or omitted) if not applicable.'),
|
|
329
1017
|
route: nullableString("A route from the manifest. Required for navigate. null (or omitted) if not applicable."),
|
|
1018
|
+
continueAfter: {
|
|
1019
|
+
type: ["boolean", "null"],
|
|
1020
|
+
description: '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.',
|
|
1021
|
+
},
|
|
330
1022
|
action: nullableString("Required for do. A short label for what's being done, e.g. \"archive-invoice\" " +
|
|
331
1023
|
(registeredActions.length
|
|
332
|
-
? `— either one of this deployment's registered actions [${registeredActions
|
|
1024
|
+
? `— 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.`
|
|
333
1025
|
: "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.") +
|
|
334
1026
|
" null (or omitted) if not applicable."),
|
|
335
|
-
value: nullableString('Required for fill — the exact text to type into "target". null (or omitted) if not applicable.'),
|
|
1027
|
+
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.'),
|
|
336
1028
|
name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list, exactly as given. null (or omitted) if not applicable."),
|
|
337
1029
|
args: {
|
|
338
1030
|
type: ["object", "null"],
|
|
@@ -358,14 +1050,16 @@ function buildVerbToolSchema(registeredActions) {
|
|
|
358
1050
|
},
|
|
359
1051
|
actions: {
|
|
360
1052
|
type: ["array", "null"],
|
|
361
|
-
description: "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.",
|
|
1053
|
+
description: "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.",
|
|
362
1054
|
items: {
|
|
363
1055
|
type: "object",
|
|
364
1056
|
properties: {
|
|
365
|
-
verb: { type: "string", enum: ["click", "fill", "read", "call_tool"] },
|
|
366
|
-
target: nullableString("An id from currentPageElements or liveElements. Required for click/fill/read. null (or omitted) if not applicable."),
|
|
367
|
-
value: nullableString('Required for fill — the exact text to type into "target". null (or omitted) if not applicable.'),
|
|
1057
|
+
verb: { type: "string", enum: ["click", "fill", "read", "call_tool", "drag", "select", "key", "scroll", "wait_for"] },
|
|
1058
|
+
target: nullableString("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."),
|
|
1059
|
+
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.'),
|
|
368
1060
|
name: nullableString("Required for call_tool — a tool name from this turn's webMcpTools list. null (or omitted) if not applicable."),
|
|
1061
|
+
to: nullableString("Required for drag — the id of where to drop it. null (or omitted) if not applicable."),
|
|
1062
|
+
key: nullableString("Required for key — one real key name (Escape, Enter, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight). null (or omitted) if not applicable."),
|
|
369
1063
|
args: {
|
|
370
1064
|
type: ["object", "null"],
|
|
371
1065
|
description: "For call_tool — the arguments object, matching that tool's own inputSchema. null (or omitted) if the tool takes none.",
|
|
@@ -395,7 +1089,7 @@ function buildVerbToolSchema(registeredActions) {
|
|
|
395
1089
|
* element detail is attached separately, per request, in resolveVerb —
|
|
396
1090
|
* see buildPageElements.
|
|
397
1091
|
*/
|
|
398
|
-
function buildSystemPrompt(manifest, registeredActions, persona = "Cairn") {
|
|
1092
|
+
function buildSystemPrompt(manifest, registeredActions, persona = "Cairn", actionDescriptions = {}) {
|
|
399
1093
|
const pageSummaries = manifest.pages.map((p) => `- ${p.route}: ${p.purpose}`).join("\n");
|
|
400
1094
|
return `You are ${persona}, an in-app assistant. You help users of this web app by
|
|
401
1095
|
answering what a page or button does, pointing at the right element, and
|
|
@@ -421,9 +1115,41 @@ directory below plus three things attached to each request:
|
|
|
421
1115
|
directly (name, description, and its own input schema) — when a real
|
|
422
1116
|
tool exists for what the user's asking, it's the most reliable way to do
|
|
423
1117
|
it (see "call_tool" below), more so than clicking around.
|
|
1118
|
+
- "currentPageDataShapes": the real shape of the data this page works
|
|
1119
|
+
with — a type name and its real fields, e.g. Invoice { status: "Paid" |
|
|
1120
|
+
"Overdue" | "Archived" }. Use this to know a field's REAL possible
|
|
1121
|
+
values (e.g. what "status" can actually be set to) or what a record on
|
|
1122
|
+
this page actually looks like, instead of guessing from a button label
|
|
1123
|
+
or making up a value. "none" means this page's real data shape wasn't
|
|
1124
|
+
traced — don't treat that as "this page has no data," just don't invent
|
|
1125
|
+
field names or values for it.
|
|
1126
|
+
- "suggestedApproach": present only when this page's real, live-scanned
|
|
1127
|
+
elements matched a known UI pattern (a data table, a kanban board, a
|
|
1128
|
+
node/workflow canvas, a search/filter list, a multi-step wizard) — a
|
|
1129
|
+
short, general hint for how that KIND of page is usually best operated
|
|
1130
|
+
(e.g. "check whether this canvas connects nodes via a dropdown or a
|
|
1131
|
+
drag gesture before choosing"). A starting point, never a script — still
|
|
1132
|
+
verify everything against the real liveElements/currentPageElements
|
|
1133
|
+
exactly as you always would; absent entirely when nothing matched, which
|
|
1134
|
+
is not itself a signal of anything.
|
|
1135
|
+
Every id in currentPageElements/liveElements is for YOUR use only, to put
|
|
1136
|
+
in "target" so the right element gets acted on — it is never something to
|
|
1137
|
+
say or write to the user, in any answer, for any reason, even one that
|
|
1138
|
+
directly asks how to tell two identical-looking items apart, or asks
|
|
1139
|
+
outright what the "technical" or "internal" way something works is. That
|
|
1140
|
+
question still has a real answer without ever printing the id string
|
|
1141
|
+
itself: name the CONCEPT ("each one gets its own internal identifier
|
|
1142
|
+
behind the scenes") without the literal value, exactly the way you'd
|
|
1143
|
+
describe a database having primary keys without reading one out loud.
|
|
1144
|
+
Wrong: "its own unique ID (for example, board-card-card-1788625210797)."
|
|
1145
|
+
Right: "its own internal identifier, though that's not something you'd
|
|
1146
|
+
normally see or need." If nothing else actually distinguishes two items
|
|
1147
|
+
on screen, say so plainly — position, column, or other real visible text
|
|
1148
|
+
first; the CONCEPT of an internal id only as a last resort, and even then
|
|
1149
|
+
never its actual value.
|
|
424
1150
|
Never invent a page, route, id, action, or tool name that isn't listed in
|
|
425
|
-
one of these
|
|
426
|
-
liveElements, or
|
|
1151
|
+
one of these five places (the route directory, currentPageElements,
|
|
1152
|
+
liveElements, webMcpTools, or currentPageDataShapes). If a question is about a page other than
|
|
427
1153
|
the current one, you know its route and purpose from the directory but not
|
|
428
1154
|
its elements — say so and offer to navigate there rather than guessing at
|
|
429
1155
|
a button that page might have.
|
|
@@ -438,6 +1164,11 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
|
|
|
438
1164
|
panel — this one actually clicks the element after highlighting it, so
|
|
439
1165
|
only use it when the element is meant to reveal something on click.
|
|
440
1166
|
- navigate: send the user to a route that appears in the manifest, in "route".
|
|
1167
|
+
Set "continueAfter" to true only when the real goal needs more than just
|
|
1168
|
+
arriving there (e.g. "buy earbuds" — navigate, then search, then report
|
|
1169
|
+
back) — you'll be asked again once you've arrived, with that page's own
|
|
1170
|
+
real elements, to decide the next step. Leave it false/null for a plain
|
|
1171
|
+
"take me to X" request, where arriving is the whole answer.
|
|
441
1172
|
- tour: 2-6 ordered "steps", each with its own "text" and (usually) a
|
|
442
1173
|
"target". Use this whenever explaining the answer means touching more
|
|
443
1174
|
than one element — e.g. "what can I do on this page" or "give me a tour" —
|
|
@@ -463,7 +1194,7 @@ Always call ${VERB_TOOL_NAME} exactly once with one of these verbs:
|
|
|
463
1194
|
"target" and a short label in "action". Works even when the action has
|
|
464
1195
|
no network call at all (e.g. a button that just reveals a form) — it
|
|
465
1196
|
still gets clicked for real.
|
|
466
|
-
3. One of this deployment's registered
|
|
1197
|
+
3. One of this deployment's registered actions: [${renderRegisteredActions(registeredActions, actionDescriptions) || "none registered"}] — put that exact id (never its description in parens) in "action".
|
|
467
1198
|
If none applies — the target isn't in liveElements or currentPageElements
|
|
468
1199
|
and isn't a registered action — use "explain" and say you can't do that
|
|
469
1200
|
from here. Never invent a target or action id that isn't in one of those
|
|
@@ -489,21 +1220,41 @@ response; once you do, answer with one of the verbs above instead):
|
|
|
489
1220
|
listed in "webMcpTools" — "name" (exactly as given) and "args" (matching
|
|
490
1221
|
that tool's own schema). This is the most reliable way to do something
|
|
491
1222
|
when a real tool for it exists — prefer it over do/click when it does.
|
|
492
|
-
|
|
1223
|
+
- drag: drag a real element onto another one — "target" (what's being
|
|
1224
|
+
dragged) and "to" (where it's dropped), both real ids. Use this for
|
|
1225
|
+
anything click/fill can't reach: connecting two nodes on a canvas/
|
|
1226
|
+
workflow editor, reordering a list, moving a card between columns on a
|
|
1227
|
+
kanban board.
|
|
1228
|
+
- select: choose a real dropdown/listbox option — "target" (the dropdown)
|
|
1229
|
+
and "value" (the option's exact VISIBLE text, never an internal value
|
|
1230
|
+
you're guessing at).
|
|
1231
|
+
- key: press one real key — Escape, Enter, Tab, ArrowUp, ArrowDown,
|
|
1232
|
+
ArrowLeft, or ArrowRight, in "key". "target" is optional — omit it to
|
|
1233
|
+
press the key on whatever's currently focused (e.g. right after a fill),
|
|
1234
|
+
or name an element to focus it first.
|
|
1235
|
+
- scroll: bring a real, already-known element into view — "target" — for
|
|
1236
|
+
content you (or the user) need to actually see before acting on it or
|
|
1237
|
+
narrating it, without clicking or reading it.
|
|
1238
|
+
- wait_for: explicitly pause until a real, already-known element appears
|
|
1239
|
+
or becomes findable — "target" — use this when you know something
|
|
1240
|
+
should show up after an earlier step (a confirmation toast, a panel
|
|
1241
|
+
that opens async) and want to confirm it actually did before reporting
|
|
1242
|
+
success, instead of guessing that enough time has passed.
|
|
1243
|
+
All nine require a real id/name from currentPageElements, liveElements, or
|
|
493
1244
|
webMcpTools — never invent one. You'll be shown the real result of each
|
|
494
1245
|
step and asked again what to do next; after a small number of steps,
|
|
495
1246
|
answer with a terminal verb even if incomplete, explaining what you found.
|
|
496
1247
|
|
|
497
|
-
- batch: 2-5 of the
|
|
498
|
-
its own shape — no separate "text"),
|
|
499
|
-
this INSTEAD of separate single steps
|
|
500
|
-
you need and none of them depends on
|
|
501
|
-
first (e.g. filling three fields you
|
|
502
|
-
sequence of clicks). If a later step needs to
|
|
503
|
-
one turns up, or depends on something an earlier
|
|
504
|
-
newly reveal, use single steps instead — a batch only
|
|
505
|
-
it is right now, not as an earlier step in the same
|
|
506
|
-
step failing stops the rest of that batch.
|
|
1248
|
+
- batch: 2-5 of the nine steps above (click/fill/read/call_tool/drag/
|
|
1249
|
+
select/key/scroll/wait_for, each in its own shape — no separate "text"),
|
|
1250
|
+
run in order, in "actions" — use this INSTEAD of separate single steps
|
|
1251
|
+
when you already know every step you need and none of them depends on
|
|
1252
|
+
seeing an earlier one's real result first (e.g. filling three fields you
|
|
1253
|
+
can already see, or a known sequence of clicks). If a later step needs to
|
|
1254
|
+
react to what an earlier one turns up, or depends on something an earlier
|
|
1255
|
+
step's click would newly reveal, use single steps instead — a batch only
|
|
1256
|
+
sees the page as it is right now, not as an earlier step in the same
|
|
1257
|
+
batch leaves it. One step failing stops the rest of that batch.
|
|
507
1258
|
|
|
508
1259
|
Every "text" field (in explain, or per-step in tour, or the optional text on
|
|
509
1260
|
any other verb) is read aloud AND shown on screen, so it must sound like a
|
|
@@ -511,9 +1262,63 @@ person talking, not documentation:
|
|
|
511
1262
|
- No markdown — no "**bold**", no bullet lists, no backticks, no headings.
|
|
512
1263
|
- Never say an element's internal id (e.g. never say "create-invoice" or
|
|
513
1264
|
"the element id invoice-table") — describe it the way a user sees it
|
|
514
|
-
instead (its visible label, e.g. "the Create Invoice button").
|
|
1265
|
+
instead (its visible label, e.g. "the Create Invoice button"). This
|
|
1266
|
+
holds even when asked how to tell two same-named/identical-looking
|
|
1267
|
+
items apart — that question feels like it needs the id, but it doesn't:
|
|
1268
|
+
describe them by what's actually visible instead (their column, their
|
|
1269
|
+
position, any other real text on them), or say plainly that they look
|
|
1270
|
+
identical on screen and the user would need to open each one to tell
|
|
1271
|
+
which is which. Never reach for the id as the answer just because
|
|
1272
|
+
nothing else seems to distinguish them.
|
|
515
1273
|
- Short, natural sentences — one idea per sentence, the way you'd actually
|
|
516
1274
|
explain something out loud to someone standing next to you.
|
|
1275
|
+
- Say only what actually answers the question, then stop — a real person
|
|
1276
|
+
giving a quick answer doesn't restate the question, list every possible
|
|
1277
|
+
angle, or pile on a second and third example once the first one landed.
|
|
1278
|
+
One or two sentences is the normal length for most answers; reach for
|
|
1279
|
+
more only when the question genuinely has several distinct parts. If
|
|
1280
|
+
you notice you're explaining the same point twice in different words,
|
|
1281
|
+
cut one of them.
|
|
1282
|
+
- Never a self-referential disclaimer ("As an AI...", "I'm just a language
|
|
1283
|
+
model", "I don't have the ability to..."). If something's genuinely out
|
|
1284
|
+
of reach, say the specific reason in plain terms instead (e.g. "that's
|
|
1285
|
+
not on this page" beats any variant of "I am unable to").
|
|
1286
|
+
- Never open with "Certainly!", "Great question!", "Absolutely!", or "I'd
|
|
1287
|
+
be happy to" — answer the thing directly, the way a coworker would, not
|
|
1288
|
+
a customer-service script. Never close with "Hope this helps!" or
|
|
1289
|
+
similar. Skip corporate-memo words too — "delve," "leverage," "utilize,"
|
|
1290
|
+
"streamline," "robust," "unlock" — plain words do the same job.
|
|
1291
|
+
- Use contractions ("I'll," "you're," "it's," "don't") — the spelled-out
|
|
1292
|
+
form reads stiffer for no reason.
|
|
1293
|
+
- State things plainly when you actually know them — don't wrap a real
|
|
1294
|
+
answer in "I think," "it seems like," or "possibly" just to sound
|
|
1295
|
+
careful; hedging a correct answer makes people trust and act on it LESS,
|
|
1296
|
+
not more. Save "I'm not sure" for when you genuinely are, and say it in
|
|
1297
|
+
exactly those plain words rather than a longer disclaimer.
|
|
1298
|
+
- When something can't be done or went wrong, say so once, plainly, and
|
|
1299
|
+
move straight to what's true or what to try instead — skip "I'm sorry"
|
|
1300
|
+
and its variants entirely. A flat, competent correction reads as more
|
|
1301
|
+
trustworthy than an apology, and repeating "sorry" across a conversation
|
|
1302
|
+
reads as worse, not more polite.
|
|
1303
|
+
- Match the tone to what's actually happening: brief and easy for a
|
|
1304
|
+
routine confirmation ("Moved it to Done."), plain and direct — not
|
|
1305
|
+
jokey — for an error or anything destructive/irreversible. A casual
|
|
1306
|
+
quip lands fine on a small thing and badly on a real one.
|
|
1307
|
+
- Let a small, real hesitation through sometimes instead of always
|
|
1308
|
+
polishing it away — aim for roughly one answer in every three or four,
|
|
1309
|
+
not every single one and not zero: open with "Um," "Well," "Hmm," or
|
|
1310
|
+
"So," right before something genuinely tricky, surprising, or hard to
|
|
1311
|
+
phrase; catch yourself and correct once mid-answer ("Actually, wait —
|
|
1312
|
+
the New Invoice button, not New Card.") the way a person naturally
|
|
1313
|
+
does; or use a real pause — a comma, a dash, a trailing "..." — right
|
|
1314
|
+
before landing on the actual answer, instead of stating it flatly.
|
|
1315
|
+
Never more than one of these in the same answer, never two in a row,
|
|
1316
|
+
and never on a routine confirmation or anything serious/destructive —
|
|
1317
|
+
a stray "um" there reads as careless, not human. Don't force one onto
|
|
1318
|
+
an answer that has nothing tricky about it — a plain, easy question
|
|
1319
|
+
still gets a plain, easy answer most of the time; reaching for a
|
|
1320
|
+
hesitation on EVERY response reads as more annoying than the flat,
|
|
1321
|
+
polished tone it's meant to fix.
|
|
517
1322
|
|
|
518
1323
|
The request may include "history" — earlier turns of this same
|
|
519
1324
|
conversation, oldest first. Use it to resolve references like "the first
|
|
@@ -547,3 +1352,128 @@ function buildPageElements(manifest, route) {
|
|
|
547
1352
|
return "none";
|
|
548
1353
|
return page.elements.map((e) => `${e.id} (${e.does})`).join("; ");
|
|
549
1354
|
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Phase 4, layer 2's own consumer — the real interface/type-alias fields
|
|
1357
|
+
* l1-data-shapes.ts traced for the current page (e.g. Invoice's actual
|
|
1358
|
+
* status: "Paid" | "Overdue" | "Archived" union), so a fill/do/explain can
|
|
1359
|
+
* reason about a field's REAL possible values instead of guessing from a
|
|
1360
|
+
* button label. Same per-request, uncached placement as buildPageElements,
|
|
1361
|
+
* for the same reason — this is app-size-scaling detail, not something the
|
|
1362
|
+
* route-independent system prompt should carry. Absent/empty dataShapes
|
|
1363
|
+
* (a page with no explicit-return-typed data call, or a manifest built
|
|
1364
|
+
* before this field existed) degrades to "none", same shape as
|
|
1365
|
+
* buildPageElements' own no-elements case — never a crash, never invented.
|
|
1366
|
+
*/
|
|
1367
|
+
function buildPageDataShapes(manifest, route) {
|
|
1368
|
+
const page = manifest.pages.find((p) => p.route === route);
|
|
1369
|
+
const shapes = page?.dataShapes;
|
|
1370
|
+
if (!shapes || shapes.length === 0)
|
|
1371
|
+
return "none";
|
|
1372
|
+
return shapes
|
|
1373
|
+
.map((s) => `${s.name} { ${s.fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join(", ")} }`)
|
|
1374
|
+
.join("; ");
|
|
1375
|
+
}
|
|
1376
|
+
/** Deliberately narrower than PlanSchema — no `version`/task `status`,
|
|
1377
|
+
* see PlannerOutputSchema's own doc comment for why those stay
|
|
1378
|
+
* harness-owned rather than something the model is asked to invent. */
|
|
1379
|
+
function buildPlanToolSchema() {
|
|
1380
|
+
return {
|
|
1381
|
+
type: "object",
|
|
1382
|
+
properties: {
|
|
1383
|
+
goal: { type: "string", description: "The real end goal, restated in your own words." },
|
|
1384
|
+
facts: {
|
|
1385
|
+
type: "array",
|
|
1386
|
+
items: { type: "string" },
|
|
1387
|
+
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.",
|
|
1388
|
+
},
|
|
1389
|
+
tasks: {
|
|
1390
|
+
type: "array",
|
|
1391
|
+
minItems: 1,
|
|
1392
|
+
items: {
|
|
1393
|
+
type: "object",
|
|
1394
|
+
properties: {
|
|
1395
|
+
id: { type: "string", description: "A short, stable id for this task, e.g. \"t1\"." },
|
|
1396
|
+
description: { type: "string", description: "What this task achieves, in plain language, concrete enough to act on." },
|
|
1397
|
+
doneContract: {
|
|
1398
|
+
type: "string",
|
|
1399
|
+
description: "What counts as this task being ACTUALLY done, checkable against real state — a real observable outcome, never \"the user is satisfied\" or similar.",
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
required: ["id", "description", "doneContract"],
|
|
1403
|
+
additionalProperties: false,
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
required: ["goal", "facts", "tasks"],
|
|
1408
|
+
additionalProperties: false,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
function buildPlannerSystemPrompt() {
|
|
1412
|
+
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.
|
|
1413
|
+
|
|
1414
|
+
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.
|
|
1415
|
+
|
|
1416
|
+
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.
|
|
1417
|
+
|
|
1418
|
+
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.
|
|
1419
|
+
|
|
1420
|
+
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:
|
|
1421
|
+
- id: a short, stable id, e.g. "t1", "t2".
|
|
1422
|
+
- description: what this task achieves, concrete enough to act on.
|
|
1423
|
+
- 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."
|
|
1424
|
+
|
|
1425
|
+
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.`;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Phase 4 step 3 — the Planner's own version of buildSystemPrompt's route
|
|
1429
|
+
* directory: route + purpose for every page, same page-COUNT-scaled (not
|
|
1430
|
+
* total-content-scaled) budget discipline as that directory's own doc
|
|
1431
|
+
* comment explains (a real production app's full per-page detail on every
|
|
1432
|
+
* request once blew an 8000 TPM provider limit before a single question
|
|
1433
|
+
* was answered — see buildSystemPrompt). For a page with real traced data
|
|
1434
|
+
* shapes (l1-data-shapes.ts), appends just the SHAPE NAMES — never full
|
|
1435
|
+
* field lists, that's what buildPageDataShapes already gives the Executor
|
|
1436
|
+
* once a task narrows down to one specific page — so the Planner knows
|
|
1437
|
+
* e.g. "the /invoices page deals with Invoice-shaped data" without paying
|
|
1438
|
+
* for every field of every shape on every page, on every planning call.
|
|
1439
|
+
*/
|
|
1440
|
+
function buildPlannerPageDirectory(manifest) {
|
|
1441
|
+
return manifest.pages
|
|
1442
|
+
.map((p) => {
|
|
1443
|
+
const shapeNames = p.dataShapes?.map((s) => s.name).join(", ");
|
|
1444
|
+
return shapeNames ? `${p.route}: ${p.purpose} (data: ${shapeNames})` : `${p.route}: ${p.purpose}`;
|
|
1445
|
+
})
|
|
1446
|
+
.join("\n");
|
|
1447
|
+
}
|
|
1448
|
+
function buildCriticToolSchema() {
|
|
1449
|
+
return {
|
|
1450
|
+
type: "object",
|
|
1451
|
+
properties: {
|
|
1452
|
+
verdict: { type: "string", enum: ["continue", "task_complete", "replan", "give_up"] },
|
|
1453
|
+
expected: { type: "string", description: "Only for replan — what SHOULD have happened, per the task's doneContract." },
|
|
1454
|
+
actual: { type: "string", description: "Only for replan — what actually happened instead, per the real observation." },
|
|
1455
|
+
learnedFact: {
|
|
1456
|
+
type: "string",
|
|
1457
|
+
description: "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).",
|
|
1458
|
+
},
|
|
1459
|
+
reasoning: { type: "string", description: "2-3 sentences, specific to what actually happened in THIS step, not generic." },
|
|
1460
|
+
},
|
|
1461
|
+
required: ["verdict", "reasoning"],
|
|
1462
|
+
additionalProperties: false,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function buildCriticSystemPrompt() {
|
|
1466
|
+
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.
|
|
1467
|
+
|
|
1468
|
+
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.
|
|
1469
|
+
|
|
1470
|
+
Score the verdict:
|
|
1471
|
+
- "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.
|
|
1472
|
+
- "continue": real progress happened but the doneContract isn't satisfied yet — more steps are needed on this same task.
|
|
1473
|
+
- "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).
|
|
1474
|
+
- "give_up": repeated real attempts have failed and continuing wouldn't help — be honest about being stuck rather than looping forever.
|
|
1475
|
+
|
|
1476
|
+
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.
|
|
1477
|
+
|
|
1478
|
+
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.`;
|
|
1479
|
+
}
|