🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@agentskit/chat-server

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@agentskit/chat-server - npm Package Compare versions

Comparing version
0.1.0
to
0.2.0
+317
-48
dist/index.cjs

@@ -24,2 +24,3 @@ "use strict";

ChatHandlerError: () => ChatHandlerError,
createAskServiceHandler: () => createAskServiceHandler,
createChatHandler: () => createChatHandler

@@ -30,26 +31,7 @@ });

var import_chat = require("@agentskit/chat");
var import_chat_protocol = require("@agentskit/chat-protocol");
var ChatHandlerError = class extends Error {
status;
code;
retryable;
constructor(options) {
super(options.message);
this.name = "ChatHandlerError";
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? false;
}
};
var encoder = new TextEncoder();
var import_chat_protocol2 = require("@agentskit/chat-protocol");
// src/internal.ts
var decoder = new TextDecoder();
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
});
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 } };
var fail = (status, code, message, retryable = false) => {
throw new ChatHandlerError({ status, code, message, retryable });
};
var withSignal = async (operation, signal) => {
var withAbort = async (operation, signal) => {
if (signal.aborted) throw signal.reason;

@@ -68,7 +50,7 @@ let rejectAbort;

};
var readBody = async (request, maxBodyBytes, signal) => {
var readBoundedJson = async (request, maxBodyBytes, signal, fail3) => {
const declared = Number(request.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBodyBytes) fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
if (Number.isFinite(declared) && declared > maxBodyBytes) fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
const reader = request.body?.getReader();
if (!reader) return fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
if (!reader) return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
const chunks = [];

@@ -78,3 +60,3 @@ let size = 0;

while (true) {
const result = await withSignal(reader.read(), signal);
const result = await withAbort(reader.read(), signal);
if (result.done) break;

@@ -84,3 +66,3 @@ size += result.value.byteLength;

await reader.cancel();
return fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
return fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
}

@@ -101,5 +83,291 @@ chunks.push(result.value);

} catch {
return fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
}
};
// src/ask-service.ts
var import_chat_protocol = require("@agentskit/chat-protocol");
var AskServiceError = class extends Error {
status;
diagnostic;
retryAfterSeconds;
constructor(status, diagnostic, retryAfterSeconds) {
super(diagnostic.message);
this.name = "AskServiceError";
this.status = status;
this.diagnostic = diagnostic;
this.retryAfterSeconds = retryAfterSeconds;
}
};
var encoder = new TextEncoder();
var fail = (status, code, message, retryable = false, retryAfterSeconds) => {
throw new AskServiceError(status, import_chat_protocol.AskBackendDiagnosticSchema.parse({ code, message, retryable }), retryAfterSeconds);
};
var safeFailure = (error) => error instanceof AskServiceError ? error : new AskServiceError(500, { code: "ASK_INTERNAL", message: "The Ask request failed.", retryable: true });
var errorResponse = (error, requestId) => new Response(JSON.stringify({ error: error.diagnostic }), {
status: error.status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
"x-request-id": requestId,
...error.retryAfterSeconds === void 0 ? {} : { "retry-after": String(error.retryAfterSeconds) }
}
});
var stableSubject = (value) => {
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/.test(value)) fail(500, "ASK_INTERNAL", "The Ask host identity is invalid.");
return value;
};
var latestQuestion = (messages) => {
const question = [...messages].reverse().find((message) => message.role === "user")?.content.trim();
return question === void 0 || question === "" ? fail(400, "ASK_INVALID_REQUEST", "A user question is required.") : question;
};
var mergeSessionMessages = (stored, submitted) => {
const question = [...submitted].reverse().find((message) => message.role === "user");
if (question === void 0) return stored;
const last = stored.at(-1);
return last?.role === "user" && last.content === question.content ? stored : [...stored, question].slice(-64);
};
var createAskServiceHandler = (options) => {
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
const bootstrapTimeoutMs = options.bootstrapTimeoutMs ?? 3e4;
if (![maxBodyBytes, bootstrapTimeoutMs].every((value) => Number.isSafeInteger(value) && value > 0)) {
fail(500, "ASK_INTERNAL", "The Ask handler configuration is invalid.");
}
const createId = options.createId ?? (() => crypto.randomUUID());
const now = options.now ?? (() => /* @__PURE__ */ new Date());
const clock = options.clock ?? Date.now;
return async (request) => {
let candidateRequestId;
try {
candidateRequestId = createId();
} catch {
candidateRequestId = crypto.randomUUID();
}
const requestId = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(candidateRequestId) ? candidateRequestId : crypto.randomUUID();
const startedAt = clock();
const bootstrap = AbortSignal.timeout(bootstrapTimeoutMs);
const bootstrapSignal = AbortSignal.any([request.signal, bootstrap]);
let site;
let workDeadline;
let emittedErrorMetric = false;
const metric = (name, value, unit, outcome) => {
if (site === void 0) return;
const record = import_chat_protocol.AskBackendMetricSchema.parse({
protocol: "agentskit.chat.backend-metric",
version: 1,
name,
siteId: site.siteId,
corpusId: site.corpus.id,
requestId,
value: Math.max(0, value),
unit,
outcome,
emittedAt: now().toISOString()
});
try {
void Promise.resolve(options.onMetric?.(record)).catch(() => void 0);
} catch {
}
};
const recordError = (outcome) => {
if (emittedErrorMetric) return;
emittedErrorMetric = true;
metric(outcome === "cancelled" ? "cancellation.count" : "error.count", 1, "count", outcome);
metric("request.total_ms", clock() - startedAt, "ms", outcome);
};
try {
if (request.method !== "POST") fail(405, "ASK_INVALID_REQUEST", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
fail(415, "ASK_INVALID_REQUEST", "Content-Type must be application/json.");
}
const authenticated = await withAbort(options.authenticate(request, bootstrapSignal), bootstrapSignal);
if (!authenticated.ok) return authenticated.response;
site = import_chat_protocol.AskBackendSiteConfigSchema.parse(await withAbort(options.resolveSite(authenticated.context, bootstrapSignal), bootstrapSignal));
const subjectId = stableSubject(options.resolveSubjectId(authenticated.context));
const url = new URL(request.url);
const corpusHint = url.searchParams.get("corpus");
const personaHint = url.searchParams.get("persona");
if (corpusHint !== null && corpusHint !== site.corpus.id || personaHint !== null && personaHint !== site.assistant.id) {
fail(403, "ASK_FORBIDDEN", "The requested assistant or corpus is not authorized for this site.");
}
const requestDeadline = AbortSignal.timeout(site.limits.requestTimeoutMs);
workDeadline = requestDeadline;
const responseAbort = new AbortController();
const signal = AbortSignal.any([request.signal, requestDeadline, responseAbort.signal]);
const limited = await withAbort(
options.rateLimit?.({ context: authenticated.context, site, subjectId, signal }) ?? { allowed: true },
signal
);
if (!limited.allowed) fail(429, "ASK_RATE_LIMITED", "The Ask rate limit was exceeded.", true, limited.retryAfterSeconds);
const raw = await readBoundedJson(request, maxBodyBytes, signal, (status, _code, message) => fail(status, "ASK_INVALID_REQUEST", message));
const parsed = import_chat_protocol.AskBackendRequestSchema.safeParse(raw);
const input = parsed.success && parsed.data !== void 0 ? parsed.data : fail(400, "ASK_INVALID_REQUEST", "The Ask request payload is invalid.");
metric("deterministic.fallback", input.deterministic === void 0 ? 0 : 1, "count", "ok");
if (site.persistence.mode === "required" && (input.sessionId === void 0 || options.sessionStore === void 0)) {
fail(500, "ASK_INTERNAL", "Required Ask persistence is not configured.");
}
const key = input.sessionId === void 0 ? void 0 : { siteId: site.siteId, subjectId, sessionId: input.sessionId };
let stored;
if (key !== void 0 && options.sessionStore !== void 0) {
const persistenceStarted = clock();
const loaded = await withAbort(options.sessionStore.load(key, signal), signal);
stored = loaded === void 0 ? void 0 : import_chat_protocol.AskBackendSessionRecordSchema.parse(loaded);
metric("persistence.total_ms", clock() - persistenceStarted, "ms", "ok");
}
const messages = stored === void 0 ? input.messages : mergeSessionMessages(stored.messages, input.messages);
const query = latestQuestion(messages);
const retriever = options.retrievers[site.corpus.mode] ?? fail(500, "ASK_INTERNAL", "The configured Ask retriever is unavailable.");
const retrievalStarted = clock();
const retrievalSignal = AbortSignal.any([signal, AbortSignal.timeout(site.limits.retrievalTimeoutMs)]);
const sources = await (async () => {
try {
const candidates = await withAbort(retriever.retrieve({ query, messages, site, signal: retrievalSignal }), retrievalSignal);
return candidates.flatMap((candidate) => {
const decoded = import_chat_protocol.AskBackendSourceSchema.safeParse(candidate);
return decoded.success ? [decoded.data] : [];
}).slice(0, site.limits.maxSources);
} catch (error) {
if (signal.aborted) throw error;
if (retrievalSignal.aborted) fail(408, "ASK_TIMEOUT", "Grounded retrieval timed out.", true);
return fail(502, "ASK_RETRIEVAL_FAILED", "Grounded retrieval is temporarily unavailable.", true);
}
})();
metric("retrieval.total_ms", clock() - retrievalStarted, "ms", "ok");
metric("retrieval.documents", sources.length, "count", "ok");
if (sources.length === 0) fail(422, "ASK_NO_GROUNDED_SOURCES", "No grounded sources were found for this question.");
const body = new ReadableStream({
async start(controller) {
let bytes = 0;
let events = 0;
let firstEvent = false;
let firstToken = false;
let answer = "";
let usage;
const generationDeadline = AbortSignal.timeout(site.limits.generationTimeoutMs);
const generationSignal = AbortSignal.any([signal, generationDeadline]);
const emit = (candidate) => {
const event = import_chat_protocol.AskEventSchema.parse(candidate);
const chunk = encoder.encode(`${JSON.stringify(event)}
`);
if (!firstEvent) {
firstEvent = true;
metric("stream.first_event_ms", clock() - startedAt, "ms", "ok");
}
if (event.type === "text" && !firstToken) {
firstToken = true;
metric("stream.first_token_ms", clock() - startedAt, "ms", "ok");
}
bytes += chunk.byteLength;
events += 1;
controller.enqueue(chunk);
};
try {
const generation = options.generator.generate({ query, messages, site, sources, signal: generationSignal });
for await (const chunk of generation) {
if (generationSignal.aborted) throw generationSignal.reason;
if (chunk.type === "usage") {
usage = import_chat_protocol.AskBackendUsageSchema.parse(chunk.usage);
continue;
}
if (chunk.delta === "") continue;
answer += chunk.delta;
if (answer.length > 16384) fail(502, "ASK_GENERATION_FAILED", "The grounded answer exceeded its safe limit.", true);
emit({ type: "text", delta: chunk.delta });
}
if (answer.trim() === "") fail(502, "ASK_GENERATION_FAILED", "The grounded answer was empty.", true);
emit({
type: "tool",
id: `sources-${requestId}`,
name: "cite",
args: { sources: sources.map((source) => ({ id: source.id, title: source.title, path: source.href })) }
});
if (key !== void 0 && options.sessionStore !== void 0) {
const persistenceStarted = clock();
const revision = (stored?.revision ?? 0) + 1;
const saved = await withAbort(options.sessionStore.save(key, {
revision,
messages: [...messages, { role: "assistant", content: answer }].slice(-64)
}, stored?.revision ?? 0, signal), signal);
metric("persistence.total_ms", clock() - persistenceStarted, "ms", saved ? "ok" : "error");
if (!saved) {
metric("conflict.count", 1, "count", "error");
fail(409, "ASK_PERSISTENCE_CONFLICT", "The Ask session changed concurrently.", true);
}
}
if (usage?.inputTokens !== void 0) metric("usage.input_tokens", usage.inputTokens, "tokens", "ok");
if (usage?.outputTokens !== void 0) metric("usage.output_tokens", usage.outputTokens, "tokens", "ok");
if (usage?.totalTokens !== void 0) metric("usage.total_tokens", usage.totalTokens, "tokens", "ok");
if (usage?.costUsd !== void 0) metric("cost.usd", usage.costUsd, "usd", "ok");
emit({ type: "done", ...usage?.model === void 0 ? {} : { model: usage.model } });
metric("stream.bytes", bytes, "bytes", "ok");
metric("stream.events", events, "count", "ok");
metric("stream.snapshots", 0, "count", "ok");
metric("request.total_ms", clock() - startedAt, "ms", "ok");
} catch (error) {
const timeout = !request.signal.aborted && (requestDeadline.aborted || generationDeadline.aborted || generationSignal.aborted);
const interrupted = signal.aborted || generationSignal.aborted;
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;
if (!interrupted || timeout) {
try {
emit({ type: "error", message: diagnostic.message, code: diagnostic.code, retryable: diagnostic.retryable });
} catch {
}
}
recordError(interrupted && !timeout ? "cancelled" : "error");
} finally {
try {
controller.close();
} catch {
}
}
},
cancel() {
responseAbort.abort();
recordError("cancelled");
}
});
return new Response(body, {
status: 200,
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"x-request-id": requestId
}
});
} catch (error) {
const deadlineExpired = bootstrap.aborted || workDeadline?.aborted === true;
const cancelled = bootstrapSignal.aborted || request.signal.aborted || deadlineExpired;
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);
recordError(cancelled ? "cancelled" : safe.status < 500 ? "rejected" : "error");
return errorResponse(safe, requestId);
}
};
};
// src/index.ts
var ChatHandlerError = class extends Error {
status;
code;
retryable;
constructor(options) {
super(options.message);
this.name = "ChatHandlerError";
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? false;
}
};
var encoder2 = new TextEncoder();
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
});
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 } };
var fail2 = (status, code, message, retryable = false) => {
throw new ChatHandlerError({ status, code, message, retryable });
};
var readBody = async (request, maxBodyBytes, signal) => {
return readBoundedJson(request, maxBodyBytes, signal, fail2);
};
var snapshotStatus = (state) => state.status === "error" ? "error" : state.status === "streaming" ? "streaming" : state.messages.length === 0 ? "idle" : "complete";

