@alexkroman1/aai 0.9.3 → 0.10.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.
- package/dist/_internal-types.d.ts +49 -22
- package/dist/_internal-types.js +43 -1
- package/dist/_mock-ws.d.ts +1 -2
- package/dist/_run-code.d.ts +31 -0
- package/dist/_session-ctx.d.ts +73 -0
- package/dist/_session-otel.d.ts +43 -0
- package/dist/_session-persist.d.ts +30 -0
- package/dist/_ssrf.d.ts +30 -0
- package/dist/_ssrf.js +123 -0
- package/dist/_utils.d.ts +25 -0
- package/dist/_utils.js +54 -1
- package/dist/builtin-tools.d.ts +5 -34
- package/dist/direct-executor-Ca0wt5H0.js +572 -0
- package/dist/direct-executor.d.ts +34 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -2
- package/dist/kv.d.ts +30 -38
- package/dist/kv.js +19 -86
- package/dist/matchers.d.ts +20 -0
- package/dist/matchers.js +41 -0
- package/dist/memory-tools.d.ts +39 -0
- package/dist/middleware-core.d.ts +47 -0
- package/dist/middleware-core.js +107 -0
- package/dist/middleware.d.ts +37 -0
- package/dist/protocol.d.ts +44 -24
- package/dist/protocol.js +34 -14
- package/dist/runtime.d.ts +26 -2
- package/dist/runtime.js +44 -7
- package/dist/s2s.d.ts +19 -29
- package/dist/s2s.js +117 -87
- package/dist/server.d.ts +31 -3
- package/dist/server.js +102 -28
- package/dist/session-BkN9u0ni.js +683 -0
- package/dist/session.d.ts +55 -28
- package/dist/session.js +2 -312
- package/dist/sqlite-kv.d.ts +34 -0
- package/dist/sqlite-kv.js +133 -0
- package/dist/sqlite-vector.d.ts +58 -0
- package/dist/sqlite-vector.js +149 -0
- package/dist/system-prompt.d.ts +21 -0
- package/dist/telemetry.d.ts +49 -0
- package/dist/telemetry.js +95 -0
- package/dist/testing-MRl3SXsI.js +519 -0
- package/dist/testing.d.ts +299 -0
- package/dist/testing.js +2 -0
- package/dist/types.d.ts +324 -39
- package/dist/types.js +62 -9
- package/dist/vector.d.ts +18 -22
- package/dist/vector.js +41 -48
- package/dist/worker-entry.d.ts +11 -3
- package/dist/worker-entry.js +19 -8
- package/dist/ws-handler.d.ts +7 -3
- package/dist/ws-handler.js +64 -12
- package/package.json +55 -8
- package/dist/_mock-ws.js +0 -158
- package/dist/builtin-tools.js +0 -270
- package/dist/direct-executor.js +0 -125
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { DEFAULT_INSTRUCTIONS } from "./types.js";
|
|
2
|
+
import { errorDetail, errorMessage, toolError } from "./_utils.js";
|
|
3
|
+
import { HOOK_TIMEOUT_MS, MAX_TOOL_RESULT_CHARS } from "./protocol.js";
|
|
4
|
+
import { consoleLogger } from "./runtime.js";
|
|
5
|
+
import { activeSessionsUpDown, bargeInCounter, idleTimeoutCounter, sessionCounter, toolCallCounter, toolCallDuration, toolCallErrorCounter, tracer, turnCounter, turnStepsHistogram } from "./telemetry.js";
|
|
6
|
+
import { connectS2s, defaultCreateS2sWebSocket } from "./s2s.js";
|
|
7
|
+
//#region _session-ctx.ts
|
|
8
|
+
const DEFAULT_MAX_HISTORY = 200;
|
|
9
|
+
function buildCtx(opts) {
|
|
10
|
+
const { id, agentConfig, hookInvoker, log } = opts;
|
|
11
|
+
const maxHistory = opts.maxHistory ?? DEFAULT_MAX_HISTORY;
|
|
12
|
+
/** Track in-flight hook promises so they can be awaited during shutdown. */
|
|
13
|
+
const pendingHooks = /* @__PURE__ */ new Set();
|
|
14
|
+
const ctx = {
|
|
15
|
+
...opts,
|
|
16
|
+
s2s: null,
|
|
17
|
+
pendingTools: [],
|
|
18
|
+
toolCallCount: 0,
|
|
19
|
+
turnPromise: null,
|
|
20
|
+
conversationMessages: [],
|
|
21
|
+
maxHistory,
|
|
22
|
+
currentReplyId: null,
|
|
23
|
+
filterChain: Promise.resolve(),
|
|
24
|
+
resolveTurnConfig() {
|
|
25
|
+
if (!hookInvoker) return Promise.resolve(null);
|
|
26
|
+
return hookInvoker.resolveTurnConfig(id, HOOK_TIMEOUT_MS);
|
|
27
|
+
},
|
|
28
|
+
consumeToolCallStep(turnConfig, _name, replyId) {
|
|
29
|
+
if (replyId === null || replyId !== ctx.currentReplyId) return toolError("Reply was interrupted. Discarding stale tool call.");
|
|
30
|
+
const maxSteps = turnConfig?.maxSteps ?? agentConfig.maxSteps;
|
|
31
|
+
ctx.toolCallCount++;
|
|
32
|
+
if (maxSteps !== void 0 && ctx.toolCallCount > maxSteps) {
|
|
33
|
+
log.info("maxSteps exceeded, refusing tool call", {
|
|
34
|
+
toolCallCount: ctx.toolCallCount,
|
|
35
|
+
maxSteps
|
|
36
|
+
});
|
|
37
|
+
return toolError("Maximum tool steps reached. Please respond to the user now.");
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
fireHook(name, fn) {
|
|
42
|
+
if (!hookInvoker) return;
|
|
43
|
+
const notifyOnError = (err) => {
|
|
44
|
+
log.warn(`${name} hook failed`, { err: errorMessage(err) });
|
|
45
|
+
if (name !== "onError") try {
|
|
46
|
+
const r = hookInvoker.onError(id, { message: errorMessage(err) });
|
|
47
|
+
if (r && typeof r.catch === "function") r.catch((e) => {
|
|
48
|
+
log.warn("onError hook failed", { err: errorMessage(e) });
|
|
49
|
+
});
|
|
50
|
+
} catch (e) {
|
|
51
|
+
log.warn("onError hook failed", { err: errorMessage(e) });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
const p = fn(hookInvoker).catch(notifyOnError).finally(() => pendingHooks.delete(p));
|
|
56
|
+
pendingHooks.add(p);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
notifyOnError(err);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async drainHooks() {
|
|
62
|
+
if (pendingHooks.size > 0) await Promise.all([...pendingHooks]);
|
|
63
|
+
},
|
|
64
|
+
pushMessages(...msgs) {
|
|
65
|
+
ctx.conversationMessages.push(...msgs);
|
|
66
|
+
if (maxHistory > 0 && ctx.conversationMessages.length > maxHistory) ctx.conversationMessages = ctx.conversationMessages.slice(-maxHistory);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region _session-otel.ts
|
|
73
|
+
/**
|
|
74
|
+
* Complete a tool call by truncating the result, emitting a `tool_call_done` event,
|
|
75
|
+
* and accumulating the result in `ctx.pendingTools` — but only if the reply that
|
|
76
|
+
* initiated this call is still active.
|
|
77
|
+
*/
|
|
78
|
+
function finishToolCall(ctx, callId, result, replyId) {
|
|
79
|
+
const truncatedResult = result.length > 4e3 ? result.slice(0, MAX_TOOL_RESULT_CHARS) : result;
|
|
80
|
+
ctx.client.event({
|
|
81
|
+
type: "tool_call_done",
|
|
82
|
+
toolCallId: callId,
|
|
83
|
+
result: truncatedResult
|
|
84
|
+
});
|
|
85
|
+
if (replyId !== null && replyId === ctx.currentReplyId) {
|
|
86
|
+
ctx.pendingTools.push({
|
|
87
|
+
callId,
|
|
88
|
+
result
|
|
89
|
+
});
|
|
90
|
+
if (ctx.maxHistory > 0 && ctx.pendingTools.length > ctx.maxHistory) ctx.pendingTools.shift();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Orchestrate the full tool call pipeline for a single S2S tool invocation.
|
|
95
|
+
*
|
|
96
|
+
* Steps: resolve per-turn config → check step/tool limits → run middleware
|
|
97
|
+
* `interceptToolCall` (which may block, return a cached result, or modify args)
|
|
98
|
+
* → execute the tool → run `afterToolCall` middleware → record metrics and
|
|
99
|
+
* finish via {@link finishToolCall}. Each step is wrapped in an OpenTelemetry
|
|
100
|
+
* span (`tool.call`) with agent/session/tool attributes.
|
|
101
|
+
*
|
|
102
|
+
* @param ctx - The shared mutable session context (see {@link S2sSessionCtx}).
|
|
103
|
+
* @param detail - The tool call details from the S2S API (call ID, name, parsed args).
|
|
104
|
+
*/
|
|
105
|
+
async function handleToolCall(ctx, detail) {
|
|
106
|
+
const { callId, name, args: parsedArgs } = detail;
|
|
107
|
+
const replyId = ctx.currentReplyId;
|
|
108
|
+
const span = tracer.startSpan("tool.call", { attributes: {
|
|
109
|
+
"aai.tool.name": name,
|
|
110
|
+
"aai.tool.call_id": callId,
|
|
111
|
+
"aai.agent": ctx.agent,
|
|
112
|
+
"aai.session.id": ctx.id
|
|
113
|
+
} });
|
|
114
|
+
const startTime = performance.now();
|
|
115
|
+
ctx.client.event({
|
|
116
|
+
type: "tool_call_start",
|
|
117
|
+
toolCallId: callId,
|
|
118
|
+
toolName: name,
|
|
119
|
+
args: parsedArgs
|
|
120
|
+
});
|
|
121
|
+
let turnConfig;
|
|
122
|
+
try {
|
|
123
|
+
turnConfig = await ctx.resolveTurnConfig();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const msg = `resolveTurnConfig hook error: ${errorMessage(err)}`;
|
|
126
|
+
ctx.log.error(msg);
|
|
127
|
+
span.setStatus({
|
|
128
|
+
code: 2,
|
|
129
|
+
message: msg
|
|
130
|
+
});
|
|
131
|
+
span.end();
|
|
132
|
+
finishToolCall(ctx, callId, toolError(msg), replyId);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const refused = ctx.consumeToolCallStep(turnConfig, name, replyId);
|
|
136
|
+
if (refused !== null) {
|
|
137
|
+
span.setAttribute("aai.tool.refused", true);
|
|
138
|
+
span.end();
|
|
139
|
+
finishToolCall(ctx, callId, refused, replyId);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
ctx.fireHook("onStep", (h) => h.onStep(ctx.id, {
|
|
143
|
+
stepNumber: ctx.toolCallCount - 1,
|
|
144
|
+
toolCalls: [{
|
|
145
|
+
toolName: name,
|
|
146
|
+
args: parsedArgs
|
|
147
|
+
}],
|
|
148
|
+
text: ""
|
|
149
|
+
}, HOOK_TIMEOUT_MS));
|
|
150
|
+
ctx.log.info("S2S tool call", {
|
|
151
|
+
tool: name,
|
|
152
|
+
callId,
|
|
153
|
+
args: parsedArgs,
|
|
154
|
+
agent: ctx.agent
|
|
155
|
+
});
|
|
156
|
+
toolCallCounter.add(1, {
|
|
157
|
+
agent: ctx.agent,
|
|
158
|
+
tool: name
|
|
159
|
+
});
|
|
160
|
+
let effectiveArgs = parsedArgs;
|
|
161
|
+
if (ctx.hookInvoker?.interceptToolCall) try {
|
|
162
|
+
const ic = await ctx.hookInvoker.interceptToolCall(ctx.id, name, parsedArgs, HOOK_TIMEOUT_MS);
|
|
163
|
+
if (ic?.type === "block") {
|
|
164
|
+
span.setAttribute("aai.tool.blocked", true);
|
|
165
|
+
span.end();
|
|
166
|
+
finishToolCall(ctx, callId, toolError(ic.reason), replyId);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (ic?.type === "result") {
|
|
170
|
+
span.setAttribute("aai.tool.cached", true);
|
|
171
|
+
span.end();
|
|
172
|
+
finishToolCall(ctx, callId, ic.result, replyId);
|
|
173
|
+
ctx.fireHook("afterToolCall", (h) => h.afterToolCall?.(ctx.id, name, parsedArgs, ic.result, 5e3) ?? Promise.resolve());
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (ic?.type === "args") effectiveArgs = ic.args;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
ctx.log.warn("interceptToolCall middleware failed (fail-open, tool call proceeds)", {
|
|
179
|
+
err: errorMessage(err),
|
|
180
|
+
tool: name
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const onUpdate = (data) => {
|
|
184
|
+
const serialized = typeof data === "string" ? data : JSON.stringify(data);
|
|
185
|
+
const truncated = serialized.length > 4e3 ? serialized.slice(0, MAX_TOOL_RESULT_CHARS) : serialized;
|
|
186
|
+
ctx.client.event({
|
|
187
|
+
type: "tool_call_update",
|
|
188
|
+
toolCallId: callId,
|
|
189
|
+
data: truncated
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = await ctx.executeTool(name, effectiveArgs, ctx.id, ctx.conversationMessages, onUpdate);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const msg = errorMessage(err);
|
|
197
|
+
ctx.log.error("Tool execution failed", {
|
|
198
|
+
tool: name,
|
|
199
|
+
error: errorDetail(err)
|
|
200
|
+
});
|
|
201
|
+
toolCallErrorCounter.add(1, {
|
|
202
|
+
agent: ctx.agent,
|
|
203
|
+
tool: name
|
|
204
|
+
});
|
|
205
|
+
span.setStatus({
|
|
206
|
+
code: 2,
|
|
207
|
+
message: msg
|
|
208
|
+
});
|
|
209
|
+
span.recordException(err instanceof Error ? err : new Error(msg));
|
|
210
|
+
result = toolError(msg);
|
|
211
|
+
}
|
|
212
|
+
if (ctx.hookInvoker?.afterToolCall) ctx.fireHook("afterToolCall", (h) => h.afterToolCall?.(ctx.id, name, effectiveArgs, result, 5e3) ?? Promise.resolve());
|
|
213
|
+
toolCallDuration.record((performance.now() - startTime) / 1e3, {
|
|
214
|
+
agent: ctx.agent,
|
|
215
|
+
tool: name
|
|
216
|
+
});
|
|
217
|
+
ctx.log.info("S2S tool result", {
|
|
218
|
+
tool: name,
|
|
219
|
+
callId,
|
|
220
|
+
resultLength: result.length
|
|
221
|
+
});
|
|
222
|
+
finishToolCall(ctx, callId, result, replyId);
|
|
223
|
+
span.end();
|
|
224
|
+
}
|
|
225
|
+
function handleUserTranscript(ctx, text) {
|
|
226
|
+
ctx.log.info("S2S user transcript", { text });
|
|
227
|
+
turnCounter.add(1, { agent: ctx.agent });
|
|
228
|
+
ctx.client.event({
|
|
229
|
+
type: "transcript",
|
|
230
|
+
text,
|
|
231
|
+
isFinal: true
|
|
232
|
+
});
|
|
233
|
+
ctx.client.event({
|
|
234
|
+
type: "turn",
|
|
235
|
+
text
|
|
236
|
+
});
|
|
237
|
+
const processFiltered = (filtered) => {
|
|
238
|
+
ctx.pushMessages({
|
|
239
|
+
role: "user",
|
|
240
|
+
content: filtered
|
|
241
|
+
});
|
|
242
|
+
const fireTurn = () => ctx.fireHook("onTurn", (h) => h.onTurn(ctx.id, filtered, HOOK_TIMEOUT_MS));
|
|
243
|
+
if (!ctx.hookInvoker?.beforeTurn) {
|
|
244
|
+
fireTurn();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
ctx.hookInvoker.beforeTurn(ctx.id, filtered, HOOK_TIMEOUT_MS).then((reason) => {
|
|
248
|
+
if (reason) {
|
|
249
|
+
ctx.log.info("Turn blocked by middleware", { reason });
|
|
250
|
+
ctx.client.event({
|
|
251
|
+
type: "chat",
|
|
252
|
+
text: reason
|
|
253
|
+
});
|
|
254
|
+
ctx.client.event({ type: "tts_done" });
|
|
255
|
+
} else fireTurn();
|
|
256
|
+
}).catch((err) => {
|
|
257
|
+
ctx.log.warn("beforeTurn hook failed", { error: errorMessage(err) });
|
|
258
|
+
fireTurn();
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
if (!ctx.hookInvoker?.filterInput) {
|
|
262
|
+
processFiltered(text);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
ctx.hookInvoker.filterInput(ctx.id, text, HOOK_TIMEOUT_MS).then((filtered) => processFiltered(filtered)).catch((err) => {
|
|
266
|
+
ctx.log.warn("filterInput hook failed", { error: errorMessage(err) });
|
|
267
|
+
processFiltered(text);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function handleAgentTranscriptDelta(ctx, text) {
|
|
271
|
+
const filterOutput = ctx.hookInvoker?.filterOutput;
|
|
272
|
+
if (!filterOutput) {
|
|
273
|
+
ctx.client.event({
|
|
274
|
+
type: "chat_delta",
|
|
275
|
+
text
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
ctx.filterChain = ctx.filterChain.then(async () => {
|
|
280
|
+
try {
|
|
281
|
+
const f = await filterOutput.call(ctx.hookInvoker, ctx.id, text, HOOK_TIMEOUT_MS);
|
|
282
|
+
ctx.client.event({
|
|
283
|
+
type: "chat_delta",
|
|
284
|
+
text: f
|
|
285
|
+
});
|
|
286
|
+
} catch (err) {
|
|
287
|
+
ctx.log.warn("filterOutput hook failed", { error: errorMessage(err) });
|
|
288
|
+
ctx.client.event({
|
|
289
|
+
type: "chat_delta",
|
|
290
|
+
text
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function handleAgentTranscript(ctx, text, interrupted) {
|
|
296
|
+
const emit = (t) => {
|
|
297
|
+
ctx.client.event({
|
|
298
|
+
type: "chat",
|
|
299
|
+
text: t
|
|
300
|
+
});
|
|
301
|
+
if (!interrupted) ctx.pushMessages({
|
|
302
|
+
role: "assistant",
|
|
303
|
+
content: t
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
const filterOutput = ctx.hookInvoker?.filterOutput;
|
|
307
|
+
if (!filterOutput) {
|
|
308
|
+
emit(text);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
ctx.filterChain = ctx.filterChain.then(async () => {
|
|
312
|
+
try {
|
|
313
|
+
emit(await filterOutput.call(ctx.hookInvoker, ctx.id, text, HOOK_TIMEOUT_MS));
|
|
314
|
+
} catch (err) {
|
|
315
|
+
ctx.log.warn("filterOutput hook failed", { error: errorMessage(err) });
|
|
316
|
+
emit(text);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
function handleReplyDone(ctx, status) {
|
|
321
|
+
if (status === "interrupted") {
|
|
322
|
+
ctx.log.info("S2S reply interrupted (barge-in)");
|
|
323
|
+
bargeInCounter.add(1, { agent: ctx.agent });
|
|
324
|
+
ctx.currentReplyId = null;
|
|
325
|
+
ctx.pendingTools = [];
|
|
326
|
+
ctx.filterChain = Promise.resolve();
|
|
327
|
+
ctx.client.event({ type: "cancelled" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const doneReplyId = ctx.currentReplyId;
|
|
331
|
+
const sendPending = () => {
|
|
332
|
+
if (ctx.currentReplyId !== doneReplyId) {
|
|
333
|
+
ctx.pendingTools = [];
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (ctx.pendingTools.length > 0) {
|
|
337
|
+
for (const tool of ctx.pendingTools) ctx.s2s?.sendToolResult(tool.callId, tool.result);
|
|
338
|
+
ctx.pendingTools = [];
|
|
339
|
+
} else {
|
|
340
|
+
const stepsUsed = ctx.toolCallCount;
|
|
341
|
+
if (stepsUsed > 0) {
|
|
342
|
+
ctx.log.info("Turn complete", {
|
|
343
|
+
steps: stepsUsed,
|
|
344
|
+
agent: ctx.agent
|
|
345
|
+
});
|
|
346
|
+
turnStepsHistogram.record(stepsUsed, { agent: ctx.agent });
|
|
347
|
+
}
|
|
348
|
+
if (ctx.hookInvoker?.afterTurn) {
|
|
349
|
+
const last = ctx.conversationMessages.at(-1);
|
|
350
|
+
ctx.fireHook("afterTurn", (h) => h.afterTurn?.(ctx.id, last?.content ?? "", 5e3) ?? Promise.resolve());
|
|
351
|
+
}
|
|
352
|
+
ctx.client.playAudioDone();
|
|
353
|
+
ctx.client.event({ type: "tts_done" });
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
if (ctx.turnPromise !== null) ctx.turnPromise.then(sendPending);
|
|
357
|
+
else sendPending();
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Wire all S2S events to the client sink, hooks, and session state.
|
|
361
|
+
*
|
|
362
|
+
* Registers listeners on the S2S handle for: ready, session expiry, speech
|
|
363
|
+
* start/stop, user/agent transcripts, reply lifecycle, tool calls, audio
|
|
364
|
+
* chunks, errors, and close. Each listener delegates to a focused handler
|
|
365
|
+
* function that updates `ctx` and emits client events.
|
|
366
|
+
*
|
|
367
|
+
* @param ctx - The shared mutable session context.
|
|
368
|
+
* @param handle - The S2S WebSocket handle to listen on.
|
|
369
|
+
* @param opts - Optional overrides for listener behavior.
|
|
370
|
+
*/
|
|
371
|
+
function setupListeners(ctx, handle, opts) {
|
|
372
|
+
handle.on("ready", ({ sessionId }) => ctx.log.info("S2S session ready", { sessionId }));
|
|
373
|
+
handle.on("sessionExpired", () => {
|
|
374
|
+
if (opts?.onSessionExpired) opts.onSessionExpired();
|
|
375
|
+
else {
|
|
376
|
+
ctx.log.info("S2S session expired");
|
|
377
|
+
handle.close();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
handle.on("speechStarted", () => ctx.client.event({ type: "speech_started" }));
|
|
381
|
+
handle.on("speechStopped", () => ctx.client.event({ type: "speech_stopped" }));
|
|
382
|
+
handle.on("userTranscriptDelta", ({ text }) => ctx.client.event({
|
|
383
|
+
type: "transcript",
|
|
384
|
+
text,
|
|
385
|
+
isFinal: false
|
|
386
|
+
}));
|
|
387
|
+
handle.on("userTranscript", ({ text }) => handleUserTranscript(ctx, text));
|
|
388
|
+
handle.on("replyStarted", ({ replyId }) => {
|
|
389
|
+
ctx.toolCallCount = 0;
|
|
390
|
+
ctx.currentReplyId = replyId;
|
|
391
|
+
ctx.pendingTools = [];
|
|
392
|
+
ctx.turnPromise = null;
|
|
393
|
+
ctx.filterChain = Promise.resolve();
|
|
394
|
+
});
|
|
395
|
+
handle.on("audio", ({ audio }) => ctx.client.playAudioChunk(audio));
|
|
396
|
+
handle.on("agentTranscriptDelta", ({ text }) => handleAgentTranscriptDelta(ctx, text));
|
|
397
|
+
handle.on("agentTranscript", ({ text, interrupted }) => handleAgentTranscript(ctx, text, interrupted));
|
|
398
|
+
handle.on("toolCall", (detail) => {
|
|
399
|
+
const p = handleToolCall(ctx, detail).catch((err) => {
|
|
400
|
+
ctx.log.error("Tool call handler failed", { err: errorMessage(err) });
|
|
401
|
+
});
|
|
402
|
+
ctx.turnPromise = (ctx.turnPromise ?? Promise.resolve()).then(() => p);
|
|
403
|
+
});
|
|
404
|
+
handle.on("replyDone", ({ status }) => handleReplyDone(ctx, status));
|
|
405
|
+
handle.on("error", ({ code, message }) => {
|
|
406
|
+
ctx.log.error("S2S error", {
|
|
407
|
+
code,
|
|
408
|
+
message
|
|
409
|
+
});
|
|
410
|
+
ctx.client.event({
|
|
411
|
+
type: "error",
|
|
412
|
+
code: "internal",
|
|
413
|
+
message
|
|
414
|
+
});
|
|
415
|
+
handle.close();
|
|
416
|
+
});
|
|
417
|
+
handle.on("close", () => {
|
|
418
|
+
ctx.log.info("S2S closed");
|
|
419
|
+
ctx.s2s = null;
|
|
420
|
+
ctx.currentReplyId = null;
|
|
421
|
+
ctx.pendingTools = [];
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region _session-persist.ts
|
|
426
|
+
const PERSIST_PREFIX = "__persist:";
|
|
427
|
+
function persistKey(sessionId) {
|
|
428
|
+
return `${PERSIST_PREFIX}${sessionId}`;
|
|
429
|
+
}
|
|
430
|
+
async function restorePersistedSession(persistence, resumeFrom, ctx, log) {
|
|
431
|
+
const persisted = await persistence.kv.get(persistKey(resumeFrom));
|
|
432
|
+
if (!persisted) return null;
|
|
433
|
+
log.info("Restoring persisted session", { resumeFrom });
|
|
434
|
+
persistence.setState(persisted.state);
|
|
435
|
+
if (persisted.messages.length > 0) ctx.pushMessages(...persisted.messages);
|
|
436
|
+
return persisted.s2sSessionId;
|
|
437
|
+
}
|
|
438
|
+
async function saveSessionData(persistence, sessionId, ctx, s2sSessionId, log, cleanupKey) {
|
|
439
|
+
const data = {
|
|
440
|
+
s2sSessionId,
|
|
441
|
+
messages: ctx.conversationMessages,
|
|
442
|
+
state: persistence.getState()
|
|
443
|
+
};
|
|
444
|
+
const key = persistKey(sessionId);
|
|
445
|
+
const ops = [persistence.kv.set(key, data, { expireIn: persistence.ttl })];
|
|
446
|
+
if (cleanupKey && cleanupKey !== sessionId) ops.push(persistence.kv.delete(persistKey(cleanupKey)));
|
|
447
|
+
await Promise.all(ops);
|
|
448
|
+
log.info("Session persisted", { sessionId });
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region system-prompt.ts
|
|
452
|
+
function getFormattedDate() {
|
|
453
|
+
return (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", {
|
|
454
|
+
weekday: "long",
|
|
455
|
+
year: "numeric",
|
|
456
|
+
month: "long",
|
|
457
|
+
day: "numeric"
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const VOICE_RULES = "\n\nCRITICAL OUTPUT RULES — you MUST follow these for EVERY response:\nYour response will be spoken aloud by a TTS system and displayed as plain text.\n- NEVER use markdown: no **, no *, no _, no #, no `, no [](), no ---\n- NEVER use bullet points (-, *, •) or numbered lists (1., 2.)\n- NEVER use code blocks or inline code\n- NEVER mention tools, search, APIs, or technical failures to the user. If a tool returns no results, just answer naturally without explaining why.\n- Write exactly as you would say it out loud to a friend\n- Use short conversational sentences. To list things, say \"First,\" \"Next,\" \"Finally,\"\n- Keep responses concise — 1 to 3 sentences max";
|
|
461
|
+
/**
|
|
462
|
+
* Build the system prompt sent to the LLM from the agent configuration.
|
|
463
|
+
*
|
|
464
|
+
* Assembles the default instructions, today's date, agent-specific instructions,
|
|
465
|
+
* and optional sections for tool usage preamble and voice output rules.
|
|
466
|
+
*
|
|
467
|
+
* @param config - The serializable agent configuration (name, instructions, etc.).
|
|
468
|
+
* @param opts.hasTools - When `true`, appends a preamble instructing the LLM to
|
|
469
|
+
* speak a brief phrase before each tool call to fill silence.
|
|
470
|
+
* @param opts.voice - When `true`, appends strict voice-specific output rules
|
|
471
|
+
* (no markdown, no bullet points, conversational tone, concise responses).
|
|
472
|
+
* @returns The assembled system prompt string.
|
|
473
|
+
*/
|
|
474
|
+
function buildSystemPrompt(config, opts) {
|
|
475
|
+
const { hasTools } = opts;
|
|
476
|
+
const agentInstructions = config.instructions && config.instructions !== DEFAULT_INSTRUCTIONS ? `\n\nAgent-Specific Instructions:\n${config.instructions}` : "";
|
|
477
|
+
const toolPreamble = hasTools ? "\n\nWhen you decide to use a tool, ALWAYS say a brief natural phrase BEFORE the tool call (e.g. \"Let me look that up\" or \"One moment while I check\"). This fills silence while the tool executes. Keep preambles to one short sentence." : "";
|
|
478
|
+
return DEFAULT_INSTRUCTIONS + `\n\nToday's date is ${getFormattedDate()}.` + agentInstructions + toolPreamble + (opts.voice ? VOICE_RULES : "");
|
|
479
|
+
}
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region session.ts
|
|
482
|
+
/** @internal Not part of the public API. Exposed for testing only. */
|
|
483
|
+
const _internals = { connectS2s };
|
|
484
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 3e5;
|
|
485
|
+
function createIdleTimer(opts) {
|
|
486
|
+
if (opts.timeoutMs <= 0) return {
|
|
487
|
+
reset() {},
|
|
488
|
+
clear() {}
|
|
489
|
+
};
|
|
490
|
+
let timer = null;
|
|
491
|
+
return {
|
|
492
|
+
reset() {
|
|
493
|
+
if (timer !== null) clearTimeout(timer);
|
|
494
|
+
timer = setTimeout(() => {
|
|
495
|
+
opts.log.info("S2S idle timeout", {
|
|
496
|
+
timeoutMs: opts.timeoutMs,
|
|
497
|
+
agent: opts.agent
|
|
498
|
+
});
|
|
499
|
+
idleTimeoutCounter.add(1, { agent: opts.agent });
|
|
500
|
+
opts.client.event({ type: "idle_timeout" });
|
|
501
|
+
opts.ctx.s2s?.close();
|
|
502
|
+
}, opts.timeoutMs);
|
|
503
|
+
},
|
|
504
|
+
clear() {
|
|
505
|
+
if (timer !== null) {
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
timer = null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Create a Speech-to-Speech backed session implementing the {@link Session} interface.
|
|
514
|
+
*
|
|
515
|
+
* Connects to AssemblyAI's S2S WebSocket, configures the system prompt and tools,
|
|
516
|
+
* and wires up event listeners for user transcripts, agent replies, tool calls,
|
|
517
|
+
* barge-ins, and session lifecycle. Manages reconnection on `onReset` via a
|
|
518
|
+
* `connectGeneration` guard that prevents stale connection attempts from overwriting
|
|
519
|
+
* newer ones during rapid resets. A `sessionAbort` AbortController is used to
|
|
520
|
+
* coordinate cleanup on `stop()`.
|
|
521
|
+
*
|
|
522
|
+
* @param opts - Session configuration. See {@link S2sSessionOptions} for all fields
|
|
523
|
+
* including the agent config, tool schemas, API key, and optional hooks.
|
|
524
|
+
* @returns A {@link Session} with `start`, `stop`, `onAudio`, `onReset`, and other
|
|
525
|
+
* lifecycle methods.
|
|
526
|
+
*/
|
|
527
|
+
function createS2sSession(opts) {
|
|
528
|
+
const { id, agent, client, toolSchemas, apiKey, s2sConfig, executeTool, createWebSocket = defaultCreateS2sWebSocket, hookInvoker, logger: log = consoleLogger, persistence, resumeFrom } = opts;
|
|
529
|
+
const agentConfig = opts.skipGreeting ? {
|
|
530
|
+
...opts.agentConfig,
|
|
531
|
+
greeting: ""
|
|
532
|
+
} : opts.agentConfig;
|
|
533
|
+
const systemPrompt = buildSystemPrompt(agentConfig, {
|
|
534
|
+
hasTools: toolSchemas.length > 0 || (agentConfig.builtinTools?.length ?? 0) > 0,
|
|
535
|
+
voice: true
|
|
536
|
+
});
|
|
537
|
+
const s2sTools = toolSchemas.map((ts) => ({
|
|
538
|
+
type: "function",
|
|
539
|
+
name: ts.name,
|
|
540
|
+
description: ts.description,
|
|
541
|
+
parameters: ts.parameters
|
|
542
|
+
}));
|
|
543
|
+
const sessionAbort = new AbortController();
|
|
544
|
+
const ctx = buildCtx({
|
|
545
|
+
id,
|
|
546
|
+
agent,
|
|
547
|
+
client,
|
|
548
|
+
agentConfig,
|
|
549
|
+
executeTool,
|
|
550
|
+
hookInvoker,
|
|
551
|
+
log,
|
|
552
|
+
maxHistory: opts.maxHistory
|
|
553
|
+
});
|
|
554
|
+
const rawTimeout = agentConfig.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
555
|
+
const idle = createIdleTimer({
|
|
556
|
+
timeoutMs: rawTimeout === 0 || !Number.isFinite(rawTimeout) ? 0 : rawTimeout,
|
|
557
|
+
agent,
|
|
558
|
+
log,
|
|
559
|
+
client,
|
|
560
|
+
ctx
|
|
561
|
+
});
|
|
562
|
+
let currentS2sSessionId = null;
|
|
563
|
+
let resumeS2sId = null;
|
|
564
|
+
const pendingCleanupKey = resumeFrom;
|
|
565
|
+
/** Monotonically increasing counter bumped on each connectAndSetup call.
|
|
566
|
+
* Only the most recent invocation is allowed to set ctx.s2s, preventing
|
|
567
|
+
* earlier completions from overwriting a newer connection during rapid resets. */
|
|
568
|
+
let connectGeneration = 0;
|
|
569
|
+
/** The session.update payload shared by fresh and fallback paths. */
|
|
570
|
+
const sessionUpdatePayload = {
|
|
571
|
+
systemPrompt,
|
|
572
|
+
tools: s2sTools,
|
|
573
|
+
...agentConfig.greeting ? { greeting: agentConfig.greeting } : {}
|
|
574
|
+
};
|
|
575
|
+
async function connectAndSetup() {
|
|
576
|
+
const generation = ++connectGeneration;
|
|
577
|
+
try {
|
|
578
|
+
const handle = await _internals.connectS2s({
|
|
579
|
+
apiKey,
|
|
580
|
+
config: s2sConfig,
|
|
581
|
+
createWebSocket,
|
|
582
|
+
logger: log
|
|
583
|
+
});
|
|
584
|
+
if (sessionAbort.signal.aborted || generation !== connectGeneration) {
|
|
585
|
+
handle.close();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
handle.on("ready", ({ sessionId: s2sId }) => {
|
|
589
|
+
currentS2sSessionId = s2sId;
|
|
590
|
+
});
|
|
591
|
+
if (resumeS2sId) {
|
|
592
|
+
const s2sId = resumeS2sId;
|
|
593
|
+
resumeS2sId = null;
|
|
594
|
+
let resumeFallbackUsed = false;
|
|
595
|
+
setupListeners(ctx, handle, { onSessionExpired: () => {
|
|
596
|
+
if (!resumeFallbackUsed) {
|
|
597
|
+
resumeFallbackUsed = true;
|
|
598
|
+
log.info("S2S session resume failed, falling back to new session");
|
|
599
|
+
handle.updateSession(sessionUpdatePayload);
|
|
600
|
+
} else {
|
|
601
|
+
ctx.log.info("S2S session expired");
|
|
602
|
+
handle.close();
|
|
603
|
+
}
|
|
604
|
+
} });
|
|
605
|
+
handle.resumeSession(s2sId);
|
|
606
|
+
} else {
|
|
607
|
+
setupListeners(ctx, handle);
|
|
608
|
+
handle.updateSession(sessionUpdatePayload);
|
|
609
|
+
}
|
|
610
|
+
ctx.s2s = handle;
|
|
611
|
+
idle.reset();
|
|
612
|
+
} catch (err) {
|
|
613
|
+
const msg = errorMessage(err);
|
|
614
|
+
log.error("S2S connect failed", { error: errorDetail(err) });
|
|
615
|
+
client.event({
|
|
616
|
+
type: "error",
|
|
617
|
+
code: "internal",
|
|
618
|
+
message: msg
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
async start() {
|
|
624
|
+
if (persistence && resumeFrom) try {
|
|
625
|
+
const s2sId = await restorePersistedSession(persistence, resumeFrom, ctx, log);
|
|
626
|
+
if (s2sId) resumeS2sId = s2sId;
|
|
627
|
+
} catch (err) {
|
|
628
|
+
log.warn("Failed to restore persisted session", { error: errorMessage(err) });
|
|
629
|
+
}
|
|
630
|
+
sessionCounter.add(1, { agent });
|
|
631
|
+
activeSessionsUpDown.add(1, { agent });
|
|
632
|
+
ctx.fireHook("onConnect", (h) => h.onConnect(id, HOOK_TIMEOUT_MS));
|
|
633
|
+
await connectAndSetup();
|
|
634
|
+
},
|
|
635
|
+
async stop() {
|
|
636
|
+
if (sessionAbort.signal.aborted) return;
|
|
637
|
+
sessionAbort.abort();
|
|
638
|
+
idle.clear();
|
|
639
|
+
activeSessionsUpDown.add(-1, { agent });
|
|
640
|
+
if (ctx.turnPromise !== null) await ctx.turnPromise;
|
|
641
|
+
await ctx.drainHooks();
|
|
642
|
+
if (persistence) try {
|
|
643
|
+
await saveSessionData(persistence, id, ctx, currentS2sSessionId, log, pendingCleanupKey);
|
|
644
|
+
} catch (err) {
|
|
645
|
+
log.warn("Failed to persist session", { error: errorMessage(err) });
|
|
646
|
+
}
|
|
647
|
+
ctx.s2s?.close();
|
|
648
|
+
ctx.fireHook("onDisconnect", (h) => h.onDisconnect(id, HOOK_TIMEOUT_MS));
|
|
649
|
+
await ctx.drainHooks();
|
|
650
|
+
},
|
|
651
|
+
onAudio(data) {
|
|
652
|
+
idle.reset();
|
|
653
|
+
ctx.s2s?.sendAudio(data);
|
|
654
|
+
},
|
|
655
|
+
onAudioReady() {},
|
|
656
|
+
onCancel() {
|
|
657
|
+
client.event({ type: "cancelled" });
|
|
658
|
+
},
|
|
659
|
+
onReset() {
|
|
660
|
+
ctx.conversationMessages = [];
|
|
661
|
+
ctx.toolCallCount = 0;
|
|
662
|
+
ctx.turnPromise = null;
|
|
663
|
+
ctx.pendingTools = [];
|
|
664
|
+
ctx.currentReplyId = null;
|
|
665
|
+
currentS2sSessionId = null;
|
|
666
|
+
idle.clear();
|
|
667
|
+
ctx.s2s?.close();
|
|
668
|
+
client.event({ type: "reset" });
|
|
669
|
+
connectAndSetup().catch((err) => log.error("S2S reset reconnect failed", { error: errorMessage(err) }));
|
|
670
|
+
},
|
|
671
|
+
onHistory(incoming) {
|
|
672
|
+
ctx.pushMessages(...incoming.map((m) => ({
|
|
673
|
+
role: m.role,
|
|
674
|
+
content: m.content
|
|
675
|
+
})));
|
|
676
|
+
},
|
|
677
|
+
waitForTurn() {
|
|
678
|
+
return ctx.turnPromise ?? Promise.resolve();
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
//#endregion
|
|
683
|
+
export { persistKey as i, createS2sSession as n, buildSystemPrompt as r, _internals as t };
|