@cairnvibe/sdk 0.2.6 → 0.2.8
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/cairn-widget.js +2 -2
- package/dist/element-ladder.d.ts +16 -1
- package/dist/element-ladder.js +51 -3
- package/dist/index.js +198 -21
- package/dist/realtime-server.d.ts +4 -2
- package/dist/realtime-server.js +158 -31
- package/dist/runtime-scan.d.ts +36 -0
- package/dist/runtime-scan.js +143 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +149 -58
- package/dist/verb-executor.d.ts +31 -0
- package/dist/verb-executor.js +124 -17
- package/dist/webmcp-client.d.ts +13 -0
- package/dist/webmcp-client.js +70 -0
- package/package.json +1 -1
- package/src/element-ladder.ts +51 -3
- package/src/index.tsx +224 -27
- package/src/realtime-server.ts +161 -31
- package/src/runtime-scan.ts +171 -0
- package/src/server.ts +161 -59
- package/src/verb-executor.ts +146 -17
- package/src/webmcp-client.ts +79 -0
package/src/verb-executor.ts
CHANGED
|
@@ -6,7 +6,50 @@
|
|
|
6
6
|
// enforces the same schema independently — never trust the client alone.
|
|
7
7
|
|
|
8
8
|
import { VerbResponseSchema, type ApiCall, type TourStep, type VerbResponse } from "@cairnvibe/core";
|
|
9
|
-
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
9
|
+
import { findElement, fillElement, highlightElement, logMiss, readElement, type MissContext } from "./element-ladder";
|
|
10
|
+
import { executeWebMcpTool } from "./webmcp-client";
|
|
11
|
+
|
|
12
|
+
/** The real result of one agent-loop step (click/fill/read/call_tool) —
|
|
13
|
+
* fed back to the model as its next turn's "observation" so it can decide
|
|
14
|
+
* what to do next instead of acting blind. The loop that drives this lives
|
|
15
|
+
* on the caller's side, not here: index.tsx's runTypedAgentLoop for the
|
|
16
|
+
* HTTP path, realtime-server.ts's finalizeTurn for the realtime one — this
|
|
17
|
+
* module only ever executes one step at a time. */
|
|
18
|
+
export interface ToolStepResult {
|
|
19
|
+
verb: "click" | "fill" | "read" | "call_tool";
|
|
20
|
+
target?: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
observation: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Promise wrapper around executeVerbResponse for a continuing verb
|
|
27
|
+
* (click/fill/read/call_tool) — resolves once the real action has actually
|
|
28
|
+
* finished (synchronously for click/fill/read, after a real await for
|
|
29
|
+
* call_tool) with its real observation, instead of the fire-and-forget
|
|
30
|
+
* callback shape every other verb uses. This is what a loop driver awaits
|
|
31
|
+
* before deciding whether to call the model again.
|
|
32
|
+
*/
|
|
33
|
+
export function executeToolStep(raw: unknown, route: string, liveElements?: Map<string, HTMLElement>): Promise<ToolStepResult | null> {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
// executeVerbResponse only ever reaches onToolStep for a genuinely
|
|
36
|
+
// continuing verb — callers are only expected to call this after
|
|
37
|
+
// already confirming (via TERMINAL_VERBS) that the parsed verb is one,
|
|
38
|
+
// so this should always fire; a real timeout (not an immediate
|
|
39
|
+
// microtask — call_tool's own real network round trip needs the time)
|
|
40
|
+
// is the safety net for the case where it somehow doesn't, so a loop
|
|
41
|
+
// driver awaiting this can never hang forever.
|
|
42
|
+
const timer = setTimeout(() => resolve(null), 15000);
|
|
43
|
+
executeVerbResponse(raw, route, {
|
|
44
|
+
onExplain: () => {},
|
|
45
|
+
liveElements,
|
|
46
|
+
onToolStep: (result) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
resolve(result);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
10
53
|
|
|
11
54
|
export interface VerbExecutorOptions {
|
|
12
55
|
onExplain: (text: string) => void;
|
|
@@ -19,8 +62,18 @@ export interface VerbExecutorOptions {
|
|
|
19
62
|
* owns the UI (progress display) and, for voice, the TTS sequencing.
|
|
20
63
|
*/
|
|
21
64
|
onTour?: (steps: TourStep[]) => void;
|
|
65
|
+
/** A click/fill/read/call_tool step finished — see ToolStepResult. Only
|
|
66
|
+
* called for the agent loop's continuing verbs, never the terminal ones. */
|
|
67
|
+
onToolStep?: (result: ToolStepResult) => void;
|
|
22
68
|
/** Action ids the customer has actually wired up. "do" is rejected for anything else. */
|
|
23
69
|
registeredActions?: string[];
|
|
70
|
+
/**
|
|
71
|
+
* This turn's frozen runtime-scan.ts snapshot (id -> real element),
|
|
72
|
+
* checked before the static data-ai/aria-label/text ladder — lets a verb
|
|
73
|
+
* target a dynamically-rendered element (a list row) the manifest never
|
|
74
|
+
* saw. Absent entirely for a caller that hasn't wired up live scanning.
|
|
75
|
+
*/
|
|
76
|
+
liveElements?: Map<string, HTMLElement>;
|
|
24
77
|
}
|
|
25
78
|
|
|
26
79
|
const FALLBACK_TEXT = "I'm not sure — I couldn't understand that response. Try rephrasing your question.";
|
|
@@ -43,13 +96,16 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
43
96
|
|
|
44
97
|
case "highlight":
|
|
45
98
|
case "open": {
|
|
46
|
-
const el = findElement(verb.target);
|
|
99
|
+
const el = findElement(verb.target, options.liveElements);
|
|
47
100
|
if (!el) {
|
|
48
101
|
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
49
102
|
options.onExplain(verb.text ?? "I know what you need, but I can't find it on this page right now.");
|
|
50
103
|
return;
|
|
51
104
|
}
|
|
52
105
|
highlightElement(el);
|
|
106
|
+
// "open" means make the thing actually appear (a menu, a modal, a
|
|
107
|
+
// panel) — highlighting alone doesn't do that; a real click does.
|
|
108
|
+
if (verb.verb === "open") el.click();
|
|
53
109
|
if (verb.text) options.onExplain(verb.text);
|
|
54
110
|
return;
|
|
55
111
|
}
|
|
@@ -68,22 +124,38 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
68
124
|
return;
|
|
69
125
|
}
|
|
70
126
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const el = verb.target ? findElement(verb.target) : null;
|
|
77
|
-
if (el)
|
|
78
|
-
|
|
127
|
+
// Auto-discovered path: the server already verified `target` names a
|
|
128
|
+
// real element (the static manifest or this exact request's own
|
|
129
|
+
// live-DOM scan) before ever returning this verb — never something
|
|
130
|
+
// the model invented (see resolveVerb in server.ts).
|
|
131
|
+
if (verb.target || verb.apiCall) {
|
|
132
|
+
const el = verb.target ? findElement(verb.target, options.liveElements) : null;
|
|
133
|
+
if (el) {
|
|
134
|
+
// Click-first: the real element's own handler runs in full (any
|
|
135
|
+
// local state update, spinner, or non-network side effect a raw
|
|
136
|
+
// fetch would silently skip), and it's the only way to fire an
|
|
137
|
+
// action that has no fetch/axios call at all — a button that
|
|
138
|
+
// just reveals a form, e.g. — which never gets an `apiCall` in
|
|
139
|
+
// the first place. `apiCall` is only ever the fallback below,
|
|
140
|
+
// for a target that can't be resolved live right now (e.g. it's
|
|
141
|
+
// on a different page) — never fired in addition to a real
|
|
142
|
+
// click, so the action can't run twice.
|
|
143
|
+
highlightElement(el);
|
|
144
|
+
el.click();
|
|
145
|
+
if (verb.text) options.onExplain(verb.text);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (verb.target) (options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
79
149
|
|
|
80
|
-
if (verb.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
150
|
+
if (verb.apiCall) {
|
|
151
|
+
if (verb.text) options.onExplain(verb.text);
|
|
152
|
+
void executeApiCall(verb.apiCall).then((result) => {
|
|
153
|
+
if (!result.ok) {
|
|
154
|
+
options.onExplain("I tried to do that, but something went wrong — try again in a moment.");
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
87
159
|
}
|
|
88
160
|
|
|
89
161
|
options.onExplain(verb.text ?? "That action isn't available here.");
|
|
@@ -100,6 +172,63 @@ function dispatchVerb(verb: VerbResponse, route: string, options: VerbExecutorOp
|
|
|
100
172
|
options.onExplain(verb.steps.map((s) => s.text).join(" "));
|
|
101
173
|
}
|
|
102
174
|
return;
|
|
175
|
+
|
|
176
|
+
// The agent loop's steps (server.ts's runAgentLoop) — each executes for
|
|
177
|
+
// real and reports a real observation back via onToolStep, instead of
|
|
178
|
+
// ending the turn the way every verb above does. `target` for these
|
|
179
|
+
// always came from the manifest/currentPageElements/liveElements this
|
|
180
|
+
// exact turn showed the model — never invented, same invariant as do.
|
|
181
|
+
case "click": {
|
|
182
|
+
if (verb.text) options.onExplain(verb.text);
|
|
183
|
+
const el = findElement(verb.target, options.liveElements);
|
|
184
|
+
if (!el) {
|
|
185
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
186
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
highlightElement(el);
|
|
190
|
+
el.click();
|
|
191
|
+
options.onToolStep?.({ verb: "click", target: verb.target, ok: true, observation: "Clicked it." });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case "fill": {
|
|
196
|
+
if (verb.text) options.onExplain(verb.text);
|
|
197
|
+
const el = findElement(verb.target, options.liveElements);
|
|
198
|
+
if (!el || !fillElement(el, verb.value)) {
|
|
199
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
200
|
+
options.onToolStep?.({
|
|
201
|
+
verb: "fill",
|
|
202
|
+
target: verb.target,
|
|
203
|
+
ok: false,
|
|
204
|
+
observation: el ? "That element isn't a real form field — can't type into it." : "Could not find that element on the page.",
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
highlightElement(el);
|
|
209
|
+
options.onToolStep?.({ verb: "fill", target: verb.target, ok: true, observation: `Typed "${verb.value}" into it.` });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "read": {
|
|
214
|
+
if (verb.text) options.onExplain(verb.text);
|
|
215
|
+
const el = findElement(verb.target, options.liveElements);
|
|
216
|
+
if (!el) {
|
|
217
|
+
(options.onMiss ?? logMiss)({ attempted: verb.target, route });
|
|
218
|
+
options.onToolStep?.({ verb: "read", target: verb.target, ok: false, observation: "Could not find that element on the page." });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
options.onToolStep?.({ verb: "read", target: verb.target, ok: true, observation: readElement(el) });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case "call_tool": {
|
|
226
|
+
if (verb.text) options.onExplain(verb.text);
|
|
227
|
+
void executeWebMcpTool(verb.name, verb.args).then((result) => {
|
|
228
|
+
options.onToolStep?.({ verb: "call_tool", target: verb.name, ok: result.ok, observation: result.observation });
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
103
232
|
}
|
|
104
233
|
}
|
|
105
234
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Discovers and calls real tools a page has registered via WebMCP
|
|
2
|
+
// (https://webmachinelearning.github.io/webmcp/) — an in-progress web
|
|
3
|
+
// standard letting a site expose its own functions as typed, parameterized
|
|
4
|
+
// tools ("document.modelContext.registerTool(...)"), running in the page's
|
|
5
|
+
// own JS with the user's real session. This is the highest-trust action
|
|
6
|
+
// source there is: a real function the app's own developer wrote, with a
|
|
7
|
+
// real return value — not a click simulated from static analysis. Almost
|
|
8
|
+
// no site has adopted it yet, so this is deliberately a no-op (empty list,
|
|
9
|
+
// nothing to call) everywhere it isn't present, not a hard dependency.
|
|
10
|
+
|
|
11
|
+
import type { WebMcpTool } from "@cairnvibe/core";
|
|
12
|
+
|
|
13
|
+
interface ModelContextTool {
|
|
14
|
+
name: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
inputSchema?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ModelContext {
|
|
20
|
+
getTools?: () => Promise<ModelContextTool[]> | ModelContextTool[];
|
|
21
|
+
executeTool?: (tool: ModelContextTool, args: unknown) => Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getModelContext(): ModelContext | null {
|
|
25
|
+
if (typeof document === "undefined") return null;
|
|
26
|
+
return (document as unknown as { modelContext?: ModelContext }).modelContext ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Bounded the same way LiveElementSchema/CopilotRequestSchema bound the
|
|
30
|
+
* live DOM scan — a hard cap on both count and description length, so a
|
|
31
|
+
* page that registers an unreasonable number of tools (or one with a huge
|
|
32
|
+
* description) can't blow the request payload or the prompt. */
|
|
33
|
+
const MAX_TOOLS = 30;
|
|
34
|
+
const MAX_DESCRIPTION_LENGTH = 500;
|
|
35
|
+
|
|
36
|
+
export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
|
|
37
|
+
const modelContext = getModelContext();
|
|
38
|
+
if (!modelContext?.getTools) return [];
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const tools = await modelContext.getTools();
|
|
42
|
+
if (!Array.isArray(tools)) return [];
|
|
43
|
+
return tools.slice(0, MAX_TOOLS).map((tool) => ({
|
|
44
|
+
name: String(tool.name),
|
|
45
|
+
description: String(tool.description ?? "").slice(0, MAX_DESCRIPTION_LENGTH),
|
|
46
|
+
inputSchema: tool.inputSchema,
|
|
47
|
+
}));
|
|
48
|
+
} catch {
|
|
49
|
+
// A page's own registerTool()/getTools() implementation throwing is
|
|
50
|
+
// that page's bug, not Cairn's — degrade to "no WebMCP tools" rather
|
|
51
|
+
// than breaking the rest of the turn.
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calls a real WebMCP tool by name — `name` must be one the model was
|
|
58
|
+
* actually shown this turn (server.ts only ever includes tools from this
|
|
59
|
+
* exact request's own discoverWebMcpTools() call), never invented.
|
|
60
|
+
* Returns a plain-text observation for the agent loop to reason about
|
|
61
|
+
* next, the same shape a click/fill/read result already takes.
|
|
62
|
+
*/
|
|
63
|
+
export async function executeWebMcpTool(name: string, args: Record<string, unknown> | undefined): Promise<{ ok: boolean; observation: string }> {
|
|
64
|
+
const modelContext = getModelContext();
|
|
65
|
+
if (!modelContext?.getTools || !modelContext.executeTool) {
|
|
66
|
+
return { ok: false, observation: "This page no longer has that tool available." };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const tools = await modelContext.getTools();
|
|
70
|
+
const tool = Array.isArray(tools) ? tools.find((t) => t.name === name) : undefined;
|
|
71
|
+
if (!tool) return { ok: false, observation: `No tool named "${name}" is available on this page right now.` };
|
|
72
|
+
|
|
73
|
+
const result = await modelContext.executeTool(tool, args ?? {});
|
|
74
|
+
const observation = typeof result === "string" ? result : JSON.stringify(result ?? null);
|
|
75
|
+
return { ok: true, observation: observation.slice(0, 2000) };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { ok: false, observation: `That tool failed: ${err instanceof Error ? err.message : "unknown error"}` };
|
|
78
|
+
}
|
|
79
|
+
}
|