@alexkroman1/aai 1.7.1 → 1.8.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.
Files changed (133) hide show
  1. package/.turbo/turbo-build.log +11 -9
  2. package/CHANGELOG.md +10 -0
  3. package/dist/{_internal-types-CrnTi9Ew.js → _internal-types-CfOAbK6V.js} +22 -35
  4. package/dist/constants-y68COEGj.js +29 -0
  5. package/dist/host/_base64.d.ts +2 -0
  6. package/dist/host/_mock-ws.d.ts +0 -61
  7. package/dist/host/_pipeline-test-fakes.d.ts +7 -4
  8. package/dist/host/_run-code.d.ts +0 -25
  9. package/dist/host/_runtime-conformance.d.ts +3 -34
  10. package/dist/host/memory-vector.d.ts +0 -11
  11. package/dist/host/providers/resolve-kv.d.ts +0 -7
  12. package/dist/host/providers/resolve-vector.d.ts +0 -8
  13. package/dist/host/providers/stt/assemblyai.d.ts +0 -14
  14. package/dist/host/providers/stt/deepgram.d.ts +2 -14
  15. package/dist/host/providers/stt/soniox.d.ts +0 -22
  16. package/dist/host/providers/tts/rime.d.ts +10 -31
  17. package/dist/host/runtime-barrel.js +619 -630
  18. package/dist/host/runtime-config.d.ts +9 -6
  19. package/dist/host/runtime.d.ts +3 -0
  20. package/dist/host/to-vercel-tools.d.ts +3 -33
  21. package/dist/host/transports/openai-realtime-transport.d.ts +43 -0
  22. package/dist/host/unstorage-kv.d.ts +0 -26
  23. package/dist/index.js +3 -3
  24. package/dist/openai-realtime-cjPAHMMx.js +10 -0
  25. package/dist/sdk/_internal-types.d.ts +6 -55
  26. package/dist/sdk/allowed-hosts.d.ts +4 -3
  27. package/dist/sdk/constants.d.ts +4 -29
  28. package/dist/sdk/define.d.ts +7 -4
  29. package/dist/sdk/kv.d.ts +13 -37
  30. package/dist/sdk/manifest-barrel.js +1 -1
  31. package/dist/sdk/manifest.d.ts +8 -2
  32. package/dist/sdk/protocol.js +1 -1
  33. package/dist/sdk/providers/s2s/openai-realtime.d.ts +17 -0
  34. package/dist/sdk/providers/s2s-barrel.d.ts +9 -0
  35. package/dist/sdk/providers/s2s-barrel.js +2 -0
  36. package/dist/sdk/providers/tts/rime.d.ts +1 -1
  37. package/dist/sdk/providers.d.ts +6 -2
  38. package/dist/sdk/types.d.ts +7 -1
  39. package/dist/{types-KUgezM6u.js → types-DOWVZhb9.js} +1 -7
  40. package/dist/{ws-upgrade-BeOQ7fXL.js → ws-upgrade-CG8-by1n.js} +2 -3
  41. package/host/_base64.ts +9 -0
  42. package/host/_mock-ws.ts +0 -65
  43. package/host/_pipeline-test-fakes.ts +19 -31
  44. package/host/_run-code.ts +10 -53
  45. package/host/_runtime-conformance.ts +3 -44
  46. package/host/_test-utils.ts +20 -42
  47. package/host/builtin-tools.test.ts +127 -222
  48. package/host/builtin-tools.ts +6 -10
  49. package/host/cleanup.test.ts +30 -73
  50. package/host/integration/pipeline-reference.integration.test.ts +12 -17
  51. package/host/integration.test.ts +0 -7
  52. package/host/memory-vector.test.ts +3 -1
  53. package/host/memory-vector.ts +16 -21
  54. package/host/pinecone-vector.test.ts +14 -17
  55. package/host/pinecone-vector.ts +10 -19
  56. package/host/providers/providers.test-d.ts +5 -3
  57. package/host/providers/resolve-kv.ts +23 -41
  58. package/host/providers/resolve-vector.ts +3 -12
  59. package/host/providers/resolve.test.ts +15 -28
  60. package/host/providers/resolve.ts +24 -24
  61. package/host/providers/stt/assemblyai.test.ts +2 -14
  62. package/host/providers/stt/assemblyai.ts +12 -35
  63. package/host/providers/stt/deepgram.test.ts +23 -83
  64. package/host/providers/stt/deepgram.ts +15 -40
  65. package/host/providers/stt/elevenlabs.test.ts +26 -38
  66. package/host/providers/stt/elevenlabs.ts +10 -9
  67. package/host/providers/stt/soniox.test.ts +35 -85
  68. package/host/providers/stt/soniox.ts +8 -53
  69. package/host/providers/tts/cartesia.test.ts +19 -58
  70. package/host/providers/tts/cartesia.ts +36 -66
  71. package/host/providers/tts/rime.test.ts +12 -38
  72. package/host/providers/tts/rime.ts +23 -86
  73. package/host/runtime-config.test.ts +9 -9
  74. package/host/runtime-config.ts +16 -22
  75. package/host/runtime.test.ts +111 -73
  76. package/host/runtime.ts +138 -86
  77. package/host/s2s.test.ts +92 -191
  78. package/host/s2s.ts +55 -49
  79. package/host/server-shutdown.test.ts +9 -30
  80. package/host/server.test.ts +2 -13
  81. package/host/server.ts +85 -100
  82. package/host/session-core.test.ts +15 -30
  83. package/host/session-core.ts +10 -13
  84. package/host/session-prompt.test.ts +1 -5
  85. package/host/to-vercel-tools.test.ts +53 -72
  86. package/host/to-vercel-tools.ts +9 -39
  87. package/host/tool-executor.test.ts +25 -51
  88. package/host/tool-executor.ts +18 -12
  89. package/host/transports/openai-realtime-transport.test.ts +371 -0
  90. package/host/transports/openai-realtime-transport.ts +319 -0
  91. package/host/transports/pipeline-transport.test.ts +125 -298
  92. package/host/transports/pipeline-transport.ts +20 -68
  93. package/host/transports/s2s-transport-fixtures.test.ts +31 -92
  94. package/host/transports/s2s-transport.test.ts +65 -134
  95. package/host/transports/s2s-transport.ts +15 -43
  96. package/host/transports/types.test.ts +4 -8
  97. package/host/unstorage-kv.test.ts +3 -2
  98. package/host/unstorage-kv.ts +5 -35
  99. package/host/ws-handler.test.ts +72 -176
  100. package/host/ws-handler.ts +6 -12
  101. package/package.json +6 -1
  102. package/sdk/__snapshots__/exports.test.ts.snap +7 -0
  103. package/sdk/__snapshots__/schema-shapes.test.ts.snap +1 -0
  104. package/sdk/_internal-types.test.ts +6 -9
  105. package/sdk/_internal-types.ts +16 -57
  106. package/sdk/_test-matchers.ts +25 -15
  107. package/sdk/allowed-hosts.test.ts +50 -114
  108. package/sdk/allowed-hosts.ts +8 -14
  109. package/sdk/constants.ts +5 -52
  110. package/sdk/define.test.ts +7 -6
  111. package/sdk/define.ts +7 -3
  112. package/sdk/exports.test.ts +6 -1
  113. package/sdk/kv.ts +13 -37
  114. package/sdk/manifest.test-d.ts +5 -0
  115. package/sdk/manifest.test.ts +61 -9
  116. package/sdk/manifest.ts +11 -11
  117. package/sdk/protocol-compat.test.ts +66 -98
  118. package/sdk/protocol-snapshot.test.ts +2 -16
  119. package/sdk/protocol.test.ts +13 -22
  120. package/sdk/providers/s2s/openai-realtime.ts +36 -0
  121. package/sdk/providers/s2s-barrel.ts +12 -0
  122. package/sdk/providers/tts/rime.ts +1 -1
  123. package/sdk/providers.ts +24 -5
  124. package/sdk/schema-alignment.test.ts +25 -73
  125. package/sdk/schema-shapes.test.ts +1 -29
  126. package/sdk/system-prompt.test.ts +0 -1
  127. package/sdk/system-prompt.ts +17 -19
  128. package/sdk/types-inference.test.ts +10 -36
  129. package/sdk/types.ts +7 -0
  130. package/sdk/ws-upgrade.test.ts +24 -23
  131. package/sdk/ws-upgrade.ts +2 -3
  132. package/tsdown.config.ts +8 -11
  133. package/dist/constants-C2nirZUI.js +0 -54
