@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/verb-executor.ts
CHANGED
|
@@ -6,36 +6,58 @@
|
|
|
6
6
|
// enforces the same schema independently — never trust the client alone.
|
|
7
7
|
|
|
8
8
|
import { VerbResponseSchema, type ApiCall, type BatchAction, type TourStep, type VerbResponse } from "@cairnvibe/core";
|
|
9
|
-
import { findElement, fillElement, highlightElement, logMiss, readElement, type MissContext } from "./element-ladder";
|
|
9
|
+
import { dragElement, findElement, findElementWithRetry, fillElement, highlightElement, logMiss, pressKey, readElement, selectOption, waitForDomSettle, type MissContext } from "./element-ladder";
|
|
10
|
+
import { moveCursorTo } from "./cursor-overlay";
|
|
10
11
|
import { executeWebMcpTool } from "./webmcp-client";
|
|
11
12
|
|
|
12
|
-
/** The real result of one agent-loop step (click/fill/read/call_tool
|
|
13
|
-
* batch of several) — fed back to the model as its next
|
|
14
|
-
* "observation" so it can decide what to do next instead of acting
|
|
15
|
-
* The loop that drives this lives on the caller's side, not here:
|
|
13
|
+
/** The real result of one agent-loop step (click/fill/read/call_tool/
|
|
14
|
+
* navigate, or a batch of several) — fed back to the model as its next
|
|
15
|
+
* turn's "observation" so it can decide what to do next instead of acting
|
|
16
|
+
* blind. The loop that drives this lives on the caller's side, not here:
|
|
16
17
|
* index.tsx's runTypedAgentLoop for the HTTP path, realtime-server.ts's
|
|
17
18
|
* finalizeTurn for the realtime one — this module only ever executes one
|
|
18
19
|
* step (or one batch of steps) at a time. */
|
|
19
20
|
export interface ToolStepResult {
|
|
20
|
-
verb: "click" | "fill" | "read" | "call_tool" | "batch";
|
|
21
|
+
verb: "click" | "fill" | "read" | "call_tool" | "batch" | "navigate" | "drag" | "select" | "key" | "scroll" | "wait_for";
|
|
21
22
|
target?: string;
|
|
22
23
|
ok: boolean;
|
|
23
24
|
observation: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// wait_for's own real, bounded retry budget — longer than
|
|
28
|
+
// findElementWithRetry's own default (2 attempts, 300ms apart, ~300ms
|
|
29
|
+
// total), since this verb exists specifically for "I know something
|
|
30
|
+
// async should show up" — a toast, a panel appearing after a click — not
|
|
31
|
+
// the incidental transient-miss recovery findElementWithRetry's default
|
|
32
|
+
// already covers for click/fill/batch steps.
|
|
33
|
+
const WAIT_FOR_ATTEMPTS = 6;
|
|
34
|
+
const WAIT_FOR_DELAY_MS = 500;
|
|
35
|
+
|
|
26
36
|
/**
|
|
27
37
|
* Promise wrapper around executeVerbResponse for a continuing verb
|
|
28
|
-
* (click/fill/read/call_tool
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
38
|
+
* (click/fill/read/call_tool, or now a navigate marked `continueAfter` —
|
|
39
|
+
* see isTerminalVerb in @cairnvibe/core) — resolves once the real action
|
|
40
|
+
* has actually finished (synchronously for click/fill/read, after a real
|
|
41
|
+
* await for call_tool/navigate) with its real observation, instead of the
|
|
42
|
+
* fire-and-forget callback shape every other verb uses. This is what a
|
|
43
|
+
* loop driver awaits before deciding whether to call the model again.
|
|
44
|
+
* `onNavigate` is only needed for that new navigate-as-continuing-step
|
|
45
|
+
* case — every existing caller that never passes it keeps working
|
|
46
|
+
* unchanged (a continueAfter navigate with no onNavigate here would just
|
|
47
|
+
* never actually move the page; real callers always pass one, same as
|
|
48
|
+
* handleVerb's own options already do for the terminal case).
|
|
33
49
|
*/
|
|
34
|
-
export function executeToolStep(
|
|
50
|
+
export function executeToolStep(
|
|
51
|
+
raw: unknown,
|
|
52
|
+
route: string,
|
|
53
|
+
liveElements?: Map<string, HTMLElement>,
|
|
54
|
+
onNavigate?: (route: string) => void,
|
|
55
|
+
onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>,
|
|
56
|
+
): Promise<ToolStepResult | null> {
|
|
35
57
|
return new Promise((resolve) => {
|
|
36
58
|
// executeVerbResponse only ever reaches onToolStep for a genuinely
|
|
37
59
|
// continuing verb — callers are only expected to call this after
|
|
38
|
-
// already confirming (via
|
|
60
|
+
// already confirming (via isTerminalVerb) that the parsed verb is one,
|
|
39
61
|
// so this should always fire; a real timeout (not an immediate
|
|
40
62
|
// microtask — call_tool's own real network round trip needs the time)
|
|
41
63
|
// is the safety net for the case where it somehow doesn't, so a loop
|
|
@@ -44,6 +66,8 @@ export function executeToolStep(raw: unknown, route: string, liveElements?: Map<
|
|
|
44
66
|
executeVerbResponse(raw, route, {
|
|
45
67
|
onExplain: () => {},
|
|
46
68
|
liveElements,
|
|
69
|
+
onNavigate,
|
|
70
|
+
onConfirmTool,
|
|
47
71
|
onToolStep: (result) => {
|
|
48
72
|
clearTimeout(timer);
|
|
49
73
|
resolve(result);
|
|
@@ -75,6 +99,15 @@ export interface VerbExecutorOptions {
|
|
|
75
99
|
* saw. Absent entirely for a caller that hasn't wired up live scanning.
|
|
76
100
|
*/
|
|
77
101
|
liveElements?: Map<string, HTMLElement>;
|
|
102
|
+
/**
|
|
103
|
+
* Architecture Pillar 6 (the safety layer) — real confirmation for a
|
|
104
|
+
* WebMCP tool whose own registration declared `riskTier: "confirm"`
|
|
105
|
+
* (webmcp-client.ts's own doc comment covers the enforcement point).
|
|
106
|
+
* Absent means every "confirm"-tier tool call is declined by default —
|
|
107
|
+
* the safe fallback for a host app that hasn't wired up a real
|
|
108
|
+
* confirmation UI, never an implicit yes.
|
|
109
|
+
*/
|
|
110
|
+
onConfirmTool?: (tool: { name: string; description: string }) => Promise<boolean>;
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
const FALLBACK_TEXT = "I'm not sure — I couldn't understand that response. Try rephrasing your question.";
|
|
@@ -104,17 +137,46 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
104
137
|
return;
|
|
105
138
|
}
|
|
106
139
|
highlightElement(el);
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
// The cursor visibly arrives before "open"'s real click fires — the
|
|
141
|
+
// whole point is a user watching sees where it's about to click
|
|
142
|
+
// BEFORE the menu/modal/panel actually opens, not simultaneously.
|
|
143
|
+
void moveCursorTo(el).then(() => {
|
|
144
|
+
// "open" means make the thing actually appear (a menu, a modal, a
|
|
145
|
+
// panel) — highlighting alone doesn't do that; a real click does.
|
|
146
|
+
if (verb.verb === "open") el.click();
|
|
147
|
+
if (verb.text) options.onExplain(verb.text);
|
|
148
|
+
});
|
|
111
149
|
return;
|
|
112
150
|
}
|
|
113
151
|
|
|
114
|
-
case "navigate":
|
|
152
|
+
case "navigate": {
|
|
153
|
+
// Real, live-reported gap this closes: navigate used to ALWAYS end
|
|
154
|
+
// the turn the instant it fired, even for a compound goal like "buy
|
|
155
|
+
// earbuds" that needs navigate, then search, then a real report
|
|
156
|
+
// back — see isTerminalVerb's own doc comment in @cairnvibe/core.
|
|
157
|
+
// `options.onToolStep` is only ever set by executeToolStep's own
|
|
158
|
+
// continuing-step wrapper — handleVerb's options never provide it —
|
|
159
|
+
// so this branch can only run when the caller already confirmed
|
|
160
|
+
// (via isTerminalVerb) that this navigate was genuinely marked
|
|
161
|
+
// continueAfter; the defensive `verb.continueAfter` check here is
|
|
162
|
+
// belt-and-suspenders, not the real gate.
|
|
163
|
+
if (verb.continueAfter && options.onToolStep) {
|
|
164
|
+
if (verb.text) options.onExplain(verb.text);
|
|
165
|
+
options.onNavigate?.(verb.route);
|
|
166
|
+
// A client-side route change is itself an async re-render (a new
|
|
167
|
+
// page's whole DOM mounting) — same real race waitForDomSettle
|
|
168
|
+
// already closes for fill/click, arguably more likely here. The
|
|
169
|
+
// NEXT resolveVerb call needs the settled new page's context, not
|
|
170
|
+
// whatever was on screen the instant router.push was called.
|
|
171
|
+
void waitForDomSettle(300, 200, 2000).then(() => {
|
|
172
|
+
options.onToolStep?.({ verb: "navigate", target: verb.route, ok: true, observation: `Navigated to ${verb.route}.` });
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
115
176
|
options.onNavigate?.(verb.route);
|
|
116
177
|
if (verb.text) options.onExplain(verb.text);
|
|
117
178
|
return;
|
|
179
|
+
}
|
|
118
180
|
|
|
119
181
|
case "do": {
|
|
120
182
|
const allowed = options.registeredActions ?? [];
|
|
@@ -142,8 +204,10 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
142
204
|
// on a different page) — never fired in addition to a real
|
|
143
205
|
// click, so the action can't run twice.
|
|
144
206
|
highlightElement(el);
|
|
145
|
-
el.
|
|
146
|
-
|
|
207
|
+
void moveCursorTo(el).then(() => {
|
|
208
|
+
el.click();
|
|
209
|
+
if (verb.text) options.onExplain(verb.text);
|
|
210
|
+
});
|
|
147
211
|
return;
|
|
148
212
|
}
|
|
149
213
|
if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
@@ -188,26 +252,48 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
188
252
|
return;
|
|
189
253
|
}
|
|
190
254
|
highlightElement(el);
|
|
191
|
-
el.
|
|
192
|
-
|
|
255
|
+
void moveCursorTo(el).then(() => {
|
|
256
|
+
el.click();
|
|
257
|
+
// Real, live-found race this closes — see waitForDomSettle's own
|
|
258
|
+
// doc comment: a click can trigger an async re-render (a cart count
|
|
259
|
+
// updating, a filtered list refreshing) that hasn't happened yet the
|
|
260
|
+
// instant .click() returns. A subsequent read step in the same turn
|
|
261
|
+
// needs the SETTLED result, not whatever was on screen a moment ago.
|
|
262
|
+
void waitForDomSettle().then(() => {
|
|
263
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
193
266
|
return;
|
|
194
267
|
}
|
|
195
268
|
|
|
196
269
|
case "fill": {
|
|
197
270
|
if (verb.text) options.onExplain(verb.text);
|
|
198
271
|
const el = findElement(verb.target, options.liveElements);
|
|
199
|
-
if (!el
|
|
272
|
+
if (!el) {
|
|
200
273
|
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
201
|
-
options.onToolStep?.({
|
|
202
|
-
verb: "fill",
|
|
203
|
-
target: verb.target,
|
|
204
|
-
ok: false,
|
|
205
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
206
|
-
});
|
|
274
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
207
275
|
return;
|
|
208
276
|
}
|
|
209
277
|
highlightElement(el);
|
|
210
|
-
|
|
278
|
+
// fillElement itself is both the "is this a real form field" check
|
|
279
|
+
// AND the commit (it sets the value the instant it returns true) —
|
|
280
|
+
// deliberately called from inside this .then(), not the synchronous
|
|
281
|
+
// miss-check above, so the cursor is genuinely seen arriving BEFORE
|
|
282
|
+
// any text appears, not simultaneously with it.
|
|
283
|
+
void moveCursorTo(el).then(() => {
|
|
284
|
+
if (!fillElement(el, verb.value)) {
|
|
285
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
286
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: false, observation: "That element isn't a real form field — can't type into it." });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// See the click case's own comment — the exact real bug found live:
|
|
290
|
+
// typing into a search box, then reading the still-unfiltered
|
|
291
|
+
// results a moment later and reporting a match the real, since-
|
|
292
|
+
// filtered page never actually showed.
|
|
293
|
+
void waitForDomSettle().then(() => {
|
|
294
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
211
297
|
return;
|
|
212
298
|
}
|
|
213
299
|
|
|
@@ -219,18 +305,137 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
219
305
|
options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
220
306
|
return;
|
|
221
307
|
}
|
|
222
|
-
|
|
308
|
+
// No mutation here, but the cursor still visits what's being read —
|
|
309
|
+
// real visual proof of what the agent is actually looking at, not
|
|
310
|
+
// just a claim in the eventual reported observation.
|
|
311
|
+
void moveCursorTo(el).then(() => {
|
|
312
|
+
options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
|
|
313
|
+
});
|
|
223
314
|
return;
|
|
224
315
|
}
|
|
225
316
|
|
|
226
317
|
case "call_tool": {
|
|
227
318
|
if (verb.text) options.onExplain(verb.text);
|
|
228
|
-
void executeWebMcpTool(verb.name, verb.args).then((result) => {
|
|
319
|
+
void executeWebMcpTool(verb.name, verb.args, options.onConfirmTool).then((result) => {
|
|
229
320
|
options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
|
|
230
321
|
});
|
|
231
322
|
return;
|
|
232
323
|
}
|
|
233
324
|
|
|
325
|
+
case "drag": {
|
|
326
|
+
if (verb.text) options.onExplain(verb.text);
|
|
327
|
+
const from = findElement(verb.target, options.liveElements);
|
|
328
|
+
const to = from ? findElement(verb.to, options.liveElements) : null;
|
|
329
|
+
if (!from || !to) {
|
|
330
|
+
(options.onMiss ?? logMiss)({ attempted: from ? verb.to : verb.target, route });
|
|
331
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
highlightElement(from);
|
|
335
|
+
void moveCursorTo(from).then(() => {
|
|
336
|
+
dragElement(from, to);
|
|
337
|
+
// The cursor also glides to the drop point — fire-and-forget, not
|
|
338
|
+
// awaited, since it's purely a visual echo of a drag that already
|
|
339
|
+
// happened via real pointer events; nothing downstream depends on
|
|
340
|
+
// it finishing.
|
|
341
|
+
void moveCursorTo(to);
|
|
342
|
+
// Same real re-render race as click/fill — a drop can trigger an
|
|
343
|
+
// async re-render (a canvas connection line, a reordered list) that
|
|
344
|
+
// hasn't settled the instant the pointer sequence finishes.
|
|
345
|
+
void waitForDomSettle().then(() => {
|
|
346
|
+
options.onToolStep?.({ verb: "drag", target: verb.target, ok: true, observation: `Dragged it to ${verb.to}.` });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case "select": {
|
|
353
|
+
if (verb.text) options.onExplain(verb.text);
|
|
354
|
+
const el = findElement(verb.target, options.liveElements);
|
|
355
|
+
if (!el) {
|
|
356
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
357
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
highlightElement(el);
|
|
361
|
+
// Same reasoning as fill's own comment — selectOption is both the
|
|
362
|
+
// "does a matching option exist" check and the commit, so it's called
|
|
363
|
+
// from inside this .then(), after the cursor genuinely arrives.
|
|
364
|
+
void moveCursorTo(el).then(() => {
|
|
365
|
+
if (!selectOption(el, verb.value)) {
|
|
366
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
367
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: false, observation: `Could not find an option matching "${verb.value}".` });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
void waitForDomSettle().then(() => {
|
|
371
|
+
options.onToolStep?.({ verb: "select", target: verb.target, ok: true, observation: `Selected "${verb.value}".` });
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "key": {
|
|
378
|
+
if (verb.text) options.onExplain(verb.text);
|
|
379
|
+
const el = verb.target ? findElement(verb.target, options.liveElements) : (document.activeElement as HTMLElement | null);
|
|
380
|
+
if (!el) {
|
|
381
|
+
if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
382
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const afterMove = () => {
|
|
386
|
+
pressKey(el, verb.key);
|
|
387
|
+
void waitForDomSettle().then(() => {
|
|
388
|
+
options.onToolStep?.({ verb: "key", target: verb.target, ok: true, observation: `Pressed ${verb.key}.` });
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
// With no explicit target ("whatever's currently focused"), there's
|
|
392
|
+
// nothing sensible for the cursor to glide to — call straight through
|
|
393
|
+
// instead of routing through a promise callback for no reason, so
|
|
394
|
+
// this path stays exactly as synchronous as it always was.
|
|
395
|
+
if (verb.target) {
|
|
396
|
+
void moveCursorTo(el).then(afterMove);
|
|
397
|
+
} else {
|
|
398
|
+
afterMove();
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case "scroll": {
|
|
404
|
+
if (verb.text) options.onExplain(verb.text);
|
|
405
|
+
const el = findElement(verb.target, options.liveElements);
|
|
406
|
+
if (!el) {
|
|
407
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
408
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// A real, already-known element (never a coordinate or something
|
|
412
|
+
// not yet discovered) — highlightElement's own scrollIntoView is
|
|
413
|
+
// exactly the real repositioning this verb exists for; the glow
|
|
414
|
+
// also gives the user a visible cue of where the agent just moved.
|
|
415
|
+
highlightElement(el);
|
|
416
|
+
void moveCursorTo(el).then(() => {
|
|
417
|
+
void waitForDomSettle().then(() => {
|
|
418
|
+
options.onToolStep?.({ verb: "scroll", target: verb.target, ok: true, observation: "Scrolled it into view." });
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case "wait_for": {
|
|
425
|
+
if (verb.text) options.onExplain(verb.text);
|
|
426
|
+
void findElementWithRetry(verb.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS).then((el) => {
|
|
427
|
+
if (!el) {
|
|
428
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
429
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: false, observation: "It never appeared." });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
void moveCursorTo(el).then(() => {
|
|
433
|
+
options.onToolStep?.({ verb: "wait_for", target: verb.target, ok: true, observation: "It appeared." });
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
234
439
|
// Several click/fill/read/call_tool steps in one round trip instead of
|
|
235
440
|
// one each — server.ts's resolveVerb already validated every action's
|
|
236
441
|
// target/name against real state before this ever arrived. Runs in
|
|
@@ -257,48 +462,130 @@ async function executeBatchActions(
|
|
|
257
462
|
const steps: string[] = [];
|
|
258
463
|
for (const action of actions) {
|
|
259
464
|
const result = await executeOneBatchAction(action, route, options);
|
|
260
|
-
|
|
465
|
+
const label = ("target" in action && action.target) || ("name" in action && action.name) || "(focused element)";
|
|
466
|
+
steps.push(`${action.verb} ${label}: ${result.observation}`);
|
|
261
467
|
if (!result.ok) return { ok: false, observation: steps.join(" | ") };
|
|
262
468
|
}
|
|
263
469
|
return { ok: true, observation: steps.join(" | ") };
|
|
264
470
|
}
|
|
265
471
|
|
|
472
|
+
// Phase 3 step 4 — real, bounded, LLM-free retry latitude for the
|
|
473
|
+
// Executor's own lookups (CODA's own point: the Executor stays
|
|
474
|
+
// opinion-free; anything requiring judgment escalates to the Critic,
|
|
475
|
+
// which now genuinely exists as of step 3). Scoped to batch specifically,
|
|
476
|
+
// per the plan's own build order — a batch's later steps are the ones
|
|
477
|
+
// most likely to race a DOM update the batch's OWN earlier step just
|
|
478
|
+
// triggered, which is exactly the "stale re-render" case this recovers
|
|
479
|
+
// from; single-step click/fill/read stay unchanged (findElement, no
|
|
480
|
+
// retry) rather than widening scope beyond what was actually planned.
|
|
266
481
|
async function executeOneBatchAction(action: BatchAction, route: string, options: VerbExecutorOptions): Promise<{ ok: boolean; observation: string }> {
|
|
267
482
|
switch (action.verb) {
|
|
268
483
|
case "click": {
|
|
269
|
-
const el =
|
|
484
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
270
485
|
if (!el) {
|
|
271
486
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
272
487
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
273
488
|
}
|
|
274
489
|
highlightElement(el);
|
|
490
|
+
await moveCursorTo(el);
|
|
275
491
|
el.click();
|
|
492
|
+
// Same real race as the single-step case (see waitForDomSettle's own
|
|
493
|
+
// doc comment) — arguably MORE likely here, since a batch's next
|
|
494
|
+
// step often deliberately reads what THIS step just changed.
|
|
495
|
+
await waitForDomSettle();
|
|
276
496
|
return { ok: true, observation: "Clicked it." };
|
|
277
497
|
}
|
|
278
498
|
case "fill": {
|
|
279
|
-
const el =
|
|
280
|
-
if (!el
|
|
499
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
500
|
+
if (!el) {
|
|
281
501
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
282
|
-
return {
|
|
283
|
-
ok: false,
|
|
284
|
-
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
285
|
-
};
|
|
502
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
286
503
|
}
|
|
287
504
|
highlightElement(el);
|
|
505
|
+
// See the single-step fill case's own comment — fillElement is both
|
|
506
|
+
// the field-type check and the commit, called after the cursor
|
|
507
|
+
// genuinely arrives rather than in the synchronous miss-check.
|
|
508
|
+
await moveCursorTo(el);
|
|
509
|
+
if (!fillElement(el, action.value)) {
|
|
510
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
511
|
+
return { ok: false, observation: "That element isn't a real form field — can't type into it." };
|
|
512
|
+
}
|
|
513
|
+
await waitForDomSettle();
|
|
288
514
|
return { ok: true, observation: `Typed "${action.value}" into it.` };
|
|
289
515
|
}
|
|
290
516
|
case "read": {
|
|
291
|
-
const el =
|
|
517
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
292
518
|
if (!el) {
|
|
293
519
|
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
294
520
|
return { ok: false, observation: "Could not find that element on the page." };
|
|
295
521
|
}
|
|
522
|
+
await moveCursorTo(el);
|
|
296
523
|
return { ok: true, observation: readElement(el) };
|
|
297
524
|
}
|
|
298
525
|
case "call_tool": {
|
|
299
|
-
const result = await executeWebMcpTool(action.name, action.args);
|
|
526
|
+
const result = await executeWebMcpTool(action.name, action.args, options.onConfirmTool);
|
|
300
527
|
return { ok: result.ok, observation: result.observation };
|
|
301
528
|
}
|
|
529
|
+
case "drag": {
|
|
530
|
+
const from = await findElementWithRetry(action.target, options.liveElements);
|
|
531
|
+
const to = from ? await findElementWithRetry(action.to, options.liveElements) : null;
|
|
532
|
+
if (!from || !to) {
|
|
533
|
+
(options.onMiss ?? logMiss)({ attempted: from ? action.to : action.target, route });
|
|
534
|
+
return { ok: false, observation: from ? "Could not find the drop destination on the page." : "Could not find that element on the page." };
|
|
535
|
+
}
|
|
536
|
+
highlightElement(from);
|
|
537
|
+
await moveCursorTo(from);
|
|
538
|
+
dragElement(from, to);
|
|
539
|
+
void moveCursorTo(to); // visual echo of the drop point — not awaited, purely decorative
|
|
540
|
+
await waitForDomSettle();
|
|
541
|
+
return { ok: true, observation: `Dragged it to ${action.to}.` };
|
|
542
|
+
}
|
|
543
|
+
case "select": {
|
|
544
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
545
|
+
if (!el) {
|
|
546
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
547
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
548
|
+
}
|
|
549
|
+
highlightElement(el);
|
|
550
|
+
await moveCursorTo(el);
|
|
551
|
+
if (!selectOption(el, action.value)) {
|
|
552
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
553
|
+
return { ok: false, observation: `Could not find an option matching "${action.value}".` };
|
|
554
|
+
}
|
|
555
|
+
await waitForDomSettle();
|
|
556
|
+
return { ok: true, observation: `Selected "${action.value}".` };
|
|
557
|
+
}
|
|
558
|
+
case "key": {
|
|
559
|
+
const el = action.target ? await findElementWithRetry(action.target, options.liveElements) : (document.activeElement as HTMLElement | null);
|
|
560
|
+
if (!el) {
|
|
561
|
+
if (action.target) (options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
562
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
563
|
+
}
|
|
564
|
+
if (action.target) await moveCursorTo(el);
|
|
565
|
+
pressKey(el, action.key);
|
|
566
|
+
await waitForDomSettle();
|
|
567
|
+
return { ok: true, observation: `Pressed ${action.key}.` };
|
|
568
|
+
}
|
|
569
|
+
case "scroll": {
|
|
570
|
+
const el = await findElementWithRetry(action.target, options.liveElements);
|
|
571
|
+
if (!el) {
|
|
572
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
573
|
+
return { ok: false, observation: "Could not find that element on the page." };
|
|
574
|
+
}
|
|
575
|
+
highlightElement(el);
|
|
576
|
+
await moveCursorTo(el);
|
|
577
|
+
await waitForDomSettle();
|
|
578
|
+
return { ok: true, observation: "Scrolled it into view." };
|
|
579
|
+
}
|
|
580
|
+
case "wait_for": {
|
|
581
|
+
const el = await findElementWithRetry(action.target, options.liveElements, WAIT_FOR_ATTEMPTS, WAIT_FOR_DELAY_MS);
|
|
582
|
+
if (!el) {
|
|
583
|
+
(options.onMiss ?? logMiss)({ attempted: action.target, route });
|
|
584
|
+
return { ok: false, observation: "It never appeared." };
|
|
585
|
+
}
|
|
586
|
+
await moveCursorTo(el);
|
|
587
|
+
return { ok: true, observation: "It appeared." };
|
|
588
|
+
}
|
|
302
589
|
}
|
|
303
590
|
}
|
|
304
591
|
|