@alexkroman1/aai 1.2.3 → 1.3.1

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 (69) hide show
  1. package/.turbo/turbo-build.log +14 -12
  2. package/CHANGELOG.md +20 -0
  3. package/dist/{constants-VTFoymJ-.js → constants-BL3nvg4I.js} +8 -1
  4. package/dist/host/_pipeline-test-fakes.d.ts +117 -0
  5. package/dist/host/pipeline-session-ctx.d.ts +24 -0
  6. package/dist/host/pipeline-session.d.ts +48 -0
  7. package/dist/host/providers/llm.d.ts +2 -0
  8. package/dist/host/providers/stt/assemblyai.d.ts +31 -0
  9. package/dist/host/providers/stt-barrel.d.ts +8 -0
  10. package/dist/host/providers/stt-barrel.js +92 -0
  11. package/dist/host/providers/stt.d.ts +2 -0
  12. package/dist/host/providers/tts/cartesia.d.ts +39 -0
  13. package/dist/host/providers/tts-barrel.d.ts +8 -0
  14. package/dist/host/providers/tts-barrel.js +182 -0
  15. package/dist/host/providers/tts.d.ts +2 -0
  16. package/dist/host/runtime-barrel.js +565 -81
  17. package/dist/host/runtime.d.ts +17 -0
  18. package/dist/host/s2s.d.ts +5 -0
  19. package/dist/host/session-ctx.d.ts +22 -4
  20. package/dist/host/to-vercel-tools.d.ts +45 -0
  21. package/dist/index.js +7 -2
  22. package/dist/sdk/_internal-types.d.ts +15 -1
  23. package/dist/sdk/constants.d.ts +7 -0
  24. package/dist/sdk/define.d.ts +21 -0
  25. package/dist/sdk/manifest.d.ts +22 -0
  26. package/dist/sdk/protocol.d.ts +3 -3
  27. package/dist/sdk/protocol.js +1 -1
  28. package/dist/sdk/providers.d.ts +70 -0
  29. package/dist/sdk/types.d.ts +16 -0
  30. package/exports-no-dev-deps.test.ts +39 -14
  31. package/host/_pipeline-test-fakes.ts +357 -0
  32. package/host/_test-utils.ts +1 -0
  33. package/host/integration/fixtures/README.md +49 -0
  34. package/host/integration/pipeline-reference.integration.test.ts +124 -0
  35. package/host/pipeline-session-ctx.test.ts +31 -0
  36. package/host/pipeline-session-ctx.ts +36 -0
  37. package/host/pipeline-session.test.ts +572 -0
  38. package/host/pipeline-session.ts +489 -0
  39. package/host/providers/llm.ts +3 -0
  40. package/host/providers/providers.test-d.ts +31 -0
  41. package/host/providers/stt/assemblyai.test.ts +100 -0
  42. package/host/providers/stt/assemblyai.ts +154 -0
  43. package/host/providers/stt/fixtures/assemblyai/basic-turn.json +30 -0
  44. package/host/providers/stt-barrel.ts +13 -0
  45. package/host/providers/stt.ts +3 -0
  46. package/host/providers/tts/cartesia.test.ts +210 -0
  47. package/host/providers/tts/cartesia.ts +251 -0
  48. package/host/providers/tts-barrel.ts +13 -0
  49. package/host/providers/tts.ts +3 -0
  50. package/host/runtime.test.ts +81 -1
  51. package/host/runtime.ts +61 -0
  52. package/host/s2s.test.ts +19 -0
  53. package/host/s2s.ts +10 -0
  54. package/host/session-ctx.ts +35 -8
  55. package/host/to-vercel-tools.test.ts +187 -0
  56. package/host/to-vercel-tools.ts +74 -0
  57. package/package.json +15 -1
  58. package/sdk/__snapshots__/exports.test.ts.snap +2 -0
  59. package/sdk/_internal-types.ts +16 -0
  60. package/sdk/constants.ts +8 -0
  61. package/sdk/define.test-d.ts +21 -0
  62. package/sdk/define.test.ts +33 -0
  63. package/sdk/define.ts +21 -0
  64. package/sdk/manifest.test-d.ts +14 -0
  65. package/sdk/manifest.test.ts +51 -0
  66. package/sdk/manifest.ts +39 -0
  67. package/sdk/providers.ts +90 -0
  68. package/sdk/types.ts +16 -0
  69. package/vitest.config.ts +1 -0
