@nxgt/ai-alxia 0.0.0-stage → 0.1.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/dist/wire.d.ts ADDED
@@ -0,0 +1,275 @@
1
+ import { type AgentEvent } from '@nxgt/ai-core';
2
+ import { z } from 'zod';
3
+ /** The code a client sees for a failure that has none of its own. */
4
+ export declare const INTERNAL_ERROR = "INTERNAL_ERROR";
5
+ /** The code of a thrown value: an `AiError`'s, else `INTERNAL_ERROR`. */
6
+ export declare function codeOf(error: unknown): string;
7
+ /** A tool result as sent: a failure is its code, with no message and no issues. */
8
+ export declare const wireToolResultSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
9
+ isError: z.ZodLiteral<false>;
10
+ output: z.ZodUnknown;
11
+ type: z.ZodLiteral<"tool-result">;
12
+ callId: z.ZodString;
13
+ name: z.ZodString;
14
+ }, z.core.$strip>, z.ZodObject<{
15
+ isError: z.ZodLiteral<true>;
16
+ error: z.ZodObject<{
17
+ code: z.ZodString;
18
+ }, z.core.$strip>;
19
+ type: z.ZodLiteral<"tool-result">;
20
+ callId: z.ZodString;
21
+ name: z.ZodString;
22
+ }, z.core.$strip>], "isError">;
23
+ /** Each wire event by its `type`, which is also its SSE `event:` name. */
24
+ export declare const wireEventSchemas: {
25
+ readonly 'step-start': z.ZodObject<{
26
+ type: z.ZodLiteral<"step-start">;
27
+ step: z.ZodNumber;
28
+ runId: z.ZodString;
29
+ }, z.core.$strip>;
30
+ readonly 'text-delta': z.ZodObject<{
31
+ type: z.ZodLiteral<"text-delta">;
32
+ text: z.ZodString;
33
+ start: z.ZodOptional<z.ZodLiteral<true>>;
34
+ }, z.core.$strip>;
35
+ readonly 'thinking-delta': z.ZodObject<{
36
+ type: z.ZodLiteral<"thinking-delta">;
37
+ text: z.ZodString;
38
+ start: z.ZodOptional<z.ZodLiteral<true>>;
39
+ signature: z.ZodOptional<z.ZodString>;
40
+ redacted: z.ZodOptional<z.ZodString>;
41
+ }, z.core.$strip>;
42
+ readonly 'tool-call': z.ZodObject<{
43
+ type: z.ZodLiteral<"tool-call">;
44
+ id: z.ZodString;
45
+ name: z.ZodString;
46
+ input: z.ZodUnknown;
47
+ }, z.core.$strip>;
48
+ readonly 'tool-result': z.ZodObject<{
49
+ type: z.ZodLiteral<"tool-result">;
50
+ result: z.ZodDiscriminatedUnion<[z.ZodObject<{
51
+ isError: z.ZodLiteral<false>;
52
+ output: z.ZodUnknown;
53
+ type: z.ZodLiteral<"tool-result">;
54
+ callId: z.ZodString;
55
+ name: z.ZodString;
56
+ }, z.core.$strip>, z.ZodObject<{
57
+ isError: z.ZodLiteral<true>;
58
+ error: z.ZodObject<{
59
+ code: z.ZodString;
60
+ }, z.core.$strip>;
61
+ type: z.ZodLiteral<"tool-result">;
62
+ callId: z.ZodString;
63
+ name: z.ZodString;
64
+ }, z.core.$strip>], "isError">;
65
+ }, z.core.$strip>;
66
+ readonly 'proposed-action': z.ZodObject<{
67
+ type: z.ZodLiteral<"proposed-action">;
68
+ action: z.ZodObject<{
69
+ type: z.ZodLiteral<"proposed-action">;
70
+ id: z.ZodString;
71
+ tool: z.ZodString;
72
+ input: z.ZodUnknown;
73
+ summary: z.ZodOptional<z.ZodString>;
74
+ expiresAt: z.ZodNumber;
75
+ }, z.core.$strip>;
76
+ }, z.core.$strip>;
77
+ readonly message: z.ZodObject<{
78
+ type: z.ZodLiteral<"message">;
79
+ message: z.ZodObject<{
80
+ role: z.ZodEnum<{
81
+ tool: "tool";
82
+ user: "user";
83
+ assistant: "assistant";
84
+ }>;
85
+ parts: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
86
+ type: z.ZodLiteral<"text">;
87
+ text: z.ZodString;
88
+ }, z.core.$strip>, z.ZodObject<{
89
+ type: z.ZodLiteral<"thinking">;
90
+ text: z.ZodString;
91
+ signature: z.ZodOptional<z.ZodString>;
92
+ redacted: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strip>, z.ZodObject<{
94
+ type: z.ZodLiteral<"tool-call">;
95
+ id: z.ZodString;
96
+ name: z.ZodString;
97
+ input: z.ZodUnknown;
98
+ }, z.core.$strip>, z.ZodDiscriminatedUnion<[z.ZodObject<{
99
+ isError: z.ZodLiteral<false>;
100
+ output: z.ZodUnknown;
101
+ type: z.ZodLiteral<"tool-result">;
102
+ callId: z.ZodString;
103
+ name: z.ZodString;
104
+ }, z.core.$strip>, z.ZodObject<{
105
+ isError: z.ZodLiteral<true>;
106
+ error: z.ZodObject<{
107
+ code: z.ZodString;
108
+ }, z.core.$strip>;
109
+ type: z.ZodLiteral<"tool-result">;
110
+ callId: z.ZodString;
111
+ name: z.ZodString;
112
+ }, z.core.$strip>], "isError">, z.ZodObject<{
113
+ type: z.ZodLiteral<"proposed-action">;
114
+ id: z.ZodString;
115
+ tool: z.ZodString;
116
+ input: z.ZodUnknown;
117
+ summary: z.ZodOptional<z.ZodString>;
118
+ expiresAt: z.ZodNumber;
119
+ }, z.core.$strip>]>>;
120
+ }, z.core.$strip>;
121
+ }, z.core.$strip>;
122
+ readonly usage: z.ZodObject<{
123
+ type: z.ZodLiteral<"usage">;
124
+ usage: z.ZodObject<{
125
+ inputTokens: z.ZodNumber;
126
+ outputTokens: z.ZodNumber;
127
+ cacheReadTokens: z.ZodNumber;
128
+ cacheWriteTokens: z.ZodNumber;
129
+ }, z.core.$strip>;
130
+ }, z.core.$strip>;
131
+ readonly error: z.ZodObject<{
132
+ type: z.ZodLiteral<"error">;
133
+ error: z.ZodObject<{
134
+ code: z.ZodString;
135
+ }, z.core.$strip>;
136
+ }, z.core.$strip>;
137
+ readonly done: z.ZodObject<{
138
+ type: z.ZodLiteral<"done">;
139
+ reason: z.ZodString;
140
+ usage: z.ZodObject<{
141
+ inputTokens: z.ZodNumber;
142
+ outputTokens: z.ZodNumber;
143
+ cacheReadTokens: z.ZodNumber;
144
+ cacheWriteTokens: z.ZodNumber;
145
+ }, z.core.$strip>;
146
+ }, z.core.$strip>;
147
+ };
148
+ /**
149
+ * Validates an event as read from the stream (unlike `agentEventSchema` of
150
+ * `@nxgt/ai-core`, which wants the `message` the wire leaves out).
151
+ */
152
+ export declare const wireEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
153
+ type: z.ZodLiteral<"step-start">;
154
+ step: z.ZodNumber;
155
+ runId: z.ZodString;
156
+ }, z.core.$strip>, z.ZodObject<{
157
+ type: z.ZodLiteral<"text-delta">;
158
+ text: z.ZodString;
159
+ start: z.ZodOptional<z.ZodLiteral<true>>;
160
+ }, z.core.$strip>, z.ZodObject<{
161
+ type: z.ZodLiteral<"thinking-delta">;
162
+ text: z.ZodString;
163
+ start: z.ZodOptional<z.ZodLiteral<true>>;
164
+ signature: z.ZodOptional<z.ZodString>;
165
+ redacted: z.ZodOptional<z.ZodString>;
166
+ }, z.core.$strip>, z.ZodObject<{
167
+ type: z.ZodLiteral<"tool-call">;
168
+ id: z.ZodString;
169
+ name: z.ZodString;
170
+ input: z.ZodUnknown;
171
+ }, z.core.$strip>, z.ZodObject<{
172
+ type: z.ZodLiteral<"tool-result">;
173
+ result: z.ZodDiscriminatedUnion<[z.ZodObject<{
174
+ isError: z.ZodLiteral<false>;
175
+ output: z.ZodUnknown;
176
+ type: z.ZodLiteral<"tool-result">;
177
+ callId: z.ZodString;
178
+ name: z.ZodString;
179
+ }, z.core.$strip>, z.ZodObject<{
180
+ isError: z.ZodLiteral<true>;
181
+ error: z.ZodObject<{
182
+ code: z.ZodString;
183
+ }, z.core.$strip>;
184
+ type: z.ZodLiteral<"tool-result">;
185
+ callId: z.ZodString;
186
+ name: z.ZodString;
187
+ }, z.core.$strip>], "isError">;
188
+ }, z.core.$strip>, z.ZodObject<{
189
+ type: z.ZodLiteral<"proposed-action">;
190
+ action: z.ZodObject<{
191
+ type: z.ZodLiteral<"proposed-action">;
192
+ id: z.ZodString;
193
+ tool: z.ZodString;
194
+ input: z.ZodUnknown;
195
+ summary: z.ZodOptional<z.ZodString>;
196
+ expiresAt: z.ZodNumber;
197
+ }, z.core.$strip>;
198
+ }, z.core.$strip>, z.ZodObject<{
199
+ type: z.ZodLiteral<"message">;
200
+ message: z.ZodObject<{
201
+ role: z.ZodEnum<{
202
+ tool: "tool";
203
+ user: "user";
204
+ assistant: "assistant";
205
+ }>;
206
+ parts: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
207
+ type: z.ZodLiteral<"text">;
208
+ text: z.ZodString;
209
+ }, z.core.$strip>, z.ZodObject<{
210
+ type: z.ZodLiteral<"thinking">;
211
+ text: z.ZodString;
212
+ signature: z.ZodOptional<z.ZodString>;
213
+ redacted: z.ZodOptional<z.ZodString>;
214
+ }, z.core.$strip>, z.ZodObject<{
215
+ type: z.ZodLiteral<"tool-call">;
216
+ id: z.ZodString;
217
+ name: z.ZodString;
218
+ input: z.ZodUnknown;
219
+ }, z.core.$strip>, z.ZodDiscriminatedUnion<[z.ZodObject<{
220
+ isError: z.ZodLiteral<false>;
221
+ output: z.ZodUnknown;
222
+ type: z.ZodLiteral<"tool-result">;
223
+ callId: z.ZodString;
224
+ name: z.ZodString;
225
+ }, z.core.$strip>, z.ZodObject<{
226
+ isError: z.ZodLiteral<true>;
227
+ error: z.ZodObject<{
228
+ code: z.ZodString;
229
+ }, z.core.$strip>;
230
+ type: z.ZodLiteral<"tool-result">;
231
+ callId: z.ZodString;
232
+ name: z.ZodString;
233
+ }, z.core.$strip>], "isError">, z.ZodObject<{
234
+ type: z.ZodLiteral<"proposed-action">;
235
+ id: z.ZodString;
236
+ tool: z.ZodString;
237
+ input: z.ZodUnknown;
238
+ summary: z.ZodOptional<z.ZodString>;
239
+ expiresAt: z.ZodNumber;
240
+ }, z.core.$strip>]>>;
241
+ }, z.core.$strip>;
242
+ }, z.core.$strip>, z.ZodObject<{
243
+ type: z.ZodLiteral<"usage">;
244
+ usage: z.ZodObject<{
245
+ inputTokens: z.ZodNumber;
246
+ outputTokens: z.ZodNumber;
247
+ cacheReadTokens: z.ZodNumber;
248
+ cacheWriteTokens: z.ZodNumber;
249
+ }, z.core.$strip>;
250
+ }, z.core.$strip>, z.ZodObject<{
251
+ type: z.ZodLiteral<"error">;
252
+ error: z.ZodObject<{
253
+ code: z.ZodString;
254
+ }, z.core.$strip>;
255
+ }, z.core.$strip>, z.ZodObject<{
256
+ type: z.ZodLiteral<"done">;
257
+ reason: z.ZodString;
258
+ usage: z.ZodObject<{
259
+ inputTokens: z.ZodNumber;
260
+ outputTokens: z.ZodNumber;
261
+ cacheReadTokens: z.ZodNumber;
262
+ cacheWriteTokens: z.ZodNumber;
263
+ }, z.core.$strip>;
264
+ }, z.core.$strip>], "type">;
265
+ /** An event as the client reads it: errors are codes, never messages. */
266
+ export type WireEvent = z.infer<typeof wireEventSchema>;
267
+ export type WireToolResult = z.infer<typeof wireToolResultSchema>;
268
+ /**
269
+ * The event a client may read. Everywhere an event holds an error (an
270
+ * `error` event, a failed `tool-result`, a failed result inside a `message`)
271
+ * only its `code` is kept: messages and issues can hold what a provider, a
272
+ * tool or an exception said, so they stay on the server.
273
+ */
274
+ export declare function toWireEvent(event: AgentEvent): WireEvent;
275
+ //# sourceMappingURL=wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire.d.ts","sourceRoot":"","sources":["../src/wire.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,UAAU,EAKf,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,qEAAqE;AACrE,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C,yEAAyE;AACzE,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAE7C;AAiBD,mFAAmF;AACnF,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;8BAW/B,CAAC;AAkCH,0EAA0E;AAC1E,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCnB,CAAC;AAEX;;;GAGG;AACH,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAW1B,CAAC;AAEH,yEAAyE;AACzE,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AACxD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAgBlE;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,SAAS,CAWxD"}
package/docs/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # `@nxgt/ai-alxia` documentation
2
+
3
+ | Page | Read it when |
4
+ | ------------------------------------------- | ---------------------------------------------------------------------------------------------- |
5
+ | [Request and SSE](guide/request-and-sse.md) | you serve an agent from an alxia route: abort, language, observers, the event stream, a client |
6
+ | [Troubleshooting](troubleshooting.md) | you meet an error and want its cause and fix |
7
+ | [Roadmap](roadmap.md) | you want to know what is coming |
8
+
9
+ The [README](../README.md) is the short version.
@@ -0,0 +1,224 @@
1
+ # Request and SSE
2
+
3
+ An agent behind an alxia route needs three things from the request (a signal
4
+ that fires when the client leaves, its language, the app's observers) and a way
5
+ to stream events. `aiRequest` gives the first three, `agentEvents` the stream.
6
+
7
+ - [aiRequest](#airequest)
8
+ - [The abort signal](#the-abort-signal)
9
+ - [The language](#the-language)
10
+ - [Observers](#observers)
11
+ - [agentEvents](#agentevents)
12
+ - [What a client reads](#what-a-client-reads)
13
+ - [A request body and memory](#a-request-body-and-memory)
14
+ - [Errors carry codes only](#errors-carry-codes-only)
15
+ - [Heartbeat](#heartbeat)
16
+ - [Test your route](#test-your-route)
17
+
18
+ ## aiRequest
19
+
20
+ ```ts
21
+ import { alxia } from '@alxia/core';
22
+ import { language } from '@alxia/language';
23
+ import { aiRequest } from '@nxgt/ai-alxia';
24
+
25
+ const app = alxia()
26
+ .use(language({ supported: ['en', 'fr'] as const, fallback: 'en' }))
27
+ .use(aiRequest({ observers: [] }))
28
+ .get('/who', ({ ai, reply }) => reply(200, ai.language));
29
+ ```
30
+
31
+ After it, every handler reads `ai`: `{ signal, language, observers }`. The
32
+ three keys are those of `runAgent`'s options, so `...ai` spreads them.
33
+
34
+ ## The abort signal
35
+
36
+ `ai.signal` is the request's own signal, which alxia aborts when the client
37
+ hangs up. `...ai` gives it to `runAgent` (the model stream and running tools
38
+ stop); `agentEvents(run, ai)` stops the stream at once and aborts the run:
39
+
40
+ ```ts
41
+ return reply(200, agentEvents(runAgent({ ...ai, model, messages }), ai));
42
+ ```
43
+
44
+ ## The language
45
+
46
+ `ai.language` is what `@alxia/language` or `@alxia/i18n` decided (query,
47
+ cookie, path, `Accept-Language`, then the fallback). Use it in the system
48
+ prompt or to pick a tool's wording:
49
+
50
+ ```ts
51
+ runAgent({ ...ai, model, system: `Answer in ${ai.language}.`, messages });
52
+ ```
53
+
54
+ ## Observers
55
+
56
+ `aiRequest({ observers })` holds the observers once for the app. Each request
57
+ gets the same array, so an observer serving concurrent runs must key by `runId`
58
+ (the `AgentObserver` hooks all receive it).
59
+
60
+ ```ts
61
+ import { aiTelemetry } from '@nxgt/ai-telemetry';
62
+
63
+ alxia().use(aiRequest({ observers: [aiTelemetry()] }));
64
+ ```
65
+
66
+ ## agentEvents
67
+
68
+ `agentEvents(run, { signal?, heartbeatMs? })` is an async generator for
69
+ `reply(200, …)`. alxia turns it into `text/event-stream` (`no-cache`,
70
+ `x-accel-buffering: no`) and closes it when the client leaves. Declare the
71
+ route with `agentEventStream` so each event is written under its name:
72
+
73
+ ```ts
74
+ app.post('/chat', responds({ 200: agentEventStream }), ({ ai, reply }) =>
75
+ reply(200, agentEvents(runAgent({ ...ai, model, messages }), ai)),
76
+ );
77
+ ```
78
+
79
+ It ends after `done`, and aborts the run when it is closed or the signal aborts.
80
+
81
+ ## What a client reads
82
+
83
+ One block per event: `event:` is the `AgentEvent` type, `data:` its JSON, on one
84
+ line (JSON escapes line breaks, so text cannot forge another field).
85
+
86
+ ```text
87
+ event: text-delta
88
+ data: {"type":"text-delta","text":"Hello"}
89
+
90
+ event: done
91
+ data: {"type":"done","reason":"end","usage":{"inputTokens":3,"outputTokens":2,"cacheReadTokens":0,"cacheWriteTokens":0}}
92
+ ```
93
+
94
+ `wireEventSchema` validates one event; `agentEventSchema` of `@nxgt/ai-core`
95
+ does not parse the wire (it wants the error `message`). `EventSource` only does
96
+ GET, so for a POST read the body of `fetch`:
97
+
98
+ ```ts
99
+ import { type WireEvent, wireEventSchema } from '@nxgt/ai-alxia';
100
+
101
+ async function chat(
102
+ url: string,
103
+ body: unknown,
104
+ onEvent: (event: WireEvent) => void,
105
+ ) {
106
+ const response = await fetch(url, {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify(body),
110
+ });
111
+ if (!response.ok || !response.body)
112
+ throw new Error(`HTTP ${response.status}`);
113
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
114
+ let buffer = '';
115
+ for (;;) {
116
+ const { done, value } = await reader.read();
117
+ if (done) return;
118
+ buffer += value;
119
+ let end: number;
120
+ while ((end = buffer.indexOf('\n\n')) !== -1) {
121
+ const block = buffer.slice(0, end);
122
+ buffer = buffer.slice(end + 2);
123
+ // a block is `event:` and `data:` lines; a `:` line is a comment
124
+ const data = block.split('\n').find((line) => line.startsWith('data: '));
125
+ if (!data) continue;
126
+ const event = wireEventSchema.parse(JSON.parse(data.slice(6)));
127
+ onEvent(event);
128
+ if (event.type === 'done') return reader.cancel();
129
+ }
130
+ }
131
+ }
132
+
133
+ await chat('/chat', { question: 'Hi' }, (event) => {
134
+ switch (event.type) {
135
+ case 'text-delta':
136
+ console.log(event.text);
137
+ break;
138
+ case 'error':
139
+ console.error('failed:', event.error.code); // a code, never a message
140
+ break;
141
+ default:
142
+ break; // keep a default: newer servers add event types
143
+ }
144
+ });
145
+ ```
146
+
147
+ A GET route with the conversation id in the path works with `EventSource` too.
148
+
149
+ ## A request body and memory
150
+
151
+ Never append parts from the client's request body to a memory store. A client
152
+ could send a forged `proposed-action` part; the store would record it as a
153
+ pending action that anyone can confirm. Append only the user's text, plus the
154
+ messages the run produced (`run.result.messages`). Parse the body with
155
+ `userMessageSchema`, which accepts a `user` message of text parts only and
156
+ refuses every other part.
157
+
158
+ ```ts
159
+ import { responds } from '@alxia/core';
160
+ import { runAgent } from '@nxgt/ai-core';
161
+ import { z } from 'zod';
162
+ import {
163
+ agentEvents,
164
+ agentEventStream,
165
+ userMessageSchema,
166
+ } from '@nxgt/ai-alxia';
167
+
168
+ // `app`, `model` and `store` are those of the first example and your memory store.
169
+ const body = z.object({ threadId: z.string(), message: userMessageSchema });
170
+
171
+ app.post(
172
+ '/chat',
173
+ { body },
174
+ responds({ 200: agentEventStream }),
175
+ ({ ai, body, user, reply }) => {
176
+ const { threadId, message } = body;
177
+ async function* stream() {
178
+ const history = (await store.load(threadId, user.id))?.messages ?? [];
179
+ const run = runAgent({ ...ai, model, messages: [...history, message] });
180
+ yield* agentEvents(run, ai);
181
+ // after the stream: the user's text, then what the run produced
182
+ await store.append(threadId, user.id, [
183
+ message,
184
+ ...(await run.result).messages,
185
+ ]);
186
+ }
187
+ return reply(200, stream());
188
+ },
189
+ );
190
+ ```
191
+
192
+ `user` is whatever your auth middleware puts on the context; the store is
193
+ scoped by its id on every call.
194
+
195
+ ## Errors carry codes only
196
+
197
+ An `error` event is `{ "type": "error", "error": { "code": "RATE_LIMITED" } }`.
198
+ The message of a run error can hold what a provider or a tool said, so it
199
+ stays in the server's logs and observers. Every failed tool result, alone or inside a `message` event, is reduced the
200
+ same way. A failure of the stream
201
+ itself ends it with `INTERNAL_ERROR`, or the code of the `AiError`.
202
+ `toWireEvent` is the function that does it, if you write your own transport.
203
+
204
+ ## Heartbeat
205
+
206
+ alxia writes `: keep-alive` after eight silent seconds. A proxy that cuts idle
207
+ connections sooner needs your own period:
208
+
209
+ ```ts
210
+ agentEvents(run, { ...ai, heartbeatMs: 10_000 });
211
+ ```
212
+
213
+ A comment is a line `EventSource` skips; it is not an event and moves no id.
214
+
215
+ ## Test your route
216
+
217
+ ```ts
218
+ const response = await app.request('/chat?lang=fr', { method: 'POST' });
219
+ const body = await response.text();
220
+ expect(body).toContain('event: done');
221
+ ```
222
+
223
+ Abort test: pass `{ signal }` to `app.request`, read one chunk, call
224
+ `controller.abort()`, and check that an observer's `onRunEnd` saw `aborted`.
@@ -0,0 +1,27 @@
1
+ # Roadmap
2
+
3
+ A direction, not a commitment. See the [repository roadmap](https://github.com/softistx/nxgt-ai/blob/develop/docs/roadmap.md) for the whole SDK.
4
+
5
+ ## Now
6
+
7
+ - **Request middleware and SSE reply** — `aiRequest` and `agentEvents`: you get:
8
+ - `ctx.ai = { signal, language, observers }` to spread into `runAgent`, the signal aborting when the client leaves;
9
+ - a run's events as named Server-Sent Events written by alxia, typed by `agentEventStream`, with an optional heartbeat comment;
10
+ - errors on the wire as codes only, and the run stopped on disconnect.
11
+
12
+ ## Next
13
+
14
+ Nothing yet.
15
+
16
+ ## Later
17
+
18
+ - **A client reader** — an async iterable of `AgentEvent` from a `fetch` response, for a browser or another service.
19
+
20
+ ## Not planned
21
+
22
+ - **Its own SSE writer** — alxia's `eventStream` already frames, validates and keeps alive.
23
+ - **WebSocket transport** — SSE is enough for a one-way stream of events.
24
+
25
+ ## Shipped
26
+
27
+ Nothing yet. Releases are listed in `CHANGELOG.md`.
@@ -0,0 +1,68 @@
1
+ # Troubleshooting
2
+
3
+ - [A compile error on `.use(aiRequest())`](#a-compile-error-on-useairequest)
4
+ - [`ai.language` is always the fallback](#ailanguage-is-always-the-fallback)
5
+ - [The run goes on after the client left](#the-run-goes-on-after-the-client-left)
6
+ - [The client sees no error message](#the-client-sees-no-error-message)
7
+ - [`agentEventSchema` fails on an event](#agenteventschema-fails-on-an-event)
8
+ - [The events arrive all at once](#the-events-arrive-all-at-once)
9
+ - [The connection closes while the model thinks](#the-connection-closes-while-the-model-thinks)
10
+ - [`EventSource` cannot call my POST route](#eventsource-cannot-call-my-post-route)
11
+
12
+ ## A compile error on `.use(aiRequest())`
13
+
14
+ **When:** TypeScript says the middleware requires `language`.
15
+ **Why:** `aiRequest` reads `language` from the context, and nothing before it adds it.
16
+ **Fix:** use `@alxia/language` (or `@alxia/i18n`) first.
17
+
18
+ ```ts
19
+ alxia()
20
+ .use(language({ supported: ['en'] as const, fallback: 'en' }))
21
+ .use(aiRequest());
22
+ ```
23
+
24
+ ## `ai.language` is always the fallback
25
+
26
+ **When:** `?lang=fr` or `Accept-Language: fr` still gives the fallback.
27
+ **Why:** the language middleware decides, not `aiRequest`: the language is not in `supported`, or its `order` leaves the source out.
28
+ **Fix:** check `supported` and `order` of the language middleware; `languageSource` says which source decided.
29
+
30
+ ## The run goes on after the client left
31
+
32
+ **When:** the model keeps streaming, or tools keep running, after a disconnect.
33
+ **Why:** the run was not given the request's signal, or `agentEvents` was not (a run waiting on a model is only seen by the signal).
34
+ **Fix:** write the pair: spread `ai` into `runAgent` and pass `ai` to `agentEvents`.
35
+
36
+ ```ts
37
+ reply(200, agentEvents(runAgent({ ...ai, model, messages }), ai));
38
+ ```
39
+
40
+ ## The client sees no error message
41
+
42
+ **When:** an `error` event is `{ "code": "MODEL_ERROR" }` with nothing else, or `INTERNAL_ERROR`.
43
+ **Why:** by design: messages can hold provider or tool details. `INTERNAL_ERROR` means the stream itself failed with something that is not an `AiError` (a bug or an unexpected exception on the server).
44
+ **Fix:** map the code to your own text on the client; for the cause, check the server logs or an observer (`onRunEnd`'s `error`).
45
+
46
+ ## `agentEventSchema` fails on an event
47
+
48
+ **When:** `agentEventSchema.parse` from `@nxgt/ai-core` rejects an `error` event or a failed tool result.
49
+ **Why:** that schema requires `message`; the wire has none.
50
+ **Fix:** parse with `wireEventSchema` from `@nxgt/ai-alxia`.
51
+
52
+ ## The events arrive all at once
53
+
54
+ **When:** the client gets the whole answer at the end.
55
+ **Why:** a proxy buffers the response, or the client buffers the body. alxia sends `x-accel-buffering: no` for nginx; other proxies have their own switch. Compression middleware can buffer too.
56
+ **Fix:** disable buffering for the route in the proxy, and keep a compression middleware off `text/event-stream`.
57
+
58
+ ## The connection closes while the model thinks
59
+
60
+ **When:** the stream drops after some seconds of silence, before any event.
61
+ **Why:** a proxy or load balancer cuts idle connections, more often than alxia's 8 s keep-alive.
62
+ **Fix:** `agentEvents(run, { ...ai, heartbeatMs: 5000 })`, below the proxy's idle timeout.
63
+
64
+ ## `EventSource` cannot call my POST route
65
+
66
+ **When:** `new EventSource('/chat')` gets a 404 or 405.
67
+ **Why:** `EventSource` sends GET and no body.
68
+ **Fix:** read `fetch(...).body` as a stream, or serve a GET route that takes the conversation id.
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
- "name": "@nxgt/ai-alxia",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@nxgt/ai-alxia",
3
+ "version": "0.1.0",
4
+ "description": "alxia glue for @nxgt/ai-core: a request middleware (abort signal, language, observers) and a Server-Sent Events reply that streams an agent run",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "ai",
26
+ "agent",
27
+ "llm",
28
+ "alxia",
29
+ "sse",
30
+ "server-sent-events",
31
+ "bun",
32
+ "typescript"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/softistx/nxgt-ai.git",
37
+ "directory": "packages/ai-alxia"
38
+ },
39
+ "publishConfig": {
40
+ "registry": "https://registry.npmjs.org",
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "bun run ../../build.ts",
45
+ "test": "bun test src",
46
+ "typecheck": "tsc --noEmit"
47
+ },
48
+ "nxgt": {
49
+ "entrypoints": [
50
+ "src/index.ts"
51
+ ]
52
+ },
53
+ "devDependencies": {
54
+ "@alxia/core": "^0.16.0",
55
+ "@alxia/language": "^0.3.12",
56
+ "@nxgt/ai-core": "^0.2.0",
57
+ "@nxgt/ai-testing": "^0.1.1",
58
+ "@types/bun": "^1.4.4",
59
+ "zod": "4.6.5"
60
+ },
61
+ "peerDependencies": {
62
+ "@alxia/core": ">=0.16.0 <0.17.0",
63
+ "@nxgt/ai-core": "^0.2.0",
64
+ "typescript": "^6.0.3 || ^7.0.0",
65
+ "zod": ">=4.6.5 <5"
66
+ }
67
+ }