@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/LICENSE +21 -0
- package/README.md +86 -2
- package/dist/agent-events.d.ts +32 -0
- package/dist/agent-events.d.ts.map +1 -0
- package/dist/ai-request.d.ts +32 -0
- package/dist/ai-request.d.ts.map +1 -0
- package/dist/events.d.ts +134 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +230 -0
- package/dist/index.js.map +14 -0
- package/dist/user-message.d.ts +16 -0
- package/dist/user-message.d.ts.map +1 -0
- package/dist/wire.d.ts +275 -0
- package/dist/wire.d.ts.map +1 -0
- package/docs/README.md +9 -0
- package/docs/guide/request-and-sse.md +224 -0
- package/docs/roadmap.md +27 -0
- package/docs/troubleshooting.md +68 -0
- package/package.json +66 -5
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`.
|
package/docs/roadmap.md
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|