@@ -5,8 +5,13 @@ import { afterEach, describe, expect, test, vi } from "vitest";
5
5
  import { z } from "zod";
6
6
  import { toAgentConfig } from "../sdk/_internal-types.ts";
7
7
  import type { ToolDef } from "../sdk/types.ts";
8
+ import {
9
+ createFakeLanguageModel,
10
+ createFakeSttProvider,
11
+ createFakeTtsProvider,
12
+ } from "./_pipeline-test-fakes.ts";
8
13
  import { CONFORMANCE_AGENT, testRuntime } from "./_runtime-conformance.ts";
9
- import { flush, makeAgent, makeMockHandle, silentLogger } from "./_test-utils.ts";
14
+ import { flush, makeAgent, makeClient, makeMockHandle, silentLogger } from "./_test-utils.ts";
10
15
  import { createRuntime } from "./runtime.ts";
11
16
  import { _internals } from "./session.ts";
12
17
  import { executeToolCall } from "./tool-executor.ts";
@@ -629,6 +634,81 @@ describe("createRuntime with custom options", () => {
629
634
  });
630
635
  });
631
636
 
637
+ describe("Runtime — session routing", () => {
638
+ test("manifest with stt/llm/tts routes to PipelineSession (no S2S socket opened)", async () => {
639
+ const createWebSocket = vi.fn();
640
+ const stt = createFakeSttProvider();
641
+ const tts = createFakeTtsProvider();
642
+ const llm = createFakeLanguageModel({ script: [] });
643
+
644
+ const runtime = createRuntime({
645
+ agent: makeAgent(),
646
+ env: { ASSEMBLYAI_API_KEY: "stt-key", CARTESIA_API_KEY: "tts-key" },
647
+ logger: silentLogger,
648
+ createWebSocket,
649
+ stt,
650
+ llm,
651
+ tts,
652
+ });
653
+
654
+ const client = makeClient();
655
+ const session = runtime.createSession({
656
+ id: "sess-pipeline",
657
+ agent: "test-agent",
658
+ client,
659
+ });
660
+
661
+ expect(typeof session.start).toBe("function");
662
+ expect(typeof session.stop).toBe("function");
663
+
664
+ // Opening providers drives the pipeline path end-to-end; the S2S WS factory
665
+ // must never be called for a pipeline-mode session.
666
+ await session.start();
667
+ expect(stt.last()).toBeDefined();
668
+ expect(tts.last()).toBeDefined();
669
+ expect(createWebSocket).not.toHaveBeenCalled();
670
+
671
+ // Pipeline providers saw the resolved host-side credentials.
672
+ expect(stt.last()?.opts.apiKey).toBe("stt-key");
673
+ expect(tts.last()?.opts.apiKey).toBe("tts-key");
674
+
675
+ await session.stop();
676
+ });
677
+
678
+ test("manifest without stt/llm/tts routes to S2sSession (createWebSocket IS called)", async () => {
679
+ const mockHandle = makeMockHandle();
680
+ const connectSpy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
681
+ setTimeout(() => mockHandle._fire("ready", { sessionId: "mock-sid" }), 0);
682
+ return mockHandle;
683
+ });
684
+
685
+ const createWebSocket = vi.fn();
686
+ const runtime = createRuntime({
687
+ agent: makeAgent(),
688
+ env: { ASSEMBLYAI_API_KEY: "s2s-key" },
689
+ logger: silentLogger,
690
+ createWebSocket,
691
+ });
692
+
693
+ const client = makeClient();
694
+ const session = runtime.createSession({
695
+ id: "sess-s2s",
696
+ agent: "test-agent",
697
+ client,
698
+ });
699
+
700
+ await session.start();
701
+ // connectS2s is the seam that consumes our createWebSocket factory inside
702
+ // the S2S path. If routing picked the pipeline branch this would never fire.
703
+ expect(connectSpy).toHaveBeenCalledWith(
704
+ expect.objectContaining({ createWebSocket, apiKey: "s2s-key" }),
705
+ );
706
+
707
+ await session.stop();
708
+ connectSpy.mockRestore();
709
+ });
710
+ });
711
+
632
712
  // ── Shared conformance suite (same tests run against sandbox in integration) ─
