@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/web-component.ts
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
import type { HistoryTurn as HistoryEntry, TourStep } from "@cairnvibe/core";
|
|
25
25
|
import { collectVisible } from "./context-collector";
|
|
26
26
|
import { findElement, highlightElement, logMiss, type MissContext } from "./element-ladder";
|
|
27
|
+
import { hideCursor } from "./cursor-overlay";
|
|
27
28
|
import { executeVerbResponse } from "./verb-executor";
|
|
29
|
+
import { createBargeInGate, createVadDetector } from "./vad";
|
|
28
30
|
|
|
29
31
|
type Status = "idle" | "asking" | "recording" | "rt-connecting" | "rt-listening" | "rt-thinking" | "rt-speaking";
|
|
30
32
|
|
|
@@ -55,9 +57,13 @@ const STYLES = `
|
|
|
55
57
|
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
|
56
58
|
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
|
57
59
|
}
|
|
58
|
-
@keyframes cairn-pulse-
|
|
59
|
-
0%, 100% { box-shadow: 0 0 0 0 rgba(
|
|
60
|
-
70% { box-shadow: 0 0 0 10px rgba(
|
|
60
|
+
@keyframes cairn-pulse-ember {
|
|
61
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.4); }
|
|
62
|
+
70% { box-shadow: 0 0 0 10px rgba(224, 122, 63, 0); }
|
|
63
|
+
}
|
|
64
|
+
@keyframes cairn-cursor-arrive {
|
|
65
|
+
0% { box-shadow: 0 0 0 0 rgba(224, 122, 63, 0.55); }
|
|
66
|
+
100% { box-shadow: 0 0 0 9px rgba(224, 122, 63, 0); }
|
|
61
67
|
}
|
|
62
68
|
@keyframes cairn-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
63
69
|
@keyframes cairn-rt-dot {
|
|
@@ -74,7 +80,7 @@ const STYLES = `
|
|
|
74
80
|
}
|
|
75
81
|
@keyframes cairn-word-sweep {
|
|
76
82
|
0% { opacity: 0.35; text-shadow: none; }
|
|
77
|
-
35% { opacity: 1; color: #
|
|
83
|
+
35% { opacity: 1; color: #E07A3F; text-shadow: 0 0 10px rgba(224, 122, 63, 0.45); }
|
|
78
84
|
100% { opacity: 1; color: inherit; text-shadow: none; }
|
|
79
85
|
}
|
|
80
86
|
@keyframes cairn-thinking-bounce {
|
|
@@ -82,14 +88,15 @@ const STYLES = `
|
|
|
82
88
|
40% { opacity: 0.9; transform: translateY(-3px); }
|
|
83
89
|
}
|
|
84
90
|
.cairn-glow {
|
|
85
|
-
animation: cairn-pulse-
|
|
86
|
-
outline: 2px solid #
|
|
91
|
+
animation: cairn-pulse-ember 1.1s ease-out 2;
|
|
92
|
+
outline: 2px solid #E07A3F;
|
|
87
93
|
outline-offset: 3px;
|
|
88
94
|
border-radius: 8px;
|
|
89
95
|
}
|
|
90
96
|
.cairn-spin { animation: cairn-spin 0.8s linear infinite; }
|
|
97
|
+
.cairn-cursor-hover { animation: cairn-cursor-arrive 0.3s ease-out; }
|
|
91
98
|
@media (prefers-reduced-motion: reduce) {
|
|
92
|
-
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot {
|
|
99
|
+
.cairn-fab, .cairn-panel, .cairn-bubble, .cairn-word, .cairn-thinking-dot, #cairn-cursor {
|
|
93
100
|
animation: none !important;
|
|
94
101
|
transition: none !important;
|
|
95
102
|
}
|
|
@@ -333,6 +340,15 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
333
340
|
private rtMicMuted = false;
|
|
334
341
|
private rtSpeakerMuted = false;
|
|
335
342
|
private rtStarting = false; // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
|
|
343
|
+
// Generation of the most recent "final" this client has processed — see
|
|
344
|
+
// ServerMessage's own doc comment in realtime-server.ts (index.tsx
|
|
345
|
+
// carries the same fix, ported here) for the real, live-found race this
|
|
346
|
+
// closes: a locally-triggered barge-in can start a new turn before an
|
|
347
|
+
// earlier turn's own verb/audio, already in flight when the server
|
|
348
|
+
// processed the barge-in, actually arrives. isStaleRtMessage() drops
|
|
349
|
+
// anything older than this instead of applying it to whatever caption
|
|
350
|
+
// is now current.
|
|
351
|
+
private rtLastFinalGeneration = 0;
|
|
336
352
|
private rtPlaybackCtx: AudioContext | null = null;
|
|
337
353
|
private rtPlaybackGain: GainNode | null = null;
|
|
338
354
|
private rtNextPlayTime = 0;
|
|
@@ -363,6 +379,17 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
363
379
|
if (name === "persona" && this.panel) this.panel.setAttribute("aria-label", `${this.persona} help panel`);
|
|
364
380
|
}
|
|
365
381
|
|
|
382
|
+
// Same real gap index.tsx's own unmount-safety-net closes, ported here:
|
|
383
|
+
// without this, removing the element from the DOM (an SPA route change,
|
|
384
|
+
// conditional rendering) while a realtime call is open left the
|
|
385
|
+
// WebSocket, the open mic stream, and both AudioContexts running
|
|
386
|
+
// orphaned — a zombie connection that keeps transcribing and replying
|
|
387
|
+
// in parallel with whatever comes next.
|
|
388
|
+
disconnectedCallback() {
|
|
389
|
+
if (this.rtSocket || this.rtCleanup) this.endRealtime();
|
|
390
|
+
hideCursor();
|
|
391
|
+
}
|
|
392
|
+
|
|
366
393
|
// --- attributes -----------------------------------------------------
|
|
367
394
|
private get endpoint(): string { return this.getAttribute("endpoint") ?? "/api/copilot"; }
|
|
368
395
|
private get speakEndpoint(): string | null { return this.getAttribute("speak-endpoint"); }
|
|
@@ -541,6 +568,7 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
541
568
|
this.fab.innerHTML = this.isOpen ? CLOSE_ICON : MARK_ICON;
|
|
542
569
|
this.fab.setAttribute("aria-label", this.isOpen ? `Close ${this.persona} help` : `Open ${this.persona} help`);
|
|
543
570
|
if (this.isOpen && !this.realtimeActive) this.inputEl.focus();
|
|
571
|
+
if (!this.isOpen) hideCursor();
|
|
544
572
|
}
|
|
545
573
|
|
|
546
574
|
// --- rendering ------------------------------------------------------
|
|
@@ -907,6 +935,13 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
907
935
|
// Real-time voice conversation
|
|
908
936
|
// ---------------------------------------------------------------------
|
|
909
937
|
|
|
938
|
+
/** See rtLastFinalGeneration's own doc comment. A message with no
|
|
939
|
+
* generation field at all is treated as current rather than dropped —
|
|
940
|
+
* additive/backward-compatible against a server predating this fix. */
|
|
941
|
+
private isStaleRtMessage(msg: { generation?: unknown }): boolean {
|
|
942
|
+
return typeof msg.generation === "number" && msg.generation < this.rtLastFinalGeneration;
|
|
943
|
+
}
|
|
944
|
+
|
|
910
945
|
private async startRealtime() {
|
|
911
946
|
// rtStarting closes the gap between click and the first status update
|
|
912
947
|
// landing — without it a rapid double-click could race past the
|
|
@@ -946,6 +981,12 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
946
981
|
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
|
|
947
982
|
const silence = audioCtx.createGain();
|
|
948
983
|
silence.gain.value = 0;
|
|
984
|
+
const bargeInVad = createVadDetector();
|
|
985
|
+
// See index.tsx's own doc comment on its matching bargeInGate for
|
|
986
|
+
// the real, live-reported bug this closes (a single noise-burst
|
|
987
|
+
// VAD frame permanently cutting the agent off) and the production
|
|
988
|
+
// research (Pipecat/LiveKit/Vapi/Deepgram) it's grounded in.
|
|
989
|
+
const bargeInGate = createBargeInGate();
|
|
949
990
|
|
|
950
991
|
// Only flips back to "listening" (and lets the mic resume sending)
|
|
951
992
|
// once BOTH the server has said no more audio is coming for this
|
|
@@ -963,6 +1004,7 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
963
1004
|
this.rtTourAudioDoneResolve = null;
|
|
964
1005
|
return;
|
|
965
1006
|
}
|
|
1007
|
+
void audioCtx.resume().catch(() => {}); // don't wait up to 2s for the periodic health check if the browser already suspended capture
|
|
966
1008
|
this.setStatus("rt-listening");
|
|
967
1009
|
this.setCaption("");
|
|
968
1010
|
};
|
|
@@ -979,9 +1021,21 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
979
1021
|
this.rtThinkingWatchdog = setTimeout(() => {
|
|
980
1022
|
this.rtThinkingWatchdog = null;
|
|
981
1023
|
console.warn("[cairn] realtime turn timed out waiting on the server — resuming listening");
|
|
982
|
-
|
|
983
|
-
this
|
|
984
|
-
|
|
1024
|
+
// Same real, live-found fix as index.tsx's own watchdog (see its
|
|
1025
|
+
// doc comment) — this used to only reset LOCAL state, never
|
|
1026
|
+
// telling the server anything, so a turn that was merely SLOW
|
|
1027
|
+
// (e.g. retrying a rate-limited call across every configured
|
|
1028
|
+
// key) kept running server-side and its reply arrived late,
|
|
1029
|
+
// landing on whatever the user had moved on to instead of being
|
|
1030
|
+
// recognized as stale. triggerBargeIn() sends the real barge_in
|
|
1031
|
+
// signal, bumping the server's generation so that late reply
|
|
1032
|
+
// gets correctly dropped by isStaleRtMessage when it arrives.
|
|
1033
|
+
triggerBargeIn();
|
|
1034
|
+
// triggerBargeIn() clears the caption but never touched the
|
|
1035
|
+
// answer — without this, a timed-out turn gave the user
|
|
1036
|
+
// literally nothing: no reply, no error, a silent reset that
|
|
1037
|
+
// reads as "it heard me and did nothing."
|
|
1038
|
+
this.setAnswer("That's taking longer than expected — try asking again.");
|
|
985
1039
|
}, 20000);
|
|
986
1040
|
};
|
|
987
1041
|
|
|
@@ -1020,10 +1074,12 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
1020
1074
|
// sent yet, and cut the agent off the instant the user starts
|
|
1021
1075
|
// talking over it instead of making them wait for it to finish.
|
|
1022
1076
|
if (this.status === "rt-speaking" && !this.touringActive) {
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1077
|
+
const frame = bargeInVad.process(e.inputBuffer.getChannelData(0));
|
|
1078
|
+
const frameDurationMs = (e.inputBuffer.length / audioCtx.sampleRate) * 1000;
|
|
1079
|
+
if (bargeInGate.update(frame, frameDurationMs)) triggerBargeIn();
|
|
1025
1080
|
return;
|
|
1026
1081
|
}
|
|
1082
|
+
bargeInGate.reset(); // not currently interruptible — don't let stale progress carry into the next speaking phase
|
|
1027
1083
|
|
|
1028
1084
|
if (this.status !== "rt-listening") return; // don't send our own mic while the agent is thinking/speaking
|
|
1029
1085
|
const pcm = floatTo16BitPCM(downsampleTo16k(e.inputBuffer.getChannelData(0), audioCtx.sampleRate));
|
|
@@ -1033,7 +1089,31 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
1033
1089
|
processor.connect(silence);
|
|
1034
1090
|
silence.connect(audioCtx.destination);
|
|
1035
1091
|
|
|
1092
|
+
// See index.tsx's own matching doc comment for the real, live-
|
|
1093
|
+
// reported bug this closes: browsers can silently suspend an
|
|
1094
|
+
// AudioContext with no active output (this capture context has
|
|
1095
|
+
// none by design), after which onaudioprocess just stops firing —
|
|
1096
|
+
// "Listening…" stays on screen while nothing is actually captured.
|
|
1097
|
+
const micHealthCheck = setInterval(() => {
|
|
1098
|
+
if (audioCtx.state !== "running") {
|
|
1099
|
+
void audioCtx.resume().catch(() => {});
|
|
1100
|
+
}
|
|
1101
|
+
const track = stream.getAudioTracks()[0];
|
|
1102
|
+
if (track && (track.readyState === "ended" || track.muted)) {
|
|
1103
|
+
this.setAnswer("The microphone connection was lost — try starting the call again.");
|
|
1104
|
+
this.endRealtime();
|
|
1105
|
+
}
|
|
1106
|
+
}, 2000);
|
|
1107
|
+
|
|
1108
|
+
const handleMicTrackEnded = () => {
|
|
1109
|
+
this.setAnswer("The microphone connection was lost — try starting the call again.");
|
|
1110
|
+
this.endRealtime();
|
|
1111
|
+
};
|
|
1112
|
+
stream.getAudioTracks().forEach((t) => t.addEventListener("ended", handleMicTrackEnded));
|
|
1113
|
+
|
|
1036
1114
|
this.rtCleanup = () => {
|
|
1115
|
+
clearInterval(micHealthCheck);
|
|
1116
|
+
stream.getAudioTracks().forEach((t) => t.removeEventListener("ended", handleMicTrackEnded));
|
|
1037
1117
|
processor.disconnect();
|
|
1038
1118
|
source.disconnect();
|
|
1039
1119
|
stream.getTracks().forEach((t) => t.stop());
|
|
@@ -1056,17 +1136,21 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
1056
1136
|
if (msg.type === "interim") {
|
|
1057
1137
|
this.setCaption(msg.text);
|
|
1058
1138
|
} else if (msg.type === "final") {
|
|
1139
|
+
this.rtLastFinalGeneration = typeof msg.generation === "number" ? msg.generation : 0;
|
|
1059
1140
|
this.setCaption(msg.text);
|
|
1060
1141
|
this.setStatus("rt-thinking");
|
|
1061
1142
|
armThinkingWatchdog();
|
|
1062
1143
|
} else if (msg.type === "verb") {
|
|
1144
|
+
if (this.isStaleRtMessage(msg)) return; // belongs to a turn a later "final" already superseded
|
|
1063
1145
|
disarmThinkingWatchdog();
|
|
1064
1146
|
this.handleVerb(msg.verb);
|
|
1065
1147
|
} else if (msg.type === "speaking_start") {
|
|
1148
|
+
if (this.isStaleRtMessage(msg)) return;
|
|
1066
1149
|
disarmThinkingWatchdog();
|
|
1067
1150
|
this.rtAudioDoneArriving = false;
|
|
1068
1151
|
this.setStatus("rt-speaking");
|
|
1069
1152
|
} else if (msg.type === "audio_chunk") {
|
|
1153
|
+
if (this.isStaleRtMessage(msg)) return; // the literal "two speakers" case — a chunk from an abandoned turn, already in flight when the barge-in landed
|
|
1070
1154
|
const ctx = this.rtPlaybackCtx;
|
|
1071
1155
|
const gain = this.rtPlaybackGain;
|
|
1072
1156
|
if (!ctx || !gain) return;
|
|
@@ -1101,6 +1185,7 @@ export class CairnWidgetElement extends HTMLElement {
|
|
|
1101
1185
|
maybeResumeListening();
|
|
1102
1186
|
};
|
|
1103
1187
|
} else if (msg.type === "speaking_end" || msg.type === "turn_complete") {
|
|
1188
|
+
if (this.isStaleRtMessage(msg)) return; // a newer turn's own speaking_end/turn_complete will arrive and resume listening correctly on its own
|
|
1104
1189
|
// turn_complete covers a verb with nothing spoken — no audio_chunk
|
|
1105
1190
|
// ever arrives for it, so rtScheduledSources is already empty and
|
|
1106
1191
|
// maybeResumeListening() resumes immediately.
|
|
@@ -1202,18 +1287,6 @@ function summarizeVerbForHistory(raw: unknown): string {
|
|
|
1202
1287
|
// identical to index.tsx's — same protocol, same math, ported directly)
|
|
1203
1288
|
// ---------------------------------------------------------------------------
|
|
1204
1289
|
|
|
1205
|
-
// Heuristic energy gate for barge-in: real speech into a laptop/phone mic
|
|
1206
|
-
// typically sits well above this; normal room noise and the mic's own
|
|
1207
|
-
// noise floor typically sit below it. Same threshold as the React widget —
|
|
1208
|
-
// not independently recalibrated, since it's the same audio pipeline.
|
|
1209
|
-
const BARGE_IN_RMS_THRESHOLD = 0.02;
|
|
1210
|
-
|
|
1211
|
-
function computeRms(channelData: Float32Array): number {
|
|
1212
|
-
let sumSquares = 0;
|
|
1213
|
-
for (let i = 0; i < channelData.length; i++) sumSquares += channelData[i] * channelData[i];
|
|
1214
|
-
return Math.sqrt(sumSquares / channelData.length);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
1290
|
function downsampleTo16k(input: Float32Array, inputSampleRate: number): Float32Array {
|
|
1218
1291
|
const targetRate = 16000;
|
|
1219
1292
|
if (inputSampleRate === targetRate) return input;
|
package/src/webmcp-client.ts
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// no site has adopted it yet, so this is deliberately a no-op (empty list,
|
|
9
9
|
// nothing to call) everywhere it isn't present, not a hard dependency.
|
|
10
10
|
|
|
11
|
-
import type { WebMcpTool } from "@cairnvibe/core";
|
|
11
|
+
import type { WebMcpRiskTier, WebMcpTool } from "@cairnvibe/core";
|
|
12
12
|
|
|
13
13
|
interface ModelContextTool {
|
|
14
14
|
name: string;
|
|
15
15
|
description?: string;
|
|
16
16
|
inputSchema?: Record<string, unknown>;
|
|
17
|
+
/** Architecture Pillar 6 — declared by the page's own tool registration, never invented by Cairn. See WebMcpToolSchema's own doc comment. */
|
|
18
|
+
riskTier?: WebMcpRiskTier;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
interface ModelContext {
|
|
@@ -44,6 +46,11 @@ export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
|
|
|
44
46
|
name: String(tool.name),
|
|
45
47
|
description: String(tool.description ?? "").slice(0, MAX_DESCRIPTION_LENGTH),
|
|
46
48
|
inputSchema: tool.inputSchema,
|
|
49
|
+
// Architecture Pillar 6 — passed through only when the page's own
|
|
50
|
+
// registration declared a real "confirm" tier; anything else
|
|
51
|
+
// (absent, or a value that isn't literally "confirm") stays
|
|
52
|
+
// undefined/"safe" — never invented, never widened by a typo.
|
|
53
|
+
riskTier: tool.riskTier === "confirm" ? "confirm" : undefined,
|
|
47
54
|
}));
|
|
48
55
|
} catch {
|
|
49
56
|
// A page's own registerTool()/getTools() implementation throwing is
|
|
@@ -59,8 +66,22 @@ export async function discoverWebMcpTools(): Promise<WebMcpTool[]> {
|
|
|
59
66
|
* exact request's own discoverWebMcpTools() call), never invented.
|
|
60
67
|
* Returns a plain-text observation for the agent loop to reason about
|
|
61
68
|
* next, the same shape a click/fill/read result already takes.
|
|
69
|
+
*
|
|
70
|
+
* Architecture Pillar 6 (the safety layer) — `confirmTool` is only ever
|
|
71
|
+
* consulted for a tool whose OWN registration declared `riskTier:
|
|
72
|
+
* "confirm"` (never something the model or this call site can widen) — a
|
|
73
|
+
* real-world-effect tool (a payment, a delete, anything hard to undo)
|
|
74
|
+
* that must get a genuine yes from the END USER before it runs, not just
|
|
75
|
+
* the model's own decision to call it. No `confirmTool` provided (a host
|
|
76
|
+
* app that hasn't wired up a confirmation UI) is treated as a decline,
|
|
77
|
+
* never as an implicit yes — the safe default when there's no real way
|
|
78
|
+
* to ask.
|
|
62
79
|
*/
|
|
63
|
-
export async function executeWebMcpTool(
|
|
80
|
+
export async function executeWebMcpTool(
|
|
81
|
+
name: string,
|
|
82
|
+
args: Record<string, unknown> | undefined,
|
|
83
|
+
confirmTool?: (tool: { name: string; description: string }) => Promise<boolean>,
|
|
84
|
+
): Promise<{ ok: boolean; observation: string }> {
|
|
64
85
|
const modelContext = getModelContext();
|
|
65
86
|
if (!modelContext?.getTools || !modelContext.executeTool) {
|
|
66
87
|
return { ok: false, observation: "This page no longer has that tool available." };
|
|
@@ -70,6 +91,13 @@ export async function executeWebMcpTool(name: string, args: Record<string, unkno
|
|
|
70
91
|
const tool = Array.isArray(tools) ? tools.find((t) => t.name === name) : undefined;
|
|
71
92
|
if (!tool) return { ok: false, observation: `No tool named "${name}" is available on this page right now.` };
|
|
72
93
|
|
|
94
|
+
if (tool.riskTier === "confirm") {
|
|
95
|
+
const confirmed = confirmTool ? await confirmTool({ name: tool.name, description: tool.description ?? "" }) : false;
|
|
96
|
+
if (!confirmed) {
|
|
97
|
+
return { ok: false, observation: "This action needs the user's real confirmation before it can run, and it wasn't confirmed." };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
73
101
|
const result = await modelContext.executeTool(tool, args ?? {});
|
|
74
102
|
const observation = typeof result === "string" ? result : JSON.stringify(result ?? null);
|
|
75
103
|
return { ok: true, observation: observation.slice(0, 2000) };
|