@alexkroman1/aai 0.12.2 → 1.0.2

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.
Files changed (135) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/CHANGELOG.md +174 -0
  3. package/dist/constants-VTFoymJ-.js +47 -0
  4. package/dist/host/_run-code.d.ts +4 -2
  5. package/dist/host/_runtime-conformance.d.ts +4 -5
  6. package/dist/host/builtin-tools.d.ts +11 -7
  7. package/dist/host/runtime-barrel.d.ts +15 -0
  8. package/dist/{direct-executor-ZUU0Ke4j.js → host/runtime-barrel.js} +463 -345
  9. package/dist/host/runtime-config.d.ts +42 -0
  10. package/dist/host/runtime.d.ts +119 -35
  11. package/dist/host/s2s.d.ts +14 -38
  12. package/dist/host/server.d.ts +16 -8
  13. package/dist/host/session-ctx.d.ts +55 -0
  14. package/dist/host/session.d.ts +21 -70
  15. package/dist/host/tool-executor.d.ts +20 -0
  16. package/dist/host/unstorage-kv.d.ts +1 -1
  17. package/dist/host/ws-handler.d.ts +4 -2
  18. package/dist/index.d.ts +9 -20
  19. package/dist/index.js +63 -2
  20. package/dist/{isolate → sdk}/_internal-types.d.ts +6 -10
  21. package/dist/{isolate → sdk}/constants.d.ts +6 -4
  22. package/dist/sdk/define.d.ts +66 -0
  23. package/dist/{isolate → sdk}/kv.d.ts +1 -49
  24. package/dist/sdk/manifest-barrel.d.ts +8 -0
  25. package/dist/sdk/manifest-barrel.js +52 -0
  26. package/dist/sdk/manifest.d.ts +50 -0
  27. package/dist/{isolate → sdk}/protocol.d.ts +59 -36
  28. package/dist/sdk/protocol.js +163 -0
  29. package/dist/{isolate → sdk}/system-prompt.d.ts +3 -2
  30. package/dist/sdk/types.d.ts +201 -0
  31. package/dist/sdk/ws-upgrade.d.ts +5 -0
  32. package/dist/{system-prompt-CVJSQJiA.js → system-prompt-nik_iavo.js} +11 -10
  33. package/dist/types-Cfx_4QDK.js +39 -0
  34. package/dist/ws-upgrade-BeOQ7fXL.js +30 -0
  35. package/exports-no-dev-deps.test.ts +62 -0
  36. package/host/_mock-ws.ts +185 -0
  37. package/host/_run-code.ts +217 -0
  38. package/host/_runtime-conformance.ts +143 -0
  39. package/host/_test-utils.ts +276 -0
  40. package/host/builtin-tools.test.ts +774 -0
  41. package/host/builtin-tools.ts +255 -0
  42. package/host/cleanup.test.ts +422 -0
  43. package/host/fixture-replay.test.ts +463 -0
  44. package/host/fixtures/README.md +40 -0
  45. package/host/fixtures/greeting-session-sequence.json +40 -0
  46. package/host/fixtures/reply-audio-samples.json +42 -0
  47. package/host/fixtures/reply-lifecycle.json +21 -0
  48. package/host/fixtures/session-ready.json +48 -0
  49. package/host/fixtures/session-updated.json +45 -0
  50. package/host/fixtures/simple-question-sequence.json +73 -0
  51. package/host/fixtures/tool-call-sequence.json +114 -0
  52. package/host/fixtures/tool-calls.json +11 -0
  53. package/host/fixtures/tool-config-session-sequence.json +51 -0
  54. package/host/fixtures/user-speech-recognition.json +30 -0
  55. package/host/fixtures/web-search-sequence.json +122 -0
  56. package/host/integration.test.ts +222 -0
  57. package/host/runtime-barrel.ts +25 -0
  58. package/host/runtime-config.test.ts +71 -0
  59. package/host/runtime-config.ts +99 -0
  60. package/host/runtime.test.ts +641 -0
  61. package/host/runtime.ts +308 -0
  62. package/host/s2s-fixtures.test.ts +237 -0
  63. package/host/s2s.test.ts +562 -0
  64. package/host/s2s.ts +310 -0
  65. package/host/server-shutdown.test.ts +76 -0
  66. package/host/server.test.ts +116 -0
  67. package/host/server.ts +223 -0
  68. package/host/session-ctx.ts +107 -0
  69. package/host/session-fixture-replay.test.ts +136 -0
  70. package/host/session-prompt.test.ts +77 -0
  71. package/host/session.test.ts +590 -0
  72. package/host/session.ts +370 -0
  73. package/host/tool-executor.test.ts +124 -0
  74. package/host/tool-executor.ts +80 -0
  75. package/host/unstorage-kv.test.ts +99 -0
  76. package/host/unstorage-kv.ts +69 -0
  77. package/host/ws-handler.test.ts +739 -0
  78. package/host/ws-handler.ts +255 -0
  79. package/index.ts +16 -0
  80. package/package.json +28 -72
  81. package/sdk/_internal-types.test.ts +34 -0
  82. package/sdk/_internal-types.ts +115 -0
  83. package/sdk/compat-fixtures/README.md +26 -0
  84. package/sdk/compat-fixtures/v1.json +68 -0
  85. package/sdk/constants.ts +77 -0
  86. package/sdk/define.test.ts +57 -0
  87. package/sdk/define.ts +88 -0
  88. package/sdk/kv.ts +60 -0
  89. package/sdk/manifest-barrel.ts +12 -0
  90. package/sdk/manifest.test.ts +56 -0
  91. package/sdk/manifest.ts +89 -0
  92. package/sdk/protocol-compat.test.ts +187 -0
  93. package/sdk/protocol-snapshot.test.ts +199 -0
  94. package/sdk/protocol.test.ts +170 -0
  95. package/sdk/protocol.ts +223 -0
  96. package/sdk/schema-alignment.test.ts +191 -0
  97. package/sdk/system-prompt.test.ts +111 -0
  98. package/sdk/system-prompt.ts +74 -0
  99. package/sdk/tsconfig.json +12 -0
  100. package/sdk/types-inference.test.ts +122 -0
  101. package/sdk/types.test.ts +14 -0
  102. package/sdk/types.ts +226 -0
  103. package/sdk/utils.test.ts +52 -0
  104. package/sdk/utils.ts +20 -0
  105. package/sdk/ws-upgrade.test.ts +48 -0
  106. package/sdk/ws-upgrade.ts +13 -0
  107. package/tsconfig.build.json +14 -0
  108. package/tsconfig.json +10 -0
  109. package/tsdown.config.ts +26 -0
  110. package/vitest.config.ts +17 -0
  111. package/dist/host/_test-utils.d.ts +0 -73
  112. package/dist/host/direct-executor.d.ts +0 -128
  113. package/dist/host/index.d.ts +0 -18
  114. package/dist/host/index.js +0 -165
  115. package/dist/host/matchers.d.ts +0 -20
  116. package/dist/host/matchers.js +0 -41
  117. package/dist/host/server.js +0 -164
  118. package/dist/host/testing.d.ts +0 -294
  119. package/dist/host/testing.js +0 -2
  120. package/dist/host/vite-plugin.d.ts +0 -15
  121. package/dist/host/vite-plugin.js +0 -83
  122. package/dist/isolate/_kv-utils.d.ts +0 -10
  123. package/dist/isolate/_utils.js +0 -17
  124. package/dist/isolate/hooks.d.ts +0 -44
  125. package/dist/isolate/hooks.js +0 -58
  126. package/dist/isolate/index.d.ts +0 -18
  127. package/dist/isolate/index.js +0 -6
  128. package/dist/isolate/kv.js +0 -1
  129. package/dist/isolate/protocol.js +0 -2
  130. package/dist/isolate/types.d.ts +0 -418
  131. package/dist/isolate/types.js +0 -175
  132. package/dist/protocol-rcOrz7T3.js +0 -183
  133. package/dist/testing-Bb2B5Uob.js +0 -513
  134. package/dist/types.test-d.d.ts +0 -7
  135. /package/dist/{isolate/_utils.d.ts → sdk/utils.d.ts} +0 -0