633
713
 
634
714
  const directExec = createRuntime({
package/host/runtime.ts CHANGED
@@ -14,9 +14,11 @@ import { DEFAULT_SHUTDOWN_TIMEOUT_MS } from "../sdk/constants.ts";
14
14
  import type { Kv } from "../sdk/kv.ts";
15
15
  import type { ClientSink } from "../sdk/protocol.ts";
16
16
  import { buildReadyConfig, type ReadyConfig } from "../sdk/protocol.ts";
17
+ import type { LlmProvider, SttProvider, TtsProvider } from "../sdk/providers.ts";
17
18
  import type { AgentDef } from "../sdk/types.ts";
18
19
  import { toolError } from "../sdk/utils.ts";
19
20
  import { resolveAllBuiltins } from "./builtin-tools.ts";
21
+ import { createPipelineSession } from "./pipeline-session.ts";
20
22
  import type { Logger, S2SConfig } from "./runtime-config.ts";
21
23
  import { consoleLogger, DEFAULT_S2S_CONFIG } from "./runtime-config.ts";
22
24
  import type { CreateS2sWebSocket } from "./s2s.ts";
@@ -58,6 +60,18 @@ function createLocalKv(): Kv {
58
60
  return createUnstorageKv({ storage: createStorage() });
59
61
  }
60
62
 
63
+ /**
64
+ * Resolve an API key host-side for pipeline providers.
65
+ *
66
+ * Checks the agent's declared env first, then the host process env as a
67
+ * fallback. Returns `""` when absent — pipeline providers surface a clear
68
+ * `MissingCredentialsError` via their `open()` that the orchestrator
69
+ * converts to a `session.error` wire event.
70
+ */
71
+ function resolveApiKey(envVar: string, env: Record<string, string>): string {
72
+ return env[envVar] ?? process.env[envVar] ?? "";
73
+ }
74
+
61
75
  /**
62
76
  * Configuration for {@link createRuntime}.
63
77
  *
@@ -111,6 +125,22 @@ export type RuntimeOptions = {
111
125
  * their own fetch wrapper.
112
126
  */
113
127
  fetch?: typeof globalThis.fetch | undefined;
128
+ /**
129
+ * Pluggable STT provider. Must be set together with `llm` and `tts` to
130
+ * route sessions through the pipeline path; leave all three unset for
131
+ * the default AssemblyAI Streaming Speech-to-Speech (S2S) path.
132
+ */
133
+ stt?: SttProvider | undefined;
134
+ /**
135
+ * Pluggable LLM provider (Vercel AI SDK `LanguageModel`). Must be set
136
+ * together with `stt` and `tts` to route sessions through the pipeline path.
137
+ */
138
+ llm?: LlmProvider | undefined;
139
+ /**
140
+ * Pluggable TTS provider. Must be set together with `stt` and `llm` to
141
+ * route sessions through the pipeline path.
142
+ */
143
+ tts?: TtsProvider | undefined;
114
144
  };
115
145
 
116
146
  /**
@@ -160,6 +190,14 @@ export function createRuntime(opts: RuntimeOptions): Runtime {
160
190
  sessionStartTimeoutMs,
161
191
  shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
162
192
  } = opts;
193
+ // Derive session mode from the provider triple: all three set ⇒ pipeline,
194
+ // none set ⇒ s2s. Anything in-between is a configuration error.
195
+ const providerCount =
196
+ (opts.stt != null ? 1 : 0) + (opts.llm != null ? 1 : 0) + (opts.tts != null ? 1 : 0);
197
+ if (providerCount !== 0 && providerCount !== 3) {
198
+ throw new Error("stt, llm, and tts must be set together");
199
+ }
200
+ const mode: "s2s" | "pipeline" = providerCount === 3 ? "pipeline" : "s2s";
163
201
  const agentConfig = toAgentConfig(agent);
164
202
  const sessions = new Map<string, Session>();
165
203
  const sinkMap = new Map<string, ClientSink>();
@@ -241,6 +279,29 @@ export function createRuntime(opts: RuntimeOptions): Runtime {
241
279
  resumeFrom?: string;
242
280
  }): Session {
243
281
  sinkMap.set(sessionOpts.id, sessionOpts.client);
282
+ if (mode === "pipeline") {
283
+ // biome-ignore lint/style/noNonNullAssertion: providerCount === 3 ⇒ all set
284
+ const stt = opts.stt!;
285
+ // biome-ignore lint/style/noNonNullAssertion: providerCount === 3 ⇒ all set
286
+ const llm = opts.llm!;
287
+ // biome-ignore lint/style/noNonNullAssertion: providerCount === 3 ⇒ all set
288
+ const tts = opts.tts!;
289
+ return createPipelineSession({
290
+ id: sessionOpts.id,
291
+ agent: sessionOpts.agent,
292
+ client: sessionOpts.client,
293
+ agentConfig,
294
+ toolSchemas,
295
+ toolGuidance,
296
+ executeTool,
297
+ stt,
298
+ llm,
299
+ tts,
300
+ sttApiKey: resolveApiKey("ASSEMBLYAI_API_KEY", env),
301
+ ttsApiKey: resolveApiKey("CARTESIA_API_KEY", env),
302
+ logger,
303
+ });
304
+ }
244
305
  const apiKey = env.ASSEMBLYAI_API_KEY ?? "";
245
306
  return createS2sSession({
246
307
  id: sessionOpts.id,
package/host/s2s.test.ts CHANGED
@@ -66,6 +66,7 @@ describe("connectS2s", () => {
66
66
  expect(handle).toEqual(
67
67
  expect.objectContaining({
68
68
  sendAudio: expect.any(Function),
69
+ sendAudioRaw: expect.any(Function),
69
70
  sendToolResult: expect.any(Function),
70
71
  updateSession: expect.any(Function),
71
72
  resumeSession: expect.any(Function),
@@ -125,6 +126,24 @@ describe("connectS2s", () => {
125
126
  expect(raw.send).not.toHaveBeenCalled();
126
127
  });
127
128
 
129
+ test("sendAudioRaw forwards the exact string to the socket", async () => {
130
+ const { raw, handle } = await setupHandle();
131
+
132
+ const frame = '{"type":"input.audio","audio":"abc"}';
133
+ handle.sendAudioRaw(frame);
134
+
135
+ expect(raw.send).toHaveBeenCalledOnce();
136
+ expect(raw.send.mock.calls[0]?.[0]).toBe(frame);
137
+ });
138
+
139
+ test("sendAudioRaw is no-op when ws is not open", async () => {
140
+ const { raw, handle } = await setupHandle();
141
+ raw.readyState = 3; // CLOSED
142
+
143
+ handle.sendAudioRaw('{"type":"input.audio","audio":"abc"}');
144
+ expect(raw.send).not.toHaveBeenCalled();
145
+ });
146
+
128
147
  test("sendToolResult sends tool.result message", async () => {
129
148
  const { raw, handle } = await setupHandle();
130
149
 
package/host/s2s.ts CHANGED
@@ -159,6 +159,11 @@ export type S2sEvents = {
159
159
  export type S2sHandle = {
160
160
  on<K extends keyof S2sEvents>(event: K, cb: S2sEvents[K]): Unsubscribe;
161
161
  sendAudio(audio: Uint8Array): void;
162
+ /**
163
+ * Send a pre-encoded audio wire frame. For perf-critical callers (load tests)
164
+ * that batch-encode up front. Skips logging; caller owns wire format.
165
+ */
166
+ sendAudioRaw(jsonFrame: string): void;
162
167
  sendToolResult(callId: string, result: string): void;
163
168
  updateSession(config: S2sSessionConfig): void;
164
169
  resumeSession(sessionId: string): void;
@@ -212,6 +217,11 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
212
217
  ws.send(`{"type":"input.audio","audio":"${uint8ToBase64(audio)}"}`);
213
218
  },