@@ -58,12 +58,14 @@ export type SessionCore = {
58
58
  export function createSessionCore(opts: SessionCoreOptions): SessionCore {
59
59
  const log = opts.logger ?? consoleLogger;
60
60
  const maxHistory = opts.maxHistory ?? DEFAULT_MAX_HISTORY;
61
- const idleMs = (() => {
62
- const raw = opts.agentConfig.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
63
- return raw === 0 || !Number.isFinite(raw) ? 0 : raw;
64
- })();
61
+ const rawIdleMs = opts.agentConfig.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
62
+ const idleMs = rawIdleMs === 0 || !Number.isFinite(rawIdleMs) ? 0 : rawIdleMs;
65
63
 
66
- let reply: ReplyState = { currentReplyId: null, pendingTools: [], toolCallCount: 0 };
64
+ function emptyReply(): ReplyState {
65
+ return { currentReplyId: null, pendingTools: [], toolCallCount: 0 };
66
+ }
67
+
68
+ let reply: ReplyState = emptyReply();
67
69
  let history: Message[] = [];
68
70
  let turnPromise: Promise<void> | null = null;
69
71
  let idleTimer: NodeJS.Timeout | null = null;
@@ -90,12 +92,12 @@ export function createSessionCore(opts: SessionCoreOptions): SessionCore {
90
92
  }
91
93
 
92
94
  function beginReply(replyId: string): void {
93
- reply = { currentReplyId: replyId, pendingTools: [], toolCallCount: 0 };
95
+ reply = { ...emptyReply(), currentReplyId: replyId };
94
96
  turnPromise = null;
95
97
  }
96
98
 
97
99
  function cancelReply(): void {
98
- reply = { currentReplyId: null, pendingTools: [], toolCallCount: 0 };
100
+ reply = emptyReply();
99
101
  }
100
102
 
101
103
  function flushReply(startMs: number, hadTurnPromise: boolean): void {
@@ -209,12 +211,7 @@ export function createSessionCore(opts: SessionCoreOptions): SessionCore {
209
211
  },
210
212
 
211
213
  onToolCall(callId, name, args) {
212
- emit({
213
- type: "tool_call",
214
- toolCallId: callId,
215
- toolName: name,
216
- args: args as Record<string, unknown>,
217
- });
214
+ emit({ type: "tool_call", toolCallId: callId, toolName: name, args });
218
215
  if (reply.currentReplyId === null) {
219
216
  log.warn("tool_call with no active reply", { sid: opts.id, name });
220
217
  return;
@@ -4,13 +4,9 @@ import { DEFAULT_SYSTEM_PROMPT } from "../sdk/types.ts";
4
4
  import { makeConfig } from "./_test-utils.ts";
5
5
 
6
6
  describe("buildSystemPrompt", () => {
7
- test("starts with DEFAULT_SYSTEM_PROMPT when no custom instructions", () => {
7
+ test("uses DEFAULT_SYSTEM_PROMPT with no agent-specific section when no custom instructions", () => {
8
8
  const result = buildSystemPrompt(makeConfig(), { hasTools: false });
9
9
  expect(result.startsWith(DEFAULT_SYSTEM_PROMPT)).toBe(true);
10
- });
11
-
12
- test("does not include agent-specific instructions section for default instructions", () => {
13
- const result = buildSystemPrompt(makeConfig(), { hasTools: false });
14
10
  expect(result).not.toContain("Agent-Specific Instructions:");
15
11
  });
16
12
 
@@ -1,6 +1,7 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
+ import type { Tool, ToolExecutionOptions } from "ai";
2
3
  import { describe, expect, test, vi } from "vitest";
3
- import type { ExecuteTool, ToolSchema } from "../sdk/_internal-types.ts";
4
+ import type { ExecuteTool, ExecuteToolOptions, ToolSchema } from "../sdk/_internal-types.ts";
4
5
  import type { Message } from "../sdk/types.ts";
5
6
  import { toVercelTools } from "./to-vercel-tools.ts";
6
7
 
@@ -17,6 +18,15 @@ const schemas: ToolSchema[] = [
17
18
  },
18
19
  ];
19
20
 
21
+ function runTool(
22
+ tool: Tool | undefined,
23
+ args: Readonly<Record<string, unknown>>,
24
+ options: ToolExecutionOptions,
25
+ ): Promise<unknown> {
26
+ if (!tool?.execute) throw new Error("tool.execute missing");
27
+ return tool.execute(args, options);
28
+ }
29
+
20
30
  describe("toVercelTools", () => {
21
31
  test("produces one Vercel AI SDK tool per schema, keyed by name", () => {
22
32
  const executeTool = vi.fn(async () => "sunny");
@@ -26,9 +36,7 @@ describe("toVercelTools", () => {
26
36
  messages: () => [],
27
37
  });
28
38
  expect(Object.keys(tools)).toEqual(["get_weather"]);
29
- expect(tools.get_weather).toMatchObject({
30
- description: "Look up the weather.",
31
- });
39
+ expect(tools.get_weather).toMatchObject({ description: "Look up the weather." });
32
40
  });
33
41
 
34
42
  test("execute delegates to ctx.executeTool with (name, args, sessionId, messages)", async () => {
@@ -38,9 +46,13 @@ describe("toVercelTools", () => {
38
46
  sessionId: "sess-42",
39
47
  messages: () => [{ role: "user", content: "?" }],
40
48
  });
41
- const result = await tools.get_weather?.execute?.(
49
+ const result = await runTool(
50
+ tools.get_weather,
42
51
  { city: "SF" },
43
- { toolCallId: "tc-1", messages: [] },
52
+ {
53
+ toolCallId: "tc-1",
54
+ messages: [],
55
+ },
44
56
  );
45
57
  expect(executeTool).toHaveBeenCalledWith(
46
58
  "get_weather",
@@ -54,103 +66,77 @@ describe("toVercelTools", () => {
54
66
 
55
67
  test("execute passes through abort signal when provided", async () => {
56
68
  const controller = new AbortController();
57
- const executeTool = vi.fn(
58
- async (
59
- _n: string,
60
- _a: Readonly<Record<string, unknown>>,
61
- _s?: string,
62
- _m?: readonly unknown[],
63
- opts?: { signal?: AbortSignal },
64
- ) => {
65
- expect(opts?.signal).toBe(controller.signal);
66
- return "ok";
67
- },
68
- );
69
+ let received: ExecuteToolOptions | undefined;
70
+ const executeTool: ExecuteTool = async (_n, _a, _s, _m, opts) => {
71
+ received = opts;
72
+ return "ok";
73
+ };
69
74
  const tools = toVercelTools(schemas, {
70
75
  executeTool,
71
76
  sessionId: "s",
72
77
  messages: () => [],
73
78
  signal: controller.signal,
74
79
  });
75
- await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-2", messages: [] });
76
- expect(executeTool).toHaveBeenCalledTimes(1);
80
+ await runTool(tools.get_weather, { city: "NY" }, { toolCallId: "tc-2", messages: [] });
81
+ expect(received?.signal).toBe(controller.signal);
77
82
  });
78
83
 
79
84
  test("execute prefers options.abortSignal over ctx.signal", async () => {
80
85
  const ctxController = new AbortController();
81
86
  const callController = new AbortController();
82
- let receivedSignal: AbortSignal | undefined;
83
- const executeTool = vi.fn(
84
- async (
85
- _n: string,
86
- _a: Readonly<Record<string, unknown>>,
87
- _s?: string,
88
- _m?: readonly unknown[],
89
- opts?: { signal?: AbortSignal },
90
- ) => {
91
- receivedSignal = opts?.signal;
92
- return "ok";
93
- },
94
- );
87
+ let received: ExecuteToolOptions | undefined;
88
+ const executeTool: ExecuteTool = async (_n, _a, _s, _m, opts) => {
89
+ received = opts;
90
+ return "ok";
91
+ };
95
92
  const tools = toVercelTools(schemas, {
96
93
  executeTool,
97
94
  sessionId: "s",
98
95
  messages: () => [],
99
96
  signal: ctxController.signal,
100
97
  });
101
- await tools.get_weather?.execute?.(
98
+ await runTool(
99
+ tools.get_weather,
102
100
  { city: "NY" },
103
- { toolCallId: "tc-1", messages: [], abortSignal: callController.signal },
101
+ {
102
+ toolCallId: "tc-1",
103
+ messages: [],
104
+ abortSignal: callController.signal,
105
+ },
104
106
  );
105
- expect(receivedSignal).toBe(callController.signal);
107
+ expect(received?.signal).toBe(callController.signal);
106
108
  });
107
109
 
108
110
  test("execute falls back to ctx.signal when options.abortSignal is absent", async () => {
109
111
  const ctxController = new AbortController();
110
- let receivedSignal: AbortSignal | undefined;
111
- const executeTool = vi.fn(
112
- async (
113
- _n: string,
114
- _a: Readonly<Record<string, unknown>>,
115
- _s?: string,
116
- _m?: readonly unknown[],
117
- opts?: { signal?: AbortSignal },
118
- ) => {
119
- receivedSignal = opts?.signal;
120
- return "ok";
121
- },
122
- );
112
+ let received: ExecuteToolOptions | undefined;
113
+ const executeTool: ExecuteTool = async (_n, _a, _s, _m, opts) => {
114
+ received = opts;
115
+ return "ok";
116
+ };
123
117
  const tools = toVercelTools(schemas, {
124
118
  executeTool,
125
119
  sessionId: "s",
126
120
  messages: () => [],
127
121
  signal: ctxController.signal,
128
122
  });
129
- await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-2", messages: [] });
130
- expect(receivedSignal).toBe(ctxController.signal);
123
+ await runTool(tools.get_weather, { city: "NY" }, { toolCallId: "tc-2", messages: [] });
124
+ expect(received?.signal).toBe(ctxController.signal);
131
125
  });
132
126
 
133
127
  test("execute propagates toolCallId from options", async () => {
134
- let receivedCallId: string | undefined;
135
- const executeTool = vi.fn(
136
- async (
137
- _n: string,
138
- _a: Readonly<Record<string, unknown>>,
139
- _s?: string,
140
- _m?: readonly unknown[],
141
- opts?: { toolCallId?: string },
142
- ) => {
143
- receivedCallId = opts?.toolCallId;
144
- return "ok";
145
- },
146
- );
128
+ let received: ExecuteToolOptions | undefined;
129
+ const executeTool: ExecuteTool = async (_n, _a, _s, _m, opts) => {
130
+ received = opts;
131
+ return "ok";
132
+ };
147
133
  const tools = toVercelTools(schemas, {
148
134
  executeTool,
149
135
  sessionId: "s",
150
136
  messages: () => [],
151
137
  });
152
- await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-3", messages: [] });
153
- expect(receivedCallId).toBe("tc-3");
138
+ await runTool(tools.get_weather, { city: "NY" }, { toolCallId: "tc-3", messages: [] });
139
+ expect(received?.toolCallId).toBe("tc-3");
154
140
  });
155
141
  });
156
142
 
@@ -161,7 +147,6 @@ describe("toVercelTools — message snapshot isolation", () => {
161
147
 
162
148
  const executeTool: ExecuteTool = async (_name, _args, _sid, msgs) => {
163
149
  observedInsideExecute = msgs;
164
- // Mutate the original array; the snapshot we captured must be unaffected.
165
150
  messagesBox.messages.push({ role: "user", content: "second" });
166
151
  return "ok";
167
152
  };
@@ -182,13 +167,9 @@ describe("toVercelTools — message snapshot isolation", () => {
182
167
  },
183
168
  );
184
169
 
185
- const t = tools.t;
186
- if (!t?.execute) throw new Error("tool.execute missing");
187
- await t.execute({}, { toolCallId: "c1", messages: [] });
170
+ await runTool(tools.t, {}, { toolCallId: "c1", messages: [] });
188
171
 
189
- // The caller-observable messages array has 2 entries after the push.
190
172
  expect(messagesBox.messages).toHaveLength(2);
191
- // But the snapshot the tool executed against was frozen at length 1.
192
173
  expect(observedInsideExecute).toHaveLength(1);
193
174
  expect(observedInsideExecute?.[0]).toMatchObject({ content: "first" });
194
175
  });
@@ -1,18 +1,8 @@
1
1
  // Copyright 2025 the AAI authors. MIT license.
2
2
  /**
3
- * Converts agent {@link ToolSchema}[] to Vercel AI SDK tools with `execute`
4
- * delegation to the agent's {@link ExecuteTool} function.
5
- *
6
- * The pipeline orchestrator passes the output to `streamText({ tools })`.
7
- * Each produced tool's `execute` closure calls
8
- * `ctx.executeTool(name, args, sessionId, messages(), { signal, toolCallId })`,
9
- * so the existing agent tool infrastructure (argument validation, KV, hooks,
10
- * timeout) remains the single source of truth for tool behavior.
11
- *
12
- * Per-call `options.abortSignal` (forwarded by `streamText` when the
13
- * outer turn is aborted, e.g. barge-in) takes precedence over the
14
- * bag-level `ctx.signal` so individual invocations respect streamText
15
- * aborts.
3
+ * Converts agent {@link ToolSchema}[] to Vercel AI SDK tools, delegating
4
+ * `execute` to the agent's {@link ExecuteTool} so validation, KV, hooks,
5
+ * and timeouts remain the single source of truth for tool behavior.
16
6
  */
17
7
 
18
8
  import { jsonSchema, type Tool, type ToolExecutionOptions, tool } from "ai";
@@ -20,32 +10,12 @@ import type { ExecuteTool, ExecuteToolOptions, ToolSchema } from "../sdk/_intern
20
10
  import type { Message } from "../sdk/types.ts";
21
11
 
22
12
  export interface ToVercelToolsContext {
23
- /** The agent's tool-execution function (from the runtime). */
24
13
  executeTool: ExecuteTool;
25
- /** Session id threaded to {@link executeTool}. */
26
14
  sessionId: string;
27
- /**
28
- * Returns the current conversation history at call-time. The orchestrator
29
- * calls this per invocation; `toVercelTools` snapshots the returned array
30
- * before forwarding to `executeTool` so concurrent mutations cannot leak
31
- * across tool calls.
32
- */
33
15
  messages: () => readonly Message[];
34
- /**
35
- * Bag-level abort signal. Used as a fallback when the per-call
36
- * `options.abortSignal` from Vercel's `ToolExecutionOptions` is absent.
37
- */
38
16
  signal?: AbortSignal;
39
17
  }
40
18
 
41
- /**
42
- * Convert an array of {@link ToolSchema} to a Vercel AI SDK `ToolSet`
43
- * (record keyed by tool name).
44
- *
45
- * Uses the v6 `tool()` helper with `inputSchema: jsonSchema(...)` wrapping
46
- * the agent's JSON Schema `parameters`. Execution is delegated to
47
- * `ctx.executeTool` so validation, KV, timeouts, and hooks keep working.
48
- */
49
19
  export function toVercelTools(
50
20
  schemas: readonly ToolSchema[],
51
21
  ctx: ToVercelToolsContext,
@@ -57,16 +27,16 @@ export function toVercelTools(
57
27
  inputSchema: jsonSchema(schema.parameters),
58
28
  execute: async (args: unknown, options: ToolExecutionOptions) => {
59
29
  const input = (args ?? {}) as Readonly<Record<string, unknown>>;
60
- // Prefer the per-call abortSignal forwarded by streamText over the
61
- // bag-level ctx.signal so individual invocations respect aborts.
30
+ // Per-call abortSignal from streamText takes precedence over bag-level
31
+ // ctx.signal so individual invocations respect outer-turn aborts.
62
32
  const signal = options.abortSignal ?? ctx.signal;
63
33
  const opts: ExecuteToolOptions = {};
64
34
  if (signal !== undefined) opts.signal = signal;
65
35
  if (options.toolCallId !== undefined) opts.toolCallId = options.toolCallId;
66
- // Snapshot the messages array so concurrent mutation (e.g. a new
67
- // turn starting after this one was aborted) can't leak into this
68
- // tool's view of history.
69
- return ctx.executeTool(schema.name, input, ctx.sessionId, ctx.messages().slice(), opts);
36
+ // Snapshot history so concurrent mutation from a newer turn can't
37
+ // leak into this tool's view.
38
+ const history = ctx.messages().slice();
39
+ return ctx.executeTool(schema.name, input, ctx.sessionId, history, opts);
70
40
  },
71
41
  });
72
42
  }
@@ -46,31 +46,21 @@ describe("executeToolCall", () => {
46
46
  });
47
47
 
48
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" }));
49
+ const tool = makeTool({
50
+ execute: () => {
51
+ throw new Error("boom");
52
+ },
53
+ });
54
+ expect(await run("fail", {}, tool)).toBe(JSON.stringify({ error: "boom" }));
60
55
  });
61
56
 
62
57
  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" }));
58
+ const tool = makeTool({
59
+ execute: () => {
60
+ throw new Error("string error");
61
+ },
62
+ });
63
+ expect(await run("fail", {}, tool)).toBe(JSON.stringify({ error: "string error" }));
74
64
  });
75
65
 
76
66
  test("passes env to tool context", async () => {
@@ -86,15 +76,11 @@ describe("executeToolCall", () => {
86
76
  test("kv throws when not provided", async () => {
87
77
  const tool = makeTool({
88
78
  execute: (_args, ctx) => {
89
- try {
90
- void ctx.kv;
91
- return "no error";
92
- } catch (e) {
93
- return (e as Error).message;
94
- }
79
+ void ctx.kv;
80
+ return "no error";
95
81
  },
96
82
  });
97
- expect(await run("test", {}, tool)).toBe("KV not available");
83
+ expect(await run("test", {}, tool)).toBe(JSON.stringify({ error: "KV not available" }));
98
84
  });
99
85
 
100
86
  test("handles async tool execution", async () => {
@@ -111,50 +97,38 @@ describe("executeToolCall", () => {
111
97
  vi.useFakeTimers();
112
98
  const tool = makeTool({
113
99
  execute: () =>
114
- new Promise(() => {
100
+ new Promise<never>(() => {
115
101
  /* never resolves */
116
102
  }),
117
103
  });
118
104
  const promise = run("slow", {}, tool);
119
105
  await vi.advanceTimersByTimeAsync(30_000);
120
- const result = await promise;
121
- expect(result).toBe(JSON.stringify({ error: 'Tool "slow" timed out after 30000ms' }));
106
+ expect(await promise).toBe(JSON.stringify({ error: 'Tool "slow" timed out after 30000ms' }));
122
107
  vi.useRealTimers();
123
108
  });
124
109
 
125
110
  test("ctx.send calls the send callback", async () => {
126
111
  const sends: Array<{ event: string; data: unknown }> = [];
127
- const tool: ToolDef = {
128
- description: "sends an event",
129
- parameters: z.object({}),
112
+ const tool = makeTool({
130
113
  execute: (_args, ctx) => {
131
114
  ctx.send("game_state", { hp: 10 });
132
115
  return "ok";
133
116
  },
134
- };
135
- const result = await executeToolCall(
136
- "sender",
137
- {},
138
- {
139
- tool,
140
- env: {},
141
- send: (event, data) => sends.push({ event, data }),
142
- },
143
- );
117
+ });
118
+ const result = await run("sender", {}, tool, {
119
+ send: (event: string, data: unknown) => sends.push({ event, data }),
120
+ });
144
121
  expect(result).toBe("ok");
145
122
  expect(sends).toEqual([{ event: "game_state", data: { hp: 10 } }]);
146
123
  });
147
124
 
148
125
  test("ctx.send is a no-op when no send callback provided", async () => {
149
- const tool: ToolDef = {
150
- description: "sends an event",
151
- parameters: z.object({}),
126
+ const tool = makeTool({
152
127
  execute: (_args, ctx) => {
153
128
  ctx.send("test", {});
154
129
  return "ok";
155
130
  },
156
- };
157
- const result = await executeToolCall("sender", {}, { tool, env: {} });
158
- expect(result).toBe("ok");
131
+ });
132
+ expect(await run("sender", {}, tool)).toBe("ok");
159
133
  });
160
134
  });
@@ -33,7 +33,7 @@ export type ExecuteToolCallOptions = {
33
33
  };
34
34
 
35
35
  function buildToolContext(opts: ExecuteToolCallOptions): ToolContext {
36
- const { env, state, kv, vector, messages, sessionId } = opts;
36
+ const { env, state, kv, vector, messages, sessionId, send } = opts;
37
37
  return {
38
38
  env,
39
39
  state: state ?? {},
@@ -48,24 +48,32 @@ function buildToolContext(opts: ExecuteToolCallOptions): ToolContext {
48
48
  messages: messages ?? [],
49
49
  sessionId: sessionId ?? "",
50
50
  send(event: string, data: unknown): void {
51
- opts.send?.(event, data);
51
+ send?.(event, data);
52
52
  },
53
53
  };
54
54
  }
55
55
 
56
+ function formatZodIssues(error: z.ZodError | undefined): string {
57
+ return (error?.issues ?? [])
58
+ .map((i: z.ZodIssue) => `${i.path.map(String).join(".")}: ${i.message}`)
59
+ .join(", ");
60
+ }
61
+
62
+ function stringifyResult(result: unknown): string {
63
+ if (result == null) return "null";
64
+ return typeof result === "string" ? result : JSON.stringify(result);
65
+ }
66
+
56
67
  export async function executeToolCall(
57
68
  name: string,
58
69
  args: Readonly<Record<string, unknown>>,
59
70
  options: ExecuteToolCallOptions,
60
71
  ): Promise<string> {
61
- const { tool } = options;
72
+ const { tool, logger } = options;
62
73
  const schema = tool.parameters ?? EMPTY_PARAMS;
63
74
  const parsed = schema.safeParse(args);
64
75
  if (!parsed.success) {
65
- const issues = (parsed.error?.issues ?? [])
66
- .map((i: z.ZodIssue) => `${i.path.map(String).join(".")}: ${i.message}`)
67
- .join(", ");
68
- return toolError(`Invalid arguments for tool "${name}": ${issues}`);
76
+ return toolError(`Invalid arguments for tool "${name}": ${formatZodIssues(parsed.error)}`);
69
77
  }
70
78
 
71
79
  try {
@@ -76,12 +84,10 @@ export async function executeToolCall(
76
84
  message: `Tool "${name}" timed out after ${TOOL_EXECUTION_TIMEOUT_MS}ms`,
77
85
  });
78
86
  await yieldTick();
79
- if (result == null) return "null";
80
- return typeof result === "string" ? result : JSON.stringify(result);
87
+ return stringifyResult(result);
81
88
  } catch (err: unknown) {
82
- const log = options.logger;
83
- if (log) {
84
- log.warn("Tool execution failed", { tool: name, error: errorDetail(err) });
89
+ if (logger) {
90
+ logger.warn("Tool execution failed", { tool: name, error: errorDetail(err) });
85
91
  } else {
86
92
  console.warn(`[tool-executor] Tool execution failed: ${name}`, err);
87
93
  }