@nanoagent/kernel 0.0.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 +678 -0
- package/dist/index.d.ts +407 -0
- package/dist/index.js +1136 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
# @nanoagent/kernel
|
|
2
|
+
|
|
3
|
+
Durable run loop for agent products.
|
|
4
|
+
|
|
5
|
+
Kernel owns execution state: turn sequencing, phase commits, pause/resume,
|
|
6
|
+
model calls, tool calls, stream events, middleware composition, and
|
|
7
|
+
cancellation. Product code owns prompts, memory, storage, model credentials,
|
|
8
|
+
tools, auth, sandboxing, UI, and policy.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @nanoagent/kernel
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Contract
|
|
15
|
+
|
|
16
|
+
`runAgent` advances one durable state machine. Nothing runs until caller
|
|
17
|
+
iterates returned async generator.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
runAgent({
|
|
21
|
+
state, // AgentRunState | { runId?, context }
|
|
22
|
+
hooks, // phase decisions
|
|
23
|
+
tools, // AI SDK ToolSet
|
|
24
|
+
modelProviders, // provider registry
|
|
25
|
+
middleware, // model/tool wrappers
|
|
26
|
+
saveState, // durable commit callback
|
|
27
|
+
signal, // cancellation
|
|
28
|
+
maxTurns
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Turn flow:
|
|
33
|
+
|
|
34
|
+
1. `onTurnPrepared` returns exact model input.
|
|
35
|
+
2. Kernel calls model and yields `stream_part` events.
|
|
36
|
+
3. Kernel commits model result and extracts tool calls.
|
|
37
|
+
4. Hooks accept, rewrite, skip, pause, finish, or continue.
|
|
38
|
+
5. Middleware wraps model and tool I/O.
|
|
39
|
+
6. `saveState` receives revisioned `AgentRunState` plus commit events.
|
|
40
|
+
|
|
41
|
+
Persist `AgentRunState`, load it, pass it back to `runAgent`. Runtime values
|
|
42
|
+
like `tools`, `modelProviders`, `middleware`, `saveState`, and `signal` are
|
|
43
|
+
process-local and recreated per call.
|
|
44
|
+
|
|
45
|
+
## Small Run
|
|
46
|
+
|
|
47
|
+
`onTurnPrepared` supplies current prompt. `onTurnCompleted` decides which model
|
|
48
|
+
output enters caller memory.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import type { ModelMessage } from 'ai'
|
|
52
|
+
import {
|
|
53
|
+
type AgentRunState,
|
|
54
|
+
type AgentStreamEvent,
|
|
55
|
+
type JsonLike,
|
|
56
|
+
runAgent
|
|
57
|
+
} from '@nanoagent/kernel'
|
|
58
|
+
|
|
59
|
+
type Context = {
|
|
60
|
+
[key: string]: JsonLike
|
|
61
|
+
sessionId: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Store = {
|
|
65
|
+
load(runId: string): Promise<AgentRunState<Context> | undefined>
|
|
66
|
+
save(state: AgentRunState<Context>): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Messages = {
|
|
70
|
+
load(sessionId: string): Promise<ModelMessage[]>
|
|
71
|
+
append(sessionId: string, messages: ModelMessage[]): Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runChat(params: {
|
|
75
|
+
emit(event: AgentStreamEvent): void
|
|
76
|
+
messages: Messages
|
|
77
|
+
runId: string
|
|
78
|
+
store: Store
|
|
79
|
+
}) {
|
|
80
|
+
const state = (await params.store.load(params.runId)) ?? {
|
|
81
|
+
runId: params.runId,
|
|
82
|
+
context: { sessionId: params.runId }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for await (const event of runAgent({
|
|
86
|
+
state,
|
|
87
|
+
maxTurns: 20,
|
|
88
|
+
saveState: ({ state }) => params.store.save(state),
|
|
89
|
+
hooks: {
|
|
90
|
+
onTurnPrepared: async ({ context }) => ({
|
|
91
|
+
value: {
|
|
92
|
+
model: 'openai/gpt-5.5',
|
|
93
|
+
messages: await params.messages.load(context.sessionId)
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
onTurnCompleted: async ({ context, turn }) => {
|
|
97
|
+
if (!turn.modelResult) return
|
|
98
|
+
|
|
99
|
+
await params.messages.append(
|
|
100
|
+
context.sessionId,
|
|
101
|
+
turn.modelResult.response.messages
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return { control: { type: 'finish', reason: 'model_done' } }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})) {
|
|
108
|
+
params.emit(event)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Kernel stores completed turns, but it does not rebuild future prompts. Caller
|
|
114
|
+
loads transcript, summaries, retrieved context, or prior turn output inside
|
|
115
|
+
`onTurnPrepared`.
|
|
116
|
+
|
|
117
|
+
## State
|
|
118
|
+
|
|
119
|
+
Fresh run state can be compact:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
{
|
|
123
|
+
runId: 'run_123',
|
|
124
|
+
context: {
|
|
125
|
+
sessionId: 'session_123'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Kernel expands it into durable `AgentRunState`:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
type AgentRunState<Context extends JsonLike> = {
|
|
134
|
+
runId: string
|
|
135
|
+
revision: number
|
|
136
|
+
status: AgentRunStatus
|
|
137
|
+
context: Context
|
|
138
|
+
turns: Turn[]
|
|
139
|
+
currentTurn?: Turn
|
|
140
|
+
updatedAt: string
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`revision` increments on every durable commit. `saveState.events` contains only
|
|
145
|
+
events for current commit, not full history. Persist state and events in same
|
|
146
|
+
transaction when ordering matters.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { type AgentSaveState, type JsonLike } from '@nanoagent/kernel'
|
|
150
|
+
|
|
151
|
+
type Context = {
|
|
152
|
+
[key: string]: JsonLike
|
|
153
|
+
sessionId: string
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type Pg = {
|
|
157
|
+
tx<T>(fn: (tx: Pg) => Promise<T>): Promise<T>
|
|
158
|
+
query(sql: string, values: unknown[]): Promise<void>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function saveToPostgres(pg: Pg): AgentSaveState<Context> {
|
|
162
|
+
return ({ events, state }) =>
|
|
163
|
+
pg.tx(async tx => {
|
|
164
|
+
await tx.query(
|
|
165
|
+
`INSERT INTO agent_runs (id, revision, state)
|
|
166
|
+
VALUES ($1, $2, $3)
|
|
167
|
+
ON CONFLICT (id) DO UPDATE
|
|
168
|
+
SET revision = $2, state = $3
|
|
169
|
+
WHERE agent_runs.revision < $2`,
|
|
170
|
+
[state.runId, state.revision, state]
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
await tx.query(
|
|
174
|
+
`INSERT INTO agent_events (run_id, revision, event)
|
|
175
|
+
SELECT $1, $2, event
|
|
176
|
+
FROM jsonb_array_elements($3::jsonb) AS event`,
|
|
177
|
+
[state.runId, state.revision, JSON.stringify(events)]
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Pause
|
|
184
|
+
|
|
185
|
+
Any hook can pause. Kernel commits paused state and exits generator. Later
|
|
186
|
+
process loads same state, updates caller-owned `context` if needed, and calls
|
|
187
|
+
`runAgent` again.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import type { ModelMessage, ToolSet } from 'ai'
|
|
191
|
+
import {
|
|
192
|
+
type AgentHooks,
|
|
193
|
+
type AgentRunState,
|
|
194
|
+
type JsonLike,
|
|
195
|
+
runAgent
|
|
196
|
+
} from '@nanoagent/kernel'
|
|
197
|
+
|
|
198
|
+
type Context = {
|
|
199
|
+
[key: string]: JsonLike
|
|
200
|
+
approvedToolCalls: string[]
|
|
201
|
+
sessionId: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type Store = {
|
|
205
|
+
load(runId: string): Promise<AgentRunState<Context> | undefined>
|
|
206
|
+
save(state: AgentRunState<Context>): Promise<void>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type Messages = {
|
|
210
|
+
load(sessionId: string): Promise<ModelMessage[]>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function hooks(messages: Messages): AgentHooks<Context> {
|
|
214
|
+
return {
|
|
215
|
+
onTurnPrepared: async ({ context }) => ({
|
|
216
|
+
value: {
|
|
217
|
+
model: 'openai/gpt-5.5',
|
|
218
|
+
messages: await messages.load(context.sessionId)
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
onToolCallStarted: ({ context, toolCallId, toolName }) => {
|
|
222
|
+
if (toolName !== 'ChargeCard') return
|
|
223
|
+
if (context.approvedToolCalls.includes(toolCallId)) return
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
control: {
|
|
227
|
+
type: 'pause',
|
|
228
|
+
reason: 'approval_required',
|
|
229
|
+
metadata: { toolCallId, toolName }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function processRun(params: {
|
|
237
|
+
messages: Messages
|
|
238
|
+
runId: string
|
|
239
|
+
store: Store
|
|
240
|
+
tools: ToolSet
|
|
241
|
+
}) {
|
|
242
|
+
const state = (await params.store.load(params.runId)) ?? {
|
|
243
|
+
runId: params.runId,
|
|
244
|
+
context: { approvedToolCalls: [], sessionId: params.runId }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await Array.fromAsync(
|
|
248
|
+
runAgent({
|
|
249
|
+
state,
|
|
250
|
+
tools: params.tools,
|
|
251
|
+
hooks: hooks(params.messages),
|
|
252
|
+
saveState: ({ state }) => params.store.save(state),
|
|
253
|
+
maxTurns: 20
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function approveToolCall(params: {
|
|
259
|
+
messages: Messages
|
|
260
|
+
runId: string
|
|
261
|
+
store: Store
|
|
262
|
+
toolCallId: string
|
|
263
|
+
tools: ToolSet
|
|
264
|
+
}) {
|
|
265
|
+
const state = await params.store.load(params.runId)
|
|
266
|
+
if (!state) throw new Error(`missing run: ${params.runId}`)
|
|
267
|
+
|
|
268
|
+
await params.store.save({
|
|
269
|
+
...state,
|
|
270
|
+
context: {
|
|
271
|
+
...state.context,
|
|
272
|
+
approvedToolCalls: [...state.context.approvedToolCalls, params.toolCallId]
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await processRun(params)
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Approval lives in `context` because product owns policy. Kernel preserves
|
|
281
|
+
execution position: phase, current turn, pending tool calls, in-flight tool
|
|
282
|
+
calls, completed tool responses, and prior turns.
|
|
283
|
+
|
|
284
|
+
## Tool Boundary
|
|
285
|
+
|
|
286
|
+
Hooks decide policy. Middleware wraps execution.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import type { ModelMessage, ToolSet } from 'ai'
|
|
290
|
+
import {
|
|
291
|
+
type AgentCallToolArgs,
|
|
292
|
+
type AgentHooks,
|
|
293
|
+
type AgentMiddleware,
|
|
294
|
+
type AgentToolCallResponse,
|
|
295
|
+
type JsonLike,
|
|
296
|
+
runAgent
|
|
297
|
+
} from '@nanoagent/kernel'
|
|
298
|
+
|
|
299
|
+
type Context = {
|
|
300
|
+
[key: string]: JsonLike
|
|
301
|
+
sessionId: string
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
type Messages = {
|
|
305
|
+
load(sessionId: string): Promise<ModelMessage[]>
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function hasCommand(input: unknown): input is { command: string } {
|
|
309
|
+
return (
|
|
310
|
+
typeof input === 'object' &&
|
|
311
|
+
input !== null &&
|
|
312
|
+
'command' in input &&
|
|
313
|
+
typeof input.command === 'string'
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hooks(messages: Messages): AgentHooks<Context> {
|
|
318
|
+
return {
|
|
319
|
+
onTurnPrepared: async ({ context }) => ({
|
|
320
|
+
value: {
|
|
321
|
+
model: 'openai/gpt-5.5',
|
|
322
|
+
messages: await messages.load(context.sessionId)
|
|
323
|
+
}
|
|
324
|
+
}),
|
|
325
|
+
onToolCallStarted: ({ input, toolCallId, toolName }) => {
|
|
326
|
+
if (toolName === 'DeleteAccount') {
|
|
327
|
+
return {
|
|
328
|
+
value: {
|
|
329
|
+
type: 'skip',
|
|
330
|
+
result: {
|
|
331
|
+
toolCallId,
|
|
332
|
+
toolName,
|
|
333
|
+
input,
|
|
334
|
+
error: 'blocked by policy'
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (toolName !== 'Bash') return
|
|
341
|
+
if (!hasCommand(input)) throw new Error('invalid Bash input')
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
value: {
|
|
345
|
+
toolCallId,
|
|
346
|
+
toolName,
|
|
347
|
+
input: { command: `sandbox ${JSON.stringify(input.command)}` }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const callTool: AgentMiddleware<
|
|
355
|
+
AgentCallToolArgs<Context>,
|
|
356
|
+
AgentToolCallResponse
|
|
357
|
+
> = async ({ input, next }) => {
|
|
358
|
+
if (
|
|
359
|
+
input.toolCall.toolName === 'WebFetch' &&
|
|
360
|
+
process.env.NODE_ENV === 'test'
|
|
361
|
+
) {
|
|
362
|
+
return {
|
|
363
|
+
toolCallId: input.toolCall.toolCallId,
|
|
364
|
+
toolName: input.toolCall.toolName,
|
|
365
|
+
input: input.toolCall.input,
|
|
366
|
+
output: { fixture: true }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return next(input)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function runWithTools(params: {
|
|
374
|
+
messages: Messages
|
|
375
|
+
runId: string
|
|
376
|
+
tools: ToolSet
|
|
377
|
+
}) {
|
|
378
|
+
await Array.fromAsync(
|
|
379
|
+
runAgent({
|
|
380
|
+
state: {
|
|
381
|
+
runId: params.runId,
|
|
382
|
+
context: { sessionId: params.runId }
|
|
383
|
+
},
|
|
384
|
+
tools: params.tools,
|
|
385
|
+
hooks: hooks(params.messages),
|
|
386
|
+
middleware: { callTool: [callTool] },
|
|
387
|
+
maxTurns: 10
|
|
388
|
+
})
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`onToolCallStarted` can continue, rewrite, skip, pause, or finish. `callTool`
|
|
394
|
+
middleware can fixture, retry, time, audit, sandbox, or short-circuit execution.
|
|
395
|
+
|
|
396
|
+
Tool errors become completed tool responses unless middleware throws outside
|
|
397
|
+
`AgentToolCallResponse` shape.
|
|
398
|
+
|
|
399
|
+
## Model Boundary
|
|
400
|
+
|
|
401
|
+
Model string uses provider prefix:
|
|
402
|
+
|
|
403
|
+
```txt
|
|
404
|
+
<provider>/<model-name>
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Built-in provider keys:
|
|
408
|
+
|
|
409
|
+
```txt
|
|
410
|
+
openai
|
|
411
|
+
anthropic
|
|
412
|
+
azure
|
|
413
|
+
baseten
|
|
414
|
+
cerebras
|
|
415
|
+
cohere
|
|
416
|
+
deepinfra
|
|
417
|
+
deepseek
|
|
418
|
+
fireworks
|
|
419
|
+
google
|
|
420
|
+
gemini
|
|
421
|
+
vertex
|
|
422
|
+
google-vertex
|
|
423
|
+
groq
|
|
424
|
+
grok
|
|
425
|
+
mistral
|
|
426
|
+
perplexity
|
|
427
|
+
together
|
|
428
|
+
togetherai
|
|
429
|
+
bedrock
|
|
430
|
+
amazon-bedrock
|
|
431
|
+
vercel
|
|
432
|
+
xai
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`modelProviders` overrides or adds providers. `callModel` middleware handles
|
|
436
|
+
retry, routing, tracing, caching, or output transforms around model call.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
440
|
+
import { createOpenAI } from '@ai-sdk/openai'
|
|
441
|
+
import type { ModelMessage } from 'ai'
|
|
442
|
+
import {
|
|
443
|
+
type AgentCallModelArgs,
|
|
444
|
+
type AgentCallModelResult,
|
|
445
|
+
type AgentMiddleware,
|
|
446
|
+
type JsonLike,
|
|
447
|
+
runAgent
|
|
448
|
+
} from '@nanoagent/kernel'
|
|
449
|
+
|
|
450
|
+
type Context = {
|
|
451
|
+
[key: string]: JsonLike
|
|
452
|
+
sessionId: string
|
|
453
|
+
tenant: 'public' | 'private'
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
type Messages = {
|
|
457
|
+
load(sessionId: string): Promise<ModelMessage[]>
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const retry429: AgentMiddleware<
|
|
461
|
+
AgentCallModelArgs<Context>,
|
|
462
|
+
AgentCallModelResult
|
|
463
|
+
> = async ({ input, next }) => {
|
|
464
|
+
for (let attempt = 0; ; attempt++) {
|
|
465
|
+
try {
|
|
466
|
+
return await next(input)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (attempt === 2 || !isRateLimit(error)) throw error
|
|
469
|
+
await new Promise(resolve => setTimeout(resolve, 500 * 2 ** attempt))
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isRateLimit(error: unknown) {
|
|
475
|
+
return error instanceof Error && /rate limit|429/i.test(error.message)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function runTenant(params: {
|
|
479
|
+
anthropicKey: string
|
|
480
|
+
messages: Messages
|
|
481
|
+
openaiKey: string
|
|
482
|
+
runId: string
|
|
483
|
+
tenant: Context['tenant']
|
|
484
|
+
}) {
|
|
485
|
+
await Array.fromAsync(
|
|
486
|
+
runAgent({
|
|
487
|
+
state: {
|
|
488
|
+
runId: params.runId,
|
|
489
|
+
context: {
|
|
490
|
+
sessionId: params.runId,
|
|
491
|
+
tenant: params.tenant
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
modelProviders: {
|
|
495
|
+
anthropic: createAnthropic({ apiKey: params.anthropicKey }),
|
|
496
|
+
openai: createOpenAI({ apiKey: params.openaiKey })
|
|
497
|
+
},
|
|
498
|
+
hooks: {
|
|
499
|
+
onTurnPrepared: async ({ context }) => ({
|
|
500
|
+
value: {
|
|
501
|
+
model:
|
|
502
|
+
context.tenant === 'private'
|
|
503
|
+
? 'anthropic/claude-opus-4-7'
|
|
504
|
+
: 'openai/gpt-5.5',
|
|
505
|
+
messages: await params.messages.load(context.sessionId)
|
|
506
|
+
}
|
|
507
|
+
})
|
|
508
|
+
},
|
|
509
|
+
middleware: { callModel: [retry429] },
|
|
510
|
+
maxTurns: 10
|
|
511
|
+
})
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
`onModelCompleted` receives canonical `AgentModelResult` plus `rawResult`.
|
|
517
|
+
Extract SDK-shaped fields from `rawResult` there and stash needed values in
|
|
518
|
+
caller context.
|
|
519
|
+
|
|
520
|
+
## Resume
|
|
521
|
+
|
|
522
|
+
Resume behavior:
|
|
523
|
+
|
|
524
|
+
- `paused` resumes from stored phase.
|
|
525
|
+
- `failed` resumes from failed phase.
|
|
526
|
+
- `completed` exits without new work.
|
|
527
|
+
- `model_started` reruns model call and emits `model_restarted`.
|
|
528
|
+
- `tool_call_completed` resumes only when `inFlight` is empty.
|
|
529
|
+
- In-flight tool calls fail resume by default because external side effects may
|
|
530
|
+
already have started.
|
|
531
|
+
|
|
532
|
+
Tool owner decides when replay is safe. For idempotent APIs, use stable
|
|
533
|
+
`toolCallId` as idempotency key and move in-flight calls back to pending before
|
|
534
|
+
resume.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import {
|
|
538
|
+
type AgentRunState,
|
|
539
|
+
type AgentToolCall,
|
|
540
|
+
type JsonLike
|
|
541
|
+
} from '@nanoagent/kernel'
|
|
542
|
+
|
|
543
|
+
type Context = {
|
|
544
|
+
[key: string]: JsonLike
|
|
545
|
+
customerId: string
|
|
546
|
+
sessionId: string
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function replayChargeCalls(state: AgentRunState<Context>) {
|
|
550
|
+
if (state.status.type !== 'running') return state
|
|
551
|
+
if (state.status.phase !== 'tool_call_completed') return state
|
|
552
|
+
|
|
553
|
+
const turn = state.currentTurn
|
|
554
|
+
if (!turn?.toolCalls.inFlight.length) return state
|
|
555
|
+
if (!turn.toolCalls.inFlight.every(isChargeCall)) return state
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
...state,
|
|
559
|
+
status: { ...state.status, phase: 'tool_call_started' as const },
|
|
560
|
+
currentTurn: {
|
|
561
|
+
...turn,
|
|
562
|
+
toolCalls: {
|
|
563
|
+
pending: turn.toolCalls.inFlight,
|
|
564
|
+
inFlight: [],
|
|
565
|
+
completed: turn.toolCalls.completed
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function isChargeCall(call: AgentToolCall) {
|
|
572
|
+
return call.toolName === 'ChargeCard'
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## JSON Session Recovery Example
|
|
577
|
+
|
|
578
|
+
Run failure and recovery against real OpenAI model with file-backed state:
|
|
579
|
+
|
|
580
|
+
```sh
|
|
581
|
+
OPENAI_API_KEY=... bun packages/kernel/examples/json-session-recovery.ts fail "Remember that my project is called Atlas"
|
|
582
|
+
OPENAI_API_KEY=... bun packages/kernel/examples/json-session-recovery.ts model-fail "Remember that my project is called Atlas"
|
|
583
|
+
OPENAI_API_KEY=... bun packages/kernel/examples/json-session-recovery.ts reply "Continue after the failure and answer with the project name"
|
|
584
|
+
bun packages/kernel/examples/json-session-recovery.ts show
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
`fail` throws before model call and writes `examples/.sessions/demo.json` with
|
|
588
|
+
`status: failed` and saved phase. `model-fail` reaches `model_started`, then
|
|
589
|
+
throws fake provider failure from `callModel` before `streamText` returns.
|
|
590
|
+
`reply` appends new user message, passes saved snapshot back to `runAgent`, and
|
|
591
|
+
kernel resumes from failed phase. Events append to
|
|
592
|
+
`examples/.sessions/demo.jsonl`.
|
|
593
|
+
|
|
594
|
+
Set `MODEL` to override default `openai/gpt-5.5`.
|
|
595
|
+
|
|
596
|
+
## Cancellation
|
|
597
|
+
|
|
598
|
+
`AbortSignal` means caller cancellation. Kernel throws abort reason and stops
|
|
599
|
+
without writing failed run state.
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
import type { ToolSet } from 'ai'
|
|
603
|
+
import { type AgentHooks, type JsonLike, runAgent } from '@nanoagent/kernel'
|
|
604
|
+
|
|
605
|
+
type Context = {
|
|
606
|
+
[key: string]: JsonLike
|
|
607
|
+
sessionId: string
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
declare const hooks: AgentHooks<Context>
|
|
611
|
+
declare const tools: ToolSet
|
|
612
|
+
|
|
613
|
+
const controller = new AbortController()
|
|
614
|
+
|
|
615
|
+
for await (const event of runAgent({
|
|
616
|
+
state: { context: { sessionId: 's_123' } },
|
|
617
|
+
hooks,
|
|
618
|
+
tools,
|
|
619
|
+
signal: controller.signal,
|
|
620
|
+
maxTurns: 20
|
|
621
|
+
})) {
|
|
622
|
+
console.log(event.type)
|
|
623
|
+
}
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Caller decides where `controller.abort(reason)` comes from: HTTP disconnect,
|
|
627
|
+
button click, queue timeout, or worker shutdown.
|
|
628
|
+
|
|
629
|
+
## Events
|
|
630
|
+
|
|
631
|
+
`runAgent` yields `AgentStreamEvent` values. Durable phase events also go to
|
|
632
|
+
`saveState.events`.
|
|
633
|
+
|
|
634
|
+
Phase event types:
|
|
635
|
+
|
|
636
|
+
```txt
|
|
637
|
+
run_started
|
|
638
|
+
turn_started
|
|
639
|
+
turn_prepared
|
|
640
|
+
model_started
|
|
641
|
+
model_restarted
|
|
642
|
+
model_completed
|
|
643
|
+
tool_calls_started
|
|
644
|
+
tool_call_started
|
|
645
|
+
tool_call_completed
|
|
646
|
+
tool_calls_completed
|
|
647
|
+
turn_completed
|
|
648
|
+
run_completed
|
|
649
|
+
run_failed
|
|
650
|
+
pause
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
`stream_part` events are live model stream parts. They are yielded, but not sent
|
|
654
|
+
to `saveState.events`; final model output is committed at `model_completed`.
|
|
655
|
+
|
|
656
|
+
## API Surface
|
|
657
|
+
|
|
658
|
+
Primary exports:
|
|
659
|
+
|
|
660
|
+
- `runAgent`
|
|
661
|
+
- `AgentRunState`
|
|
662
|
+
- `AgentRunStatus`
|
|
663
|
+
- `Turn`
|
|
664
|
+
- `AgentHooks`
|
|
665
|
+
- `AgentMiddleware`
|
|
666
|
+
- `AgentMiddlewareMap`
|
|
667
|
+
- `AgentSaveState`
|
|
668
|
+
- `AgentStreamEvent`
|
|
669
|
+
- `AgentPhaseEvent`
|
|
670
|
+
- `AgentModelArgs`
|
|
671
|
+
- `AgentModelResult`
|
|
672
|
+
- `AgentRawModelResult`
|
|
673
|
+
- `AgentToolCall`
|
|
674
|
+
- `AgentToolCallResponse`
|
|
675
|
+
- `AgentModelProviders`
|
|
676
|
+
- `JsonLike`
|
|
677
|
+
|
|
678
|
+
Everything else belongs to product code.
|