214
219
 
220
+ sendAudioRaw(jsonFrame: string): void {
221
+ if (ws.readyState !== WS_OPEN) return;
222
+ ws.send(jsonFrame);
223
+ },
224
+
215
225
  sendToolResult(callId: string, result: string): void {
216
226
  const msg = { type: "tool.result", call_id: callId, result };
217
227
  log.info("S2S >> tool.result", { call_id: callId, resultLength: result.length });
@@ -30,15 +30,18 @@ export type SessionDeps = {
30
30
  };
31
31
 
32
32
  /**
33
- * Session context threaded through event handlers.
33
+ * Transport-agnostic session context shared by S2S and pipeline sessions.
34
+ *
35
+ * Owns reply lifecycle, conversation history (with sliding-window truncation),
36
+ * and per-turn tool-call step enforcement. Transport-specific fields (e.g.
37
+ * `s2s` for S2S, `stt`/`tts` for the pipeline) live on the extending types.
34
38
  *
35
39
  * Split into three layers:
36
40
  * - {@link SessionDeps} — immutable dependencies (set once)
37
41
  * - {@link ReplyState} via `reply` — per-reply mutable state (reset on beginReply/cancelReply)
38
- * - Remaining fields — connection, conversation, and lifecycle methods
42
+ * - Remaining fields — conversation and lifecycle methods
39
43
  */
40
- export type S2sSessionCtx = SessionDeps & {
41
- s2s: S2sHandle | null;
44
+ export type BaseSessionCtx = SessionDeps & {
42
45
  reply: ReplyState;
43
46
  turnPromise: Promise<void> | null;
44
47
  conversationMessages: Message[];
@@ -50,7 +53,14 @@ export type S2sSessionCtx = SessionDeps & {
50
53
  chainTurn(p: Promise<void>): void;
51
54
  };
52
55
 
53
- export function buildCtx(opts: {
56
+ /**
57
+ * S2S session context — {@link BaseSessionCtx} plus the S2S WebSocket handle.
58
+ */
59
+ export type S2sSessionCtx = BaseSessionCtx & {
60
+ s2s: S2sHandle | null;
61
+ };
62
+
63
+ export function _buildBaseCtx(opts: {
54
64
  id: string;
55
65
  agent: string;
56
66
  client: ClientSink;
@@ -58,12 +68,11 @@ export function buildCtx(opts: {
58
68
  executeTool: ExecuteTool;
59
69
  log: Logger;
60
70
  maxHistory?: number | undefined;
61
- }): S2sSessionCtx {
71
+ }): BaseSessionCtx {
62
72
  const { agentConfig, log } = opts;
63
73
  const maxHistory = opts.maxHistory ?? DEFAULT_MAX_HISTORY;
64
- const ctx: S2sSessionCtx = {
74
+ const ctx: BaseSessionCtx = {
65
75
  ...opts,
66
- s2s: null,
67
76
  reply: { pendingTools: [], toolCallCount: 0, currentReplyId: null },
68
77
  turnPromise: null,
69
78
  conversationMessages: [],
@@ -105,3 +114,21 @@ export function buildCtx(opts: {
105
114
  };
106
115
  return ctx;
107
116
  }
117
+
118
+ export function buildCtx(opts: {
119
+ id: string;
120
+ agent: string;
121
+ client: ClientSink;
122
+ agentConfig: AgentConfig;
123
+ executeTool: ExecuteTool;
124
+ log: Logger;
125
+ maxHistory?: number | undefined;
126
+ }): S2sSessionCtx {
127
+ // Mutate the base ctx in place rather than spreading into a new object —
128
+ // the helper methods close over the base ctx reference, so spreading would
129
+ // leave them writing to an orphan object (e.g. `beginReply` would mutate
130
+ // the base `reply`, not the spread copy's `reply`).
131
+ const base = _buildBaseCtx(opts) as S2sSessionCtx;
132
+ base.s2s = null;
133
+ return base;
134
+ }
@@ -0,0 +1,187 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
2
+ import { describe, expect, test, vi } from "vitest";
3
+ import type { ExecuteTool, ToolSchema } from "../sdk/_internal-types.ts";
4
+ import type { Message } from "../sdk/types.ts";
5
+ import { toVercelTools } from "./to-vercel-tools.ts";
6
+
7
+ const schemas: ToolSchema[] = [
8
+ {
9
+ name: "get_weather",
10
+ description: "Look up the weather.",
11
+ parameters: {
12
+ type: "object",
13
+ properties: { city: { type: "string" } },
14
+ required: ["city"],
15
+ },
16
+ },
17
+ ];
18
+
19
+ describe("toVercelTools", () => {
20
+ test("produces one Vercel AI SDK tool per schema, keyed by name", () => {
21
+ const executeTool = vi.fn(async () => "sunny");
22
+ const tools = toVercelTools(schemas, {
23
+ executeTool,
24
+ sessionId: "s1",
25
+ messages: () => [],
26
+ });
27
+ expect(Object.keys(tools)).toEqual(["get_weather"]);
28
+ expect(tools.get_weather).toMatchObject({
29
+ description: "Look up the weather.",
30
+ });
31
+ });
32
+
33
+ test("execute delegates to ctx.executeTool with (name, args, sessionId, messages)", async () => {
34
+ const executeTool = vi.fn(async () => "rainy");
35
+ const tools = toVercelTools(schemas, {
36
+ executeTool,
37
+ sessionId: "sess-42",
38
+ messages: () => [{ role: "user", content: "?" }],
39
+ });
40
+ const result = await tools.get_weather?.execute?.(
41
+ { city: "SF" },
42
+ { toolCallId: "tc-1", messages: [] },
43
+ );
44
+ expect(executeTool).toHaveBeenCalledWith(
45
+ "get_weather",
46
+ { city: "SF" },
47
+ "sess-42",
48
+ [{ role: "user", content: "?" }],
49
+ { toolCallId: "tc-1" },
50
+ );
51
+ expect(result).toBe("rainy");
52
+ });
53
+
54
+ test("execute passes through abort signal when provided", async () => {
55
+ const controller = new AbortController();
56
+ const executeTool = vi.fn(
57
+ async (
58
+ _n: string,
59
+ _a: Readonly<Record<string, unknown>>,
60
+ _s?: string,
61
+ _m?: readonly unknown[],
62
+ opts?: { signal?: AbortSignal },
63
+ ) => {
64
+ expect(opts?.signal).toBe(controller.signal);
65
+ return "ok";
66
+ },
67
+ );
68
+ const tools = toVercelTools(schemas, {
69
+ executeTool,
70
+ sessionId: "s",
71
+ messages: () => [],
72
+ signal: controller.signal,
73
+ });
74
+ await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-2", messages: [] });
75
+ expect(executeTool).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ test("execute prefers options.abortSignal over ctx.signal", async () => {
79
+ const ctxController = new AbortController();
80
+ const callController = new AbortController();
81
+ let receivedSignal: AbortSignal | undefined;
82
+ const executeTool = vi.fn(
83
+ async (
84
+ _n: string,
85
+ _a: Readonly<Record<string, unknown>>,
86
+ _s?: string,
87
+ _m?: readonly unknown[],
88
+ opts?: { signal?: AbortSignal },
89
+ ) => {
90
+ receivedSignal = opts?.signal;
91
+ return "ok";
92
+ },
93
+ );
94
+ const tools = toVercelTools(schemas, {
95
+ executeTool,
96
+ sessionId: "s",
97
+ messages: () => [],
98
+ signal: ctxController.signal,
99
+ });
100
+ await tools.get_weather?.execute?.(
101
+ { city: "NY" },
102
+ { toolCallId: "tc-1", messages: [], abortSignal: callController.signal },
103
+ );
104
+ expect(receivedSignal).toBe(callController.signal);
105
+ });
106
+
107
+ test("execute falls back to ctx.signal when options.abortSignal is absent", async () => {
108
+ const ctxController = new AbortController();
109
+ let receivedSignal: AbortSignal | undefined;
110
+ const executeTool = vi.fn(
111
+ async (
112
+ _n: string,
113
+ _a: Readonly<Record<string, unknown>>,
114
+ _s?: string,
115
+ _m?: readonly unknown[],
116
+ opts?: { signal?: AbortSignal },
117
+ ) => {
118
+ receivedSignal = opts?.signal;
119
+ return "ok";
120
+ },
121
+ );
122
+ const tools = toVercelTools(schemas, {
123
+ executeTool,
124
+ sessionId: "s",
125
+ messages: () => [],
126
+ signal: ctxController.signal,
127
+ });
128
+ await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-2", messages: [] });
129
+ expect(receivedSignal).toBe(ctxController.signal);
130
+ });
131
+
132
+ test("execute propagates toolCallId from options", async () => {
133
+ let receivedCallId: string | undefined;
134
+ const executeTool = vi.fn(
135
+ async (
136
+ _n: string,
137
+ _a: Readonly<Record<string, unknown>>,
138
+ _s?: string,
139
+ _m?: readonly unknown[],
140
+ opts?: { toolCallId?: string },
141
+ ) => {
142
+ receivedCallId = opts?.toolCallId;
143
+ return "ok";
144
+ },
145
+ );
146
+ const tools = toVercelTools(schemas, {
147
+ executeTool,
148
+ sessionId: "s",
149
+ messages: () => [],
150
+ });
151
+ await tools.get_weather?.execute?.({ city: "NY" }, { toolCallId: "tc-3", messages: [] });
152
+ expect(receivedCallId).toBe("tc-3");
153
+ });
154
+ });
155
+
156
+ describe("toVercelTools — message snapshot isolation", () => {
157
+ test("tool execute sees a snapshot, not a live ref to messages array", async () => {
158
+ const messagesBox = { messages: [{ role: "user" as const, content: "first" }] };
159
+ let observedInsideExecute: readonly Message[] | undefined;
160
+
161
+ const executeTool: ExecuteTool = async (_name, _args, _sid, msgs) => {
162
+ observedInsideExecute = msgs;
163
+ // Mutate the original array; the snapshot we captured must be unaffected.
164
+ messagesBox.messages.push({ role: "user", content: "second" });
165
+ return "ok";
166
+ };
167
+
168
+ const tools = toVercelTools(
169
+ [{ name: "t", description: "", parameters: { type: "object", properties: {} } }],
170
+ {
171
+ executeTool,
172
+ sessionId: "s",
173
+ messages: () => messagesBox.messages,
174
+ },
175
+ );
176
+
177
+ const t = tools.t;
178
+ if (!t?.execute) throw new Error("tool.execute missing");
179
+ await t.execute({}, { toolCallId: "c1", messages: [] });
180
+
181
+ // The caller-observable messages array has 2 entries after the push.
182
+ expect(messagesBox.messages).toHaveLength(2);
183
+ // But the snapshot the tool executed against was frozen at length 1.
184
+ expect(observedInsideExecute).toHaveLength(1);
185
+ expect(observedInsideExecute?.[0]).toMatchObject({ content: "first" });
186
+ });
187
+ });
@@ -0,0 +1,74 @@
1
+ // Copyright 2025 the AAI authors. MIT license.
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.
16
+ */
17
+
18
+ import { jsonSchema, type Tool, type ToolExecutionOptions, tool } from "ai";
19
+ import type { ExecuteTool, ExecuteToolOptions, ToolSchema } from "../sdk/_internal-types.ts";
20
+ import type { Message } from "../sdk/types.ts";
21
+
22
+ export interface ToVercelToolsContext {
23
+ /** The agent's tool-execution function (from the runtime). */
24
+ executeTool: ExecuteTool;
25
+ /** Session id threaded to {@link executeTool}. */
26
+ 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
+ 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
+ signal?: AbortSignal;
39
+ }
40
+
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
+ export function toVercelTools(
50
+ schemas: readonly ToolSchema[],
51
+ ctx: ToVercelToolsContext,
52
+ ): Record<string, Tool> {
53
+ const out: Record<string, Tool> = {};
54
+ for (const schema of schemas) {
55
+ out[schema.name] = tool({
56
+ description: schema.description,
57
+ inputSchema: jsonSchema(schema.parameters),
58
+ execute: async (args: unknown, options: ToolExecutionOptions) => {
59
+ 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.
62
+ const signal = options.abortSignal ?? ctx.signal;
63
+ const opts: ExecuteToolOptions = {};
64
+ if (signal !== undefined) opts.signal = signal;
65
+ 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);
70
+ },
71
+ });
72
+ }
73
+ return out;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -22,9 +22,22 @@
22
22
  "@dev/source": "./sdk/manifest-barrel.ts",
23
23
  "types": "./dist/sdk/manifest-barrel.d.ts",
24
24
  "import": "./dist/sdk/manifest-barrel.js"
25
+ },
26
+ "./stt": {
27
+ "@dev/source": "./host/providers/stt-barrel.ts",
28
+ "types": "./dist/host/providers/stt-barrel.d.ts",
29
+ "import": "./dist/host/providers/stt-barrel.js"
30
+ },
31
+ "./tts": {
32
+ "@dev/source": "./host/providers/tts-barrel.ts",
33
+ "types": "./dist/host/providers/tts-barrel.d.ts",
34
+ "import": "./dist/host/providers/tts-barrel.js"
25
35
  }
26
36
  },
27
37
  "dependencies": {
38
+ "@cartesia/cartesia-js": "^3.0.0",
39
+ "ai": "^6.0.161",
40
+ "assemblyai": "^4.30.0",
28
41
  "escape-html": "^1.0.3",
29
42
  "html-to-text": "^9.0.5",
30
43
  "mime-types": "^3.0.2",
@@ -35,6 +48,7 @@
35
48
  "zod": "^4.3.6"
36
49
  },
37
50
  "devDependencies": {
51
+ "@ai-sdk/openai": "^3.0.0",
38
52
  "@types/escape-html": "^1.0.4",
39
53
  "@types/html-to-text": "^9.0.4",
40
54
  "@types/json-schema": "^7.0.15",
@@ -19,6 +19,7 @@ exports[`export surface stability > @alexkroman1/aai main export 1`] = `
19
19
  "MAX_TOOL_RESULT_CHARS",
20
20
  "MAX_VALUE_SIZE",
21
21
  "MAX_WS_PAYLOAD_BYTES",
22
+ "PIPELINE_FLUSH_TIMEOUT_MS",
22
23
  "RUN_CODE_TIMEOUT_MS",
23
24
  "TOOL_EXECUTION_TIMEOUT_MS",
24
25
  "ToolChoiceSchema",
@@ -63,6 +64,7 @@ exports[`export surface stability > @alexkroman1/aai/protocol export 1`] = `
63
64
  exports[`export surface stability > @alexkroman1/aai/runtime export 1`] = `
64
65
  [
65
66
  "DEFAULT_S2S_CONFIG",
67
+ "_buildBaseCtx",
66
68
  "_internals",
67
69
  "buildCtx",
68
70
  "consoleLogger",
@@ -10,6 +10,21 @@ import { z } from "zod";
10
10
  import type { Message } from "./types.ts";
11
11
  import { BuiltinToolSchema, ToolChoiceSchema, type ToolDef } from "./types.ts";
12
12
 
13
+ /**
14
+ * Options forwarded to an {@link ExecuteTool} invocation.
15
+ *
16
+ * Primarily used by the pipeline orchestrator (streamText tool loop) to
17
+ * thread an {@link AbortSignal} into tool execution. The S2S voice path
18
+ * does not pass these options today — recipients must treat the whole
19
+ * bag as optional.
20
+ */
21
+ export interface ExecuteToolOptions {
22
+ /** Abort signal bound to the enclosing LLM turn / request. */
23
+ signal?: AbortSignal;
24
+ /** Vercel AI SDK tool-call ID for this invocation. Useful for tracing and correlation. */
25
+ toolCallId?: string;
26
+ }
27
+
13
28
  /**
14
29
  * Function signature for executing a tool by name.
15
30
  *
@@ -21,6 +36,7 @@ export type ExecuteTool = (
21
36
  args: Readonly<Record<string, unknown>>,
22
37
  sessionId?: string,
23
38
  messages?: readonly Message[],
39
+ opts?: ExecuteToolOptions,
24
40
  ) => Promise<string>;
25
41
 
26
42
  // ─── AgentConfig ────────────────────────────────────────────────────────────