@cairnvibe/sdk 0.2.7 → 0.2.9
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 +15 -0
- package/dist/element-ladder.js +41 -0
- package/dist/index.js +212 -45
- package/dist/realtime-server.d.ts +3 -2
- package/dist/realtime-server.js +171 -33
- package/dist/runtime-scan.js +33 -3
- package/dist/server.d.ts +3 -1
- package/dist/server.js +110 -14
- package/dist/speak-server.d.ts +13 -3
- package/dist/speak-server.js +79 -23
- package/dist/verb-executor.d.ts +24 -0
- package/dist/verb-executor.js +87 -0
- 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 +41 -0
- package/src/index.tsx +221 -46
- package/src/realtime-server.ts +172 -33
- package/src/runtime-scan.ts +33 -3
- package/src/server.ts +119 -15
- package/src/speak-server.ts +99 -23
- package/src/verb-executor.ts +104 -1
- package/src/webmcp-client.ts +79 -0
package/dist/index.js
CHANGED
|
@@ -6,9 +6,11 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const navigation_1 = require("next/navigation");
|
|
8
8
|
const lucide_react_1 = require("lucide-react");
|
|
9
|
+
const core_1 = require("@cairnvibe/core");
|
|
9
10
|
const context_collector_1 = require("./context-collector");
|
|
10
11
|
const element_ladder_1 = require("./element-ladder");
|
|
11
12
|
const runtime_scan_1 = require("./runtime-scan");
|
|
13
|
+
const webmcp_client_1 = require("./webmcp-client");
|
|
12
14
|
const verb_executor_1 = require("./verb-executor");
|
|
13
15
|
function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, reportMissesEndpoint, transcribeEndpoint, speakEndpoint, realtimeUrl, persona = "Cairn", }) {
|
|
14
16
|
const pathname = (0, navigation_1.usePathname)() ?? "/";
|
|
@@ -20,7 +22,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
20
22
|
const pathnameRef = (0, react_1.useRef)(pathname);
|
|
21
23
|
(0, react_1.useEffect)(() => {
|
|
22
24
|
pathnameRef.current = pathname;
|
|
23
|
-
sendFreshContext(); // no-op if no realtime session is open
|
|
25
|
+
void sendFreshContext(); // no-op if no realtime session is open
|
|
24
26
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
27
|
}, [pathname]);
|
|
26
28
|
const router = (0, navigation_1.useRouter)();
|
|
@@ -104,7 +106,18 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
104
106
|
const rtMicMutedRef = (0, react_1.useRef)(false);
|
|
105
107
|
const rtSpeakerMutedRef = (0, react_1.useRef)(false);
|
|
106
108
|
const rtStartingRef = (0, react_1.useRef)(false); // closes the click-to-first-state-update gap so a rapid double-click can't open two sessions
|
|
107
|
-
|
|
109
|
+
// Progressive PCM playback for the buffered (non-realtime) speak endpoint
|
|
110
|
+
// — the same gapless AudioBufferSourceNode scheduling the realtime path
|
|
111
|
+
// uses for its audio_chunk messages (see rtPlaybackCtxRef below), just fed
|
|
112
|
+
// by a fetch() ReadableStream instead of WebSocket messages. This exists
|
|
113
|
+
// because res.blob()/res.arrayBuffer() always wait for the whole response
|
|
114
|
+
// body in every browser no matter how the server sent it — streaming the
|
|
115
|
+
// wire alone (speak-server.ts) doesn't help unless playback also starts
|
|
116
|
+
// before the full reply has arrived.
|
|
117
|
+
const typedPlaybackCtxRef = (0, react_1.useRef)(null);
|
|
118
|
+
const typedPlaybackGainRef = (0, react_1.useRef)(null);
|
|
119
|
+
const typedNextPlayTimeRef = (0, react_1.useRef)(0);
|
|
120
|
+
const typedScheduledSourcesRef = (0, react_1.useRef)([]);
|
|
108
121
|
// Watchdog for the "rt-thinking" state: started on every "final" transcript,
|
|
109
122
|
// cleared the moment the server responds with anything for that turn
|
|
110
123
|
// (verb/speaking_start/speaking_end/turn_complete/error). If it ever
|
|
@@ -194,13 +207,22 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
194
207
|
* `pathname`, so it's correct even called from a handler created once at
|
|
195
208
|
* connection-open time.
|
|
196
209
|
*/
|
|
197
|
-
function sendFreshContext() {
|
|
210
|
+
async function sendFreshContext() {
|
|
198
211
|
const ws = rtSocketRef.current;
|
|
199
212
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
200
213
|
return;
|
|
201
214
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
202
215
|
liveMapRef.current = liveScan.byId;
|
|
203
|
-
|
|
216
|
+
const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
|
|
217
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
218
|
+
return; // may have closed while awaiting discovery
|
|
219
|
+
ws.send(JSON.stringify({
|
|
220
|
+
type: "context",
|
|
221
|
+
route: pathnameRef.current,
|
|
222
|
+
visible: (0, context_collector_1.collectVisible)(),
|
|
223
|
+
liveElements: liveScan.elements,
|
|
224
|
+
webMcpTools,
|
|
225
|
+
}));
|
|
204
226
|
}
|
|
205
227
|
function reportMiss(context) {
|
|
206
228
|
(0, element_ladder_1.logMiss)(context);
|
|
@@ -346,6 +368,33 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
346
368
|
setLastQuestion(q);
|
|
347
369
|
setQuestion("");
|
|
348
370
|
try {
|
|
371
|
+
await runTypedAgentLoop(q);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
setAnswer("Something went wrong reaching the help service — try again in a moment.");
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
setStatus("idle");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const MAX_LOOP_ITERATIONS = 6; // a hard cap, not a target — see runTypedAgentLoop
|
|
381
|
+
/**
|
|
382
|
+
* Drives the agent loop over the stateless HTTP path: ask the server,
|
|
383
|
+
* and if it comes back with a continuing step (click/fill/read/
|
|
384
|
+
* call_tool — TERMINAL_VERBS in @cairnvibe/core says which verbs end a
|
|
385
|
+
* turn), execute that step for real, fold the real result into this
|
|
386
|
+
* turn's own working history, and ask again — repeat until a terminal
|
|
387
|
+
* verb or the iteration cap, instead of the old one-call-one-answer
|
|
388
|
+
* shape. `question` stays the original ask on every call; only
|
|
389
|
+
* `history` grows with each step's real trace, so the model always
|
|
390
|
+
* still knows what it was actually asked. `historyRef` (the
|
|
391
|
+
* conversation's real memory) is only ever committed once, at the end —
|
|
392
|
+
* a turn that hits the cap mid-loop doesn't leave partial noise in it.
|
|
393
|
+
*/
|
|
394
|
+
async function runTypedAgentLoop(q) {
|
|
395
|
+
let loopHistory = historyRef.current;
|
|
396
|
+
const webMcpTools = await (0, webmcp_client_1.discoverWebMcpTools)();
|
|
397
|
+
for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
|
|
349
398
|
const liveScan = liveRegistryRef.current.getSnapshot();
|
|
350
399
|
liveMapRef.current = liveScan.byId;
|
|
351
400
|
const res = await fetch(endpoint, {
|
|
@@ -355,53 +404,145 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
355
404
|
route: pathname,
|
|
356
405
|
question: q,
|
|
357
406
|
visible: (0, context_collector_1.collectVisible)(),
|
|
358
|
-
history:
|
|
407
|
+
history: loopHistory,
|
|
359
408
|
liveElements: liveScan.elements,
|
|
409
|
+
webMcpTools,
|
|
360
410
|
}),
|
|
361
411
|
});
|
|
362
412
|
const data = await res.json().catch(() => null);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
413
|
+
const parsed = (0, core_1.safeParseVerbResponse)(data);
|
|
414
|
+
if (!parsed || core_1.TERMINAL_VERBS.has(parsed.verb)) {
|
|
415
|
+
handleVerb(data);
|
|
416
|
+
// Unlike the realtime relay (one persistent connection, memory
|
|
417
|
+
// lives server-side), each of these POSTs is stateless — the
|
|
418
|
+
// widget itself is what remembers, and resends it above so the
|
|
419
|
+
// model has context for "the first one" / "do that instead" on
|
|
420
|
+
// the next question.
|
|
421
|
+
historyRef.current = [
|
|
422
|
+
...loopHistory,
|
|
423
|
+
{ role: "user", text: q },
|
|
424
|
+
{ role: "assistant", text: summarizeVerbForHistory(data) },
|
|
425
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
// A continuing step — show it happening, execute it for real, and
|
|
429
|
+
// go around again with the real result instead of ending the turn.
|
|
430
|
+
setAnswer(summarizeVerbForHistory(data));
|
|
431
|
+
const stepResult = await (0, verb_executor_1.executeToolStep)(data, pathname, liveMapRef.current);
|
|
432
|
+
loopHistory = [
|
|
433
|
+
...loopHistory,
|
|
434
|
+
{
|
|
435
|
+
role: "assistant",
|
|
436
|
+
text: `${summarizeVerbForHistory(data)}. Result: ${stepResult?.observation ?? "no result"}`,
|
|
437
|
+
},
|
|
372
438
|
].slice(-MAX_HISTORY_TURNS);
|
|
373
439
|
}
|
|
374
|
-
|
|
375
|
-
|
|
440
|
+
setAnswer("I wasn't able to finish that — try asking again or breaking it into smaller steps.");
|
|
441
|
+
historyRef.current = [
|
|
442
|
+
...loopHistory,
|
|
443
|
+
{ role: "user", text: q },
|
|
444
|
+
{ role: "assistant", text: "(gave up after too many steps)" },
|
|
445
|
+
].slice(-MAX_HISTORY_TURNS);
|
|
446
|
+
}
|
|
447
|
+
function ensureTypedPlaybackGraph() {
|
|
448
|
+
if (!typedPlaybackCtxRef.current) {
|
|
449
|
+
const ctx = new AudioContext();
|
|
450
|
+
const gain = ctx.createGain();
|
|
451
|
+
gain.connect(ctx.destination);
|
|
452
|
+
typedPlaybackCtxRef.current = ctx;
|
|
453
|
+
typedPlaybackGainRef.current = gain;
|
|
376
454
|
}
|
|
377
|
-
|
|
378
|
-
|
|
455
|
+
return { ctx: typedPlaybackCtxRef.current, gain: typedPlaybackGainRef.current };
|
|
456
|
+
}
|
|
457
|
+
/** Stops whatever's currently playing on the typed/mic path's playback
|
|
458
|
+
* graph, so two responses (e.g. a rapid double-click, or two answers
|
|
459
|
+
* resolved close together) can never be heard overlapping. */
|
|
460
|
+
function stopTypedPlayback() {
|
|
461
|
+
for (const source of typedScheduledSourcesRef.current) {
|
|
462
|
+
source.onended = null;
|
|
463
|
+
try {
|
|
464
|
+
source.stop();
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
// may already have finished naturally
|
|
468
|
+
}
|
|
379
469
|
}
|
|
470
|
+
typedScheduledSourcesRef.current = [];
|
|
471
|
+
typedNextPlayTimeRef.current = typedPlaybackCtxRef.current?.currentTime ?? 0;
|
|
472
|
+
}
|
|
473
|
+
function concatBytes(a, b) {
|
|
474
|
+
const out = new Uint8Array(a.length + b.length);
|
|
475
|
+
out.set(a, 0);
|
|
476
|
+
out.set(b, a.length);
|
|
477
|
+
return out;
|
|
380
478
|
}
|
|
381
479
|
/**
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
480
|
+
* Reads a raw linear16 PCM stream (mono, 24kHz — matches speak-server.ts)
|
|
481
|
+
* and schedules it gapless-appended into the Web Audio graph as chunks
|
|
482
|
+
* arrive — the same technique the realtime path uses for its audio_chunk
|
|
483
|
+
* messages, just driven by a fetch() reader instead of WebSocket frames.
|
|
484
|
+
* Resolves once every scheduled chunk has actually finished *playing*,
|
|
485
|
+
* not just finished arriving.
|
|
387
486
|
*/
|
|
388
|
-
function
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
const url = URL.createObjectURL(blob);
|
|
394
|
-
const audio = new Audio(url);
|
|
395
|
-
activeAudioRef.current = audio;
|
|
487
|
+
function playPcmStream(stream) {
|
|
488
|
+
stopTypedPlayback();
|
|
489
|
+
const { ctx, gain } = ensureTypedPlaybackGraph();
|
|
490
|
+
void ctx.resume().catch(() => { });
|
|
396
491
|
return new Promise((resolve) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
492
|
+
let doneArriving = false;
|
|
493
|
+
let leftover = new Uint8Array(0);
|
|
494
|
+
const maybeResolve = () => {
|
|
495
|
+
if (doneArriving && typedScheduledSourcesRef.current.length === 0)
|
|
496
|
+
resolve();
|
|
402
497
|
};
|
|
403
|
-
|
|
404
|
-
|
|
498
|
+
const scheduleChunk = (bytes) => {
|
|
499
|
+
const sampleCount = Math.floor(bytes.length / 2);
|
|
500
|
+
if (sampleCount === 0)
|
|
501
|
+
return;
|
|
502
|
+
const float32 = new Float32Array(sampleCount);
|
|
503
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, sampleCount * 2);
|
|
504
|
+
for (let i = 0; i < sampleCount; i++)
|
|
505
|
+
float32[i] = view.getInt16(i * 2, true) / 32768;
|
|
506
|
+
const buffer = ctx.createBuffer(1, sampleCount, 24000);
|
|
507
|
+
buffer.copyToChannel(float32, 0);
|
|
508
|
+
const source = ctx.createBufferSource();
|
|
509
|
+
source.buffer = buffer;
|
|
510
|
+
source.connect(gain);
|
|
511
|
+
const startAt = Math.max(ctx.currentTime, typedNextPlayTimeRef.current);
|
|
512
|
+
source.start(startAt);
|
|
513
|
+
typedNextPlayTimeRef.current = startAt + buffer.duration;
|
|
514
|
+
typedScheduledSourcesRef.current.push(source);
|
|
515
|
+
source.onended = () => {
|
|
516
|
+
typedScheduledSourcesRef.current = typedScheduledSourcesRef.current.filter((s) => s !== source);
|
|
517
|
+
maybeResolve();
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
(async () => {
|
|
521
|
+
const reader = stream.getReader();
|
|
522
|
+
try {
|
|
523
|
+
for (;;) {
|
|
524
|
+
const { done, value } = await reader.read();
|
|
525
|
+
if (done)
|
|
526
|
+
break;
|
|
527
|
+
if (!value || value.length === 0)
|
|
528
|
+
continue;
|
|
529
|
+
// PCM16 samples are 2 bytes each — a chunk boundary can split a
|
|
530
|
+
// sample in half, so carry any odd trailing byte into the next
|
|
531
|
+
// read instead of corrupting one sample at every chunk seam.
|
|
532
|
+
const combined = concatBytes(leftover, value);
|
|
533
|
+
const usableLen = combined.length - (combined.length % 2);
|
|
534
|
+
scheduleChunk(combined.subarray(0, usableLen));
|
|
535
|
+
leftover = combined.subarray(usableLen);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
// Best-effort — never let a stream read failure hang the caller forever.
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
doneArriving = true;
|
|
543
|
+
maybeResolve();
|
|
544
|
+
}
|
|
545
|
+
})();
|
|
405
546
|
});
|
|
406
547
|
}
|
|
407
548
|
async function speak(text) {
|
|
@@ -413,9 +554,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
413
554
|
headers: { "content-type": "application/json" },
|
|
414
555
|
body: JSON.stringify({ text }),
|
|
415
556
|
});
|
|
416
|
-
if (!res.ok)
|
|
557
|
+
if (!res.ok || !res.body)
|
|
417
558
|
return;
|
|
418
|
-
void
|
|
559
|
+
void playPcmStream(res.body);
|
|
419
560
|
}
|
|
420
561
|
catch {
|
|
421
562
|
// Best-effort — never let speech playback break the widget.
|
|
@@ -433,9 +574,9 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
433
574
|
headers: { "content-type": "application/json" },
|
|
434
575
|
body: JSON.stringify({ text }),
|
|
435
576
|
});
|
|
436
|
-
if (!res.ok)
|
|
577
|
+
if (!res.ok || !res.body)
|
|
437
578
|
return;
|
|
438
|
-
await
|
|
579
|
+
await playPcmStream(res.body);
|
|
439
580
|
}
|
|
440
581
|
catch {
|
|
441
582
|
// Best-effort — never let a synthesis failure hang the tour forever.
|
|
@@ -641,7 +782,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
641
782
|
}
|
|
642
783
|
setRtStatus("rt-listening");
|
|
643
784
|
setCaption("");
|
|
644
|
-
sendFreshContext(); // refresh before the user starts talking again, not after
|
|
785
|
+
void sendFreshContext(); // refresh before the user starts talking again, not after
|
|
645
786
|
}
|
|
646
787
|
function disarmThinkingWatchdog() {
|
|
647
788
|
if (rtThinkingWatchdogRef.current) {
|
|
@@ -717,6 +858,25 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
717
858
|
armThinkingWatchdog();
|
|
718
859
|
}
|
|
719
860
|
else if (msg.type === "verb") {
|
|
861
|
+
const parsedStep = (0, core_1.safeParseVerbResponse)(msg.verb);
|
|
862
|
+
if (parsedStep && !core_1.TERMINAL_VERBS.has(parsedStep.verb)) {
|
|
863
|
+
// A continuing agent-loop step (click/fill/read/call_tool) —
|
|
864
|
+
// the turn isn't over: execute it for real and report the
|
|
865
|
+
// result back so the server can decide the next step, instead
|
|
866
|
+
// of treating this like a normal answer (no
|
|
867
|
+
// disarmThinkingWatchdog/handleVerb — those are for when a
|
|
868
|
+
// turn actually ends). Shown visually so a multi-step turn
|
|
869
|
+
// reads as visible progress, not a silent pause; never spoken
|
|
870
|
+
// — the server's loop stays quiet between steps on purpose,
|
|
871
|
+
// to keep it fast.
|
|
872
|
+
setAnswer(summarizeVerbForHistory(msg.verb));
|
|
873
|
+
void (0, verb_executor_1.executeToolStep)(msg.verb, pathnameRef.current, liveMapRef.current).then((result) => {
|
|
874
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
875
|
+
ws.send(JSON.stringify({ type: "tool_result", observation: result?.observation ?? "no result" }));
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
720
880
|
disarmThinkingWatchdog();
|
|
721
881
|
handleVerb(msg.verb);
|
|
722
882
|
}
|
|
@@ -810,8 +970,7 @@ function Copilot({ endpoint = "/api/copilot", registeredActions = [], onDo, repo
|
|
|
810
970
|
rtThinkingWatchdogRef.current = null;
|
|
811
971
|
}
|
|
812
972
|
rtStartingRef.current = false;
|
|
813
|
-
|
|
814
|
-
activeAudioRef.current = null;
|
|
973
|
+
stopTypedPlayback();
|
|
815
974
|
rtSocketRef.current?.close();
|
|
816
975
|
rtSocketRef.current = null;
|
|
817
976
|
rtCleanupRef.current?.();
|
|
@@ -884,6 +1043,14 @@ function summarizeVerbForHistory(raw) {
|
|
|
884
1043
|
return `(ran ${String(v.action)}${v.target ? ` on ${String(v.target)}` : ""})`;
|
|
885
1044
|
case "tour":
|
|
886
1045
|
return Array.isArray(v.steps) ? v.steps.map((s) => s.text ?? "").join(" ") : "(tour)";
|
|
1046
|
+
case "click":
|
|
1047
|
+
return `(clicked ${String(v.target)})`;
|
|
1048
|
+
case "fill":
|
|
1049
|
+
return `(typed "${String(v.value)}" into ${String(v.target)})`;
|
|
1050
|
+
case "read":
|
|
1051
|
+
return `(read ${String(v.target)})`;
|
|
1052
|
+
case "call_tool":
|
|
1053
|
+
return `(called ${String(v.name)})`;
|
|
887
1054
|
default:
|
|
888
1055
|
return "(no response)";
|
|
889
1056
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
|
-
import type
|
|
3
|
+
import { type HistoryTurn, type LiveElement, type Manifest, type WebMcpTool } from "@cairnvibe/core";
|
|
4
4
|
import { createVerbLLM, type CapabilityTier, type CreateCopilotHandlerOptions } from "./server";
|
|
5
5
|
export interface CreateRealtimeServerOptions extends CreateCopilotHandlerOptions {
|
|
6
6
|
manifest: Manifest;
|
|
@@ -23,6 +23,7 @@ export declare function handleDeepgramMessage(raw: string, client: WebSocket, de
|
|
|
23
23
|
route: string;
|
|
24
24
|
visible: string[];
|
|
25
25
|
liveElements: LiveElement[];
|
|
26
|
+
webMcpTools: WebMcpTool[];
|
|
26
27
|
}, speakStreamed: (text: string) => Promise<void>, history: HistoryTurn[], turnState: {
|
|
27
28
|
buffer: string;
|
|
28
|
-
}, getGeneration: () => number): Promise<void>;
|
|
29
|
+
}, getGeneration: () => number, waitForToolResult: () => Promise<string>): Promise<void>;
|