@@ -0,0 +1,370 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /** S2S session — relays audio between client and AssemblyAI S2S API. */
3
+
4
+ import type { AgentConfig, ExecuteTool, ToolSchema } from "../sdk/_internal-types.ts";
5
+ import { DEFAULT_IDLE_TIMEOUT_MS, MAX_TOOL_RESULT_CHARS } from "../sdk/constants.ts";
6
+ import type { ClientEvent, ClientSink } from "../sdk/protocol.ts";
7
+ import { buildSystemPrompt } from "../sdk/system-prompt.ts";
8
+ import { errorDetail, errorMessage, toolError } from "../sdk/utils.ts";
9
+ import type { Logger, S2SConfig } from "./runtime-config.ts";
10
+ import { consoleLogger } from "./runtime-config.ts";
11
+ import {
12
+ type CreateS2sWebSocket,
13
+ connectS2s,
14
+ defaultCreateS2sWebSocket,
15
+ type S2sHandle,
16
+ type S2sToolSchema,
17
+ } from "./s2s.ts";
18
+ import { buildCtx, type S2sSessionCtx } from "./session-ctx.ts";
19
+
20
+ /**
21
+ * A voice session managing the Speech-to-Speech connection for one client.
22
+ *
23
+ * Created by {@link createS2sSession}. Each session owns a single S2S WebSocket
24
+ * connection and relays audio between the browser client and AssemblyAI.
25
+ *
26
+ * @internal Exported for use by `ws-handler.ts`, `server.ts`, and `runtime.ts`.
27
+ */
28
+ export type Session = {
29
+ start(): Promise<void>;
30
+ stop(): Promise<void>;
31
+ onAudio(data: Uint8Array): void;
32
+ onAudioReady(): void;
33
+ onCancel(): void;
34
+ onReset(): void;
35
+ onHistory(incoming: readonly { role: "user" | "assistant"; content: string }[]): void;
36
+ waitForTurn(): Promise<void>;
37
+ };
38
+
39
+ /** Configuration options for creating a new S2S voice session. */
40
+ export type S2sSessionOptions = {
41
+ /** Unique session identifier (used for KV scoping and logging). */
42
+ id: string;
43
+ /** Agent slug — identifies which deployed agent this session belongs to. */
44
+ agent: string;
45
+ /** Sink for pushing events and audio to the connected browser client. */
46
+ client: ClientSink;
47
+ /** Serializable agent config (name, system prompt, greeting, maxSteps, etc.). */
48
+ agentConfig: AgentConfig;
49
+ /** JSON Schema definitions for the agent's custom tools. */
50
+ toolSchemas: readonly ToolSchema[];
51
+ /** Optional natural-language guidance appended to the system prompt for tool usage. */
52
+ toolGuidance?: readonly string[];
53
+ /** AssemblyAI API key — stays host-side, never forwarded to the guest sandbox. */
54
+ apiKey: string;
55
+ /** S2S connection config (sample rates, model selection). */
56
+ s2sConfig: S2SConfig;
57
+ /** Function to invoke tools by name (wired to direct-executor or sandbox RPC). */
58
+ executeTool: ExecuteTool;
59
+ /** Override WebSocket constructor for testing. */
60
+ createWebSocket?: CreateS2sWebSocket;
61
+ /** Agent environment variables (secrets). Forwarded to tool context. */
62
+ env?: Record<string, string | undefined>;
63
+ /** Skip the initial greeting audio on connect (used for session resume). */
64
+ skipGreeting?: boolean;
65
+ /** Logger instance. Defaults to `consoleLogger`. */
66
+ logger?: Logger;
67
+ /** Max conversation messages to retain. Defaults to DEFAULT_MAX_HISTORY (200). */
68
+ maxHistory?: number;
69
+ };
70
+
71
+ /** @internal Not part of the public API. Exposed for testing only. */
72
+ export const _internals = { connectS2s };
73
+
74
+ type IdleTimer = { reset(): void; clear(): void };
75
+
76
+ /**
77
+ * Create an idle timer that closes the S2S connection after inactivity.
78
+ * Convention: `timeoutMs <= 0` disables the timer entirely (returns a no-op).
79
+ * This allows agents to opt out of idle timeout via `idleTimeoutMs: 0` in their config.
80
+ */
81
+ function createIdleTimer(opts: {
82
+ timeoutMs: number;
83
+ agent: string;
84
+ log: Logger;
85
+ client: ClientSink;
86
+ ctx: { s2s: { close(): void } | null };
87
+ }): IdleTimer {
88
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional no-op timer
89
+ if (opts.timeoutMs <= 0) return { reset() {}, clear() {} };
90
+ let timer: ReturnType<typeof setTimeout> | null = null;
91
+ return {
92
+ reset() {
93
+ if (timer !== null) clearTimeout(timer);
94
+ timer = setTimeout(() => {
95
+ opts.log.info("S2S idle timeout", { timeoutMs: opts.timeoutMs, agent: opts.agent });
96
+ opts.client.event({ type: "idle_timeout" });
97
+ opts.ctx.s2s?.close();
98
+ }, opts.timeoutMs);
99
+ },
100
+ clear() {
101
+ if (timer !== null) {
102
+ clearTimeout(timer);
103
+ timer = null;
104
+ }
105
+ },
106
+ };
107
+ }
108
+
109
+ // ─── Session event handlers ─────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Complete a tool call by truncating the result, emitting a `tool_call_done` event,
113
+ * and accumulating the result in `ctx.reply.pendingTools` — but only if the reply that
114
+ * initiated this call is still active.
115
+ */
116
+ function finishToolCall(
117
+ ctx: S2sSessionCtx,
118
+ callId: string,
119
+ result: string,
120
+ replyId: string | null,
121
+ ): void {
122
+ const truncatedResult =
123
+ result.length > MAX_TOOL_RESULT_CHARS ? result.slice(0, MAX_TOOL_RESULT_CHARS) : result;
124
+ ctx.client.event({ type: "tool_call_done", toolCallId: callId, result: truncatedResult });
125
+ if (replyId !== null && replyId === ctx.reply.currentReplyId) {
126
+ ctx.reply.pendingTools.push({ callId, result });
127
+ if (ctx.maxHistory > 0 && ctx.reply.pendingTools.length > ctx.maxHistory) {
128
+ ctx.reply.pendingTools.shift();
129
+ }
130
+ }
131
+ }
132
+
133
+ async function handleToolCall(
134
+ ctx: S2sSessionCtx,
135
+ event: Extract<ClientEvent, { type: "tool_call" }>,
136
+ ): Promise<void> {
137
+ const { toolCallId: callId, toolName: name, args: parsedArgs } = event;
138
+ const replyId = ctx.reply.currentReplyId;
139
+
140
+ ctx.client.event(event);
141
+
142
+ const refused = ctx.consumeToolCallStep(name, replyId);
143
+ if (refused !== null) {
144
+ finishToolCall(ctx, callId, refused, replyId);
145
+ return;
146
+ }
147
+
148
+ ctx.log.info("S2S tool call", { tool: name, callId, args: parsedArgs, agent: ctx.agent });
149
+
150
+ let result: string;
151
+ try {
152
+ result = await ctx.executeTool(name, parsedArgs, ctx.id, ctx.conversationMessages);
153
+ } catch (err: unknown) {
154
+ const msg = errorMessage(err);
155
+ ctx.log.error("Tool execution failed", { tool: name, error: errorDetail(err) });
156
+ result = toolError(msg);
157
+ }
158
+
159
+ ctx.log.info("S2S tool result", { tool: name, callId, resultLength: result.length });
160
+ finishToolCall(ctx, callId, result, replyId);
161
+ }
162
+
163
+ function handleUserTranscript(ctx: S2sSessionCtx, text: string): void {
164
+ ctx.log.info("S2S user transcript", { text });
165
+ ctx.client.event({ type: "user_transcript", text });
166
+ ctx.pushMessages({ role: "user", content: text });
167
+ }
168
+
169
+ function handleAgentTranscript(ctx: S2sSessionCtx, text: string, interrupted: boolean): void {
170
+ ctx.client.event({ type: "agent_transcript", text });
171
+ if (!interrupted) {
172
+ ctx.pushMessages({ role: "assistant", content: text });
173
+ }
174
+ }
175
+
176
+ function handleReplyCancelled(ctx: S2sSessionCtx): void {
177
+ ctx.log.info("S2S reply interrupted (barge-in)");
178
+ ctx.cancelReply();
179
+ ctx.client.event({ type: "cancelled" });
180
+ }
181
+
182
+ function handleReplyDone(ctx: S2sSessionCtx): void {
183
+ const doneReplyId = ctx.reply.currentReplyId;
184
+ const sendPending = () => {
185
+ if (ctx.reply.currentReplyId !== doneReplyId) {
186
+ ctx.reply.pendingTools = [];
187
+ return;
188
+ }
189
+ if (ctx.reply.pendingTools.length > 0) {
190
+ for (const tool of ctx.reply.pendingTools) ctx.s2s?.sendToolResult(tool.callId, tool.result);
191
+ ctx.reply.pendingTools = [];
192
+ } else {
193
+ const stepsUsed = ctx.reply.toolCallCount;
194
+ if (stepsUsed > 0) {
195
+ ctx.log.info("Turn complete", { steps: stepsUsed, agent: ctx.agent });
196
+ }
197
+ ctx.client.playAudioDone();
198
+ ctx.client.event({ type: "reply_done" });
199
+ }
200
+ };
201
+ if (ctx.turnPromise !== null) {
202
+ void ctx.turnPromise.then(sendPending);
203
+ } else {
204
+ sendPending();
205
+ }
206
+ }
207
+
208
+ function setupListeners(ctx: S2sSessionCtx, handle: S2sHandle): void {
209
+ handle.on("ready", ({ sessionId }) => ctx.log.info("S2S session ready", { sessionId }));
210
+ handle.on("replyStarted", ({ replyId }) => {
211
+ ctx.beginReply(replyId);
212
+ });
213
+ handle.on("sessionExpired", () => {
214
+ ctx.log.info("S2S session expired");
215
+ handle.close();
216
+ });
217
+ handle.on("audio", ({ audio }) => ctx.client.playAudioChunk(audio));
218
+ handle.on("error", (err) => {
219
+ ctx.log.error("S2S error", { message: err.message });
220
+ ctx.client.event({ type: "error", code: "internal", message: err.message });
221
+ handle.close();
222
+ });
223
+ handle.on("close", (code, reason) => {
224
+ ctx.log.info("S2S closed", { code, reason });
225
+ ctx.s2s = null;
226
+ ctx.cancelReply();
227
+ });
228
+
229
+ handle.on("event", (event) => {
230
+ switch (event.type) {
231
+ case "user_transcript":
232
+ handleUserTranscript(ctx, event.text);
233
+ break;
234
+ case "agent_transcript":
235
+ handleAgentTranscript(ctx, event.text, event._interrupted ?? false);
236
+ break;
237
+ case "tool_call": {
238
+ const p = handleToolCall(ctx, event).catch((err: unknown) => {
239
+ ctx.log.error("Tool call handler failed", { err: errorMessage(err) });
240
+ });
241
+ ctx.chainTurn(p);
242
+ break;
243
+ }
244
+ case "reply_done":
245
+ handleReplyDone(ctx);
246
+ break;
247
+ case "cancelled":
248
+ handleReplyCancelled(ctx);
249
+ break;
250
+ default:
251
+ ctx.client.event(event);
252
+ }
253
+ });
254
+ }
255
+
256
+ // ─── Main session factory ────────────────────────────────────────────────────
257
+
258
+ export function createS2sSession(opts: S2sSessionOptions): Session {
259
+ const {
260
+ id,
261
+ agent,
262
+ client,
263
+ toolSchemas,
264
+ apiKey,
265
+ s2sConfig,
266
+ executeTool,
267
+ createWebSocket = defaultCreateS2sWebSocket,
268
+ logger: log = consoleLogger,
269
+ } = opts;
270
+ const agentConfig = opts.skipGreeting ? { ...opts.agentConfig, greeting: "" } : opts.agentConfig;
271
+ const hasTools = toolSchemas.length > 0 || (agentConfig.builtinTools?.length ?? 0) > 0;
272
+ const systemPrompt = buildSystemPrompt(agentConfig, {
273
+ hasTools,
274
+ voice: true,
275
+ toolGuidance: opts.toolGuidance,
276
+ });
277
+ const s2sTools: S2sToolSchema[] = toolSchemas.map((ts) => ({
278
+ type: "function" as const,
279
+ name: ts.name,
280
+ description: ts.description,
281
+ parameters: ts.parameters,
282
+ }));
283
+
284
+ const sessionAbort = new AbortController();
285
+ const ctx = buildCtx({
286
+ id,
287
+ agent,
288
+ client,
289
+ agentConfig,
290
+ executeTool,
291
+ log,
292
+ maxHistory: opts.maxHistory,
293
+ });
294
+
295
+ const rawTimeout = agentConfig.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
296
+ const idleMs = rawTimeout === 0 || !Number.isFinite(rawTimeout) ? 0 : rawTimeout;
297
+ const idle = createIdleTimer({ timeoutMs: idleMs, agent, log, client, ctx });
298
+
299
+ let connectGeneration = 0;
300
+ const sessionUpdatePayload = {
301
+ systemPrompt,
302
+ tools: s2sTools,
303
+ ...(agentConfig.greeting ? { greeting: agentConfig.greeting } : {}),
304
+ };
305
+
306
+ async function connectAndSetup(): Promise<void> {
307
+ const generation = ++connectGeneration;
308
+ try {
309
+ const handle = await _internals.connectS2s({
310
+ apiKey,
311
+ config: s2sConfig,
312
+ createWebSocket,
313
+ logger: log,
314
+ });
315
+ if (sessionAbort.signal.aborted || generation !== connectGeneration) {
316
+ handle.close();
317
+ return;
318
+ }
319
+ setupListeners(ctx, handle);
320
+ handle.updateSession(sessionUpdatePayload);
321
+ ctx.s2s = handle;
322
+ idle.reset();
323
+ } catch (err: unknown) {
324
+ const msg = errorMessage(err);
325
+ log.error("S2S connect failed", { error: errorDetail(err) });
326
+ client.event({ type: "error", code: "internal", message: msg });
327
+ }
328
+ }
329
+
330
+ return {
331
+ async start(): Promise<void> {
332
+ await connectAndSetup();
333
+ },
334
+ async stop(): Promise<void> {
335
+ if (sessionAbort.signal.aborted) return;
336
+ sessionAbort.abort();
337
+ idle.clear();
338
+ if (ctx.turnPromise !== null) await ctx.turnPromise;
339
+ ctx.s2s?.close();
340
+ },
341
+ onAudio(data: Uint8Array): void {
342
+ idle.reset();
343
+ ctx.s2s?.sendAudio(data);
344
+ },
345
+ onAudioReady(): void {
346
+ /* S2S greeting comes automatically */
347
+ },
348
+ onCancel(): void {
349
+ client.event({ type: "cancelled" });
350
+ },
351
+ onReset(): void {
352
+ ctx.cancelReply();
353
+ ctx.conversationMessages = [];
354
+ ctx.reply.toolCallCount = 0;
355
+ ctx.turnPromise = null;
356
+ idle.clear();
357
+ ctx.s2s?.close();
358
+ client.event({ type: "reset" });
359
+ connectAndSetup().catch((err: unknown) =>
360
+ log.error("S2S reset reconnect failed", { error: errorMessage(err) }),
361
+ );
362
+ },
363
+ onHistory(incoming: readonly { role: "user" | "assistant"; content: string }[]): void {
364
+ ctx.pushMessages(...incoming.map((m) => ({ role: m.role, content: m.content })));
365
+ },
366
+ waitForTurn(): Promise<void> {
367
+ return ctx.turnPromise ?? Promise.resolve();
368
+ },
369
+ };
370
+ }
@@ -0,0 +1,124 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import { z } from "zod";
4
+ import type { ToolDef } from "../sdk/types.ts";
5
+ import { makeTool } from "./_test-utils.ts";
6
+ import { executeToolCall } from "./tool-executor.ts";
7
+
8
+ function run(
9
+ name: string,
10
+ args: Record<string, unknown>,
11
+ tool: ToolDef,
12
+ extra?: Record<string, unknown>,
13
+ ) {
14
+ return executeToolCall(name, args, { tool, env: {}, ...extra });
15
+ }
16
+
17
+ describe("executeToolCall", () => {
18
+ test("returns string result from tool", async () => {
19
+ expect(await run("test", {}, makeTool({ execute: () => "hello" }))).toBe("hello");
20
+ });
21
+
22
+ test("serializes non-string result as JSON", async () => {
23
+ expect(await run("test", {}, makeTool({ execute: () => ({ count: 42 }) }))).toBe(
24
+ '{"count":42}',
25
+ );
26
+ });
27
+
28
+ test("returns 'null' for null/undefined result", async () => {
29
+ expect(await run("test", {}, makeTool({ execute: () => null }))).toBe("null");
30
+ });
31
+
32
+ test("validates args against parameter schema", async () => {
33
+ const tool = makeTool({
34
+ parameters: z.object({ name: z.string() }),
35
+ execute: (args) => `hi ${(args as { name: string }).name}`,
36
+ });
37
+ expect(await run("greet", { name: "alice" }, tool)).toBe("hi alice");
38
+ });
39
+
40
+ test("returns error for invalid args", async () => {
41
+ const tool = makeTool({ parameters: z.object({ name: z.string() }), execute: () => "ok" });
42
+ const result = await run("greet", { name: 123 }, tool);
43
+ const parsed = JSON.parse(result);
44
+ expect(parsed.error).toContain("Invalid arguments");
45
+ expect(parsed.error).toContain("greet");
46
+ });
47
+
48
+ test("returns error when tool throws", async () => {
49
+ expect(
50
+ await run(
51
+ "fail",
52
+ {},
53
+ makeTool({
54
+ execute: () => {
55
+ throw new Error("boom");
56
+ },
57
+ }),
58
+ ),
59
+ ).toBe(JSON.stringify({ error: "boom" }));
60
+ });
61
+
62
+ test("returns error string when tool throws", async () => {
63
+ expect(
64
+ await run(
65
+ "fail",
66
+ {},
67
+ makeTool({
68
+ execute: () => {
69
+ throw new Error("string error");
70
+ },
71
+ }),
72
+ ),
73
+ ).toBe(JSON.stringify({ error: "string error" }));
74
+ });
75
+
76
+ test("passes env to tool context", async () => {
77
+ const tool = makeTool({ execute: (_args, ctx) => ctx.env.API_KEY ?? "missing" });
78
+ expect(await run("test", {}, tool, { env: { API_KEY: "secret" } })).toBe("secret");
79
+ });
80
+
81
+ test("passes messages to tool context", async () => {
82
+ const tool = makeTool({ execute: (_args, ctx) => String(ctx.messages.length) });
83
+ expect(await run("test", {}, tool, { messages: [{ role: "user", content: "hi" }] })).toBe("1");
84
+ });
85
+
86
+ test("kv throws when not provided", async () => {
87
+ const tool = makeTool({
88
+ execute: (_args, ctx) => {
89
+ try {
90
+ void ctx.kv;
91
+ return "no error";
92
+ } catch (e) {
93
+ return (e as Error).message;
94
+ }
95
+ },
96
+ });
97
+ expect(await run("test", {}, tool)).toBe("KV not available");
98
+ });
99
+
100
+ test("handles async tool execution", async () => {
101
+ const tool = makeTool({
102
+ execute: async () => {
103
+ await new Promise((r) => setTimeout(r, 10));
104
+ return "async result";
105
+ },
106
+ });
107
+ expect(await run("test", {}, tool)).toBe("async result");
108
+ });
109
+
110
+ test("times out tool that runs longer than TOOL_EXECUTION_TIMEOUT_MS", async () => {
111
+ vi.useFakeTimers();
112
+ const tool = makeTool({
113
+ execute: () =>
114
+ new Promise(() => {
115
+ /* never resolves */
116
+ }),
117
+ });
118
+ const promise = run("slow", {}, tool);
119
+ await vi.advanceTimersByTimeAsync(30_000);
120
+ const result = await promise;
121
+ expect(result).toBe(JSON.stringify({ error: 'Tool "slow" timed out after 30000ms' }));
122
+ vi.useRealTimers();
123
+ });
124
+ });
@@ -0,0 +1,80 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ /**
3
+ * Tool execution — validates arguments and invokes tool handlers.
4
+ *
5
+ * {@link executeToolCall} is the single entry point used by both the
6
+ * direct (self-hosted) runtime and the platform sandbox sidecar.
7
+ */
8
+
9
+ import pTimeout from "p-timeout";
10
+ import type { z } from "zod";
11
+ import { EMPTY_PARAMS } from "../sdk/_internal-types.ts";
12
+ import { TOOL_EXECUTION_TIMEOUT_MS } from "../sdk/constants.ts";
13
+ import type { Kv } from "../sdk/kv.ts";
14
+ import type { Message, ToolContext, ToolDef } from "../sdk/types.ts";
15
+ import { errorDetail, errorMessage, toolError } from "../sdk/utils.ts";
16
+ import type { Logger } from "./runtime-config.ts";
17
+
18
+ export type { ExecuteTool } from "../sdk/_internal-types.ts";
19
+
20
+ const yieldTick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
21
+
22
+ export type ExecuteToolCallOptions = {
23
+ tool: ToolDef;
24
+ env: Readonly<Record<string, string>>;
25
+ state?: Record<string, unknown>;
26
+ sessionId?: string | undefined;
27
+ kv?: Kv | undefined;
28
+ messages?: readonly Message[] | undefined;
29
+ logger?: Logger | undefined;
30
+ };
31
+
32
+ function buildToolContext(opts: ExecuteToolCallOptions): ToolContext {
33
+ const { env, state, kv, messages, sessionId } = opts;
34
+ return {
35
+ env,
36
+ state: state ?? {},
37
+ get kv(): Kv {
38
+ if (!kv) throw new Error("KV not available");
39
+ return kv;
40
+ },
41
+ messages: messages ?? [],
42
+ sessionId: sessionId ?? "",
43
+ };
44
+ }
45
+
46
+ export async function executeToolCall(
47
+ name: string,
48
+ args: Readonly<Record<string, unknown>>,
49
+ options: ExecuteToolCallOptions,
50
+ ): Promise<string> {
51
+ const { tool } = options;
52
+ const schema = tool.parameters ?? EMPTY_PARAMS;
53
+ const parsed = schema.safeParse(args);
54
+ if (!parsed.success) {
55
+ const issues = (parsed.error?.issues ?? [])
56
+ .map((i: z.ZodIssue) => `${i.path.map(String).join(".")}: ${i.message}`)
57
+ .join(", ");
58
+ return toolError(`Invalid arguments for tool "${name}": ${issues}`);
59
+ }
60
+
61
+ try {
62
+ const ctx = buildToolContext(options);
63
+ await yieldTick();
64
+ const result = await pTimeout(Promise.resolve(tool.execute(parsed.data, ctx)), {
65
+ milliseconds: TOOL_EXECUTION_TIMEOUT_MS,
66
+ message: `Tool "${name}" timed out after ${TOOL_EXECUTION_TIMEOUT_MS}ms`,
67
+ });
68
+ await yieldTick();
69
+ if (result == null) return "null";
70
+ return typeof result === "string" ? result : JSON.stringify(result);
71
+ } catch (err: unknown) {
72
+ const log = options.logger;
73
+ if (log) {
74
+ log.warn("Tool execution failed", { tool: name, error: errorDetail(err) });
75
+ } else {
76
+ console.warn(`[tool-executor] Tool execution failed: ${name}`, err);
77
+ }
78
+ return toolError(errorMessage(err));
79
+ }
80
+ }
@@ -0,0 +1,99 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+
3
+ import { createStorage } from "unstorage";
4
+ import { describe, expect, test } from "vitest";
5
+ import { createUnstorageKv } from "./unstorage-kv.ts";
6
+
7
+ function makeKv(prefix?: string) {
8
+ const opts = prefix != null ? { storage: createStorage(), prefix } : { storage: createStorage() };
9
+ return createUnstorageKv(opts);
10
+ }
11
+
12
+ describe("createUnstorageKv", () => {
13
+ test("get returns null for missing key", async () => {
14
+ const kv = makeKv();
15
+ expect(await kv.get("nope")).toBe(null);
16
+ });
17
+
18
+ test("set then get with auto-serialization", async () => {
19
+ const kv = makeKv();
20
+ await kv.set("k1", { name: "alice", age: 30 });
21
+ expect(await kv.get("k1")).toEqual({ name: "alice", age: 30 });
22
+ });
23
+
24
+ test("set then get with string value", async () => {
25
+ const kv = makeKv();
26
+ await kv.set("k1", "hello");
27
+ expect(await kv.get("k1")).toBe("hello");
28
+ });
29
+
30
+ test("set then get with number value", async () => {
31
+ const kv = makeKv();
32
+ await kv.set("k1", 42);
33
+ expect(await kv.get("k1")).toBe(42);
34
+ });
35
+
36
+ test("delete removes key", async () => {
37
+ const kv = makeKv();
38
+ await kv.set("k1", "v1");
39
+ await kv.delete("k1");
40
+ expect(await kv.get("k1")).toBe(null);
41
+ });
42
+
43
+ test("rejects oversized values", async () => {
44
+ const kv = makeKv();
45
+ const big = "x".repeat(65_537);
46
+ await expect(kv.set("big", big)).rejects.toThrow("exceeds max size");
47
+ });
48
+
49
+ test("set with expireIn passes ttl to driver", async () => {
50
+ const kv = makeKv();
51
+ await kv.set("temp", "val", { expireIn: 10_000 });
52
+ expect(await kv.get("temp")).toBe("val");
53
+ });
54
+
55
+ test("overwrite replaces value", async () => {
56
+ const kv = makeKv();
57
+ await kv.set("k", "v1");
58
+ await kv.set("k", "v2");
59
+ expect(await kv.get("k")).toBe("v2");
60
+ });
61
+
62
+ test("separate instances have isolated stores", async () => {
63
+ const kv1 = makeKv();
64
+ const kv2 = makeKv();
65
+ await kv1.set("x", "from1");
66
+ expect(await kv2.get("x")).toBe(null);
67
+ });
68
+
69
+ test("get with generic type", async () => {
70
+ const kv = makeKv();
71
+ await kv.set("user", { name: "alice", age: 30 });
72
+ const user = await kv.get<{ name: string; age: number }>("user");
73
+ expect(user?.name).toBe("alice");
74
+ expect(user?.age).toBe(30);
75
+ });
76
+
77
+ describe("with prefix", () => {
78
+ test("prefix isolates keys", async () => {
79
+ const storage = createStorage();
80
+ const kv1 = createUnstorageKv({ storage, prefix: "ns1" });
81
+ const kv2 = createUnstorageKv({ storage, prefix: "ns2" });
82
+ await kv1.set("key", "from-ns1");
83
+ await kv2.set("key", "from-ns2");
84
+ expect(await kv1.get("key")).toBe("from-ns1");
85
+ expect(await kv2.get("key")).toBe("from-ns2");
86
+ });
87
+ });
88
+
89
+ test("delete with array of keys", async () => {
90
+ const kv = makeKv();
91
+ await kv.set("a", 1);
92
+ await kv.set("b", 2);
93
+ await kv.set("c", 3);
94
+ await kv.delete(["a", "c"]);
95
+ expect(await kv.get("a")).toBe(null);
96
+ expect(await kv.get("b")).toBe(2);
97
+ expect(await kv.get("c")).toBe(null);
98
+ });
99
+ });