@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
package/dist/s2s.js
CHANGED
|
@@ -1,110 +1,116 @@
|
|
|
1
1
|
import { consoleLogger } from "./runtime.js";
|
|
2
|
-
import {
|
|
3
|
-
import { WebSocket } from "ws";
|
|
2
|
+
import { s2sConnectionDuration, s2sErrorCounter, tracer } from "./telemetry.js";
|
|
4
3
|
import { createNanoEvents } from "nanoevents";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
5
|
//#region s2s.ts
|
|
6
6
|
const uint8ToBase64 = (bytes) => Buffer.from(bytes).toString("base64");
|
|
7
7
|
const base64ToUint8 = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
|
|
8
|
-
/** WebSocket readyState constant for OPEN. */
|
|
9
8
|
const WS_OPEN = 1;
|
|
10
|
-
/** Default S2S WebSocket factory using the `ws` package (Node-only). */
|
|
11
9
|
const defaultCreateS2sWebSocket = (url, opts) => new WebSocket(url, { headers: opts.headers });
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
type:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
10
|
+
function hasStringFields(obj, ...keys) {
|
|
11
|
+
for (const k of keys) if (typeof obj[k] !== "string") return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function parseAgentTranscript(obj) {
|
|
15
|
+
if (typeof obj.text !== "string") return;
|
|
16
|
+
return {
|
|
17
|
+
type: "transcript.agent",
|
|
18
|
+
text: obj.text,
|
|
19
|
+
reply_id: typeof obj.reply_id === "string" ? obj.reply_id : "",
|
|
20
|
+
item_id: typeof obj.item_id === "string" ? obj.item_id : "",
|
|
21
|
+
interrupted: obj.interrupted === true
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function parseToolCall(obj) {
|
|
25
|
+
if (typeof obj.call_id !== "string" || typeof obj.name !== "string") return;
|
|
26
|
+
const args = obj.args != null && typeof obj.args === "object" && !Array.isArray(obj.args) ? obj.args : {};
|
|
27
|
+
return {
|
|
28
|
+
type: "tool.call",
|
|
29
|
+
call_id: obj.call_id,
|
|
30
|
+
name: obj.name,
|
|
31
|
+
args
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function passthrough(obj) {
|
|
35
|
+
return obj;
|
|
36
|
+
}
|
|
37
|
+
function requireFields(...keys) {
|
|
38
|
+
return (obj) => hasStringFields(obj, ...keys) ? obj : void 0;
|
|
39
|
+
}
|
|
40
|
+
const MESSAGE_VALIDATORS = new Map([
|
|
41
|
+
["session.ready", requireFields("session_id")],
|
|
42
|
+
["session.updated", passthrough],
|
|
43
|
+
["input.speech.started", passthrough],
|
|
44
|
+
["input.speech.stopped", passthrough],
|
|
45
|
+
["reply.content_part.started", passthrough],
|
|
46
|
+
["reply.content_part.done", passthrough],
|
|
47
|
+
["transcript.user.delta", requireFields("text")],
|
|
48
|
+
["transcript.user", requireFields("item_id", "text")],
|
|
49
|
+
["reply.started", requireFields("reply_id")],
|
|
50
|
+
["transcript.agent.delta", requireFields("delta")],
|
|
51
|
+
["transcript.agent", parseAgentTranscript],
|
|
52
|
+
["tool.call", parseToolCall],
|
|
53
|
+
["reply.done", (obj) => ({
|
|
54
|
+
type: "reply.done",
|
|
55
|
+
...typeof obj.status === "string" ? { status: obj.status } : {}
|
|
56
|
+
})],
|
|
57
|
+
["session.error", requireFields("code", "message")],
|
|
58
|
+
["error", requireFields("message")]
|
|
62
59
|
]);
|
|
63
|
-
|
|
60
|
+
function parseS2sMessage(obj) {
|
|
61
|
+
const type = obj.type;
|
|
62
|
+
if (typeof type !== "string") return;
|
|
63
|
+
return MESSAGE_VALIDATORS.get(type)?.(obj);
|
|
64
|
+
}
|
|
64
65
|
function dispatchS2sMessage(emitter, msg) {
|
|
65
66
|
switch (msg.type) {
|
|
66
67
|
case "session.ready":
|
|
67
|
-
emitter.emit("ready", {
|
|
68
|
+
emitter.emit("ready", { sessionId: msg.session_id });
|
|
68
69
|
break;
|
|
69
70
|
case "session.updated":
|
|
70
|
-
emitter.emit("
|
|
71
|
+
emitter.emit("sessionUpdated", msg);
|
|
71
72
|
break;
|
|
72
73
|
case "input.speech.started":
|
|
73
|
-
emitter.emit("
|
|
74
|
+
emitter.emit("speechStarted");
|
|
74
75
|
break;
|
|
75
76
|
case "input.speech.stopped":
|
|
76
|
-
emitter.emit("
|
|
77
|
+
emitter.emit("speechStopped");
|
|
77
78
|
break;
|
|
78
79
|
case "transcript.user.delta":
|
|
79
|
-
emitter.emit("
|
|
80
|
+
emitter.emit("userTranscriptDelta", { text: msg.text });
|
|
80
81
|
break;
|
|
81
82
|
case "transcript.user":
|
|
82
|
-
emitter.emit("
|
|
83
|
-
|
|
83
|
+
emitter.emit("userTranscript", {
|
|
84
|
+
itemId: msg.item_id,
|
|
84
85
|
text: msg.text
|
|
85
86
|
});
|
|
86
87
|
break;
|
|
87
88
|
case "reply.started":
|
|
88
|
-
emitter.emit("
|
|
89
|
+
emitter.emit("replyStarted", { replyId: msg.reply_id });
|
|
89
90
|
break;
|
|
90
91
|
case "transcript.agent.delta":
|
|
91
|
-
emitter.emit("
|
|
92
|
+
emitter.emit("agentTranscriptDelta", { text: msg.delta });
|
|
92
93
|
break;
|
|
93
94
|
case "transcript.agent":
|
|
94
|
-
emitter.emit("
|
|
95
|
+
emitter.emit("agentTranscript", {
|
|
96
|
+
text: msg.text,
|
|
97
|
+
replyId: msg.reply_id,
|
|
98
|
+
itemId: msg.item_id,
|
|
99
|
+
interrupted: msg.interrupted
|
|
100
|
+
});
|
|
95
101
|
break;
|
|
96
102
|
case "tool.call":
|
|
97
|
-
emitter.emit("
|
|
98
|
-
|
|
103
|
+
emitter.emit("toolCall", {
|
|
104
|
+
callId: msg.call_id,
|
|
99
105
|
name: msg.name,
|
|
100
106
|
args: msg.args
|
|
101
107
|
});
|
|
102
108
|
break;
|
|
103
109
|
case "reply.done":
|
|
104
|
-
emitter.emit("
|
|
110
|
+
emitter.emit("replyDone", msg.status ? { status: msg.status } : {});
|
|
105
111
|
break;
|
|
106
112
|
case "session.error":
|
|
107
|
-
if (msg.code === "session_not_found" || msg.code === "session_forbidden") emitter.emit("
|
|
113
|
+
if (msg.code === "session_not_found" || msg.code === "session_forbidden") emitter.emit("sessionExpired", {
|
|
108
114
|
code: msg.code,
|
|
109
115
|
message: msg.message
|
|
110
116
|
});
|
|
@@ -121,26 +127,23 @@ function dispatchS2sMessage(emitter, msg) {
|
|
|
121
127
|
break;
|
|
122
128
|
case "reply.content_part.started":
|
|
123
129
|
case "reply.content_part.done": break;
|
|
130
|
+
default: break;
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
|
-
/**
|
|
127
|
-
* Connect to AssemblyAI's Speech-to-Speech WebSocket API.
|
|
128
|
-
*
|
|
129
|
-
* Returns an {@link S2sHandle} with a typed `on()` method.
|
|
130
|
-
* Consumers listen for events: `ready`, `speech_started`, `speech_stopped`,
|
|
131
|
-
* `user_transcript_delta`, `user_transcript`, `reply_started`,
|
|
132
|
-
* `reply_done`, `audio`, `agent_transcript`, `tool_call`,
|
|
133
|
-
* `session_expired`, `error`, `close`.
|
|
134
|
-
*/
|
|
135
133
|
function connectS2s(opts) {
|
|
136
134
|
const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
|
|
137
135
|
return new Promise((resolve, reject) => {
|
|
138
136
|
log.info("S2S connecting", { url: config.wssUrl });
|
|
137
|
+
const connectionSpan = tracer.startSpan("s2s.connection", { attributes: { "aai.s2s.url": config.wssUrl } });
|
|
138
|
+
const connectStart = performance.now();
|
|
139
139
|
const ws = createWebSocket(config.wssUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
140
140
|
const emitter = createNanoEvents();
|
|
141
141
|
let opened = false;
|
|
142
142
|
function send(msg) {
|
|
143
|
-
if (ws.readyState !== WS_OPEN)
|
|
143
|
+
if (ws.readyState !== WS_OPEN) {
|
|
144
|
+
log.debug("S2S send dropped: socket not open", { type: msg.type });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
144
147
|
const json = JSON.stringify(msg);
|
|
145
148
|
if (msg.type !== "input.audio") log.info(`S2S >> ${msg.type}`, msg.type === "session.update" ? { payload: json } : void 0);
|
|
146
149
|
ws.send(json);
|
|
@@ -148,7 +151,10 @@ function connectS2s(opts) {
|
|
|
148
151
|
const handle = {
|
|
149
152
|
on: emitter.on.bind(emitter),
|
|
150
153
|
sendAudio(audio) {
|
|
151
|
-
if (ws.readyState !== WS_OPEN)
|
|
154
|
+
if (ws.readyState !== WS_OPEN) {
|
|
155
|
+
log.debug("S2S sendAudio dropped: socket not open");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
152
158
|
ws.send(`{"type":"input.audio","audio":"${uint8ToBase64(audio)}"}`);
|
|
153
159
|
},
|
|
154
160
|
sendToolResult(callId, result) {
|
|
@@ -164,9 +170,13 @@ function connectS2s(opts) {
|
|
|
164
170
|
send(msg);
|
|
165
171
|
},
|
|
166
172
|
updateSession(sessionConfig) {
|
|
173
|
+
const { systemPrompt, ...rest } = sessionConfig;
|
|
167
174
|
send({
|
|
168
175
|
type: "session.update",
|
|
169
|
-
session:
|
|
176
|
+
session: {
|
|
177
|
+
system_prompt: systemPrompt,
|
|
178
|
+
...rest
|
|
179
|
+
}
|
|
170
180
|
});
|
|
171
181
|
},
|
|
172
182
|
resumeSession(sessionId) {
|
|
@@ -183,6 +193,7 @@ function connectS2s(opts) {
|
|
|
183
193
|
ws.addEventListener("open", () => {
|
|
184
194
|
opened = true;
|
|
185
195
|
log.info("S2S WebSocket open");
|
|
196
|
+
connectionSpan.addEvent("ws.open");
|
|
186
197
|
resolve(handle);
|
|
187
198
|
});
|
|
188
199
|
function tryParseJson(data) {
|
|
@@ -190,7 +201,6 @@ function connectS2s(opts) {
|
|
|
190
201
|
return JSON.parse(String(data));
|
|
191
202
|
} catch {
|
|
192
203
|
log.warn("S2S << invalid JSON", { data: String(data).slice(0, 200) });
|
|
193
|
-
return;
|
|
194
204
|
}
|
|
195
205
|
}
|
|
196
206
|
function handleAudioFastPath(obj) {
|
|
@@ -208,15 +218,19 @@ function connectS2s(opts) {
|
|
|
208
218
|
function handleS2sMessage(ev) {
|
|
209
219
|
const raw = tryParseJson(ev.data);
|
|
210
220
|
if (raw === void 0) return;
|
|
221
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
222
|
+
log.warn("S2S << non-object JSON message", { type: typeof raw });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
211
225
|
const obj = raw;
|
|
212
226
|
logIncoming(obj);
|
|
213
227
|
if (handleAudioFastPath(obj)) return;
|
|
214
|
-
const parsed =
|
|
215
|
-
if (!parsed
|
|
228
|
+
const parsed = parseS2sMessage(obj);
|
|
229
|
+
if (!parsed) {
|
|
216
230
|
log.warn(`S2S << unrecognised message type: ${obj.type ?? JSON.stringify(raw).slice(0, 200)}`);
|
|
217
231
|
return;
|
|
218
232
|
}
|
|
219
|
-
dispatchS2sMessage(emitter, parsed
|
|
233
|
+
dispatchS2sMessage(emitter, parsed);
|
|
220
234
|
}
|
|
221
235
|
ws.addEventListener("message", handleS2sMessage);
|
|
222
236
|
ws.addEventListener("close", (ev) => {
|
|
@@ -224,14 +238,30 @@ function connectS2s(opts) {
|
|
|
224
238
|
code: ev.code ?? 0,
|
|
225
239
|
reason: ev.reason ?? ""
|
|
226
240
|
});
|
|
241
|
+
const elapsed = (performance.now() - connectStart) / 1e3;
|
|
242
|
+
s2sConnectionDuration.record(elapsed);
|
|
243
|
+
connectionSpan.addEvent("ws.closed", {
|
|
244
|
+
"ws.close.code": ev.code ?? 0,
|
|
245
|
+
"ws.close.reason": ev.reason ?? ""
|
|
246
|
+
});
|
|
247
|
+
connectionSpan.end();
|
|
248
|
+
if (!opened) reject(/* @__PURE__ */ new Error(`WebSocket closed before open (code: ${ev.code ?? 0})`));
|
|
227
249
|
emitter.emit("close");
|
|
228
250
|
});
|
|
229
251
|
ws.addEventListener("error", (ev) => {
|
|
230
252
|
const message = typeof ev.message === "string" ? ev.message : "WebSocket error";
|
|
231
253
|
const errObj = new Error(message);
|
|
232
254
|
log.error("S2S WebSocket error", { error: errObj.message });
|
|
233
|
-
|
|
234
|
-
|
|
255
|
+
s2sErrorCounter.add(1);
|
|
256
|
+
connectionSpan.setStatus({
|
|
257
|
+
code: 2,
|
|
258
|
+
message: errObj.message
|
|
259
|
+
});
|
|
260
|
+
connectionSpan.recordException(errObj);
|
|
261
|
+
if (!opened) {
|
|
262
|
+
connectionSpan.end();
|
|
263
|
+
reject(errObj);
|
|
264
|
+
} else emitter.emit("error", {
|
|
235
265
|
code: "ws_error",
|
|
236
266
|
message: errObj.message
|
|
237
267
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `createServer()` returns a server with `listen()` for HTTP + WebSocket.
|
|
5
5
|
* Calls `createDirectExecutor` + `wireSessionSocket` directly — no
|
|
6
|
-
*
|
|
6
|
+
* intermediary needed.
|
|
7
7
|
*/
|
|
8
8
|
import type { Kv } from "./kv.ts";
|
|
9
9
|
import type { Logger, S2SConfig } from "./runtime.ts";
|
|
10
10
|
import type { AgentDef } from "./types.ts";
|
|
11
|
+
import type { VectorStore } from "./vector.ts";
|
|
12
|
+
/**
|
|
13
|
+
* Configuration for a self-hosted agent server created by {@link createServer}.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
*/
|
|
11
17
|
export type ServerOptions = {
|
|
12
18
|
/** The agent definition returned by `defineAgent()`. */
|
|
13
|
-
agent: AgentDef
|
|
19
|
+
agent: AgentDef<any>;
|
|
14
20
|
/** Environment variables. Defaults to `process.env`. */
|
|
15
21
|
env?: Record<string, string>;
|
|
16
|
-
/** KV store. Defaults to
|
|
22
|
+
/** KV store. Defaults to SQLite-backed (`.aai/local.db`). */
|
|
17
23
|
kv?: Kv;
|
|
24
|
+
/** Vector store. Defaults to SQLite-backed (`.aai/local.db`). */
|
|
25
|
+
vector?: VectorStore;
|
|
18
26
|
/** HTML to serve at `GET /`. */
|
|
19
27
|
clientHtml?: string;
|
|
20
28
|
/** Directory containing built client files (index.html + assets/). */
|
|
@@ -23,11 +31,31 @@ export type ServerOptions = {
|
|
|
23
31
|
logger?: Logger;
|
|
24
32
|
/** S2S configuration. Defaults to AssemblyAI production. */
|
|
25
33
|
s2sConfig?: S2SConfig;
|
|
34
|
+
/**
|
|
35
|
+
* Timeout in ms for `session.start()` (S2S connection setup).
|
|
36
|
+
* Defaults to 10 000 (10 s). If the session doesn't initialize within
|
|
37
|
+
* this window the connection is cleaned up.
|
|
38
|
+
*/
|
|
39
|
+
sessionStartTimeoutMs?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Maximum time in milliseconds to wait for sessions to stop during
|
|
42
|
+
* {@link AgentServer.close | close()}. Sessions still running after this
|
|
43
|
+
* deadline are force-closed. Defaults to `30_000` (30 seconds).
|
|
44
|
+
*/
|
|
45
|
+
shutdownTimeoutMs?: number;
|
|
26
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Handle returned by {@link createServer} with lifecycle methods to start
|
|
49
|
+
* and stop the HTTP + WebSocket server.
|
|
50
|
+
*
|
|
51
|
+
* @public
|
|
52
|
+
*/
|
|
27
53
|
export type AgentServer = {
|
|
28
54
|
/** Start listening on the given port. */
|
|
29
55
|
listen(port?: number): Promise<void>;
|
|
30
56
|
/** Stop the server. */
|
|
31
57
|
close(): Promise<void>;
|
|
58
|
+
/** The port the server is listening on, or `undefined` before `listen()`. */
|
|
59
|
+
port: number | undefined;
|
|
32
60
|
};
|
|
33
61
|
export declare function createServer(options: ServerOptions): AgentServer;
|
package/dist/server.js
CHANGED
|
@@ -1,56 +1,115 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { filterEnv } from "./_utils.js";
|
|
2
|
+
import { t as createDirectExecutor } from "./direct-executor-Ca0wt5H0.js";
|
|
3
|
+
import { buildReadyConfig } from "./protocol.js";
|
|
2
4
|
import { DEFAULT_S2S_CONFIG, consoleLogger } from "./runtime.js";
|
|
3
|
-
import {
|
|
5
|
+
import { createSqliteKv } from "./sqlite-kv.js";
|
|
4
6
|
import { wireSessionSocket } from "./ws-handler.js";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
5
9
|
import { serve } from "@hono/node-server";
|
|
6
10
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
7
11
|
import { Hono } from "hono";
|
|
8
|
-
import { WebSocketServer } from "ws";
|
|
9
12
|
//#region server.ts
|
|
10
13
|
/**
|
|
11
14
|
* Self-hostable agent server.
|
|
12
15
|
*
|
|
13
16
|
* `createServer()` returns a server with `listen()` for HTTP + WebSocket.
|
|
14
17
|
* Calls `createDirectExecutor` + `wireSessionSocket` directly — no
|
|
15
|
-
*
|
|
18
|
+
* intermediary needed.
|
|
16
19
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
/** Escape HTML special characters to prevent XSS. */
|
|
21
|
+
function escapeHtml(s) {
|
|
22
|
+
return s.replace(/[&<>"']/g, (ch) => ({
|
|
23
|
+
"&": "&",
|
|
24
|
+
"<": "<",
|
|
25
|
+
">": ">",
|
|
26
|
+
"\"": """,
|
|
27
|
+
"'": "'"
|
|
28
|
+
})[ch]);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Create an HTTP + WebSocket server for self-hosted agent deployments.
|
|
32
|
+
*
|
|
33
|
+
* Sets up a Hono HTTP server with a `/health` endpoint and WebSocket upgrade
|
|
34
|
+
* handling. Agent tools execute directly in-process via {@link createDirectExecutor}.
|
|
35
|
+
*
|
|
36
|
+
* @param options - Server configuration including the agent definition, optional
|
|
37
|
+
* KV store, client assets, logger, and S2S config. See {@link ServerOptions}.
|
|
38
|
+
* @returns An {@link AgentServer} with `listen()` and `close()` lifecycle methods.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { defineAgent } from "@alexkroman1/aai";
|
|
43
|
+
* import { createServer } from "@alexkroman1/aai/server";
|
|
44
|
+
*
|
|
45
|
+
* const agent = defineAgent({ name: "my-agent" });
|
|
46
|
+
* const server = createServer({ agent });
|
|
47
|
+
* await server.listen(3000);
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @public
|
|
51
|
+
*/
|
|
52
|
+
async function drainSessions(sessions, shutdownTimeoutMs, logger) {
|
|
53
|
+
if (sessions.size === 0) return;
|
|
54
|
+
let timer;
|
|
55
|
+
const timeout = new Promise((resolve) => {
|
|
56
|
+
timer = setTimeout(resolve, shutdownTimeoutMs, "timeout");
|
|
57
|
+
});
|
|
58
|
+
const graceful = Promise.allSettled([...sessions.values()].map((s) => s.stop())).then((results) => {
|
|
59
|
+
for (const r of results) if (r.status === "rejected") logger.warn(`Session stop failed during close: ${r.reason}`);
|
|
60
|
+
return "done";
|
|
61
|
+
});
|
|
62
|
+
const outcome = await Promise.race([graceful, timeout]);
|
|
63
|
+
if (timer) clearTimeout(timer);
|
|
64
|
+
if (outcome === "timeout") logger.warn(`Shutdown timeout (${shutdownTimeoutMs}ms) exceeded — force-closing ${sessions.size} remaining session(s)`);
|
|
65
|
+
sessions.clear();
|
|
19
66
|
}
|
|
20
67
|
function createServer(options) {
|
|
21
|
-
|
|
68
|
+
if (options.clientHtml && options.clientDir) throw new Error("ServerOptions: clientHtml and clientDir are mutually exclusive — provide one or the other, not both.");
|
|
69
|
+
const { agent, kv, vector, clientHtml, clientDir, logger = consoleLogger, s2sConfig = DEFAULT_S2S_CONFIG, shutdownTimeoutMs = 3e4 } = options;
|
|
70
|
+
const env = filterEnv(options.env ?? (typeof process !== "undefined" ? process.env : {}));
|
|
71
|
+
const resolvedKv = kv ?? (() => {
|
|
72
|
+
mkdirSync(".aai", { recursive: true });
|
|
73
|
+
return createSqliteKv();
|
|
74
|
+
})();
|
|
22
75
|
const executor = createDirectExecutor({
|
|
23
76
|
agent,
|
|
24
|
-
env
|
|
25
|
-
|
|
77
|
+
env,
|
|
78
|
+
kv: resolvedKv,
|
|
79
|
+
...vector ? { vector } : {},
|
|
26
80
|
logger,
|
|
27
81
|
s2sConfig
|
|
28
82
|
});
|
|
29
83
|
const sessions = /* @__PURE__ */ new Map();
|
|
30
|
-
const readyConfig =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
ttsSampleRate: s2sConfig.outputSampleRate
|
|
34
|
-
};
|
|
35
|
-
function handleWs(ws, skipGreeting) {
|
|
84
|
+
const readyConfig = buildReadyConfig(s2sConfig);
|
|
85
|
+
const safeAgentName = escapeHtml(agent.name);
|
|
86
|
+
function handleWs(ws, skipGreeting, resumeFrom) {
|
|
36
87
|
wireSessionSocket(ws, {
|
|
37
88
|
sessions,
|
|
38
89
|
createSession: (sid, client) => executor.createSession({
|
|
39
90
|
id: sid,
|
|
40
91
|
agent: agent.name,
|
|
41
92
|
client,
|
|
42
|
-
skipGreeting
|
|
93
|
+
skipGreeting,
|
|
94
|
+
...resumeFrom ? { resumeFrom } : {}
|
|
43
95
|
}),
|
|
44
96
|
readyConfig,
|
|
45
|
-
logger
|
|
97
|
+
logger,
|
|
98
|
+
...options.sessionStartTimeoutMs !== void 0 ? { sessionStartTimeoutMs: options.sessionStartTimeoutMs } : {},
|
|
99
|
+
...resumeFrom ? { resumeFrom } : {}
|
|
46
100
|
});
|
|
47
101
|
}
|
|
48
102
|
let serverHandle = null;
|
|
103
|
+
let listenPort;
|
|
49
104
|
return {
|
|
105
|
+
get port() {
|
|
106
|
+
return listenPort;
|
|
107
|
+
},
|
|
50
108
|
async listen(port = 3e3) {
|
|
109
|
+
if (serverHandle) throw new Error("Server is already listening");
|
|
51
110
|
const app = new Hono();
|
|
52
111
|
app.onError((err, c) => {
|
|
53
|
-
logger.error(`${c.req.method} ${
|
|
112
|
+
logger.error(`${c.req.method} ${c.req.path} error: ${err.message}`);
|
|
54
113
|
return c.json({ error: "Internal Server Error" }, 500);
|
|
55
114
|
});
|
|
56
115
|
app.use("/*", async (c, next) => {
|
|
@@ -59,7 +118,7 @@ function createServer(options) {
|
|
|
59
118
|
const ms = Date.now() - start;
|
|
60
119
|
const { status } = c.res;
|
|
61
120
|
const method = c.req.method;
|
|
62
|
-
const path =
|
|
121
|
+
const path = c.req.path;
|
|
63
122
|
if (status >= 400) logger.error(`${method} ${path} ${status} ${ms}ms`);
|
|
64
123
|
else logger.info(`${method} ${path} ${status} ${ms}ms`);
|
|
65
124
|
});
|
|
@@ -67,37 +126,52 @@ function createServer(options) {
|
|
|
67
126
|
status: "ok",
|
|
68
127
|
name: agent.name
|
|
69
128
|
}));
|
|
129
|
+
app.get("/kv", async (c) => {
|
|
130
|
+
const key = c.req.query("key");
|
|
131
|
+
if (!key) return c.json({ error: "Missing key query parameter" }, 400);
|
|
132
|
+
const value = await resolvedKv.get(key);
|
|
133
|
+
if (value === null) return c.json(null, 404);
|
|
134
|
+
return c.json(value);
|
|
135
|
+
});
|
|
70
136
|
if (clientDir) app.use("/*", serveStatic({ root: clientDir }));
|
|
137
|
+
const csp = "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src 'self' wss: ws:; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; object-src 'none'; base-uri 'self'";
|
|
71
138
|
app.get("/", (c) => {
|
|
72
|
-
if (clientHtml) return c.html(clientHtml);
|
|
73
|
-
return c.html(`<!DOCTYPE html><html><body><h1>${
|
|
139
|
+
if (clientHtml) return c.html(clientHtml, 200, { "Content-Security-Policy": csp });
|
|
140
|
+
return c.html(`<!DOCTYPE html><html><body><h1>${safeAgentName}</h1><p>Agent server running.</p></body></html>`, 200, { "Content-Security-Policy": csp });
|
|
74
141
|
});
|
|
75
142
|
const nodeServer = serve({
|
|
76
143
|
fetch: app.fetch,
|
|
77
144
|
port
|
|
78
145
|
});
|
|
79
|
-
await new Promise((resolve) => {
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
80
147
|
nodeServer.on("listening", resolve);
|
|
148
|
+
nodeServer.on("error", reject);
|
|
81
149
|
});
|
|
150
|
+
const addr = nodeServer.address();
|
|
151
|
+
listenPort = typeof addr === "object" && addr ? addr.port : port;
|
|
82
152
|
const wss = new WebSocketServer({ noServer: true });
|
|
83
153
|
nodeServer.on("upgrade", (req, socket, head) => {
|
|
84
|
-
const reqUrl = new URL(req.url ?? "/", `http://localhost:${
|
|
85
|
-
const
|
|
86
|
-
|
|
154
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${listenPort}`);
|
|
155
|
+
const resumeFrom = reqUrl.searchParams.get("sessionId") ?? void 0;
|
|
156
|
+
const skipGreeting = reqUrl.searchParams.has("resume") || resumeFrom !== void 0;
|
|
157
|
+
logger.info(`WS upgrade ${reqUrl.pathname}${skipGreeting ? " (resume)" : ""}`);
|
|
87
158
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
88
|
-
handleWs(ws,
|
|
159
|
+
handleWs(ws, skipGreeting, resumeFrom);
|
|
89
160
|
});
|
|
90
161
|
});
|
|
91
162
|
serverHandle = { async shutdown() {
|
|
163
|
+
await new Promise((resolve, reject) => {
|
|
164
|
+
wss.close((err) => err ? reject(err) : resolve());
|
|
165
|
+
});
|
|
92
166
|
await new Promise((resolve, reject) => {
|
|
93
167
|
nodeServer.close((err) => err ? reject(err) : resolve());
|
|
94
168
|
});
|
|
95
169
|
} };
|
|
96
170
|
},
|
|
97
171
|
async close() {
|
|
98
|
-
|
|
99
|
-
sessions.clear();
|
|
172
|
+
await drainSessions(sessions, shutdownTimeoutMs, logger);
|
|
100
173
|
await serverHandle?.shutdown();
|
|
174
|
+
listenPort = void 0;
|
|
101
175
|
}
|
|
102
176
|
};
|
|
103
177
|
}
|