@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 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.