@rcrsr/rill-agent-chat 0.20.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 ADDED
@@ -0,0 +1,53 @@
1
+ # @rcrsr/rill-agent-chat
2
+
3
+ OpenAI-compatible chat completions harness for [`@rcrsr/rill-agent`](https://www.npmjs.com/package/@rcrsr/rill-agent). Wraps an `AgentRouter` in a Hono server that speaks the OpenAI Chat Completions wire format over Server-Sent Events (SSE). Stateless and provider-independent — no Azure or vendor SDK at runtime. Consumable by the openai SDK, LiteLLM, the Vercel AI SDK, and any HTTP client.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @rcrsr/rill-agent @rcrsr/rill-agent-chat
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { loadManifest, createRouter } from '@rcrsr/rill-agent';
15
+ import { createChatHarness } from '@rcrsr/rill-agent-chat';
16
+
17
+ const manifest = await loadManifest('./build');
18
+ const router = await createRouter(manifest);
19
+
20
+ const harness = createChatHarness(router);
21
+ await harness.listen(3000);
22
+ ```
23
+
24
+ An agent is chat-eligible when its handler returns a stream of OpenAI-shaped chunks. See [the reference](docs/agent-chat.md) for the handler contract and `inspectChatHandler`.
25
+
26
+ ## Routes
27
+
28
+ | Method | Path | Description |
29
+ |--------|------|-------------|
30
+ | `POST` | `/v1/chat/completions` | OpenAI-compatible; the `model` field selects the agent |
31
+ | `POST` | `/agents/:name/chat` | Per-agent chat |
32
+ | `POST` | `/chat` | Default agent chat |
33
+ | `GET` | `/agents` | List chat-eligible agents |
34
+ | `GET` | `/health` | Liveness (always on) |
35
+ | `GET` | `/metrics` | Request counters (always on) |
36
+
37
+ Request and response bodies follow the OpenAI Chat Completions format. Set `"stream": true` for an SSE stream of `chat.completion.chunk` events. Toggle the first four routes via `options.routes`.
38
+
39
+ ## API
40
+
41
+ - `createChatHarness(router, options?)` — returns a `ChatHarness` (`{ app, listen(port), close() }`)
42
+ - `inspectChatHandler(handler)` — reports whether a handler is chat-eligible
43
+ - `validateMessages(messages)` — validates an incoming messages array
44
+
45
+ The default export is a `RillHarness` adapter consumed by the rill CLI bundle mode (`rill run`) when this package is declared as a bundle harness.
46
+
47
+ ## Documentation
48
+
49
+ - [Reference](docs/agent-chat.md) — routes, options, the handler contract, streaming, and error shapes
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,222 @@
1
+ // Generated by dts-bundle-generator v9.5.1
2
+
3
+ import { Hono } from 'hono';
4
+
5
+ export interface HandlerDescription {
6
+ readonly name: string;
7
+ readonly description?: string | undefined;
8
+ readonly params: ReadonlyArray<{
9
+ readonly name: string;
10
+ readonly type: string;
11
+ readonly required: boolean;
12
+ readonly description?: string | undefined;
13
+ readonly defaultValue?: unknown;
14
+ }>;
15
+ /**
16
+ * Handler return type annotation, formatted with the same grammar as
17
+ * parameter type strings (e.g. `stream(dict(content: string)):string`).
18
+ * Undefined when the handler closure has no `:T` annotation, or when the
19
+ * rill-build emitting the handler is too old to expose the field.
20
+ */
21
+ readonly returnType?: string | undefined;
22
+ }
23
+ export interface InitContext {
24
+ readonly globalVars?: Record<string, string> | undefined;
25
+ readonly ahiResolver?: ((agentName: string, request: RunRequest) => Promise<RunResponse>) | undefined;
26
+ }
27
+ export interface RunRequest {
28
+ readonly params?: Record<string, unknown> | undefined;
29
+ readonly timeout?: number | undefined;
30
+ }
31
+ export interface RunContext {
32
+ readonly sessionVars?: Record<string, string> | undefined;
33
+ readonly onLog?: ((message: string) => void) | undefined;
34
+ readonly onChunk?: ((chunk: unknown) => Promise<void>) | undefined;
35
+ readonly signal?: AbortSignal | undefined;
36
+ }
37
+ export interface RunResponse {
38
+ readonly state: "completed" | "error";
39
+ readonly result: unknown;
40
+ readonly streamed?: boolean | undefined;
41
+ }
42
+ export interface AgentHandler {
43
+ describe(): HandlerDescription | null;
44
+ init(context?: InitContext): Promise<void>;
45
+ execute(request?: RunRequest, context?: RunContext): Promise<RunResponse>;
46
+ dispose(): Promise<void>;
47
+ }
48
+ export interface AgentManifest {
49
+ readonly defaultAgent: string;
50
+ readonly agents: ReadonlyMap<string, AgentHandler>;
51
+ }
52
+ export interface AgentRouter {
53
+ readonly manifest: AgentManifest;
54
+ run(agentName: string, request: RunRequest, context?: RunContext): Promise<RunResponse>;
55
+ describe(agentName: string): HandlerDescription | null;
56
+ agents(): string[];
57
+ defaultAgent(): string;
58
+ dispose(): Promise<void>;
59
+ }
60
+ export interface ChatMessage {
61
+ role: "system" | "user" | "assistant";
62
+ content: string;
63
+ }
64
+ export interface ChatRequest {
65
+ model?: string | undefined;
66
+ messages: ChatMessage[];
67
+ stream?: boolean | undefined;
68
+ }
69
+ export interface UsageMetadata {
70
+ prompt_tokens?: number | undefined;
71
+ completion_tokens?: number | undefined;
72
+ total_tokens?: number | undefined;
73
+ }
74
+ export interface ChatDelta {
75
+ role?: "assistant" | undefined;
76
+ content?: string | undefined;
77
+ }
78
+ export interface ChatChunk {
79
+ id?: string | undefined;
80
+ object?: "chat.completion.chunk" | undefined;
81
+ created?: number | undefined;
82
+ model?: string | undefined;
83
+ choices: [
84
+ {
85
+ index?: number | undefined;
86
+ delta: ChatDelta;
87
+ finish_reason?: "stop" | "length" | "error" | null | undefined;
88
+ }
89
+ ];
90
+ usage?: UsageMetadata | undefined;
91
+ /** Present only on in-band error frames (finish_reason: 'error'). */
92
+ error?: {
93
+ message: string;
94
+ } | undefined;
95
+ }
96
+ /**
97
+ * AHI resolver function — same shape as the resolver produced by createRouter
98
+ * in @rcrsr/rill-agent. Defined here to avoid importing a runtime value for a
99
+ * type-only position.
100
+ */
101
+ export type AhiResolver = (agentName: string, request: RunRequest) => Promise<RunResponse>;
102
+ export interface ChatHarnessOptions {
103
+ routes?: {
104
+ openai?: boolean | undefined;
105
+ perAgent?: boolean | undefined;
106
+ defaultAgent?: boolean | undefined;
107
+ discovery?: boolean | undefined;
108
+ } | undefined;
109
+ cors?: boolean | undefined;
110
+ port?: number | undefined;
111
+ }
112
+ export interface ChatHarness {
113
+ listen(port?: number): Promise<void>;
114
+ close(): Promise<void>;
115
+ readonly app: Hono;
116
+ }
117
+ /**
118
+ * Create a chat harness wrapping an AgentRouter.
119
+ *
120
+ * Routes (all configurable via options.routes):
121
+ * POST /v1/chat/completions — OpenAI-compatible; model field selects agent
122
+ * POST /agents/:name/chat — per-agent chat
123
+ * POST /chat — default agent chat
124
+ * GET /agents — discovery; chat-eligible agents only
125
+ *
126
+ * Always-present routes:
127
+ * GET /health — returns 200 "OK"
128
+ * GET /metrics — returns JSON metrics counters
129
+ *
130
+ * Throws ChatSignatureError if defaultAgent route is enabled but the default
131
+ * agent is missing or its declared signature is not chat-eligible (see
132
+ * inspectChatHandler).
133
+ */
134
+ export declare function createChatHarness(router: AgentRouter, options?: ChatHarnessOptions): ChatHarness;
135
+ /**
136
+ * Error thrown at factory time when the default agent route is enabled but
137
+ * the default agent is missing, or its declared signature is not
138
+ * chat-eligible. Not mapped to an HTTP status; indicates misconfiguration at
139
+ * startup.
140
+ */
141
+ export declare class ChatSignatureError extends Error {
142
+ constructor(message: string);
143
+ }
144
+ /**
145
+ * Chat eligibility validates the handler's declared signature via describe().
146
+ *
147
+ * The contract: a chat handler is a stream closure with the canonical chat
148
+ * input shape and the canonical chat chunk shape. Concretely, the handler's
149
+ * `describe()` must report:
150
+ * - exactly one required param named `messages` with type
151
+ * `list(dict(role: string, content: string))`;
152
+ * - a `returnType` of the form `stream(<EXPECTED_CHUNK_TYPE>)` or
153
+ * `stream(<EXPECTED_CHUNK_TYPE>):<resolution>` where the resolution type
154
+ * is unconstrained — the harness only consumes chunks via onChunk and
155
+ * leaves the closure free to resolve to a string, number, dict, etc.
156
+ *
157
+ * Any deviation in either the param shape or the chunk shape rejects the
158
+ * handler at `createChatHarness()` time rather than failing on the first
159
+ * request. Both fields are emitted by rill-build ≥ 0.19.6 (paired with
160
+ * rill ≥ 0.19.3).
161
+ */
162
+ export declare function inspectChatHandler(handler: AgentHandler): {
163
+ eligible: true;
164
+ } | {
165
+ eligible: false;
166
+ reason: string;
167
+ };
168
+ /**
169
+ * Validates that value is a non-empty array of well-formed ChatMessage objects.
170
+ * Returns a discriminated union: success carries the narrowed ChatMessage array,
171
+ * failure carries a human-readable error string.
172
+ */
173
+ export declare function validateMessages(value: unknown): {
174
+ valid: true;
175
+ messages: ChatMessage[];
176
+ } | {
177
+ valid: false;
178
+ error: string;
179
+ };
180
+ export interface RillHarnessLogger {
181
+ info(...args: unknown[]): void;
182
+ warn(...args: unknown[]): void;
183
+ error(...args: unknown[]): void;
184
+ }
185
+ export interface RillCompiledPackage {
186
+ readonly mount: string;
187
+ readonly buildOutput: {
188
+ readonly outputPath: string;
189
+ };
190
+ }
191
+ export interface RillServeContext {
192
+ readonly config: Record<string, unknown>;
193
+ readonly logger: RillHarnessLogger;
194
+ readonly packages: readonly RillCompiledPackage[];
195
+ readonly requestedMount: string | undefined;
196
+ readonly args: readonly string[];
197
+ readonly onShutdown: (handler: () => void | Promise<void>) => void;
198
+ readonly onSourceChange: (handler: () => void | Promise<void>) => void;
199
+ }
200
+ export interface RillPostBuildContext {
201
+ readonly outputDir: string;
202
+ readonly packages: readonly RillCompiledPackage[];
203
+ readonly logger: RillHarnessLogger;
204
+ }
205
+ export interface RillHarness {
206
+ readonly name: string;
207
+ readonly postBuild?: (ctx: RillPostBuildContext) => Promise<void>;
208
+ readonly serve?: (ctx: RillServeContext) => Promise<number>;
209
+ }
210
+ /**
211
+ * Default export consumed by the rill CLI (`rill install --replace`,
212
+ * `rill run`) when this package is declared as a bundle harness. `serve`
213
+ * assembles a router from the bundle's compiled packages and hosts it over the
214
+ * OpenAI-compatible chat harness on `config.port` (default 3000).
215
+ */
216
+ declare const harness: RillHarness;
217
+
218
+ export {
219
+ harness as default,
220
+ };
221
+
222
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,697 @@
1
+ // src/harness.ts
2
+ import { cors } from "hono/cors";
3
+
4
+ // ../../shared/hono-kit/src/index.ts
5
+ import { existsSync } from "fs";
6
+ import path from "path";
7
+ import { Hono } from "hono";
8
+ import { serve } from "@hono/node-server";
9
+ function assertJsonObject(parsed) {
10
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
11
+ throw new Error("Request body must be a JSON object");
12
+ }
13
+ return parsed;
14
+ }
15
+ function createHarnessLifecycle(options) {
16
+ const app = new Hono();
17
+ let server;
18
+ async function listen(port) {
19
+ if (server !== void 0) {
20
+ throw new Error("Server is already listening");
21
+ }
22
+ return new Promise((resolve) => {
23
+ server = serve({ fetch: app.fetch, port }, () => {
24
+ options?.serverTweaks?.(server);
25
+ resolve();
26
+ });
27
+ });
28
+ }
29
+ async function close() {
30
+ if (server !== void 0) {
31
+ server.close();
32
+ server = void 0;
33
+ }
34
+ }
35
+ return { app, listen, close };
36
+ }
37
+ function compiledPackageEntries(ctx) {
38
+ return ctx.packages.map((p) => ({
39
+ name: p.mount,
40
+ dir: p.buildOutput.outputPath
41
+ }));
42
+ }
43
+ function readHarnessPort(config, fallback) {
44
+ const p = config["port"];
45
+ if (typeof p === "number" && Number.isInteger(p)) return p;
46
+ if (typeof p === "string" && /^\d+$/.test(p)) return Number(p);
47
+ return fallback;
48
+ }
49
+ function assertCompiledHandlers(ctx) {
50
+ for (const pkg of ctx.packages) {
51
+ const handlerPath = path.join(pkg.buildOutput.outputPath, "handler.js");
52
+ if (!existsSync(handlerPath)) {
53
+ throw new Error(`missing handler file: ${handlerPath}`);
54
+ }
55
+ }
56
+ }
57
+ async function runRillServe(ctx, start) {
58
+ const handle = await start(compiledPackageEntries(ctx));
59
+ ctx.onShutdown(async () => {
60
+ await handle.close();
61
+ });
62
+ return new Promise(() => {
63
+ });
64
+ }
65
+
66
+ // src/errors.ts
67
+ var ChatSignatureError = class extends Error {
68
+ constructor(message) {
69
+ super(message);
70
+ this.name = "ChatSignatureError";
71
+ }
72
+ };
73
+ var ChatChunkError = class extends Error {
74
+ /** HTTP status code for this error. */
75
+ statusCode;
76
+ constructor(message) {
77
+ super(message);
78
+ this.name = "ChatChunkError";
79
+ this.statusCode = 500;
80
+ }
81
+ };
82
+
83
+ // src/eligibility.ts
84
+ var EXPECTED_MESSAGES_TYPE = "list(dict(role: string, content: string))";
85
+ var EXPECTED_CHUNK_TYPE = "dict(choices: list(dict(delta: dict(role: string, content: string), finish_reason: string)))";
86
+ function extractStreamChunkType(returnType) {
87
+ const prefix = "stream(";
88
+ if (!returnType.startsWith(prefix)) return null;
89
+ let depth = 1;
90
+ const start = prefix.length;
91
+ for (let i = start; i < returnType.length; i++) {
92
+ const c = returnType[i];
93
+ if (c === "(") {
94
+ depth++;
95
+ } else if (c === ")") {
96
+ depth--;
97
+ if (depth === 0) {
98
+ return returnType.slice(start, i);
99
+ }
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ function inspectChatHandler(handler) {
105
+ const description = handler.describe();
106
+ if (description === null) {
107
+ return {
108
+ eligible: false,
109
+ reason: "handler.describe() returned null"
110
+ };
111
+ }
112
+ const params = description.params;
113
+ if (params.length !== 1) {
114
+ return {
115
+ eligible: false,
116
+ reason: `expected exactly one param "messages", got ${params.length}`
117
+ };
118
+ }
119
+ const p = params[0];
120
+ if (p === void 0) {
121
+ return {
122
+ eligible: false,
123
+ reason: "handler.describe() returned an empty params slot"
124
+ };
125
+ }
126
+ if (p.name !== "messages") {
127
+ return {
128
+ eligible: false,
129
+ reason: `expected param "messages", got "${p.name}"`
130
+ };
131
+ }
132
+ if (!p.required) {
133
+ return {
134
+ eligible: false,
135
+ reason: 'expected param "messages" to be required'
136
+ };
137
+ }
138
+ if (p.type !== EXPECTED_MESSAGES_TYPE) {
139
+ return {
140
+ eligible: false,
141
+ reason: `expected param "messages" of type "${EXPECTED_MESSAGES_TYPE}", got "${p.type}"`
142
+ };
143
+ }
144
+ if (description.returnType === void 0) {
145
+ return {
146
+ eligible: false,
147
+ reason: "handler.describe() did not report a returnType (rebuild with rill-cli >= 0.19.6)"
148
+ };
149
+ }
150
+ const chunkType = extractStreamChunkType(description.returnType);
151
+ if (chunkType === null) {
152
+ return {
153
+ eligible: false,
154
+ reason: `expected return type "stream(<chunk>)" or "stream(<chunk>):<ret>", got "${description.returnType}"`
155
+ };
156
+ }
157
+ if (chunkType !== EXPECTED_CHUNK_TYPE) {
158
+ return {
159
+ eligible: false,
160
+ reason: `expected stream chunk type "${EXPECTED_CHUNK_TYPE}", got "${chunkType}"`
161
+ };
162
+ }
163
+ return { eligible: true };
164
+ }
165
+
166
+ // src/stream.ts
167
+ var encoder = new TextEncoder();
168
+ function toAsyncIterable(source) {
169
+ if (Symbol.asyncIterator in source) {
170
+ return source;
171
+ }
172
+ return source;
173
+ }
174
+ function fillChunk(chunk, defaults) {
175
+ return {
176
+ id: chunk.id ?? defaults.id,
177
+ object: chunk.object ?? "chat.completion.chunk",
178
+ created: chunk.created ?? defaults.created,
179
+ model: chunk.model ?? defaults.model,
180
+ choices: chunk.choices,
181
+ ...chunk.usage !== void 0 ? { usage: chunk.usage } : {}
182
+ };
183
+ }
184
+ function encodeChunk(chunk) {
185
+ return encoder.encode(`data: ${JSON.stringify(chunk)}
186
+
187
+ `);
188
+ }
189
+ var DONE_FRAME = encoder.encode("data: [DONE]\n\n");
190
+ function buildErrorChunk(defaults) {
191
+ return {
192
+ id: defaults.id,
193
+ object: "chat.completion.chunk",
194
+ created: defaults.created,
195
+ model: defaults.model,
196
+ choices: [{ index: 0, delta: {}, finish_reason: "error" }],
197
+ error: { message: "Internal server error" }
198
+ };
199
+ }
200
+ async function createChatStreamResponse(source, options) {
201
+ const iterable = toAsyncIterable(source);
202
+ const iter = iterable[Symbol.asyncIterator]();
203
+ const streamId = `chatcmpl-${crypto.randomUUID()}`;
204
+ const created = Math.floor(Date.now() / 1e3);
205
+ const defaults = { id: streamId, created, model: options.model };
206
+ const firstResult = await iter.next();
207
+ if (firstResult.done === true) {
208
+ const body2 = new ReadableStream({
209
+ start(controller) {
210
+ controller.enqueue(DONE_FRAME);
211
+ controller.close();
212
+ },
213
+ cancel() {
214
+ options.abortController.abort();
215
+ }
216
+ });
217
+ return new Response(body2, { status: 200, headers: sseHeaders() });
218
+ }
219
+ const firstChunk = fillChunk(firstResult.value, defaults);
220
+ const body = new ReadableStream({
221
+ start(controller) {
222
+ (async () => {
223
+ controller.enqueue(encodeChunk(firstChunk));
224
+ let result = await iter.next();
225
+ while (result.done !== true) {
226
+ controller.enqueue(encodeChunk(fillChunk(result.value, defaults)));
227
+ result = await iter.next();
228
+ }
229
+ controller.enqueue(DONE_FRAME);
230
+ controller.close();
231
+ })().catch((err) => {
232
+ if (options.abortController.signal.aborted || controller.desiredSize === null) {
233
+ return;
234
+ }
235
+ console.error(err);
236
+ options.onError?.(err);
237
+ try {
238
+ controller.enqueue(encodeChunk(buildErrorChunk(defaults)));
239
+ controller.enqueue(DONE_FRAME);
240
+ controller.close();
241
+ } catch {
242
+ }
243
+ });
244
+ },
245
+ cancel() {
246
+ options.abortController.abort();
247
+ }
248
+ });
249
+ return new Response(body, { status: 200, headers: sseHeaders() });
250
+ }
251
+ function checkChunkForError(chunk) {
252
+ const finishReason = chunk.choices[0]?.finish_reason;
253
+ if (finishReason === "error" || chunk.error !== void 0) {
254
+ throw new ChatChunkError(chunk.error?.message ?? "stream error");
255
+ }
256
+ }
257
+ async function createChatCompletionResponse(source, options) {
258
+ const iterable = toAsyncIterable(source);
259
+ const iter = iterable[Symbol.asyncIterator]();
260
+ const id = `chatcmpl-${crypto.randomUUID()}`;
261
+ const created = Math.floor(Date.now() / 1e3);
262
+ const firstResult = await iter.next();
263
+ let content = "";
264
+ let lastFinishReason;
265
+ let usage;
266
+ if (firstResult.done !== true) {
267
+ checkChunkForError(firstResult.value);
268
+ ({ content, lastFinishReason, usage } = accumulateChunk(
269
+ firstResult.value,
270
+ content,
271
+ usage
272
+ ));
273
+ let result = await iter.next();
274
+ while (result.done !== true) {
275
+ checkChunkForError(result.value);
276
+ ({ content, lastFinishReason, usage } = accumulateChunk(
277
+ result.value,
278
+ content,
279
+ usage
280
+ ));
281
+ result = await iter.next();
282
+ }
283
+ }
284
+ const finishReason = lastFinishReason !== void 0 && lastFinishReason !== null ? lastFinishReason : "stop";
285
+ const body = {
286
+ id,
287
+ object: "chat.completion",
288
+ created,
289
+ model: options.model,
290
+ choices: [
291
+ {
292
+ index: 0,
293
+ message: { role: "assistant", content },
294
+ finish_reason: finishReason
295
+ }
296
+ ],
297
+ ...usage !== void 0 ? { usage } : {}
298
+ };
299
+ return new Response(JSON.stringify(body), {
300
+ status: 200,
301
+ headers: { "Content-Type": "application/json" }
302
+ });
303
+ }
304
+ function accumulateChunk(chunk, content, usage) {
305
+ const delta = chunk.choices[0]?.delta.content;
306
+ const nextContent = delta !== void 0 ? content + delta : content;
307
+ const nextUsage = chunk.usage !== void 0 ? chunk.usage : usage;
308
+ return {
309
+ content: nextContent,
310
+ lastFinishReason: chunk.choices[0]?.finish_reason,
311
+ usage: nextUsage
312
+ };
313
+ }
314
+ function sseHeaders() {
315
+ return {
316
+ "Content-Type": "text/event-stream",
317
+ "Cache-Control": "no-cache",
318
+ Connection: "keep-alive"
319
+ };
320
+ }
321
+
322
+ // src/validate.ts
323
+ var VALID_ROLES = ["system", "user", "assistant"];
324
+ var MAX_MESSAGES = 1e4;
325
+ var MAX_CONTENT_LENGTH = 32e3;
326
+ function validateMessages(value) {
327
+ if (!Array.isArray(value)) {
328
+ return { valid: false, error: "messages must be an array" };
329
+ }
330
+ if (value.length === 0) {
331
+ return { valid: false, error: "messages must be a non-empty array" };
332
+ }
333
+ if (value.length > MAX_MESSAGES) {
334
+ return {
335
+ valid: false,
336
+ error: `messages must not exceed ${MAX_MESSAGES} items`
337
+ };
338
+ }
339
+ const messages = [];
340
+ for (let i = 0; i < value.length; i++) {
341
+ const item = value[i];
342
+ if (typeof item !== "object" || item === null || !("role" in item) || !VALID_ROLES.includes(
343
+ item["role"]
344
+ )) {
345
+ return {
346
+ valid: false,
347
+ error: `messages[${i}].role must be one of: system, user, assistant`
348
+ };
349
+ }
350
+ const record = item;
351
+ if (typeof record["content"] !== "string") {
352
+ return {
353
+ valid: false,
354
+ error: `messages[${i}].content must be a string`
355
+ };
356
+ }
357
+ if (record["content"].length > MAX_CONTENT_LENGTH) {
358
+ return {
359
+ valid: false,
360
+ error: `messages[${i}].content must not exceed ${MAX_CONTENT_LENGTH} characters`
361
+ };
362
+ }
363
+ messages.push({
364
+ role: record["role"],
365
+ content: record["content"]
366
+ });
367
+ }
368
+ return { valid: true, messages };
369
+ }
370
+
371
+ // src/harness.ts
372
+ function getHandlerFromRouter(router, name) {
373
+ return router.manifest.agents.get(name);
374
+ }
375
+ function invokeHandlerAsStream(handler, messages, abortController) {
376
+ return new ReadableStream({
377
+ start(controller) {
378
+ void (async () => {
379
+ try {
380
+ await handler.execute(
381
+ { params: { messages } },
382
+ {
383
+ signal: abortController.signal,
384
+ onChunk: async (chunk) => {
385
+ if (abortController.signal.aborted) return;
386
+ controller.enqueue(chunk);
387
+ }
388
+ }
389
+ );
390
+ controller.close();
391
+ } catch (err) {
392
+ controller.error(err);
393
+ }
394
+ })();
395
+ },
396
+ cancel() {
397
+ abortController.abort();
398
+ }
399
+ });
400
+ }
401
+ function createChatHarness(router, options) {
402
+ if (router === null || router === void 0) {
403
+ throw new TypeError("router is required");
404
+ }
405
+ const routeFlags = {
406
+ openai: options?.routes?.openai !== false,
407
+ perAgent: options?.routes?.perAgent !== false,
408
+ defaultAgent: options?.routes?.defaultAgent !== false,
409
+ discovery: options?.routes?.discovery !== false
410
+ };
411
+ const eligibleAgents = /* @__PURE__ */ new Set();
412
+ const ineligibleReasons = /* @__PURE__ */ new Map();
413
+ for (const name of router.agents()) {
414
+ const handler = getHandlerFromRouter(router, name);
415
+ if (handler !== void 0) {
416
+ const result = inspectChatHandler(handler);
417
+ if (result.eligible) {
418
+ eligibleAgents.add(name);
419
+ } else {
420
+ ineligibleReasons.set(name, result.reason);
421
+ }
422
+ }
423
+ }
424
+ const defaultAgentName = router.defaultAgent();
425
+ if (routeFlags.defaultAgent) {
426
+ if (defaultAgentName === "" || !router.agents().includes(defaultAgentName)) {
427
+ throw new ChatSignatureError(
428
+ `Default agent "${defaultAgentName}" is not a known agent`
429
+ );
430
+ }
431
+ if (!eligibleAgents.has(defaultAgentName)) {
432
+ const reason = ineligibleReasons.get(defaultAgentName) ?? "unknown reason";
433
+ throw new ChatSignatureError(
434
+ `Default agent "${defaultAgentName}" has incompatible chat signature: ${reason}`
435
+ );
436
+ }
437
+ }
438
+ const lifecycle = createHarnessLifecycle();
439
+ const app = lifecycle.app;
440
+ let requests = 0;
441
+ let errors = 0;
442
+ let activeConnections = 0;
443
+ if (options?.cors === true) {
444
+ app.use("*", cors());
445
+ }
446
+ app.get("/health", (c) => c.text("OK", 200));
447
+ app.get(
448
+ "/metrics",
449
+ (c) => c.json({
450
+ requests,
451
+ errors,
452
+ active_connections: activeConnections
453
+ })
454
+ );
455
+ async function streamChatResponse(source, resolvedAgent, abortController) {
456
+ activeConnections++;
457
+ const { readable, writable } = new TransformStream();
458
+ (async () => {
459
+ const writer = writable.getWriter();
460
+ try {
461
+ if (Symbol.asyncIterator in source) {
462
+ for await (const chunk of source) {
463
+ if (abortController.signal.aborted) break;
464
+ await writer.write(chunk);
465
+ }
466
+ } else {
467
+ const reader = source.getReader();
468
+ try {
469
+ let result = await reader.read();
470
+ while (!result.done) {
471
+ if (abortController.signal.aborted) {
472
+ await reader.cancel();
473
+ break;
474
+ }
475
+ await writer.write(result.value);
476
+ result = await reader.read();
477
+ }
478
+ } finally {
479
+ reader.releaseLock();
480
+ }
481
+ }
482
+ if (abortController.signal.aborted) {
483
+ await writer.abort();
484
+ } else {
485
+ await writer.close();
486
+ }
487
+ } catch (err) {
488
+ await writer.abort(err);
489
+ } finally {
490
+ activeConnections--;
491
+ requests++;
492
+ }
493
+ })().catch(() => {
494
+ });
495
+ try {
496
+ return await createChatStreamResponse(readable, {
497
+ model: resolvedAgent,
498
+ abortController,
499
+ onError: () => {
500
+ errors++;
501
+ }
502
+ });
503
+ } catch (err) {
504
+ console.error(err);
505
+ errors++;
506
+ return new Response(
507
+ JSON.stringify({ error: { message: "Internal server error" } }),
508
+ {
509
+ status: 500,
510
+ headers: { "Content-Type": "application/json" }
511
+ }
512
+ );
513
+ }
514
+ }
515
+ async function bufferedChatResponse(source, resolvedAgent) {
516
+ activeConnections++;
517
+ try {
518
+ return await createChatCompletionResponse(source, {
519
+ model: resolvedAgent
520
+ });
521
+ } catch (err) {
522
+ errors++;
523
+ if (err instanceof ChatChunkError) {
524
+ return new Response(
525
+ JSON.stringify({ error: { message: err.message } }),
526
+ {
527
+ status: err.statusCode,
528
+ headers: { "Content-Type": "application/json" }
529
+ }
530
+ );
531
+ }
532
+ console.error(err);
533
+ return new Response(
534
+ JSON.stringify({ error: { message: "Internal server error" } }),
535
+ {
536
+ status: 500,
537
+ headers: { "Content-Type": "application/json" }
538
+ }
539
+ );
540
+ } finally {
541
+ activeConnections--;
542
+ requests++;
543
+ }
544
+ }
545
+ function dispatchChatResponse(source, resolvedAgent, abortController, wantsStream) {
546
+ return wantsStream ? streamChatResponse(source, resolvedAgent, abortController) : bufferedChatResponse(source, resolvedAgent);
547
+ }
548
+ async function parseChatBody(c) {
549
+ try {
550
+ const parsed = await c.req.json();
551
+ return assertJsonObject(parsed);
552
+ } catch (err) {
553
+ return c.json(
554
+ {
555
+ error: {
556
+ message: err instanceof Error ? err.message : "Invalid JSON"
557
+ }
558
+ },
559
+ 400
560
+ );
561
+ }
562
+ }
563
+ async function handleChatRequest(c, body, agentName, defaultFallback) {
564
+ const validation = validateMessages(body["messages"]);
565
+ if (!validation.valid) {
566
+ errors++;
567
+ requests++;
568
+ return c.json({ error: { message: validation.error } }, 400);
569
+ }
570
+ let resolvedAgent = agentName;
571
+ if (!router.agents().includes(resolvedAgent) || !eligibleAgents.has(resolvedAgent)) {
572
+ if (defaultFallback && eligibleAgents.has(defaultAgentName)) {
573
+ resolvedAgent = defaultAgentName;
574
+ } else if (defaultFallback) {
575
+ errors++;
576
+ requests++;
577
+ return c.json(
578
+ { error: { message: "No default agent configured" } },
579
+ 500
580
+ );
581
+ } else {
582
+ errors++;
583
+ requests++;
584
+ return c.json(
585
+ { error: { message: `Agent "${agentName}" not found` } },
586
+ 404
587
+ );
588
+ }
589
+ }
590
+ const chatReq = {
591
+ messages: validation.messages,
592
+ ...typeof body["model"] === "string" ? { model: body["model"] } : {},
593
+ ...typeof body["stream"] === "boolean" ? { stream: body["stream"] } : {}
594
+ };
595
+ const abortController = new AbortController();
596
+ const handler = getHandlerFromRouter(router, resolvedAgent);
597
+ if (handler === void 0) {
598
+ errors++;
599
+ requests++;
600
+ return c.json(
601
+ {
602
+ error: { message: `Agent "${resolvedAgent}" handler not accessible` }
603
+ },
604
+ 500
605
+ );
606
+ }
607
+ const source = invokeHandlerAsStream(
608
+ handler,
609
+ chatReq.messages,
610
+ abortController
611
+ );
612
+ const wantsStream = chatReq.stream === true;
613
+ return dispatchChatResponse(
614
+ source,
615
+ resolvedAgent,
616
+ abortController,
617
+ wantsStream
618
+ );
619
+ }
620
+ if (routeFlags.perAgent) {
621
+ app.post("/agents/:name/chat", async (c) => {
622
+ const body = await parseChatBody(c);
623
+ if (body instanceof Response) {
624
+ errors++;
625
+ requests++;
626
+ return body;
627
+ }
628
+ const name = c.req.param("name");
629
+ return handleChatRequest(c, body, name, false);
630
+ });
631
+ }
632
+ if (routeFlags.openai) {
633
+ app.post("/v1/chat/completions", async (c) => {
634
+ const body = await parseChatBody(c);
635
+ if (body instanceof Response) {
636
+ errors++;
637
+ requests++;
638
+ return body;
639
+ }
640
+ const agentName = typeof body["model"] === "string" && body["model"] !== "" ? body["model"] : defaultAgentName;
641
+ return handleChatRequest(c, body, agentName, true);
642
+ });
643
+ }
644
+ if (routeFlags.defaultAgent) {
645
+ app.post("/chat", async (c) => {
646
+ const body = await parseChatBody(c);
647
+ if (body instanceof Response) {
648
+ errors++;
649
+ requests++;
650
+ return body;
651
+ }
652
+ return handleChatRequest(c, body, defaultAgentName, true);
653
+ });
654
+ }
655
+ if (routeFlags.discovery) {
656
+ app.get("/agents", (c) => {
657
+ requests++;
658
+ const agents = router.agents().filter((n) => eligibleAgents.has(n)).map((n) => ({
659
+ name: n,
660
+ description: router.describe(n)?.description,
661
+ default: n === defaultAgentName
662
+ }));
663
+ return c.json({ agents });
664
+ });
665
+ }
666
+ return {
667
+ app,
668
+ listen: (port) => lifecycle.listen(port ?? options?.port ?? 3e3),
669
+ close: lifecycle.close
670
+ };
671
+ }
672
+
673
+ // src/index.ts
674
+ import { createRouter, assembleManifest } from "@rcrsr/rill-agent";
675
+ var HARNESS_NAME = "@rcrsr/rill-agent-chat";
676
+ var harness = {
677
+ name: HARNESS_NAME,
678
+ postBuild: async (ctx) => {
679
+ assertCompiledHandlers(ctx);
680
+ },
681
+ serve: (ctx) => runRillServe(ctx, async (entries) => {
682
+ const router = await createRouter(await assembleManifest(entries));
683
+ const port = readHarnessPort(ctx.config, 3e3);
684
+ const server = createChatHarness(router, { port });
685
+ await server.listen(port);
686
+ ctx.logger.info(`[${HARNESS_NAME}] listening on :${port}`);
687
+ return server;
688
+ })
689
+ };
690
+ var index_default = harness;
691
+ export {
692
+ ChatSignatureError,
693
+ createChatHarness,
694
+ index_default as default,
695
+ inspectChatHandler,
696
+ validateMessages
697
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@rcrsr/rill-agent-chat",
3
+ "version": "0.20.0",
4
+ "description": "rill agent chat harness — Hono-based chat endpoint wrapping AgentRouter",
5
+ "license": "MIT",
6
+ "author": "Andre Bremer",
7
+ "type": "module",
8
+ "rill": {
9
+ "role": "harness"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup && dts-bundle-generator --config dts-bundle-generator.config.cjs && node -e \"const fs=require('fs');const walk=(d)=>fs.readdirSync(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(d+'/'+e.name):[d+'/'+e.name]);const hits=walk('dist').filter(f=>fs.readFileSync(f,'utf8').includes('@rcrsr/rill-agent-hono-kit'));if(hits.length){console.error('hono-kit leak in dist:',hits);process.exit(1);}\"",
19
+ "test": "vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "lint": "oxlint --config ../../../.oxlintrc.json src/ tests/",
22
+ "check": "pnpm run build && pnpm run test && pnpm run lint"
23
+ },
24
+ "dependencies": {
25
+ "@rcrsr/rill-agent": "workspace:~",
26
+ "@hono/node-server": "^2.0.1",
27
+ "hono": "^4.12.16"
28
+ },
29
+ "devDependencies": {
30
+ "@rcrsr/rill-agent-hono-kit": "workspace:^",
31
+ "dts-bundle-generator": "^9.5.1",
32
+ "tsup": "^8.5.0"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/rcrsr/rill-agent.git",
43
+ "directory": "packages/agent/chat"
44
+ }
45
+ }