@@ -110,5 +378,5 @@ var createChatHandler = (options) => {

const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
const leaseMs = timeoutMs + 3 * cleanupTimeoutMs;
if (!Number.isSafeInteger(leaseMs)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
if (!Number.isSafeInteger(leaseMs)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
const createId = options.createId ?? (() => crypto.randomUUID());

@@ -119,19 +387,19 @@ return async (request) => {

try {
if (request.method !== "POST") fail(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
if (request.method !== "POST") fail2(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail2(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
let context;
if (options.authenticate) {
const authenticated = await withSignal(options.authenticate(request, signal), signal);
const authenticated = await withAbort(options.authenticate(request, signal), signal);
if (!authenticated.ok) return authenticated.response;
context = authenticated.context;
}
const decoded = (0, import_chat_protocol.decodeTurnEvent)(await readBody(request, maxBodyBytes, signal));
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
const decoded = (0, import_chat_protocol2.decodeTurnEvent)(await readBody(request, maxBodyBytes, signal));
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail2(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
const submission = decoded.event;
const definition = await withSignal(options.resolveDefinition(context, submission.sessionId, signal), signal);
const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
const storage = options.sessionStorage(context, signal);
const session = await withSignal((0, import_chat.resumeChatSession)(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
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);
const session = await withAbort((0, import_chat.resumeChatSession)(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
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);
const memory = definition.chat.memory;
const loaded = memory ? await withSignal(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
const loaded = memory ? await withAbort(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
const messages = loaded.length > 0 ? loaded : definition.chat.initialMessages ?? [];

@@ -169,7 +437,7 @@ const { memory: _memory, ...chat } = definition.chat;

const settleSignal = AbortSignal.timeout(cleanupTimeoutMs);
await withSignal(send.catch(() => void 0), settleSignal).catch(() => void 0);
await withAbort(send.catch(() => void 0), settleSignal).catch(() => void 0);
const saveSignal = AbortSignal.timeout(cleanupTimeoutMs);
let outcome = "completed";
try {
await withSignal(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
await withAbort(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
} catch (error) {

@@ -180,7 +448,7 @@ outcome = "indeterminate";

const releaseSignal = AbortSignal.timeout(cleanupTimeoutMs);
await withSignal(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
await withAbort(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
}
};
const diagnosticLine = (code, message) => {
const event = import_chat_protocol.TurnEventSchema.parse({
const event = import_chat_protocol2.TurnEventSchema.parse({
protocol: "agentskit.chat.turn",

@@ -196,3 +464,3 @@ version: 1,

});
return encoder.encode(`${(0, import_chat_protocol.encodeTurnEvent)(event)}
return encoder2.encode(`${(0, import_chat_protocol2.encodeTurnEvent)(event)}
`);

@@ -215,4 +483,4 @@ };

pending = void 0;
await withSignal(session.persist(signal), signal);
const event = (0, import_chat_protocol.createSnapshotEvent)({
await withAbort(session.persist(signal), signal);
const event = (0, import_chat_protocol2.createSnapshotEvent)({
eventId: createId(),

@@ -229,3 +497,3 @@ sessionId: submission.sessionId,

});
stream.enqueue(encoder.encode(`${(0, import_chat_protocol.encodeTurnEvent)(event)}
stream.enqueue(encoder2.encode(`${(0, import_chat_protocol2.encodeTurnEvent)(event)}
`));

@@ -261,3 +529,4 @@ return;

ChatHandlerError,
createAskServiceHandler,
createChatHandler
});
import { ChatDefinition, SessionStorage } from '@agentskit/chat';
import { AskBackendUsage, AskBackendMessage, AskBackendSiteConfig, AskBackendSource, AskBackendSessionRecord, AskBackendMetric } from '@agentskit/chat-protocol';
type AskServiceHandler = (request: Request) => Promise<Response>;
type AskServiceAuthenticationResult<TContext> = {
readonly ok: true;
readonly context: TContext;
} | {
readonly ok: false;
readonly response: Response;
};
interface AskServiceRetrieverInput {
readonly query: string;
readonly messages: readonly AskBackendMessage[];
readonly site: AskBackendSiteConfig;
readonly signal: AbortSignal;
}
/** Implement with an upstream AgentsKit RAG or Retriever adapter. */
interface AskServiceRetriever {
readonly retrieve: (input: AskServiceRetrieverInput) => readonly AskBackendSource[] | Promise<readonly AskBackendSource[]>;
}
type AskServiceGenerationChunk = {
readonly type: 'text';
readonly delta: string;
} | {
readonly type: 'usage';
readonly usage: AskBackendUsage;
};
interface AskServiceGeneratorInput extends AskServiceRetrieverInput {
readonly sources: readonly AskBackendSource[];
}
/** Implement with an AgentsKit provider/adapter; the server owns only bounded projection. */
interface AskServiceGenerator {
readonly generate: (input: AskServiceGeneratorInput) => AsyncIterable<AskServiceGenerationChunk>;
}
type AskServiceSessionRecord = AskBackendSessionRecord;
interface AskServiceSessionStore {
readonly load: (key: {
readonly siteId: string;
readonly subjectId: string;
readonly sessionId: string;
}, signal: AbortSignal) => AskServiceSessionRecord | undefined | Promise<AskServiceSessionRecord | undefined>;
readonly save: (key: {
readonly siteId: string;
readonly subjectId: string;
readonly sessionId: string;
}, record: AskServiceSessionRecord, expectedRevision: number, signal: AbortSignal) => boolean | Promise<boolean>;
}
interface AskServiceRateLimitDecision {
readonly allowed: boolean;
readonly retryAfterSeconds?: number;
}
interface AskServiceHandlerOptions<TContext> {
readonly authenticate: (request: Request, signal: AbortSignal) => AskServiceAuthenticationResult<TContext> | Promise<AskServiceAuthenticationResult<TContext>>;
readonly resolveSite: (context: TContext, signal: AbortSignal) => AskBackendSiteConfig | Promise<AskBackendSiteConfig>;
readonly resolveSubjectId: (context: TContext) => string;
readonly retrievers: {
readonly local?: AskServiceRetriever;
readonly federated?: AskServiceRetriever;
};
readonly generator: AskServiceGenerator;
readonly sessionStore?: AskServiceSessionStore;
readonly rateLimit?: (input: {
readonly context: TContext;
readonly site: AskBackendSiteConfig;
readonly subjectId: string;
readonly signal: AbortSignal;
}) => AskServiceRateLimitDecision | Promise<AskServiceRateLimitDecision>;
readonly onMetric?: (metric: AskBackendMetric) => void | Promise<void>;
readonly maxBodyBytes?: number;
readonly bootstrapTimeoutMs?: number;
readonly createId?: () => string;
readonly now?: () => Date;
readonly clock?: () => number;
}
declare const createAskServiceHandler: <TContext>(options: AskServiceHandlerOptions<TContext>) => AskServiceHandler;
type ChatHandler = (request: Request) => Promise<Response>;

@@ -34,2 +109,2 @@ type AuthenticationResult<TContext> = {

export { type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createChatHandler };
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 };
import { ChatDefinition, SessionStorage } from '@agentskit/chat';
import { AskBackendUsage, AskBackendMessage, AskBackendSiteConfig, AskBackendSource, AskBackendSessionRecord, AskBackendMetric } from '@agentskit/chat-protocol';
type AskServiceHandler = (request: Request) => Promise<Response>;
type AskServiceAuthenticationResult<TContext> = {
readonly ok: true;
readonly context: TContext;
} | {
readonly ok: false;
readonly response: Response;
};
interface AskServiceRetrieverInput {
readonly query: string;
readonly messages: readonly AskBackendMessage[];
readonly site: AskBackendSiteConfig;
readonly signal: AbortSignal;
}
/** Implement with an upstream AgentsKit RAG or Retriever adapter. */
interface AskServiceRetriever {
readonly retrieve: (input: AskServiceRetrieverInput) => readonly AskBackendSource[] | Promise<readonly AskBackendSource[]>;
}
type AskServiceGenerationChunk = {
readonly type: 'text';
readonly delta: string;
} | {
readonly type: 'usage';
readonly usage: AskBackendUsage;
};
interface AskServiceGeneratorInput extends AskServiceRetrieverInput {
readonly sources: readonly AskBackendSource[];
}
/** Implement with an AgentsKit provider/adapter; the server owns only bounded projection. */
interface AskServiceGenerator {
readonly generate: (input: AskServiceGeneratorInput) => AsyncIterable<AskServiceGenerationChunk>;
}
type AskServiceSessionRecord = AskBackendSessionRecord;
interface AskServiceSessionStore {
readonly load: (key: {
readonly siteId: string;
readonly subjectId: string;
readonly sessionId: string;
}, signal: AbortSignal) => AskServiceSessionRecord | undefined | Promise<AskServiceSessionRecord | undefined>;
readonly save: (key: {
readonly siteId: string;
readonly subjectId: string;
readonly sessionId: string;
}, record: AskServiceSessionRecord, expectedRevision: number, signal: AbortSignal) => boolean | Promise<boolean>;
}
interface AskServiceRateLimitDecision {
readonly allowed: boolean;
readonly retryAfterSeconds?: number;
}
interface AskServiceHandlerOptions<TContext> {
readonly authenticate: (request: Request, signal: AbortSignal) => AskServiceAuthenticationResult<TContext> | Promise<AskServiceAuthenticationResult<TContext>>;
readonly resolveSite: (context: TContext, signal: AbortSignal) => AskBackendSiteConfig | Promise<AskBackendSiteConfig>;
readonly resolveSubjectId: (context: TContext) => string;
readonly retrievers: {
readonly local?: AskServiceRetriever;
readonly federated?: AskServiceRetriever;
};
readonly generator: AskServiceGenerator;
readonly sessionStore?: AskServiceSessionStore;
readonly rateLimit?: (input: {
readonly context: TContext;
readonly site: AskBackendSiteConfig;
readonly subjectId: string;
readonly signal: AbortSignal;
}) => AskServiceRateLimitDecision | Promise<AskServiceRateLimitDecision>;
readonly onMetric?: (metric: AskBackendMetric) => void | Promise<void>;
readonly maxBodyBytes?: number;
readonly bootstrapTimeoutMs?: number;
readonly createId?: () => string;
readonly now?: () => Date;
readonly clock?: () => number;
}
declare const createAskServiceHandler: <TContext>(options: AskServiceHandlerOptions<TContext>) => AskServiceHandler;
type ChatHandler = (request: Request) => Promise<Response>;

@@ -34,2 +109,2 @@ type AuthenticationResult<TContext> = {

export { type AuthenticationResult, type ChatHandler, ChatHandlerError, type ChatHandlerOptions, createChatHandler };
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 };

@@ -5,25 +5,6 @@ // src/index.ts

import { createSnapshotEvent, decodeTurnEvent, encodeTurnEvent, TurnEventSchema } from "@agentskit/chat-protocol";
var ChatHandlerError = class extends Error {
status;
code;
retryable;
constructor(options) {
super(options.message);
this.name = "ChatHandlerError";
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? false;
}
};
var encoder = new TextEncoder();
// src/internal.ts
var decoder = new TextDecoder();
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
});
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 } };
var fail = (status, code, message, retryable = false) => {
throw new ChatHandlerError({ status, code, message, retryable });
};
var withSignal = async (operation, signal) => {
var withAbort = async (operation, signal) => {
if (signal.aborted) throw signal.reason;

@@ -42,7 +23,7 @@ let rejectAbort;

};
var readBody = async (request, maxBodyBytes, signal) => {
var readBoundedJson = async (request, maxBodyBytes, signal, fail3) => {
const declared = Number(request.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBodyBytes) fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
if (Number.isFinite(declared) && declared > maxBodyBytes) fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
const reader = request.body?.getReader();
if (!reader) return fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
if (!reader) return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
const chunks = [];

@@ -52,3 +33,3 @@ let size = 0;

while (true) {
const result = await withSignal(reader.read(), signal);
const result = await withAbort(reader.read(), signal);
if (result.done) break;

@@ -58,3 +39,3 @@ size += result.value.byteLength;

await reader.cancel();
return fail(413, "REQUEST_TOO_LARGE", "Request body is too large.");
return fail3(413, "REQUEST_TOO_LARGE", "Request body is too large.");
}

@@ -75,5 +56,300 @@ chunks.push(result.value);

} catch {
return fail(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
return fail3(400, "REQUEST_INVALID_JSON", "Request body is not valid JSON.");
}
};
// src/ask-service.ts
import {
AskBackendDiagnosticSchema,
AskBackendMetricSchema,
AskBackendRequestSchema,
AskBackendSessionRecordSchema,
AskBackendSiteConfigSchema,
AskBackendSourceSchema,
AskBackendUsageSchema,
AskEventSchema
} from "@agentskit/chat-protocol";
var AskServiceError = class extends Error {
status;
diagnostic;
retryAfterSeconds;
constructor(status, diagnostic, retryAfterSeconds) {
super(diagnostic.message);
this.name = "AskServiceError";
this.status = status;
this.diagnostic = diagnostic;
this.retryAfterSeconds = retryAfterSeconds;
}
};
var encoder = new TextEncoder();
var fail = (status, code, message, retryable = false, retryAfterSeconds) => {
throw new AskServiceError(status, AskBackendDiagnosticSchema.parse({ code, message, retryable }), retryAfterSeconds);
};
var safeFailure = (error) => error instanceof AskServiceError ? error : new AskServiceError(500, { code: "ASK_INTERNAL", message: "The Ask request failed.", retryable: true });
var errorResponse = (error, requestId) => new Response(JSON.stringify({ error: error.diagnostic }), {
status: error.status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
"x-request-id": requestId,
...error.retryAfterSeconds === void 0 ? {} : { "retry-after": String(error.retryAfterSeconds) }
}
});
var stableSubject = (value) => {
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,255}$/.test(value)) fail(500, "ASK_INTERNAL", "The Ask host identity is invalid.");
return value;
};
var latestQuestion = (messages) => {
const question = [...messages].reverse().find((message) => message.role === "user")?.content.trim();
return question === void 0 || question === "" ? fail(400, "ASK_INVALID_REQUEST", "A user question is required.") : question;
};
var mergeSessionMessages = (stored, submitted) => {
const question = [...submitted].reverse().find((message) => message.role === "user");
if (question === void 0) return stored;
const last = stored.at(-1);
return last?.role === "user" && last.content === question.content ? stored : [...stored, question].slice(-64);
};
var createAskServiceHandler = (options) => {
const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
const bootstrapTimeoutMs = options.bootstrapTimeoutMs ?? 3e4;
if (![maxBodyBytes, bootstrapTimeoutMs].every((value) => Number.isSafeInteger(value) && value > 0)) {
fail(500, "ASK_INTERNAL", "The Ask handler configuration is invalid.");
}
const createId = options.createId ?? (() => crypto.randomUUID());
const now = options.now ?? (() => /* @__PURE__ */ new Date());
const clock = options.clock ?? Date.now;
return async (request) => {
let candidateRequestId;
try {
candidateRequestId = createId();
} catch {
candidateRequestId = crypto.randomUUID();
}
const requestId = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(candidateRequestId) ? candidateRequestId : crypto.randomUUID();
const startedAt = clock();
const bootstrap = AbortSignal.timeout(bootstrapTimeoutMs);
const bootstrapSignal = AbortSignal.any([request.signal, bootstrap]);
let site;
let workDeadline;
let emittedErrorMetric = false;
const metric = (name, value, unit, outcome) => {
if (site === void 0) return;
const record = AskBackendMetricSchema.parse({
protocol: "agentskit.chat.backend-metric",
version: 1,
name,
siteId: site.siteId,
corpusId: site.corpus.id,
requestId,
value: Math.max(0, value),
unit,
outcome,
emittedAt: now().toISOString()
});
try {
void Promise.resolve(options.onMetric?.(record)).catch(() => void 0);
} catch {
}
};
const recordError = (outcome) => {
if (emittedErrorMetric) return;
emittedErrorMetric = true;
metric(outcome === "cancelled" ? "cancellation.count" : "error.count", 1, "count", outcome);
metric("request.total_ms", clock() - startedAt, "ms", outcome);
};
try {
if (request.method !== "POST") fail(405, "ASK_INVALID_REQUEST", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
fail(415, "ASK_INVALID_REQUEST", "Content-Type must be application/json.");
}
const authenticated = await withAbort(options.authenticate(request, bootstrapSignal), bootstrapSignal);
if (!authenticated.ok) return authenticated.response;
site = AskBackendSiteConfigSchema.parse(await withAbort(options.resolveSite(authenticated.context, bootstrapSignal), bootstrapSignal));
const subjectId = stableSubject(options.resolveSubjectId(authenticated.context));
const url = new URL(request.url);
const corpusHint = url.searchParams.get("corpus");
const personaHint = url.searchParams.get("persona");
if (corpusHint !== null && corpusHint !== site.corpus.id || personaHint !== null && personaHint !== site.assistant.id) {
fail(403, "ASK_FORBIDDEN", "The requested assistant or corpus is not authorized for this site.");
}
const requestDeadline = AbortSignal.timeout(site.limits.requestTimeoutMs);
workDeadline = requestDeadline;
const responseAbort = new AbortController();
const signal = AbortSignal.any([request.signal, requestDeadline, responseAbort.signal]);
const limited = await withAbort(
options.rateLimit?.({ context: authenticated.context, site, subjectId, signal }) ?? { allowed: true },
signal
);
if (!limited.allowed) fail(429, "ASK_RATE_LIMITED", "The Ask rate limit was exceeded.", true, limited.retryAfterSeconds);
const raw = await readBoundedJson(request, maxBodyBytes, signal, (status, _code, message) => fail(status, "ASK_INVALID_REQUEST", message));
const parsed = AskBackendRequestSchema.safeParse(raw);
const input = parsed.success && parsed.data !== void 0 ? parsed.data : fail(400, "ASK_INVALID_REQUEST", "The Ask request payload is invalid.");
metric("deterministic.fallback", input.deterministic === void 0 ? 0 : 1, "count", "ok");
if (site.persistence.mode === "required" && (input.sessionId === void 0 || options.sessionStore === void 0)) {
fail(500, "ASK_INTERNAL", "Required Ask persistence is not configured.");
}
const key = input.sessionId === void 0 ? void 0 : { siteId: site.siteId, subjectId, sessionId: input.sessionId };
let stored;
if (key !== void 0 && options.sessionStore !== void 0) {
const persistenceStarted = clock();
const loaded = await withAbort(options.sessionStore.load(key, signal), signal);
stored = loaded === void 0 ? void 0 : AskBackendSessionRecordSchema.parse(loaded);
metric("persistence.total_ms", clock() - persistenceStarted, "ms", "ok");
}
const messages = stored === void 0 ? input.messages : mergeSessionMessages(stored.messages, input.messages);
const query = latestQuestion(messages);
const retriever = options.retrievers[site.corpus.mode] ?? fail(500, "ASK_INTERNAL", "The configured Ask retriever is unavailable.");
const retrievalStarted = clock();
const retrievalSignal = AbortSignal.any([signal, AbortSignal.timeout(site.limits.retrievalTimeoutMs)]);
const sources = await (async () => {
try {
const candidates = await withAbort(retriever.retrieve({ query, messages, site, signal: retrievalSignal }), retrievalSignal);
return candidates.flatMap((candidate) => {
const decoded = AskBackendSourceSchema.safeParse(candidate);
return decoded.success ? [decoded.data] : [];
}).slice(0, site.limits.maxSources);
} catch (error) {
if (signal.aborted) throw error;
if (retrievalSignal.aborted) fail(408, "ASK_TIMEOUT", "Grounded retrieval timed out.", true);
return fail(502, "ASK_RETRIEVAL_FAILED", "Grounded retrieval is temporarily unavailable.", true);
}
})();
metric("retrieval.total_ms", clock() - retrievalStarted, "ms", "ok");
metric("retrieval.documents", sources.length, "count", "ok");
if (sources.length === 0) fail(422, "ASK_NO_GROUNDED_SOURCES", "No grounded sources were found for this question.");
const body = new ReadableStream({
async start(controller) {
let bytes = 0;
let events = 0;
let firstEvent = false;
let firstToken = false;
let answer = "";
let usage;
const generationDeadline = AbortSignal.timeout(site.limits.generationTimeoutMs);
const generationSignal = AbortSignal.any([signal, generationDeadline]);
const emit = (candidate) => {
const event = AskEventSchema.parse(candidate);
const chunk = encoder.encode(`${JSON.stringify(event)}
`);
if (!firstEvent) {
firstEvent = true;
metric("stream.first_event_ms", clock() - startedAt, "ms", "ok");
}
if (event.type === "text" && !firstToken) {
firstToken = true;
metric("stream.first_token_ms", clock() - startedAt, "ms", "ok");
}
bytes += chunk.byteLength;
events += 1;
controller.enqueue(chunk);
};
try {
const generation = options.generator.generate({ query, messages, site, sources, signal: generationSignal });
for await (const chunk of generation) {
if (generationSignal.aborted) throw generationSignal.reason;
if (chunk.type === "usage") {
usage = AskBackendUsageSchema.parse(chunk.usage);
continue;
}
if (chunk.delta === "") continue;
answer += chunk.delta;
if (answer.length > 16384) fail(502, "ASK_GENERATION_FAILED", "The grounded answer exceeded its safe limit.", true);
emit({ type: "text", delta: chunk.delta });
}
if (answer.trim() === "") fail(502, "ASK_GENERATION_FAILED", "The grounded answer was empty.", true);
emit({
type: "tool",
id: `sources-${requestId}`,
name: "cite",
args: { sources: sources.map((source) => ({ id: source.id, title: source.title, path: source.href })) }
});
if (key !== void 0 && options.sessionStore !== void 0) {
const persistenceStarted = clock();
const revision = (stored?.revision ?? 0) + 1;
const saved = await withAbort(options.sessionStore.save(key, {
revision,
messages: [...messages, { role: "assistant", content: answer }].slice(-64)
}, stored?.revision ?? 0, signal), signal);
metric("persistence.total_ms", clock() - persistenceStarted, "ms", saved ? "ok" : "error");
if (!saved) {
metric("conflict.count", 1, "count", "error");
fail(409, "ASK_PERSISTENCE_CONFLICT", "The Ask session changed concurrently.", true);
}
}
if (usage?.inputTokens !== void 0) metric("usage.input_tokens", usage.inputTokens, "tokens", "ok");
if (usage?.outputTokens !== void 0) metric("usage.output_tokens", usage.outputTokens, "tokens", "ok");
if (usage?.totalTokens !== void 0) metric("usage.total_tokens", usage.totalTokens, "tokens", "ok");
if (usage?.costUsd !== void 0) metric("cost.usd", usage.costUsd, "usd", "ok");
emit({ type: "done", ...usage?.model === void 0 ? {} : { model: usage.model } });
metric("stream.bytes", bytes, "bytes", "ok");
metric("stream.events", events, "count", "ok");
metric("stream.snapshots", 0, "count", "ok");
metric("request.total_ms", clock() - startedAt, "ms", "ok");
} catch (error) {
const timeout = !request.signal.aborted && (requestDeadline.aborted || generationDeadline.aborted || generationSignal.aborted);
const interrupted = signal.aborted || generationSignal.aborted;
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;
if (!interrupted || timeout) {
try {
emit({ type: "error", message: diagnostic.message, code: diagnostic.code, retryable: diagnostic.retryable });
} catch {
}
}
recordError(interrupted && !timeout ? "cancelled" : "error");
} finally {
try {
controller.close();
} catch {
}
}
},
cancel() {
responseAbort.abort();
recordError("cancelled");
}
});
return new Response(body, {
status: 200,
headers: {
"content-type": "application/x-ndjson; charset=utf-8",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
"x-request-id": requestId
}
});
} catch (error) {
const deadlineExpired = bootstrap.aborted || workDeadline?.aborted === true;
const cancelled = bootstrapSignal.aborted || request.signal.aborted || deadlineExpired;
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);
recordError(cancelled ? "cancelled" : safe.status < 500 ? "rejected" : "error");
return errorResponse(safe, requestId);
}
};
};
// src/index.ts
var ChatHandlerError = class extends Error {
status;
code;
retryable;
constructor(options) {
super(options.message);
this.name = "ChatHandlerError";
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? false;
}
};
var encoder2 = new TextEncoder();
var json = (diagnostic, status) => new Response(JSON.stringify({ error: diagnostic }), {
status,
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }
});
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 } };
var fail2 = (status, code, message, retryable = false) => {
throw new ChatHandlerError({ status, code, message, retryable });
};
var readBody = async (request, maxBodyBytes, signal) => {
return readBoundedJson(request, maxBodyBytes, signal, fail2);
};
var snapshotStatus = (state) => state.status === "error" ? "error" : state.status === "streaming" ? "streaming" : state.messages.length === 0 ? "idle" : "complete";

@@ -84,5 +360,5 @@ var createChatHandler = (options) => {

const maxBodyBytes = options.maxBodyBytes ?? 64 * 1024;
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
if (![timeoutMs, cleanupTimeoutMs, maxBodyBytes].every((value) => Number.isSafeInteger(value) && value > 0)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
const leaseMs = timeoutMs + 3 * cleanupTimeoutMs;
if (!Number.isSafeInteger(leaseMs)) fail(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
if (!Number.isSafeInteger(leaseMs)) fail2(500, "SERVER_INVALID_CONFIG", "Chat handler configuration is invalid.");
const createId = options.createId ?? (() => crypto.randomUUID());

@@ -93,7 +369,7 @@ return async (request) => {

try {
if (request.method !== "POST") fail(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
if (request.method !== "POST") fail2(405, "REQUEST_METHOD_NOT_ALLOWED", "Only POST is supported.");
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) fail2(415, "REQUEST_UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json.");
let context;
if (options.authenticate) {
const authenticated = await withSignal(options.authenticate(request, signal), signal);
const authenticated = await withAbort(options.authenticate(request, signal), signal);
if (!authenticated.ok) return authenticated.response;

@@ -103,10 +379,10 @@ context = authenticated.context;

const decoded = decodeTurnEvent(await readBody(request, maxBodyBytes, signal));
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
if (!decoded.ok || decoded.event.event !== "client.turn.submit") return fail2(400, "REQUEST_INVALID_EVENT", "Request body must be a valid turn submission.");
const submission = decoded.event;
const definition = await withSignal(options.resolveDefinition(context, submission.sessionId, signal), signal);
const definition = await withAbort(options.resolveDefinition(context, submission.sessionId, signal), signal);
const storage = options.sessionStorage(context, signal);
const session = await withSignal(resumeChatSession(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
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);
const session = await withAbort(resumeChatSession(definition, { sessionId: submission.sessionId, storage, signal, ...options.now ? { now: options.now } : {} }), signal);
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);
const memory = definition.chat.memory;
const loaded = memory ? await withSignal(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
const loaded = memory ? await withAbort(memory.load({ signal }), signal) : definition.chat.initialMessages ?? [];
const messages = loaded.length > 0 ? loaded : definition.chat.initialMessages ?? [];

@@ -144,7 +420,7 @@ const { memory: _memory, ...chat } = definition.chat;

const settleSignal = AbortSignal.timeout(cleanupTimeoutMs);
await withSignal(send.catch(() => void 0), settleSignal).catch(() => void 0);
await withAbort(send.catch(() => void 0), settleSignal).catch(() => void 0);
const saveSignal = AbortSignal.timeout(cleanupTimeoutMs);
let outcome = "completed";
try {
await withSignal(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
await withAbort(memory?.save(controller.getState().messages, { signal: saveSignal }), saveSignal);
} catch (error) {

@@ -155,3 +431,3 @@ outcome = "indeterminate";

const releaseSignal = AbortSignal.timeout(cleanupTimeoutMs);
await withSignal(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
await withAbort(session.releaseTurn(submission.turnId, outcome, releaseSignal), releaseSignal);
}

@@ -171,3 +447,3 @@ };

});
return encoder.encode(`${encodeTurnEvent(event)}
return encoder2.encode(`${encodeTurnEvent(event)}
`);

@@ -190,3 +466,3 @@ };

pending = void 0;
await withSignal(session.persist(signal), signal);
await withAbort(session.persist(signal), signal);
const event = createSnapshotEvent({

@@ -204,3 +480,3 @@ eventId: createId(),

});
stream.enqueue(encoder.encode(`${encodeTurnEvent(event)}
stream.enqueue(encoder2.encode(`${encodeTurnEvent(event)}
`));

@@ -235,3 +511,4 @@ return;

ChatHandlerError,
createAskServiceHandler,
createChatHandler
};
+3
-3
{
"name": "@agentskit/chat-server",
"version": "0.1.0",
"version": "0.2.0",
"description": "Web-standard server handler for AgentsKit Chat applications.",

@@ -30,4 +30,4 @@ "license": "MIT",

"dependencies": {
"@agentskit/chat": "0.1.0",
"@agentskit/chat-protocol": "0.1.0"
"@agentskit/chat": "0.2.0",
"@agentskit/chat-protocol": "0.2.0"
},

@@ -34,0 +34,0 @@ "peerDependencies": {

@@ -14,1 +14,21 @@ # @agentskit/chat-server

The returned function accepts a standard `Request` and returns a standard streaming `Response`.
Semantic questions escalated by the deterministic plane use the trusted Ask
vertical:
```ts
const POST = createAskServiceHandler({
authenticate,
resolveSite,
resolveSubjectId: identity => identity.subjectId,
retrievers: { local: localRag, federated: federatedRag },
generator,
sessionStore,
rateLimit,
onMetric,
})
```
Site/corpus/assistant authority is resolved server-side. Successful responses
are cited Ask NDJSON; hosted and self-hosted routes mount the same factory. See
the [backend guide](../../docs/backend.md).