@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 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 import_chat_protocol = require("@agentskit/chat-protocol");
30
- var ChatHandlerError = class extends Error {
31
- status;
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 json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
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 readBody = async (request, maxBodyBytes, signal) => {
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) fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
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 fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
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 withSignal(reader.read(), signal);
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 fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
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 fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
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)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
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)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
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") fail(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
113
- if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be 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 withSignal(options.authenticate(request, signal), signal);
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, import_chat_protocol.decodeTurnEvent)(await readBody(request, maxBodyBytes, signal));
121
- if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
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 withSignal(options.resolveDefinition(context, submission.sessionId, signal), signal);
391
+ const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
124
392
  const storage = options.sessionStorage(context, signal);
125
- const session = await withSignal((0, import_chat.resumeChatSession)(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
126
- if (!await withSignal(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);
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 withSignal(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
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 withSignal(send.catch(() => void 0), settleSignal).catch(() => void 0);
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 withSignal(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
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 withSignal(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
439
+ await withAbort(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
172
440
  }
173
441
  };
174
442
  const diagnosticLine = (code, message) => {
175
- const event = import_chat_protocol.TurnEventSchema.parse({
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 encoder.encode(`${(0, import_chat_protocol.encodeTurnEvent)(event)}
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 withSignal(session.persist(signal), signal);
205
- const event = (0, import_chat_protocol.createSnapshotEvent)({
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(encoder.encode(`${(0, import_chat_protocol.encodeTurnEvent)(event)}
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
- var ChatHandlerError = class extends Error {
6
- status;
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 json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
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 readBody = async (request, maxBodyBytes, signal) => {
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) fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
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 fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
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 withSignal(reader.read(), signal);
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 fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
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 fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
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)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
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)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
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") fail(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
88
- if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be 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 withSignal(options.authenticate(request, signal), signal);
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 fail(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
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 withSignal(options.resolveDefinition(context, submission.sessionId, signal), signal);
374
+ const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
99
375
  const storage = options.sessionStorage(context, signal);
100
- const session = await withSignal(resumeChatSession(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
101
- if (!await withSignal(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);
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 withSignal(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
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 withSignal(send.catch(() => void 0), settleSignal).catch(() => void 0);
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 withSignal(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
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 withSignal(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
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 encoder.encode(`${encodeTurnEvent(event)}
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 withSignal(session.persist(signal), signal);
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(encoder.encode(`${encodeTurnEvent(event)}
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.1.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.1.0",
31
- "@agentskit/chat-protocol": "0.1.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"