@agentskit/chat-server 0.1.0 → 0.2.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.
- package/README.md +20 -0
- package/dist/index.cjs +317 -48
- package/dist/index.d.cts +76 -1
- package/dist/index.d.ts +76 -1
- package/dist/index.js +321 -44
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -11,3 +11,23 @@ const POST = createChatHandler({
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
The returned function accepts a standard `Request` and returns a standard streaming `Response`.
|
|
14
|
+
|
|
15
|
+
Semantic questions escalated by the deterministic plane use the trusted Ask
|
|
16
|
+
vertical:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const POST = createAskServiceHandler({
|
|
20
|
+
authenticate,
|
|
21
|
+
resolveSite,
|
|
22
|
+
resolveSubjectId: identity => identity.subjectId,
|
|
23
|
+
retrievers: { local: localRag, federated: federatedRag },
|
|
24
|
+
generator,
|
|
25
|
+
sessionStore,
|
|
26
|
+
rateLimit,
|
|
27
|
+
onMetric,
|
|
28
|
+
})
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Site/corpus/assistant authority is resolved server-side. Successful responses
|
|
32
|
+
are cited Ask NDJSON; hosted and self-hosted routes mount the same factory. See
|
|
33
|
+
the [backend guide](../../docs/backend.md).
|
package/dist/index.cjs
CHANGED
|
@@ -21,35 +21,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
ChatHandlerError: () => ChatHandlerError,
|
|
24
|
+
createAskServiceHandler: () => createAskServiceHandler,
|
|
24
25
|
createChatHandler: () => createChatHandler
|
|
25
26
|
});
|
|
26
27
|
module.exports = __toCommonJS(index_exports);
|
|
27
28
|
var import_core = require("@agentskit/core");
|
|
28
29
|
var import_chat = require("@agentskit/chat");
|
|
29
|
-
var
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
code;
|
|
33
|
-
retryable;
|
|
34
|
-
constructor(options) {
|
|
35
|
-
super(options.message);
|
|
36
|
-
this.name = "ChatHandlerError";
|
|
37
|
-
this.status = options.status;
|
|
38
|
-
this.code = options.code;
|
|
39
|
-
this.retryable = options.retryable ?? false;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
var encoder = new TextEncoder();
|
|
30
|
+
var import_chat_protocol2 = require("@agentskit/chat-protocol");
|
|
31
|
+
|
|
32
|
+
// src/internal.ts
|
|
43
33
|
var decoder = new TextDecoder();
|
|
44
|
-
var
|
|
45
|
-
status,
|
|
46
|
-
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
|
|
47
|
-
});
|
|
48
|
-
var safeError = (error) => error instanceof ChatHandlerError ? { status: error.status, diagnostic: { version: 1, code: error.code, message: error.message, retryable: error.retryable } } : error instanceof import_chat.SessionConflictError ? { status: 409, diagnostic: { version: 1, code: "SESSION_CONFLICT", message: "Another turn is active for this session.", retryable: true } } : { status: 500, diagnostic: { version: 1, code: "SERVER_INTERNAL", message: "The chat request failed.", retryable: true } };
|
|
49
|
-
var fail = (status, code, message, retryable = false) => {
|
|
50
|
-
throw new ChatHandlerError({ status, code, message, retryable });
|
|
51
|
-
};
|
|
52
|
-
var withSignal = async (operation, signal) => {
|
|
34
|
+
var withAbort = async (operation, signal) => {
|
|
53
35
|
if (signal.aborted) throw signal.reason;
|
|
54
36
|
let rejectAbort;
|
|
55
37
|
const abort = () => rejectAbort?.(signal.reason);
|
|
@@ -63,21 +45,21 @@ var withSignal = async (operation, signal) => {
|
|
|
63
45
|
signal.removeEventListener("abort", abort);
|
|
64
46
|
}
|
|
65
47
|
};
|
|
66
|
-
var
|
|
48
|
+
var readBoundedJson = async (request, maxBodyBytes, signal, fail3) => {
|
|
67
49
|
const declared = Number(request.headers.get("content-length"));
|
|
68
|
-
if (Number.isFinite(declared) && declared > maxBodyBytes)
|
|
50
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
|
|
69
51
|
const reader = request.body?.getReader();
|
|
70
|
-
if (!reader) return
|
|
52
|
+
if (!reader) return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
|
|
71
53
|
const chunks = [];
|
|
72
54
|
let size = 0;
|
|
73
55
|
try {
|
|
74
56
|
while (true) {
|
|
75
|
-
const result = await
|
|
57
|
+
const result = await withAbort(reader.read(), signal);
|
|
76
58
|
if (result.done) break;
|
|
77
59
|
size += result.value.byteLength;
|
|
78
60
|
if (size > maxBodyBytes) {
|
|
79
61
|
await reader.cancel();
|
|
80
|
-
return
|
|
62
|
+
return fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
|
|
81
63
|
}
|
|
82
64
|
chunks.push(result.value);
|
|
83
65
|
}
|
|
@@ -93,39 +75,325 @@ var readBody = async (request, maxBodyBytes, signal) => {
|
|
|
93
75
|
try {
|
|
94
76
|
return JSON.parse(decoder.decode(bytes));
|
|
95
77
|
} catch {
|
|
96
|
-
return
|
|
78
|
+
return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
|
|
97
79
|
}
|
|
98
80
|
};
|
|
81
|
+
|
|
82
|
+
// src/ask-service.ts
|
|
83
|
+
var import_chat_protocol = require("@agentskit/chat-protocol");
|
|
84
|
+
var AskServiceError = class extends Error {
|
|
85
|
+
status;
|
|
86
|
+
diagnostic;
|
|
87
|
+
retryAfterSeconds;
|
|
88
|
+
constructor(status, diagnostic, retryAfterSeconds) {
|
|
89
|
+
super(diagnostic.message);
|
|
90
|
+
this.name = "AskServiceError";
|
|
91
|
+
this.status = status;
|
|
92
|
+
this.diagnostic = diagnostic;
|
|
93
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var encoder = new TextEncoder();
|
|
97
|
+
var fail = (status, code, message, retryable = false, retryAfterSeconds) => {
|
|
98
|
+
throw new AskServiceError(status, import_chat_protocol.AskBackendDiagnosticSchema.parse({ code, message, retryable }), retryAfterSeconds);
|
|
99
|
+
};
|
|
100
|
+
var safeFailure = (error) => error instanceof AskServiceError ? error : new AskServiceError(500, { code: "ASK_INTERNAL", message: "The Ask request failed.", retryable: true });
|
|
101
|
+
var errorResponse = (error, requestId) => new Response(JSON.stringify({ error: error.diagnostic }), {
|
|
102
|
+
status: error.status,
|
|
103
|
+
headers: {
|
|
104
|
+
"content-type": "application/json; charset=utf-8",
|
|
105
|
+
"cache-control": "no-store",
|
|
106
|
+
"x-request-id": requestId,
|
|
107
|
+
...error.retryAfterSeconds === void 0 ? {} : { "retry-after": String(error.retryAfterSeconds) }
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
var stableSubject = (value) => {
|
|
111
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/.test(value)) fail(500, "ASK_INTERNAL", "The Ask host identity is invalid.");
|
|
112
|
+
return value;
|
|
113
|
+
};
|
|
114
|
+
var latestQuestion = (messages) => {
|
|
115
|
+
const question = [...messages].reverse().find((message) => message.role === "user")?.content.trim();
|
|
116
|
+
return question === void 0 || question === "" ? fail(400, "ASK_INVALID_REQUEST", "A user question is required.") : question;
|
|
117
|
+
};
|
|
118
|
+
var mergeSessionMessages = (stored, submitted) => {
|
|
119
|
+
const question = [...submitted].reverse().find((message) => message.role === "user");
|
|
120
|
+
if (question === void 0) return stored;
|
|
121
|
+
const last = stored.at(-1);
|
|
122
|
+
return last?.role === "user" && last.content === question.content ? stored : [...stored, question].slice(-64);
|
|
123
|
+
};
|
|
124
|
+
var createAskServiceHandler = (options) => {
|
|
125
|
+
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
|
|
126
|
+
const bootstrapTimeoutMs = options.bootstrapTimeoutMs ?? 3e4;
|
|
127
|
+
if (![maxBodyBytes, bootstrapTimeoutMs].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
|
128
|
+
fail(500, "ASK_INTERNAL", "The Ask handler configuration is invalid.");
|
|
129
|
+
}
|
|
130
|
+
const createId = options.createId ?? (() => crypto.randomUUID());
|
|
131
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
132
|
+
const clock = options.clock ?? Date.now;
|
|
133
|
+
return async (request) => {
|
|
134
|
+
let candidateRequestId;
|
|
135
|
+
try {
|
|
136
|
+
candidateRequestId = createId();
|
|
137
|
+
} catch {
|
|
138
|
+
candidateRequestId = crypto.randomUUID();
|
|
139
|
+
}
|
|
140
|
+
const requestId = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(candidateRequestId) ? candidateRequestId : crypto.randomUUID();
|
|
141
|
+
const startedAt = clock();
|
|
142
|
+
const bootstrap = AbortSignal.timeout(bootstrapTimeoutMs);
|
|
143
|
+
const bootstrapSignal = AbortSignal.any([request.signal, bootstrap]);
|
|
144
|
+
let site;
|
|
145
|
+
let workDeadline;
|
|
146
|
+
let emittedErrorMetric = false;
|
|
147
|
+
const metric = (name, value, unit, outcome) => {
|
|
148
|
+
if (site === void 0) return;
|
|
149
|
+
const record = import_chat_protocol.AskBackendMetricSchema.parse({
|
|
150
|
+
protocol: "agentskit.chat.backend-metric",
|
|
151
|
+
version: 1,
|
|
152
|
+
name,
|
|
153
|
+
siteId: site.siteId,
|
|
154
|
+
corpusId: site.corpus.id,
|
|
155
|
+
requestId,
|
|
156
|
+
value: Math.max(0, value),
|
|
157
|
+
unit,
|
|
158
|
+
outcome,
|
|
159
|
+
emittedAt: now().toISOString()
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
void Promise.resolve(options.onMetric?.(record)).catch(() => void 0);
|
|
163
|
+
} catch {
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const recordError = (outcome) => {
|
|
167
|
+
if (emittedErrorMetric) return;
|
|
168
|
+
emittedErrorMetric = true;
|
|
169
|
+
metric(outcome === "cancelled" ? "cancellation.count" : "error.count", 1, "count", outcome);
|
|
170
|
+
metric("request.total_ms", clock() - startedAt, "ms", outcome);
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
if (request.method !== "POST") fail(405, "ASK_INVALID_REQUEST", "Only POST is supported.");
|
|
174
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
175
|
+
fail(415, "ASK_INVALID_REQUEST", "Content-Type must be application/json.");
|
|
176
|
+
}
|
|
177
|
+
const authenticated = await withAbort(options.authenticate(request, bootstrapSignal), bootstrapSignal);
|
|
178
|
+
if (!authenticated.ok) return authenticated.response;
|
|
179
|
+
site = import_chat_protocol.AskBackendSiteConfigSchema.parse(await withAbort(options.resolveSite(authenticated.context, bootstrapSignal), bootstrapSignal));
|
|
180
|
+
const subjectId = stableSubject(options.resolveSubjectId(authenticated.context));
|
|
181
|
+
const url = new URL(request.url);
|
|
182
|
+
const corpusHint = url.searchParams.get("corpus");
|
|
183
|
+
const personaHint = url.searchParams.get("persona");
|
|
184
|
+
if (corpusHint !== null && corpusHint !== site.corpus.id || personaHint !== null && personaHint !== site.assistant.id) {
|
|
185
|
+
fail(403, "ASK_FORBIDDEN", "The requested assistant or corpus is not authorized for this site.");
|
|
186
|
+
}
|
|
187
|
+
const requestDeadline = AbortSignal.timeout(site.limits.requestTimeoutMs);
|
|
188
|
+
workDeadline = requestDeadline;
|
|
189
|
+
const responseAbort = new AbortController();
|
|
190
|
+
const signal = AbortSignal.any([request.signal, requestDeadline, responseAbort.signal]);
|
|
191
|
+
const limited = await withAbort(
|
|
192
|
+
options.rateLimit?.({ context: authenticated.context, site, subjectId, signal }) ?? { allowed: true },
|
|
193
|
+
signal
|
|
194
|
+
);
|
|
195
|
+
if (!limited.allowed) fail(429, "ASK_RATE_LIMITED", "The Ask rate limit was exceeded.", true, limited.retryAfterSeconds);
|
|
196
|
+
const raw = await readBoundedJson(request, maxBodyBytes, signal, (status, _code, message) => fail(status, "ASK_INVALID_REQUEST", message));
|
|
197
|
+
const parsed = import_chat_protocol.AskBackendRequestSchema.safeParse(raw);
|
|
198
|
+
const input = parsed.success && parsed.data !== void 0 ? parsed.data : fail(400, "ASK_INVALID_REQUEST", "The Ask request payload is invalid.");
|
|
199
|
+
metric("deterministic.fallback", input.deterministic === void 0 ? 0 : 1, "count", "ok");
|
|
200
|
+
if (site.persistence.mode === "required" && (input.sessionId === void 0 || options.sessionStore === void 0)) {
|
|
201
|
+
fail(500, "ASK_INTERNAL", "Required Ask persistence is not configured.");
|
|
202
|
+
}
|
|
203
|
+
const key = input.sessionId === void 0 ? void 0 : { siteId: site.siteId, subjectId, sessionId: input.sessionId };
|
|
204
|
+
let stored;
|
|
205
|
+
if (key !== void 0 && options.sessionStore !== void 0) {
|
|
206
|
+
const persistenceStarted = clock();
|
|
207
|
+
const loaded = await withAbort(options.sessionStore.load(key, signal), signal);
|
|
208
|
+
stored = loaded === void 0 ? void 0 : import_chat_protocol.AskBackendSessionRecordSchema.parse(loaded);
|
|
209
|
+
metric("persistence.total_ms", clock() - persistenceStarted, "ms", "ok");
|
|
210
|
+
}
|
|
211
|
+
const messages = stored === void 0 ? input.messages : mergeSessionMessages(stored.messages, input.messages);
|
|
212
|
+
const query = latestQuestion(messages);
|
|
213
|
+
const retriever = options.retrievers[site.corpus.mode] ?? fail(500, "ASK_INTERNAL", "The configured Ask retriever is unavailable.");
|
|
214
|
+
const retrievalStarted = clock();
|
|
215
|
+
const retrievalSignal = AbortSignal.any([signal, AbortSignal.timeout(site.limits.retrievalTimeoutMs)]);
|
|
216
|
+
const sources = await (async () => {
|
|
217
|
+
try {
|
|
218
|
+
const candidates = await withAbort(retriever.retrieve({ query, messages, site, signal: retrievalSignal }), retrievalSignal);
|
|
219
|
+
return candidates.flatMap((candidate) => {
|
|
220
|
+
const decoded = import_chat_protocol.AskBackendSourceSchema.safeParse(candidate);
|
|
221
|
+
return decoded.success ? [decoded.data] : [];
|
|
222
|
+
}).slice(0, site.limits.maxSources);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (signal.aborted) throw error;
|
|
225
|
+
if (retrievalSignal.aborted) fail(408, "ASK_TIMEOUT", "Grounded retrieval timed out.", true);
|
|
226
|
+
return fail(502, "ASK_RETRIEVAL_FAILED", "Grounded retrieval is temporarily unavailable.", true);
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
metric("retrieval.total_ms", clock() - retrievalStarted, "ms", "ok");
|
|
230
|
+
metric("retrieval.documents", sources.length, "count", "ok");
|
|
231
|
+
if (sources.length === 0) fail(422, "ASK_NO_GROUNDED_SOURCES", "No grounded sources were found for this question.");
|
|
232
|
+
const body = new ReadableStream({
|
|
233
|
+
async start(controller) {
|
|
234
|
+
let bytes = 0;
|
|
235
|
+
let events = 0;
|
|
236
|
+
let firstEvent = false;
|
|
237
|
+
let firstToken = false;
|
|
238
|
+
let answer = "";
|
|
239
|
+
let usage;
|
|
240
|
+
const generationDeadline = AbortSignal.timeout(site.limits.generationTimeoutMs);
|
|
241
|
+
const generationSignal = AbortSignal.any([signal, generationDeadline]);
|
|
242
|
+
const emit = (candidate) => {
|
|
243
|
+
const event = import_chat_protocol.AskEventSchema.parse(candidate);
|
|
244
|
+
const chunk = encoder.encode(`${JSON.stringify(event)}
|
|
245
|
+
`);
|
|
246
|
+
if (!firstEvent) {
|
|
247
|
+
firstEvent = true;
|
|
248
|
+
metric("stream.first_event_ms", clock() - startedAt, "ms", "ok");
|
|
249
|
+
}
|
|
250
|
+
if (event.type === "text" && !firstToken) {
|
|
251
|
+
firstToken = true;
|
|
252
|
+
metric("stream.first_token_ms", clock() - startedAt, "ms", "ok");
|
|
253
|
+
}
|
|
254
|
+
bytes += chunk.byteLength;
|
|
255
|
+
events += 1;
|
|
256
|
+
controller.enqueue(chunk);
|
|
257
|
+
};
|
|
258
|
+
try {
|
|
259
|
+
const generation = options.generator.generate({ query, messages, site, sources, signal: generationSignal });
|
|
260
|
+
for await (const chunk of generation) {
|
|
261
|
+
if (generationSignal.aborted) throw generationSignal.reason;
|
|
262
|
+
if (chunk.type === "usage") {
|
|
263
|
+
usage = import_chat_protocol.AskBackendUsageSchema.parse(chunk.usage);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (chunk.delta === "") continue;
|
|
267
|
+
answer += chunk.delta;
|
|
268
|
+
if (answer.length > 16384) fail(502, "ASK_GENERATION_FAILED", "The grounded answer exceeded its safe limit.", true);
|
|
269
|
+
emit({ type: "text", delta: chunk.delta });
|
|
270
|
+
}
|
|
271
|
+
if (answer.trim() === "") fail(502, "ASK_GENERATION_FAILED", "The grounded answer was empty.", true);
|
|
272
|
+
emit({
|
|
273
|
+
type: "tool",
|
|
274
|
+
id: `sources-${requestId}`,
|
|
275
|
+
name: "cite",
|
|
276
|
+
args: { sources: sources.map((source) => ({ id: source.id, title: source.title, path: source.href })) }
|
|
277
|
+
});
|
|
278
|
+
if (key !== void 0 && options.sessionStore !== void 0) {
|
|
279
|
+
const persistenceStarted = clock();
|
|
280
|
+
const revision = (stored?.revision ?? 0) + 1;
|
|
281
|
+
const saved = await withAbort(options.sessionStore.save(key, {
|
|
282
|
+
revision,
|
|
283
|
+
messages: [...messages, { role: "assistant", content: answer }].slice(-64)
|
|
284
|
+
}, stored?.revision ?? 0, signal), signal);
|
|
285
|
+
metric("persistence.total_ms", clock() - persistenceStarted, "ms", saved ? "ok" : "error");
|
|
286
|
+
if (!saved) {
|
|
287
|
+
metric("conflict.count", 1, "count", "error");
|
|
288
|
+
fail(409, "ASK_PERSISTENCE_CONFLICT", "The Ask session changed concurrently.", true);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (usage?.inputTokens !== void 0) metric("usage.input_tokens", usage.inputTokens, "tokens", "ok");
|
|
292
|
+
if (usage?.outputTokens !== void 0) metric("usage.output_tokens", usage.outputTokens, "tokens", "ok");
|
|
293
|
+
if (usage?.totalTokens !== void 0) metric("usage.total_tokens", usage.totalTokens, "tokens", "ok");
|
|
294
|
+
if (usage?.costUsd !== void 0) metric("cost.usd", usage.costUsd, "usd", "ok");
|
|
295
|
+
emit({ type: "done", ...usage?.model === void 0 ? {} : { model: usage.model } });
|
|
296
|
+
metric("stream.bytes", bytes, "bytes", "ok");
|
|
297
|
+
metric("stream.events", events, "count", "ok");
|
|
298
|
+
metric("stream.snapshots", 0, "count", "ok");
|
|
299
|
+
metric("request.total_ms", clock() - startedAt, "ms", "ok");
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const timeout = !request.signal.aborted && (requestDeadline.aborted || generationDeadline.aborted || generationSignal.aborted);
|
|
302
|
+
const interrupted = signal.aborted || generationSignal.aborted;
|
|
303
|
+
const diagnostic = timeout ? import_chat_protocol.AskBackendDiagnosticSchema.parse({ code: "ASK_TIMEOUT", message: "The Ask request timed out.", retryable: true }) : interrupted ? import_chat_protocol.AskBackendDiagnosticSchema.parse({ code: "ASK_CANCELLED", message: "The Ask request was cancelled.", retryable: true }) : safeFailure(error).diagnostic;
|
|
304
|
+
if (!interrupted || timeout) {
|
|
305
|
+
try {
|
|
306
|
+
emit({ type: "error", message: diagnostic.message, code: diagnostic.code, retryable: diagnostic.retryable });
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
recordError(interrupted && !timeout ? "cancelled" : "error");
|
|
311
|
+
} finally {
|
|
312
|
+
try {
|
|
313
|
+
controller.close();
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
cancel() {
|
|
319
|
+
responseAbort.abort();
|
|
320
|
+
recordError("cancelled");
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
return new Response(body, {
|
|
324
|
+
status: 200,
|
|
325
|
+
headers: {
|
|
326
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
327
|
+
"cache-control": "no-store",
|
|
328
|
+
"x-content-type-options": "nosniff",
|
|
329
|
+
"x-request-id": requestId
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const deadlineExpired = bootstrap.aborted || workDeadline?.aborted === true;
|
|
334
|
+
const cancelled = bootstrapSignal.aborted || request.signal.aborted || deadlineExpired;
|
|
335
|
+
const safe = cancelled ? new AskServiceError(deadlineExpired ? 408 : 499, { code: deadlineExpired ? "ASK_TIMEOUT" : "ASK_CANCELLED", message: deadlineExpired ? "The Ask request timed out." : "The Ask request was cancelled.", retryable: true }) : safeFailure(error);
|
|
336
|
+
recordError(cancelled ? "cancelled" : safe.status < 500 ? "rejected" : "error");
|
|
337
|
+
return errorResponse(safe, requestId);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// src/index.ts
|
|
343
|
+
var ChatHandlerError = class extends Error {
|
|
344
|
+
status;
|
|
345
|
+
code;
|
|
346
|
+
retryable;
|
|
347
|
+
constructor(options) {
|
|
348
|
+
super(options.message);
|
|
349
|
+
this.name = "ChatHandlerError";
|
|
350
|
+
this.status = options.status;
|
|
351
|
+
this.code = options.code;
|
|
352
|
+
this.retryable = options.retryable ?? false;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
var encoder2 = new TextEncoder();
|
|
356
|
+
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
|
|
357
|
+
status,
|
|
358
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
|
|
359
|
+
});
|
|
360
|
+
var safeError = (error) => error instanceof ChatHandlerError ? { status: error.status, diagnostic: { version: 1, code: error.code, message: error.message, retryable: error.retryable } } : error instanceof import_chat.SessionConflictError ? { status: 409, diagnostic: { version: 1, code: "SESSION_CONFLICT", message: "Another turn is active for this session.", retryable: true } } : { status: 500, diagnostic: { version: 1, code: "SERVER_INTERNAL", message: "The chat request failed.", retryable: true } };
|
|
361
|
+
var fail2 = (status, code, message, retryable = false) => {
|
|
362
|
+
throw new ChatHandlerError({ status, code, message, retryable });
|
|
363
|
+
};
|
|
364
|
+
var readBody = async (request, maxBodyBytes, signal) => {
|
|
365
|
+
return readBoundedJson(request, maxBodyBytes, signal, fail2);
|
|
366
|
+
};
|
|
99
367
|
var snapshotStatus = (state) => state.status === "error" ? "error" : state.status === "streaming" ? "streaming" : state.messages.length === 0 ? "idle" : "complete";
|
|
100
368
|
var createChatHandler = (options) => {
|
|
101
369
|
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
102
370
|
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5e3;
|
|
103
371
|
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
|
|
104
|
-
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0))
|
|
372
|
+
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
|
|
105
373
|
const leaseMs = timeoutMs + 3 * cleanupTimeoutMs;
|
|
106
|
-
if (!Number.isSafeInteger(leaseMs))
|
|
374
|
+
if (!Number.isSafeInteger(leaseMs)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
|
|
107
375
|
const createId = options.createId ?? (() => crypto.randomUUID());
|
|
108
376
|
return async (request) => {
|
|
109
377
|
const deadline = AbortSignal.timeout(timeoutMs);
|
|
110
378
|
const signal = AbortSignal.any([request.signal, deadline]);
|
|
111
379
|
try {
|
|
112
|
-
if (request.method !== "POST")
|
|
113
|
-
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
|
|
380
|
+
if (request.method !== "POST") fail2(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
|
|
381
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail2(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
|
|
114
382
|
let context;
|
|
115
383
|
if (options.authenticate) {
|
|
116
|
-
const authenticated = await
|
|
384
|
+
const authenticated = await withAbort(options.authenticate(request, signal), signal);
|
|
117
385
|
if (!authenticated.ok) return authenticated.response;
|
|
118
386
|
context = authenticated.context;
|
|
119
387
|
}
|
|
120
|
-
const decoded = (0,
|
|
121
|
-
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return
|
|
388
|
+
const decoded = (0, import_chat_protocol2.decodeTurnEvent)(await readBody(request, maxBodyBytes, signal));
|
|
389
|
+
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail2(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
|
|
122
390
|
const submission = decoded.event;
|
|
123
|
-
const definition = await
|
|
391
|
+
const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
|
|
124
392
|
const storage = options.sessionStorage(context, signal);
|
|
125
|
-
const session = await
|
|
126
|
-
if (!await
|
|
393
|
+
const session = await withAbort((0, import_chat.resumeChatSession)(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
|
|
394
|
+
if (!await withAbort(session.claimTurn(submission.turnId, leaseMs, signal), signal)) return json({ version: 1, code: "SESSION_BUSY", message: "Another turn is active for this session.", retryable: true }, 409);
|
|
127
395
|
const memory = definition.chat.memory;
|
|
128
|
-
const loaded = memory ? await
|
|
396
|
+
const loaded = memory ? await withAbort(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
|
|
129
397
|
const messages = loaded.length > 0 ? loaded : definition.chat.initialMessages ?? [];
|
|
130
398
|
const { memory: _memory, ...chat } = definition.chat;
|
|
131
399
|
const controller = (0, import_core.createChatController)(session.updateChat({ ...chat, initialMessages: [...messages] }));
|
|
@@ -158,21 +426,21 @@ var createChatHandler = (options) => {
|
|
|
158
426
|
signal.removeEventListener("abort", abort);
|
|
159
427
|
if (stop && !signal.aborted) controller.stop();
|
|
160
428
|
const settleSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
161
|
-
await
|
|
429
|
+
await withAbort(send.catch(() => void 0), settleSignal).catch(() => void 0);
|
|
162
430
|
const saveSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
163
431
|
let outcome = "completed";
|
|
164
432
|
try {
|
|
165
|
-
await
|
|
433
|
+
await withAbort(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
|
|
166
434
|
} catch (error) {
|
|
167
435
|
outcome = "indeterminate";
|
|
168
436
|
throw error;
|
|
169
437
|
} finally {
|
|
170
438
|
const releaseSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
171
|
-
await
|
|
439
|
+
await withAbort(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
|
|
172
440
|
}
|
|
173
441
|
};
|
|
174
442
|
const diagnosticLine = (code, message) => {
|
|
175
|
-
const event =
|
|
443
|
+
const event = import_chat_protocol2.TurnEventSchema.parse({
|
|
176
444
|
protocol: "agentskit.chat.turn",
|
|
177
445
|
version: 1,
|
|
178
446
|
eventId: createId(),
|
|
@@ -183,7 +451,7 @@ var createChatHandler = (options) => {
|
|
|
183
451
|
event: "server.turn.diagnostic",
|
|
184
452
|
payload: { version: 1, code, message, retryable: true }
|
|
185
453
|
});
|
|
186
|
-
return
|
|
454
|
+
return encoder2.encode(`${(0, import_chat_protocol2.encodeTurnEvent)(event)}
|
|
187
455
|
`);
|
|
188
456
|
};
|
|
189
457
|
const body = new ReadableStream({
|
|
@@ -201,8 +469,8 @@ var createChatHandler = (options) => {
|
|
|
201
469
|
if (pending) {
|
|
202
470
|
const state = pending;
|
|
203
471
|
pending = void 0;
|
|
204
|
-
await
|
|
205
|
-
const event = (0,
|
|
472
|
+
await withAbort(session.persist(signal), signal);
|
|
473
|
+
const event = (0, import_chat_protocol2.createSnapshotEvent)({
|
|
206
474
|
eventId: createId(),
|
|
207
475
|
sessionId: submission.sessionId,
|
|
208
476
|
turnId: submission.turnId,
|
|
@@ -214,7 +482,7 @@ var createChatHandler = (options) => {
|
|
|
214
482
|
lineage: { operation: "submit" },
|
|
215
483
|
...state.error ? { error: { version: 1, code: "CHAT_TURN_FAILED", message: "The chat turn failed.", retryable: true } } : {}
|
|
216
484
|
});
|
|
217
|
-
stream.enqueue(
|
|
485
|
+
stream.enqueue(encoder2.encode(`${(0, import_chat_protocol2.encodeTurnEvent)(event)}
|
|
218
486
|
`));
|
|
219
487
|
return;
|
|
220
488
|
}
|
|
@@ -245,5 +513,6 @@ var createChatHandler = (options) => {
|
|
|
245
513
|
// Annotate the CommonJS export names for ESM import in node:
|
|
246
514
|
0 && (module.exports = {
|
|
247
515
|
ChatHandlerError,
|
|
516
|
+
createAskServiceHandler,
|
|
248
517
|
createChatHandler
|
|
249
518
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,79 @@
|
|
|
1
1
|
import { ChatDefinition, SessionStorage } from '@agentskit/chat';
|
|
2
|
+
import { AskBackendUsage, AskBackendMessage, AskBackendSiteConfig, AskBackendSource, AskBackendSessionRecord, AskBackendMetric } from '@agentskit/chat-protocol';
|
|
3
|
+
|
|
4
|
+
type AskServiceHandler = (request: Request) => Promise<Response>;
|
|
5
|
+
type AskServiceAuthenticationResult<TContext> = {
|
|
6
|
+
readonly ok: true;
|
|
7
|
+
readonly context: TContext;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly response: Response;
|
|
11
|
+
};
|
|
12
|
+
interface AskServiceRetrieverInput {
|
|
13
|
+
readonly query: string;
|
|
14
|
+
readonly messages: readonly AskBackendMessage[];
|
|
15
|
+
readonly site: AskBackendSiteConfig;
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
/** Implement with an upstream AgentsKit RAG or Retriever adapter. */
|
|
19
|
+
interface AskServiceRetriever {
|
|
20
|
+
readonly retrieve: (input: AskServiceRetrieverInput) => readonly AskBackendSource[] | Promise<readonly AskBackendSource[]>;
|
|
21
|
+
}
|
|
22
|
+
type AskServiceGenerationChunk = {
|
|
23
|
+
readonly type: 'text';
|
|
24
|
+
readonly delta: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly type: 'usage';
|
|
27
|
+
readonly usage: AskBackendUsage;
|
|
28
|
+
};
|
|
29
|
+
interface AskServiceGeneratorInput extends AskServiceRetrieverInput {
|
|
30
|
+
readonly sources: readonly AskBackendSource[];
|
|
31
|
+
}
|
|
32
|
+
/** Implement with an AgentsKit provider/adapter; the server owns only bounded projection. */
|
|
33
|
+
interface AskServiceGenerator {
|
|
34
|
+
readonly generate: (input: AskServiceGeneratorInput) => AsyncIterable<AskServiceGenerationChunk>;
|
|
35
|
+
}
|
|
36
|
+
type AskServiceSessionRecord = AskBackendSessionRecord;
|
|
37
|
+
interface AskServiceSessionStore {
|
|
38
|
+
readonly load: (key: {
|
|
39
|
+
readonly siteId: string;
|
|
40
|
+
readonly subjectId: string;
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
}, signal: AbortSignal) => AskServiceSessionRecord | undefined | Promise<AskServiceSessionRecord | undefined>;
|
|
43
|
+
readonly save: (key: {
|
|
44
|
+
readonly siteId: string;
|
|
45
|
+
readonly subjectId: string;
|
|
46
|
+
readonly sessionId: string;
|
|
47
|
+
}, record: AskServiceSessionRecord, expectedRevision: number, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
48
|
+
}
|
|
49
|
+
interface AskServiceRateLimitDecision {
|
|
50
|
+
readonly allowed: boolean;
|
|
51
|
+
readonly retryAfterSeconds?: number;
|
|
52
|
+
}
|
|
53
|
+
interface AskServiceHandlerOptions<TContext> {
|
|
54
|
+
readonly authenticate: (request: Request, signal: AbortSignal) => AskServiceAuthenticationResult<TContext> | Promise<AskServiceAuthenticationResult<TContext>>;
|
|
55
|
+
readonly resolveSite: (context: TContext, signal: AbortSignal) => AskBackendSiteConfig | Promise<AskBackendSiteConfig>;
|
|
56
|
+
readonly resolveSubjectId: (context: TContext) => string;
|
|
57
|
+
readonly retrievers: {
|
|
58
|
+
readonly local?: AskServiceRetriever;
|
|
59
|
+
readonly federated?: AskServiceRetriever;
|
|
60
|
+
};
|
|
61
|
+
readonly generator: AskServiceGenerator;
|
|
62
|
+
readonly sessionStore?: AskServiceSessionStore;
|
|
63
|
+
readonly rateLimit?: (input: {
|
|
64
|
+
readonly context: TContext;
|
|
65
|
+
readonly site: AskBackendSiteConfig;
|
|
66
|
+
readonly subjectId: string;
|
|
67
|
+
readonly signal: AbortSignal;
|
|
68
|
+
}) => AskServiceRateLimitDecision | Promise<AskServiceRateLimitDecision>;
|
|
69
|
+
readonly onMetric?: (metric: AskBackendMetric) => void | Promise<void>;
|
|
70
|
+
readonly maxBodyBytes?: number;
|
|
71
|
+
readonly bootstrapTimeoutMs?: number;
|
|
72
|
+
readonly createId?: () => string;
|
|
73
|
+
readonly now?: () => Date;
|
|
74
|
+
readonly clock?: () => number;
|
|
75
|
+
}
|
|
76
|
+
declare const createAskServiceHandler: <TContext>(options: AskServiceHandlerOptions<TContext>) => AskServiceHandler;
|
|
2
77
|
|
|
3
78
|
type ChatHandler = (request: Request) => Promise<Response>;
|
|
4
79
|
type AuthenticationResult<TContext> = {
|
|
@@ -31,4 +106,4 @@ declare class ChatHandlerError extends Error {
|
|
|
31
106
|
}
|
|
32
107
|
declare const createChatHandler: <TContext = undefined>(options: ChatHandlerOptions<TContext>) => ChatHandler;
|
|
33
108
|
|
|
34
|
-
export { type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createChatHandler };
|
|
109
|
+
export { type AskServiceAuthenticationResult, type AskServiceGenerationChunk, type AskServiceGenerator, type AskServiceGeneratorInput, type AskServiceHandler, type AskServiceHandlerOptions, type AskServiceRateLimitDecision, type AskServiceRetriever, type AskServiceRetrieverInput, type AskServiceSessionRecord, type AskServiceSessionStore, type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createAskServiceHandler, createChatHandler };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,79 @@
|
|
|
1
1
|
import { ChatDefinition, SessionStorage } from '@agentskit/chat';
|
|
2
|
+
import { AskBackendUsage, AskBackendMessage, AskBackendSiteConfig, AskBackendSource, AskBackendSessionRecord, AskBackendMetric } from '@agentskit/chat-protocol';
|
|
3
|
+
|
|
4
|
+
type AskServiceHandler = (request: Request) => Promise<Response>;
|
|
5
|
+
type AskServiceAuthenticationResult<TContext> = {
|
|
6
|
+
readonly ok: true;
|
|
7
|
+
readonly context: TContext;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly response: Response;
|
|
11
|
+
};
|
|
12
|
+
interface AskServiceRetrieverInput {
|
|
13
|
+
readonly query: string;
|
|
14
|
+
readonly messages: readonly AskBackendMessage[];
|
|
15
|
+
readonly site: AskBackendSiteConfig;
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
/** Implement with an upstream AgentsKit RAG or Retriever adapter. */
|
|
19
|
+
interface AskServiceRetriever {
|
|
20
|
+
readonly retrieve: (input: AskServiceRetrieverInput) => readonly AskBackendSource[] | Promise<readonly AskBackendSource[]>;
|
|
21
|
+
}
|
|
22
|
+
type AskServiceGenerationChunk = {
|
|
23
|
+
readonly type: 'text';
|
|
24
|
+
readonly delta: string;
|
|
25
|
+
} | {
|
|
26
|
+
readonly type: 'usage';
|
|
27
|
+
readonly usage: AskBackendUsage;
|
|
28
|
+
};
|
|
29
|
+
interface AskServiceGeneratorInput extends AskServiceRetrieverInput {
|
|
30
|
+
readonly sources: readonly AskBackendSource[];
|
|
31
|
+
}
|
|
32
|
+
/** Implement with an AgentsKit provider/adapter; the server owns only bounded projection. */
|
|
33
|
+
interface AskServiceGenerator {
|
|
34
|
+
readonly generate: (input: AskServiceGeneratorInput) => AsyncIterable<AskServiceGenerationChunk>;
|
|
35
|
+
}
|
|
36
|
+
type AskServiceSessionRecord = AskBackendSessionRecord;
|
|
37
|
+
interface AskServiceSessionStore {
|
|
38
|
+
readonly load: (key: {
|
|
39
|
+
readonly siteId: string;
|
|
40
|
+
readonly subjectId: string;
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
}, signal: AbortSignal) => AskServiceSessionRecord | undefined | Promise<AskServiceSessionRecord | undefined>;
|
|
43
|
+
readonly save: (key: {
|
|
44
|
+
readonly siteId: string;
|
|
45
|
+
readonly subjectId: string;
|
|
46
|
+
readonly sessionId: string;
|
|
47
|
+
}, record: AskServiceSessionRecord, expectedRevision: number, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
48
|
+
}
|
|
49
|
+
interface AskServiceRateLimitDecision {
|
|
50
|
+
readonly allowed: boolean;
|
|
51
|
+
readonly retryAfterSeconds?: number;
|
|
52
|
+
}
|
|
53
|
+
interface AskServiceHandlerOptions<TContext> {
|
|
54
|
+
readonly authenticate: (request: Request, signal: AbortSignal) => AskServiceAuthenticationResult<TContext> | Promise<AskServiceAuthenticationResult<TContext>>;
|
|
55
|
+
readonly resolveSite: (context: TContext, signal: AbortSignal) => AskBackendSiteConfig | Promise<AskBackendSiteConfig>;
|
|
56
|
+
readonly resolveSubjectId: (context: TContext) => string;
|
|
57
|
+
readonly retrievers: {
|
|
58
|
+
readonly local?: AskServiceRetriever;
|
|
59
|
+
readonly federated?: AskServiceRetriever;
|
|
60
|
+
};
|
|
61
|
+
readonly generator: AskServiceGenerator;
|
|
62
|
+
readonly sessionStore?: AskServiceSessionStore;
|
|
63
|
+
readonly rateLimit?: (input: {
|
|
64
|
+
readonly context: TContext;
|
|
65
|
+
readonly site: AskBackendSiteConfig;
|
|
66
|
+
readonly subjectId: string;
|
|
67
|
+
readonly signal: AbortSignal;
|
|
68
|
+
}) => AskServiceRateLimitDecision | Promise<AskServiceRateLimitDecision>;
|
|
69
|
+
readonly onMetric?: (metric: AskBackendMetric) => void | Promise<void>;
|
|
70
|
+
readonly maxBodyBytes?: number;
|
|
71
|
+
readonly bootstrapTimeoutMs?: number;
|
|
72
|
+
readonly createId?: () => string;
|
|
73
|
+
readonly now?: () => Date;
|
|
74
|
+
readonly clock?: () => number;
|
|
75
|
+
}
|
|
76
|
+
declare const createAskServiceHandler: <TContext>(options: AskServiceHandlerOptions<TContext>) => AskServiceHandler;
|
|
2
77
|
|
|
3
78
|
type ChatHandler = (request: Request) => Promise<Response>;
|
|
4
79
|
type AuthenticationResult<TContext> = {
|
|
@@ -31,4 +106,4 @@ declare class ChatHandlerError extends Error {
|
|
|
31
106
|
}
|
|
32
107
|
declare const createChatHandler: <TContext = undefined>(options: ChatHandlerOptions<TContext>) => ChatHandler;
|
|
33
108
|
|
|
34
|
-
export { type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createChatHandler };
|
|
109
|
+
export { type AskServiceAuthenticationResult, type AskServiceGenerationChunk, type AskServiceGenerator, type AskServiceGeneratorInput, type AskServiceHandler, type AskServiceHandlerOptions, type AskServiceRateLimitDecision, type AskServiceRetriever, type AskServiceRetrieverInput, type AskServiceSessionRecord, type AskServiceSessionStore, type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createAskServiceHandler, createChatHandler };
|
package/dist/index.js
CHANGED
|
@@ -2,29 +2,10 @@
|
|
|
2
2
|
import { createChatController } from "@agentskit/core";
|
|
3
3
|
import { resumeChatSession, SessionConflictError } from "@agentskit/chat";
|
|
4
4
|
import { createSnapshotEvent, decodeTurnEvent, encodeTurnEvent, TurnEventSchema } from "@agentskit/chat-protocol";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
code;
|
|
8
|
-
retryable;
|
|
9
|
-
constructor(options) {
|
|
10
|
-
super(options.message);
|
|
11
|
-
this.name = "ChatHandlerError";
|
|
12
|
-
this.status = options.status;
|
|
13
|
-
this.code = options.code;
|
|
14
|
-
this.retryable = options.retryable ?? false;
|
|
15
|
-
}
|
|
16
|
-
};
|
|
17
|
-
var encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
// src/internal.ts
|
|
18
7
|
var decoder = new TextDecoder();
|
|
19
|
-
var
|
|
20
|
-
status,
|
|
21
|
-
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
|
|
22
|
-
});
|
|
23
|
-
var safeError = (error) => error instanceof ChatHandlerError ? { status: error.status, diagnostic: { version: 1, code: error.code, message: error.message, retryable: error.retryable } } : error instanceof SessionConflictError ? { status: 409, diagnostic: { version: 1, code: "SESSION_CONFLICT", message: "Another turn is active for this session.", retryable: true } } : { status: 500, diagnostic: { version: 1, code: "SERVER_INTERNAL", message: "The chat request failed.", retryable: true } };
|
|
24
|
-
var fail = (status, code, message, retryable = false) => {
|
|
25
|
-
throw new ChatHandlerError({ status, code, message, retryable });
|
|
26
|
-
};
|
|
27
|
-
var withSignal = async (operation, signal) => {
|
|
8
|
+
var withAbort = async (operation, signal) => {
|
|
28
9
|
if (signal.aborted) throw signal.reason;
|
|
29
10
|
let rejectAbort;
|
|
30
11
|
const abort = () => rejectAbort?.(signal.reason);
|
|
@@ -38,21 +19,21 @@ var withSignal = async (operation, signal) => {
|
|
|
38
19
|
signal.removeEventListener("abort", abort);
|
|
39
20
|
}
|
|
40
21
|
};
|
|
41
|
-
var
|
|
22
|
+
var readBoundedJson = async (request, maxBodyBytes, signal, fail3) => {
|
|
42
23
|
const declared = Number(request.headers.get("content-length"));
|
|
43
|
-
if (Number.isFinite(declared) && declared > maxBodyBytes)
|
|
24
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
|
|
44
25
|
const reader = request.body?.getReader();
|
|
45
|
-
if (!reader) return
|
|
26
|
+
if (!reader) return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
|
|
46
27
|
const chunks = [];
|
|
47
28
|
let size = 0;
|
|
48
29
|
try {
|
|
49
30
|
while (true) {
|
|
50
|
-
const result = await
|
|
31
|
+
const result = await withAbort(reader.read(), signal);
|
|
51
32
|
if (result.done) break;
|
|
52
33
|
size += result.value.byteLength;
|
|
53
34
|
if (size > maxBodyBytes) {
|
|
54
35
|
await reader.cancel();
|
|
55
|
-
return
|
|
36
|
+
return fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
|
|
56
37
|
}
|
|
57
38
|
chunks.push(result.value);
|
|
58
39
|
}
|
|
@@ -68,39 +49,334 @@ var readBody = async (request, maxBodyBytes, signal) => {
|
|
|
68
49
|
try {
|
|
69
50
|
return JSON.parse(decoder.decode(bytes));
|
|
70
51
|
} catch {
|
|
71
|
-
return
|
|
52
|
+
return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/ask-service.ts
|
|
57
|
+
import {
|
|
58
|
+
AskBackendDiagnosticSchema,
|
|
59
|
+
AskBackendMetricSchema,
|
|
60
|
+
AskBackendRequestSchema,
|
|
61
|
+
AskBackendSessionRecordSchema,
|
|
62
|
+
AskBackendSiteConfigSchema,
|
|
63
|
+
AskBackendSourceSchema,
|
|
64
|
+
AskBackendUsageSchema,
|
|
65
|
+
AskEventSchema
|
|
66
|
+
} from "@agentskit/chat-protocol";
|
|
67
|
+
var AskServiceError = class extends Error {
|
|
68
|
+
status;
|
|
69
|
+
diagnostic;
|
|
70
|
+
retryAfterSeconds;
|
|
71
|
+
constructor(status, diagnostic, retryAfterSeconds) {
|
|
72
|
+
super(diagnostic.message);
|
|
73
|
+
this.name = "AskServiceError";
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.diagnostic = diagnostic;
|
|
76
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var encoder = new TextEncoder();
|
|
80
|
+
var fail = (status, code, message, retryable = false, retryAfterSeconds) => {
|
|
81
|
+
throw new AskServiceError(status, AskBackendDiagnosticSchema.parse({ code, message, retryable }), retryAfterSeconds);
|
|
82
|
+
};
|
|
83
|
+
var safeFailure = (error) => error instanceof AskServiceError ? error : new AskServiceError(500, { code: "ASK_INTERNAL", message: "The Ask request failed.", retryable: true });
|
|
84
|
+
var errorResponse = (error, requestId) => new Response(JSON.stringify({ error: error.diagnostic }), {
|
|
85
|
+
status: error.status,
|
|
86
|
+
headers: {
|
|
87
|
+
"content-type": "application/json; charset=utf-8",
|
|
88
|
+
"cache-control": "no-store",
|
|
89
|
+
"x-request-id": requestId,
|
|
90
|
+
...error.retryAfterSeconds === void 0 ? {} : { "retry-after": String(error.retryAfterSeconds) }
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
var stableSubject = (value) => {
|
|
94
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/.test(value)) fail(500, "ASK_INTERNAL", "The Ask host identity is invalid.");
|
|
95
|
+
return value;
|
|
96
|
+
};
|
|
97
|
+
var latestQuestion = (messages) => {
|
|
98
|
+
const question = [...messages].reverse().find((message) => message.role === "user")?.content.trim();
|
|
99
|
+
return question === void 0 || question === "" ? fail(400, "ASK_INVALID_REQUEST", "A user question is required.") : question;
|
|
100
|
+
};
|
|
101
|
+
var mergeSessionMessages = (stored, submitted) => {
|
|
102
|
+
const question = [...submitted].reverse().find((message) => message.role === "user");
|
|
103
|
+
if (question === void 0) return stored;
|
|
104
|
+
const last = stored.at(-1);
|
|
105
|
+
return last?.role === "user" && last.content === question.content ? stored : [...stored, question].slice(-64);
|
|
106
|
+
};
|
|
107
|
+
var createAskServiceHandler = (options) => {
|
|
108
|
+
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
|
|
109
|
+
const bootstrapTimeoutMs = options.bootstrapTimeoutMs ?? 3e4;
|
|
110
|
+
if (![maxBodyBytes, bootstrapTimeoutMs].every((value) => Number.isSafeInteger(value) && value > 0)) {
|
|
111
|
+
fail(500, "ASK_INTERNAL", "The Ask handler configuration is invalid.");
|
|
112
|
+
}
|
|
113
|
+
const createId = options.createId ?? (() => crypto.randomUUID());
|
|
114
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
115
|
+
const clock = options.clock ?? Date.now;
|
|
116
|
+
return async (request) => {
|
|
117
|
+
let candidateRequestId;
|
|
118
|
+
try {
|
|
119
|
+
candidateRequestId = createId();
|
|
120
|
+
} catch {
|
|
121
|
+
candidateRequestId = crypto.randomUUID();
|
|
122
|
+
}
|
|
123
|
+
const requestId = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(candidateRequestId) ? candidateRequestId : crypto.randomUUID();
|
|
124
|
+
const startedAt = clock();
|
|
125
|
+
const bootstrap = AbortSignal.timeout(bootstrapTimeoutMs);
|
|
126
|
+
const bootstrapSignal = AbortSignal.any([request.signal, bootstrap]);
|
|
127
|
+
let site;
|
|
128
|
+
let workDeadline;
|
|
129
|
+
let emittedErrorMetric = false;
|
|
130
|
+
const metric = (name, value, unit, outcome) => {
|
|
131
|
+
if (site === void 0) return;
|
|
132
|
+
const record = AskBackendMetricSchema.parse({
|
|
133
|
+
protocol: "agentskit.chat.backend-metric",
|
|
134
|
+
version: 1,
|
|
135
|
+
name,
|
|
136
|
+
siteId: site.siteId,
|
|
137
|
+
corpusId: site.corpus.id,
|
|
138
|
+
requestId,
|
|
139
|
+
value: Math.max(0, value),
|
|
140
|
+
unit,
|
|
141
|
+
outcome,
|
|
142
|
+
emittedAt: now().toISOString()
|
|
143
|
+
});
|
|
144
|
+
try {
|
|
145
|
+
void Promise.resolve(options.onMetric?.(record)).catch(() => void 0);
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const recordError = (outcome) => {
|
|
150
|
+
if (emittedErrorMetric) return;
|
|
151
|
+
emittedErrorMetric = true;
|
|
152
|
+
metric(outcome === "cancelled" ? "cancellation.count" : "error.count", 1, "count", outcome);
|
|
153
|
+
metric("request.total_ms", clock() - startedAt, "ms", outcome);
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
if (request.method !== "POST") fail(405, "ASK_INVALID_REQUEST", "Only POST is supported.");
|
|
157
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
158
|
+
fail(415, "ASK_INVALID_REQUEST", "Content-Type must be application/json.");
|
|
159
|
+
}
|
|
160
|
+
const authenticated = await withAbort(options.authenticate(request, bootstrapSignal), bootstrapSignal);
|
|
161
|
+
if (!authenticated.ok) return authenticated.response;
|
|
162
|
+
site = AskBackendSiteConfigSchema.parse(await withAbort(options.resolveSite(authenticated.context, bootstrapSignal), bootstrapSignal));
|
|
163
|
+
const subjectId = stableSubject(options.resolveSubjectId(authenticated.context));
|
|
164
|
+
const url = new URL(request.url);
|
|
165
|
+
const corpusHint = url.searchParams.get("corpus");
|
|
166
|
+
const personaHint = url.searchParams.get("persona");
|
|
167
|
+
if (corpusHint !== null && corpusHint !== site.corpus.id || personaHint !== null && personaHint !== site.assistant.id) {
|
|
168
|
+
fail(403, "ASK_FORBIDDEN", "The requested assistant or corpus is not authorized for this site.");
|
|
169
|
+
}
|
|
170
|
+
const requestDeadline = AbortSignal.timeout(site.limits.requestTimeoutMs);
|
|
171
|
+
workDeadline = requestDeadline;
|
|
172
|
+
const responseAbort = new AbortController();
|
|
173
|
+
const signal = AbortSignal.any([request.signal, requestDeadline, responseAbort.signal]);
|
|
174
|
+
const limited = await withAbort(
|
|
175
|
+
options.rateLimit?.({ context: authenticated.context, site, subjectId, signal }) ?? { allowed: true },
|
|
176
|
+
signal
|
|
177
|
+
);
|
|
178
|
+
if (!limited.allowed) fail(429, "ASK_RATE_LIMITED", "The Ask rate limit was exceeded.", true, limited.retryAfterSeconds);
|
|
179
|
+
const raw = await readBoundedJson(request, maxBodyBytes, signal, (status, _code, message) => fail(status, "ASK_INVALID_REQUEST", message));
|
|
180
|
+
const parsed = AskBackendRequestSchema.safeParse(raw);
|
|
181
|
+
const input = parsed.success && parsed.data !== void 0 ? parsed.data : fail(400, "ASK_INVALID_REQUEST", "The Ask request payload is invalid.");
|
|
182
|
+
metric("deterministic.fallback", input.deterministic === void 0 ? 0 : 1, "count", "ok");
|
|
183
|
+
if (site.persistence.mode === "required" && (input.sessionId === void 0 || options.sessionStore === void 0)) {
|
|
184
|
+
fail(500, "ASK_INTERNAL", "Required Ask persistence is not configured.");
|
|
185
|
+
}
|
|
186
|
+
const key = input.sessionId === void 0 ? void 0 : { siteId: site.siteId, subjectId, sessionId: input.sessionId };
|
|
187
|
+
let stored;
|
|
188
|
+
if (key !== void 0 && options.sessionStore !== void 0) {
|
|
189
|
+
const persistenceStarted = clock();
|
|
190
|
+
const loaded = await withAbort(options.sessionStore.load(key, signal), signal);
|
|
191
|
+
stored = loaded === void 0 ? void 0 : AskBackendSessionRecordSchema.parse(loaded);
|
|
192
|
+
metric("persistence.total_ms", clock() - persistenceStarted, "ms", "ok");
|
|
193
|
+
}
|
|
194
|
+
const messages = stored === void 0 ? input.messages : mergeSessionMessages(stored.messages, input.messages);
|
|
195
|
+
const query = latestQuestion(messages);
|
|
196
|
+
const retriever = options.retrievers[site.corpus.mode] ?? fail(500, "ASK_INTERNAL", "The configured Ask retriever is unavailable.");
|
|
197
|
+
const retrievalStarted = clock();
|
|
198
|
+
const retrievalSignal = AbortSignal.any([signal, AbortSignal.timeout(site.limits.retrievalTimeoutMs)]);
|
|
199
|
+
const sources = await (async () => {
|
|
200
|
+
try {
|
|
201
|
+
const candidates = await withAbort(retriever.retrieve({ query, messages, site, signal: retrievalSignal }), retrievalSignal);
|
|
202
|
+
return candidates.flatMap((candidate) => {
|
|
203
|
+
const decoded = AskBackendSourceSchema.safeParse(candidate);
|
|
204
|
+
return decoded.success ? [decoded.data] : [];
|
|
205
|
+
}).slice(0, site.limits.maxSources);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (signal.aborted) throw error;
|
|
208
|
+
if (retrievalSignal.aborted) fail(408, "ASK_TIMEOUT", "Grounded retrieval timed out.", true);
|
|
209
|
+
return fail(502, "ASK_RETRIEVAL_FAILED", "Grounded retrieval is temporarily unavailable.", true);
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
metric("retrieval.total_ms", clock() - retrievalStarted, "ms", "ok");
|
|
213
|
+
metric("retrieval.documents", sources.length, "count", "ok");
|
|
214
|
+
if (sources.length === 0) fail(422, "ASK_NO_GROUNDED_SOURCES", "No grounded sources were found for this question.");
|
|
215
|
+
const body = new ReadableStream({
|
|
216
|
+
async start(controller) {
|
|
217
|
+
let bytes = 0;
|
|
218
|
+
let events = 0;
|
|
219
|
+
let firstEvent = false;
|
|
220
|
+
let firstToken = false;
|
|
221
|
+
let answer = "";
|
|
222
|
+
let usage;
|
|
223
|
+
const generationDeadline = AbortSignal.timeout(site.limits.generationTimeoutMs);
|
|
224
|
+
const generationSignal = AbortSignal.any([signal, generationDeadline]);
|
|
225
|
+
const emit = (candidate) => {
|
|
226
|
+
const event = AskEventSchema.parse(candidate);
|
|
227
|
+
const chunk = encoder.encode(`${JSON.stringify(event)}
|
|
228
|
+
`);
|
|
229
|
+
if (!firstEvent) {
|
|
230
|
+
firstEvent = true;
|
|
231
|
+
metric("stream.first_event_ms", clock() - startedAt, "ms", "ok");
|
|
232
|
+
}
|
|
233
|
+
if (event.type === "text" && !firstToken) {
|
|
234
|
+
firstToken = true;
|
|
235
|
+
metric("stream.first_token_ms", clock() - startedAt, "ms", "ok");
|
|
236
|
+
}
|
|
237
|
+
bytes += chunk.byteLength;
|
|
238
|
+
events += 1;
|
|
239
|
+
controller.enqueue(chunk);
|
|
240
|
+
};
|
|
241
|
+
try {
|
|
242
|
+
const generation = options.generator.generate({ query, messages, site, sources, signal: generationSignal });
|
|
243
|
+
for await (const chunk of generation) {
|
|
244
|
+
if (generationSignal.aborted) throw generationSignal.reason;
|
|
245
|
+
if (chunk.type === "usage") {
|
|
246
|
+
usage = AskBackendUsageSchema.parse(chunk.usage);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (chunk.delta === "") continue;
|
|
250
|
+
answer += chunk.delta;
|
|
251
|
+
if (answer.length > 16384) fail(502, "ASK_GENERATION_FAILED", "The grounded answer exceeded its safe limit.", true);
|
|
252
|
+
emit({ type: "text", delta: chunk.delta });
|
|
253
|
+
}
|
|
254
|
+
if (answer.trim() === "") fail(502, "ASK_GENERATION_FAILED", "The grounded answer was empty.", true);
|
|
255
|
+
emit({
|
|
256
|
+
type: "tool",
|
|
257
|
+
id: `sources-${requestId}`,
|
|
258
|
+
name: "cite",
|
|
259
|
+
args: { sources: sources.map((source) => ({ id: source.id, title: source.title, path: source.href })) }
|
|
260
|
+
});
|
|
261
|
+
if (key !== void 0 && options.sessionStore !== void 0) {
|
|
262
|
+
const persistenceStarted = clock();
|
|
263
|
+
const revision = (stored?.revision ?? 0) + 1;
|
|
264
|
+
const saved = await withAbort(options.sessionStore.save(key, {
|
|
265
|
+
revision,
|
|
266
|
+
messages: [...messages, { role: "assistant", content: answer }].slice(-64)
|
|
267
|
+
}, stored?.revision ?? 0, signal), signal);
|
|
268
|
+
metric("persistence.total_ms", clock() - persistenceStarted, "ms", saved ? "ok" : "error");
|
|
269
|
+
if (!saved) {
|
|
270
|
+
metric("conflict.count", 1, "count", "error");
|
|
271
|
+
fail(409, "ASK_PERSISTENCE_CONFLICT", "The Ask session changed concurrently.", true);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (usage?.inputTokens !== void 0) metric("usage.input_tokens", usage.inputTokens, "tokens", "ok");
|
|
275
|
+
if (usage?.outputTokens !== void 0) metric("usage.output_tokens", usage.outputTokens, "tokens", "ok");
|
|
276
|
+
if (usage?.totalTokens !== void 0) metric("usage.total_tokens", usage.totalTokens, "tokens", "ok");
|
|
277
|
+
if (usage?.costUsd !== void 0) metric("cost.usd", usage.costUsd, "usd", "ok");
|
|
278
|
+
emit({ type: "done", ...usage?.model === void 0 ? {} : { model: usage.model } });
|
|
279
|
+
metric("stream.bytes", bytes, "bytes", "ok");
|
|
280
|
+
metric("stream.events", events, "count", "ok");
|
|
281
|
+
metric("stream.snapshots", 0, "count", "ok");
|
|
282
|
+
metric("request.total_ms", clock() - startedAt, "ms", "ok");
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const timeout = !request.signal.aborted && (requestDeadline.aborted || generationDeadline.aborted || generationSignal.aborted);
|
|
285
|
+
const interrupted = signal.aborted || generationSignal.aborted;
|
|
286
|
+
const diagnostic = timeout ? AskBackendDiagnosticSchema.parse({ code: "ASK_TIMEOUT", message: "The Ask request timed out.", retryable: true }) : interrupted ? AskBackendDiagnosticSchema.parse({ code: "ASK_CANCELLED", message: "The Ask request was cancelled.", retryable: true }) : safeFailure(error).diagnostic;
|
|
287
|
+
if (!interrupted || timeout) {
|
|
288
|
+
try {
|
|
289
|
+
emit({ type: "error", message: diagnostic.message, code: diagnostic.code, retryable: diagnostic.retryable });
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
recordError(interrupted && !timeout ? "cancelled" : "error");
|
|
294
|
+
} finally {
|
|
295
|
+
try {
|
|
296
|
+
controller.close();
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
cancel() {
|
|
302
|
+
responseAbort.abort();
|
|
303
|
+
recordError("cancelled");
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
return new Response(body, {
|
|
307
|
+
status: 200,
|
|
308
|
+
headers: {
|
|
309
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
310
|
+
"cache-control": "no-store",
|
|
311
|
+
"x-content-type-options": "nosniff",
|
|
312
|
+
"x-request-id": requestId
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const deadlineExpired = bootstrap.aborted || workDeadline?.aborted === true;
|
|
317
|
+
const cancelled = bootstrapSignal.aborted || request.signal.aborted || deadlineExpired;
|
|
318
|
+
const safe = cancelled ? new AskServiceError(deadlineExpired ? 408 : 499, { code: deadlineExpired ? "ASK_TIMEOUT" : "ASK_CANCELLED", message: deadlineExpired ? "The Ask request timed out." : "The Ask request was cancelled.", retryable: true }) : safeFailure(error);
|
|
319
|
+
recordError(cancelled ? "cancelled" : safe.status < 500 ? "rejected" : "error");
|
|
320
|
+
return errorResponse(safe, requestId);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// src/index.ts
|
|
326
|
+
var ChatHandlerError = class extends Error {
|
|
327
|
+
status;
|
|
328
|
+
code;
|
|
329
|
+
retryable;
|
|
330
|
+
constructor(options) {
|
|
331
|
+
super(options.message);
|
|
332
|
+
this.name = "ChatHandlerError";
|
|
333
|
+
this.status = options.status;
|
|
334
|
+
this.code = options.code;
|
|
335
|
+
this.retryable = options.retryable ?? false;
|
|
72
336
|
}
|
|
73
337
|
};
|
|
338
|
+
var encoder2 = new TextEncoder();
|
|
339
|
+
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
|
|
340
|
+
status,
|
|
341
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
|
|
342
|
+
});
|
|
343
|
+
var safeError = (error) => error instanceof ChatHandlerError ? { status: error.status, diagnostic: { version: 1, code: error.code, message: error.message, retryable: error.retryable } } : error instanceof SessionConflictError ? { status: 409, diagnostic: { version: 1, code: "SESSION_CONFLICT", message: "Another turn is active for this session.", retryable: true } } : { status: 500, diagnostic: { version: 1, code: "SERVER_INTERNAL", message: "The chat request failed.", retryable: true } };
|
|
344
|
+
var fail2 = (status, code, message, retryable = false) => {
|
|
345
|
+
throw new ChatHandlerError({ status, code, message, retryable });
|
|
346
|
+
};
|
|
347
|
+
var readBody = async (request, maxBodyBytes, signal) => {
|
|
348
|
+
return readBoundedJson(request, maxBodyBytes, signal, fail2);
|
|
349
|
+
};
|
|
74
350
|
var snapshotStatus = (state) => state.status === "error" ? "error" : state.status === "streaming" ? "streaming" : state.messages.length === 0 ? "idle" : "complete";
|
|
75
351
|
var createChatHandler = (options) => {
|
|
76
352
|
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
77
353
|
const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 5e3;
|
|
78
354
|
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
|
|
79
|
-
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0))
|
|
355
|
+
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
|
|
80
356
|
const leaseMs = timeoutMs + 3 * cleanupTimeoutMs;
|
|
81
|
-
if (!Number.isSafeInteger(leaseMs))
|
|
357
|
+
if (!Number.isSafeInteger(leaseMs)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
|
|
82
358
|
const createId = options.createId ?? (() => crypto.randomUUID());
|
|
83
359
|
return async (request) => {
|
|
84
360
|
const deadline = AbortSignal.timeout(timeoutMs);
|
|
85
361
|
const signal = AbortSignal.any([request.signal, deadline]);
|
|
86
362
|
try {
|
|
87
|
-
if (request.method !== "POST")
|
|
88
|
-
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
|
|
363
|
+
if (request.method !== "POST") fail2(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
|
|
364
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail2(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
|
|
89
365
|
let context;
|
|
90
366
|
if (options.authenticate) {
|
|
91
|
-
const authenticated = await
|
|
367
|
+
const authenticated = await withAbort(options.authenticate(request, signal), signal);
|
|
92
368
|
if (!authenticated.ok) return authenticated.response;
|
|
93
369
|
context = authenticated.context;
|
|
94
370
|
}
|
|
95
371
|
const decoded = decodeTurnEvent(await readBody(request, maxBodyBytes, signal));
|
|
96
|
-
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return
|
|
372
|
+
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail2(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
|
|
97
373
|
const submission = decoded.event;
|
|
98
|
-
const definition = await
|
|
374
|
+
const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
|
|
99
375
|
const storage = options.sessionStorage(context, signal);
|
|
100
|
-
const session = await
|
|
101
|
-
if (!await
|
|
376
|
+
const session = await withAbort(resumeChatSession(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
|
|
377
|
+
if (!await withAbort(session.claimTurn(submission.turnId, leaseMs, signal), signal)) return json({ version: 1, code: "SESSION_BUSY", message: "Another turn is active for this session.", retryable: true }, 409);
|
|
102
378
|
const memory = definition.chat.memory;
|
|
103
|
-
const loaded = memory ? await
|
|
379
|
+
const loaded = memory ? await withAbort(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
|
|
104
380
|
const messages = loaded.length > 0 ? loaded : definition.chat.initialMessages ?? [];
|
|
105
381
|
const { memory: _memory, ...chat } = definition.chat;
|
|
106
382
|
const controller = createChatController(session.updateChat({ ...chat, initialMessages: [...messages] }));
|
|
@@ -133,17 +409,17 @@ var createChatHandler = (options) => {
|
|
|
133
409
|
signal.removeEventListener("abort", abort);
|
|
134
410
|
if (stop && !signal.aborted) controller.stop();
|
|
135
411
|
const settleSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
136
|
-
await
|
|
412
|
+
await withAbort(send.catch(() => void 0), settleSignal).catch(() => void 0);
|
|
137
413
|
const saveSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
138
414
|
let outcome = "completed";
|
|
139
415
|
try {
|
|
140
|
-
await
|
|
416
|
+
await withAbort(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
|
|
141
417
|
} catch (error) {
|
|
142
418
|
outcome = "indeterminate";
|
|
143
419
|
throw error;
|
|
144
420
|
} finally {
|
|
145
421
|
const releaseSignal = AbortSignal.timeout(cleanupTimeoutMs);
|
|
146
|
-
await
|
|
422
|
+
await withAbort(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
|
|
147
423
|
}
|
|
148
424
|
};
|
|
149
425
|
const diagnosticLine = (code, message) => {
|
|
@@ -158,7 +434,7 @@ var createChatHandler = (options) => {
|
|
|
158
434
|
event: "server.turn.diagnostic",
|
|
159
435
|
payload: { version: 1, code, message, retryable: true }
|
|
160
436
|
});
|
|
161
|
-
return
|
|
437
|
+
return encoder2.encode(`${encodeTurnEvent(event)}
|
|
162
438
|
`);
|
|
163
439
|
};
|
|
164
440
|
const body = new ReadableStream({
|
|
@@ -176,7 +452,7 @@ var createChatHandler = (options) => {
|
|
|
176
452
|
if (pending) {
|
|
177
453
|
const state = pending;
|
|
178
454
|
pending = void 0;
|
|
179
|
-
await
|
|
455
|
+
await withAbort(session.persist(signal), signal);
|
|
180
456
|
const event = createSnapshotEvent({
|
|
181
457
|
eventId: createId(),
|
|
182
458
|
sessionId: submission.sessionId,
|
|
@@ -189,7 +465,7 @@ var createChatHandler = (options) => {
|
|
|
189
465
|
lineage: { operation: "submit" },
|
|
190
466
|
...state.error ? { error: { version: 1, code: "CHAT_TURN_FAILED", message: "The chat turn failed.", retryable: true } } : {}
|
|
191
467
|
});
|
|
192
|
-
stream.enqueue(
|
|
468
|
+
stream.enqueue(encoder2.encode(`${encodeTurnEvent(event)}
|
|
193
469
|
`));
|
|
194
470
|
return;
|
|
195
471
|
}
|
|
@@ -219,5 +495,6 @@ var createChatHandler = (options) => {
|
|
|
219
495
|
};
|
|
220
496
|
export {
|
|
221
497
|
ChatHandlerError,
|
|
498
|
+
createAskServiceHandler,
|
|
222
499
|
createChatHandler
|
|
223
500
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentskit/chat-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Web-standard server handler for AgentsKit Chat applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"dist"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@agentskit/chat": "0.
|
|
31
|
-
"@agentskit/chat-protocol": "0.
|
|
30
|
+
"@agentskit/chat": "0.2.0",
|
|
31
|
+
"@agentskit/chat-protocol": "0.2.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"@agentskit/core": "^1.12.2"
|