@nifrajs/agent 2.12.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/src/index.ts ADDED
@@ -0,0 +1,803 @@
1
+ /**
2
+ * Provider-neutral agent turns.
3
+ *
4
+ * One turn makes one model decision and may execute one {@link ToolContract}. The multi-turn runner
5
+ * is intentionally bounded. The state adapter stores token-only evidence and cursor metadata; model
6
+ * input, tool input, and tool output remain transient caller data.
7
+ */
8
+
9
+ import { canAttempt, type RequestBudget } from "@nifrajs/core/budget"
10
+ import type { ExecutionPolicyAdapter } from "@nifrajs/core/execution-policy"
11
+ import type { EffectCost } from "@nifrajs/core/ledger"
12
+ import { type StandardIssue, type StandardSchemaV1, validateStandard } from "@nifrajs/core/schema"
13
+ import { type ToolCatalogEntry, toCatalogEntry } from "@nifrajs/core/tool-catalog"
14
+ import {
15
+ executeTool,
16
+ type ToolApproval,
17
+ type ToolBudget,
18
+ type ToolCallResult,
19
+ type ToolContract,
20
+ type ToolError,
21
+ type ToolIdempotencyStore,
22
+ } from "@nifrajs/core/tool-contract"
23
+
24
+ export {
25
+ createLocalProcessAdapter,
26
+ type ExecutionPolicy,
27
+ type ExecutionPolicyAdapter,
28
+ LOCAL_PROCESS_LIMITATION,
29
+ type LocalProcessAdapter,
30
+ type LocalProcessAdapterOptions,
31
+ LocalProcessPolicyError,
32
+ type LocalProcessRequest,
33
+ type LocalProcessResult,
34
+ } from "./execution-policy.ts"
35
+
36
+ export type AgentStatus = "continue" | "completed" | "suspended"
37
+ export type AgentPendingKind = "approval" | "budget" | "model" | "cancelled"
38
+
39
+ export interface AgentDefinition<
40
+ InputSchema extends StandardSchemaV1,
41
+ OutputSchema extends StandardSchemaV1,
42
+ > {
43
+ readonly name: string
44
+ readonly instruction: string
45
+ readonly input: InputSchema
46
+ readonly output: OutputSchema
47
+ readonly tools: readonly AgentToolContract[]
48
+ /** Dimensionless counters charged once per model decision. */
49
+ readonly modelCost?: EffectCost
50
+ }
51
+
52
+ /** Heterogeneous tool collection used by an agent definition. Runtime schemas remain authoritative. */
53
+ // biome-ignore lint/suspicious/noExplicitAny: a heterogeneous tool list is erased at this boundary; each contract validates at execution.
54
+ export type AgentToolContract = ToolContract<any, any>
55
+
56
+ /** The model-facing descriptor is the complete descriptive catalog entry. */
57
+ export type AgentToolDescriptor = ToolCatalogEntry
58
+
59
+ export interface AgentTurnState {
60
+ readonly version: 1
61
+ readonly turnId: string
62
+ readonly step: number
63
+ readonly status: "ready" | "suspended" | "completed"
64
+ readonly evidence: readonly AgentStepEvidence[]
65
+ readonly pending?: {
66
+ readonly kind: AgentPendingKind
67
+ readonly tool?: string
68
+ readonly effectId: string
69
+ }
70
+ }
71
+
72
+ export interface AgentStepEvidence {
73
+ readonly seq: number
74
+ readonly at: number
75
+ readonly kind: "model" | "tool" | "approval" | "budget" | "state"
76
+ readonly outcome: "started" | "passed" | "failed" | "denied" | "suspended" | "committed"
77
+ readonly name?: string
78
+ readonly effectId?: string
79
+ readonly code?: string
80
+ readonly ledgerHead?: string
81
+ }
82
+
83
+ export interface AgentModelRequest {
84
+ readonly agent: string
85
+ readonly instruction: string
86
+ readonly input: unknown
87
+ readonly state: AgentTurnState
88
+ readonly tools: readonly AgentToolDescriptor[]
89
+ readonly toolResult?: AgentToolResult
90
+ readonly previousOutput?: unknown
91
+ readonly signal: AbortSignal
92
+ /** Wall-clock budget shared with the parent request. Use withDeadlineHeader for outbound requests. */
93
+ readonly deadline?: RequestBudget
94
+ }
95
+
96
+ export type AgentModelResponse =
97
+ | { readonly kind: "output"; readonly value: unknown }
98
+ | { readonly kind: "tool"; readonly name: string; readonly input: unknown }
99
+
100
+ export interface AgentModelPort {
101
+ /** Provider output is unknown until the response parser accepts it. */
102
+ complete(request: AgentModelRequest): unknown | PromiseLike<unknown>
103
+ }
104
+
105
+ export interface AgentToolResult {
106
+ readonly name: string
107
+ readonly ok: boolean
108
+ readonly output?: unknown
109
+ readonly error?:
110
+ | ToolError
111
+ | { readonly code: "unknown_tool" | "invalid_model_response"; readonly stage: "execution" }
112
+ }
113
+
114
+ export interface AgentPendingContinuation {
115
+ readonly kind: AgentPendingKind
116
+ readonly tool?: string
117
+ readonly input?: unknown
118
+ readonly effectId: string
119
+ }
120
+
121
+ export type AgentApprovalResult =
122
+ | { readonly status: "approved"; readonly approval: ToolApproval }
123
+ | { readonly status: "denied"; readonly reason?: string }
124
+ | { readonly status: "pending"; readonly effectId: string }
125
+
126
+ export interface AgentApprovalPort {
127
+ request(input: {
128
+ readonly turnId: string
129
+ readonly effectId: string
130
+ readonly tool: string
131
+ readonly capability: string
132
+ readonly signal: AbortSignal
133
+ /** Wall-clock budget reserved for this approval hop and the following tool execution. */
134
+ readonly deadline?: RequestBudget
135
+ }): AgentApprovalResult | PromiseLike<AgentApprovalResult>
136
+ }
137
+
138
+ export interface AgentStateStore {
139
+ load(turnId: string): AgentTurnState | undefined | PromiseLike<AgentTurnState | undefined>
140
+ save(state: AgentTurnState): void | PromiseLike<void>
141
+ }
142
+
143
+ export class MemoryAgentStateStore implements AgentStateStore {
144
+ private readonly states = new Map<string, AgentTurnState>()
145
+
146
+ load(turnId: string): AgentTurnState | undefined {
147
+ return this.states.get(turnId)
148
+ }
149
+
150
+ save(state: AgentTurnState): void {
151
+ this.states.set(state.turnId, freezeState(state))
152
+ }
153
+ }
154
+
155
+ export interface AgentTelemetryPort {
156
+ step(evidence: AgentStepEvidence): void | PromiseLike<void>
157
+ }
158
+
159
+ export interface AgentPorts {
160
+ readonly model: AgentModelPort
161
+ readonly capabilities: readonly string[]
162
+ readonly budget?: ToolBudget
163
+ /** Wall-clock budget shared with the parent request. Distinct from `budget`, which is cost. */
164
+ readonly deadline?: RequestBudget
165
+ readonly idempotency?: ToolIdempotencyStore
166
+ readonly state?: AgentStateStore
167
+ readonly approval?: AgentApprovalPort
168
+ readonly telemetry?: AgentTelemetryPort
169
+ readonly clock?: () => number
170
+ /** Adapter used to satisfy execution policies declared by tool contracts. */
171
+ readonly executionPolicy?: ExecutionPolicyAdapter
172
+ /** Injectable token generator for deterministic replay and tests. */
173
+ readonly idFactory?: () => string
174
+ readonly signal?: AbortSignal
175
+ /** Replay mode skips every tool executor and marks the call dry-run. */
176
+ readonly dryRun?: boolean
177
+ }
178
+
179
+ export interface AgentTurnInput {
180
+ readonly value: unknown
181
+ readonly toolResult?: AgentToolResult
182
+ readonly previousOutput?: unknown
183
+ readonly resume?: {
184
+ readonly continuation: AgentPendingContinuation
185
+ readonly approval?: ToolApproval
186
+ }
187
+ }
188
+
189
+ export interface AgentTurnBaseResult {
190
+ readonly state: AgentTurnState
191
+ readonly evidence: readonly AgentStepEvidence[]
192
+ readonly transcript: AgentTranscript
193
+ }
194
+
195
+ export interface AgentTurnError {
196
+ readonly code: "input_invalid" | "output_invalid" | "model_failed"
197
+ readonly issues?: readonly StandardIssue[]
198
+ }
199
+
200
+ export type AgentTurnResult<Output> =
201
+ | (AgentTurnBaseResult & {
202
+ readonly status: "completed"
203
+ readonly output: Output
204
+ readonly error?: never
205
+ })
206
+ | (AgentTurnBaseResult & {
207
+ readonly status: "completed"
208
+ readonly error: AgentTurnError
209
+ readonly output?: never
210
+ })
211
+ | (AgentTurnBaseResult & { readonly status: "continue"; readonly toolResult: AgentToolResult })
212
+ | (AgentTurnBaseResult & {
213
+ readonly status: "suspended"
214
+ readonly pending: AgentPendingContinuation
215
+ readonly reason: "approval" | "budget" | "model" | "cancelled"
216
+ })
217
+
218
+ export interface AgentTranscript {
219
+ readonly version: 1
220
+ readonly turnId: string
221
+ readonly responses: readonly AgentModelResponse[]
222
+ readonly evidence: readonly AgentStepEvidence[]
223
+ }
224
+
225
+ export interface RunAgentOptions {
226
+ readonly state: AgentTurnState
227
+ readonly maxTurns?: number
228
+ readonly goal?: (output: unknown) => boolean
229
+ }
230
+
231
+ export type AgentRunResult<Output> =
232
+ | AgentTurnResult<Output>
233
+ | (AgentTurnBaseResult & {
234
+ readonly status: "suspended"
235
+ readonly pending: AgentPendingContinuation
236
+ readonly reason: "max_turns"
237
+ })
238
+
239
+ export function createAgentState(turnId: string): AgentTurnState {
240
+ if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(turnId))
241
+ throw new TypeError("agent: turnId must be a bounded token")
242
+ return Object.freeze({
243
+ version: 1,
244
+ turnId,
245
+ step: 0,
246
+ status: "ready",
247
+ evidence: Object.freeze([]),
248
+ })
249
+ }
250
+
251
+ const AGENT_SETTLE_RESERVE_MS = 5
252
+
253
+ function costMilliseconds(cost: EffectCost | undefined): number {
254
+ const value = cost?.ms
255
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0
256
+ }
257
+
258
+ export async function turn<
259
+ InputSchema extends StandardSchemaV1,
260
+ OutputSchema extends StandardSchemaV1,
261
+ >(
262
+ definition: AgentDefinition<InputSchema, OutputSchema>,
263
+ state: AgentTurnState,
264
+ input: AgentTurnInput,
265
+ ports: AgentPorts,
266
+ ): Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
267
+ const signal = ports.signal ?? new AbortController().signal
268
+ const clock = ports.clock ?? (() => performance.now())
269
+ const newId = ports.idFactory ?? (() => crypto.randomUUID())
270
+ const evidence: AgentStepEvidence[] = [...state.evidence]
271
+ const responses: AgentModelResponse[] = []
272
+ const add = async (
273
+ kind: AgentStepEvidence["kind"],
274
+ outcome: AgentStepEvidence["outcome"],
275
+ fields: Omit<AgentStepEvidence, "seq" | "at" | "kind" | "outcome"> = {},
276
+ ): Promise<AgentStepEvidence> => {
277
+ const at = clock()
278
+ if (!Number.isFinite(at) || at < 0)
279
+ throw new TypeError("agent: clock must return a finite non-negative number")
280
+ const item = Object.freeze({ seq: evidence.length, at, kind, outcome, ...fields })
281
+ evidence.push(item)
282
+ await ports.telemetry?.step(item)
283
+ return item
284
+ }
285
+ const save = async (next: AgentTurnState): Promise<void> => {
286
+ await ports.state?.save(next)
287
+ }
288
+ const suspended = async (
289
+ next: AgentTurnState,
290
+ pending: AgentPendingContinuation,
291
+ reason: "approval" | "budget" | "model" | "cancelled",
292
+ ): Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> => {
293
+ await add("state", "suspended", { effectId: pending.effectId, code: reason })
294
+ const saved = freezeState({
295
+ ...next,
296
+ status: "suspended",
297
+ pending: {
298
+ kind: pending.kind,
299
+ ...(pending.tool === undefined ? {} : { tool: pending.tool }),
300
+ effectId: pending.effectId,
301
+ },
302
+ evidence,
303
+ })
304
+ await save(saved)
305
+ return {
306
+ status: "suspended",
307
+ state: saved,
308
+ pending,
309
+ reason,
310
+ evidence: saved.evidence,
311
+ transcript: {
312
+ version: 1,
313
+ turnId: state.turnId,
314
+ responses: Object.freeze([...responses]),
315
+ evidence: saved.evidence,
316
+ },
317
+ }
318
+ }
319
+
320
+ const inputResult = await validateStandard(definition.input, input.value)
321
+ if (!inputResult.ok) {
322
+ await add("model", "failed", { code: "input_invalid" })
323
+ const saved = freezeState({ ...state, status: "completed", step: state.step + 1, evidence })
324
+ await save(saved)
325
+ return completedError(saved, responses, evidence, "input_invalid", inputResult.issues)
326
+ }
327
+ if (signal.aborted) return suspended(state, { kind: "cancelled", effectId: newId() }, "cancelled")
328
+
329
+ if (state.status === "suspended") {
330
+ if (input.resume !== undefined) {
331
+ return resumeTool(
332
+ definition,
333
+ state,
334
+ ports,
335
+ input.resume,
336
+ evidence,
337
+ responses,
338
+ add,
339
+ save,
340
+ suspended,
341
+ )
342
+ }
343
+ // Model failures and budget suspensions have no persisted payload to replay. Once the
344
+ // caller supplies a live signal/budget again, retry the model step from the token-only state.
345
+ // Tool suspensions retain no tool input in state, so they still require an explicit continuation.
346
+ if (state.pending?.tool === undefined) state = readyState(state)
347
+ else
348
+ return suspended(
349
+ state,
350
+ {
351
+ kind: state.pending?.kind ?? "cancelled",
352
+ ...(state.pending?.tool === undefined ? {} : { tool: state.pending.tool }),
353
+ effectId: state.pending?.effectId ?? newId(),
354
+ },
355
+ state.pending?.kind ?? "cancelled",
356
+ )
357
+ }
358
+ const modelMs = costMilliseconds(definition.modelCost)
359
+ if (
360
+ ports.deadline !== undefined &&
361
+ !canAttempt(ports.deadline, modelMs, AGENT_SETTLE_RESERVE_MS)
362
+ ) {
363
+ await add("budget", "denied", { code: "deadline_exceeded" })
364
+ return suspended(state, { kind: "budget", effectId: newId() }, "budget")
365
+ }
366
+ if (ports.budget !== undefined && !ports.budget.consume(definition.modelCost)) {
367
+ await add("budget", "denied", { code: "budget_exceeded" })
368
+ return suspended(state, { kind: "budget", effectId: newId() }, "budget")
369
+ }
370
+ await add("model", "started")
371
+ let response: AgentModelResponse | undefined
372
+ try {
373
+ const rawResponse = await ports.model.complete({
374
+ agent: definition.name,
375
+ instruction: definition.instruction,
376
+ input: inputResult.value,
377
+ state,
378
+ tools: definition.tools.map((tool) => toCatalogEntry(tool)),
379
+ ...(input.toolResult === undefined ? {} : { toolResult: input.toolResult }),
380
+ ...(input.previousOutput === undefined ? {} : { previousOutput: input.previousOutput }),
381
+ signal,
382
+ ...(ports.deadline === undefined
383
+ ? {}
384
+ : { deadline: ports.deadline.child(AGENT_SETTLE_RESERVE_MS) }),
385
+ })
386
+ response = parseModelResponse(rawResponse)
387
+ } catch {
388
+ await add("model", "failed", { code: signal.aborted ? "cancelled" : "model_failed" })
389
+ return suspended(
390
+ state,
391
+ { kind: signal.aborted ? "cancelled" : "model", effectId: newId() },
392
+ signal.aborted ? "cancelled" : "model",
393
+ )
394
+ }
395
+ if (ports.deadline !== undefined && !canAttempt(ports.deadline, 0, AGENT_SETTLE_RESERVE_MS)) {
396
+ await add("budget", "denied", { code: "deadline_exceeded" })
397
+ return suspended(state, { kind: "budget", effectId: newId() }, "budget")
398
+ }
399
+ if (response === undefined) {
400
+ await add("model", "failed", { code: "invalid_model_response" })
401
+ return continueWithToolError(
402
+ state,
403
+ evidence,
404
+ responses,
405
+ { name: "model", ok: false, error: { code: "invalid_model_response", stage: "execution" } },
406
+ save,
407
+ )
408
+ }
409
+ responses.push(response)
410
+ if (response.kind === "output") {
411
+ const outputResult = await validateStandard(definition.output, response.value)
412
+ if (!outputResult.ok) {
413
+ await add("model", "failed", { code: "output_invalid" })
414
+ const saved = freezeState({ ...state, status: "completed", step: state.step + 1, evidence })
415
+ await save(saved)
416
+ return completedError(saved, responses, evidence, "output_invalid", outputResult.issues)
417
+ }
418
+ await add("model", "passed")
419
+ const saved = freezeState({ ...state, status: "completed", step: state.step + 1, evidence })
420
+ await save(saved)
421
+ return {
422
+ status: "completed",
423
+ output: outputResult.value,
424
+ state: saved,
425
+ evidence: saved.evidence,
426
+ transcript: {
427
+ version: 1,
428
+ turnId: state.turnId,
429
+ responses: Object.freeze([...responses]),
430
+ evidence: saved.evidence,
431
+ },
432
+ }
433
+ }
434
+ const tool = definition.tools.find((candidate) => candidate.name === response.name)
435
+ if (tool === undefined) {
436
+ await add("tool", "denied", { name: response.name, code: "unknown_tool" })
437
+ return continueWithToolError(
438
+ state,
439
+ evidence,
440
+ responses,
441
+ { name: response.name, ok: false, error: { code: "unknown_tool", stage: "execution" } },
442
+ save,
443
+ )
444
+ }
445
+ return executeToolStep(
446
+ state,
447
+ tool,
448
+ response.input,
449
+ ports,
450
+ evidence,
451
+ responses,
452
+ add,
453
+ save,
454
+ suspended,
455
+ )
456
+ }
457
+
458
+ export async function runAgent<
459
+ InputSchema extends StandardSchemaV1,
460
+ OutputSchema extends StandardSchemaV1,
461
+ >(
462
+ definition: AgentDefinition<InputSchema, OutputSchema>,
463
+ input: AgentTurnInput,
464
+ ports: AgentPorts,
465
+ options: RunAgentOptions,
466
+ ): Promise<AgentRunResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
467
+ const maxTurns = options.maxTurns ?? 8
468
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1)
469
+ throw new RangeError("agent: maxTurns must be a positive safe integer")
470
+ let state = options.state
471
+ let nextInput = input
472
+ let last: AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]> | undefined
473
+ const recordedResponses: AgentModelResponse[] = []
474
+ for (let count = 0; count < maxTurns; count++) {
475
+ last = await turn(definition, state, nextInput, ports)
476
+ recordedResponses.push(...last.transcript.responses)
477
+ if (last.status === "completed") {
478
+ if (last.error !== undefined || options.goal === undefined || options.goal(last.output)) {
479
+ return withTranscript(last, recordedResponses)
480
+ }
481
+ state = readyState(last.state)
482
+ nextInput = { value: input.value, previousOutput: last.output }
483
+ continue
484
+ }
485
+ if (last.status === "suspended") return withTranscript(last, recordedResponses)
486
+ state = last.state
487
+ nextInput = { value: input.value, toolResult: last.toolResult }
488
+ }
489
+ const fallback = last
490
+ if (fallback === undefined) throw new Error("agent: no turn was executed")
491
+ return {
492
+ status: "suspended",
493
+ reason: "max_turns",
494
+ pending: { kind: "cancelled", effectId: (ports.idFactory ?? (() => crypto.randomUUID()))() },
495
+ state: fallback.state,
496
+ evidence: fallback.evidence,
497
+ transcript: {
498
+ version: 1,
499
+ turnId: fallback.state.turnId,
500
+ responses: Object.freeze([...recordedResponses]),
501
+ evidence: fallback.evidence,
502
+ },
503
+ }
504
+ }
505
+
506
+ /** Load a saved token-only state record and continue a bounded run. */
507
+ export async function resumeAgent<
508
+ InputSchema extends StandardSchemaV1,
509
+ OutputSchema extends StandardSchemaV1,
510
+ >(
511
+ definition: AgentDefinition<InputSchema, OutputSchema>,
512
+ turnId: string,
513
+ input: AgentTurnInput,
514
+ ports: AgentPorts,
515
+ options: Omit<RunAgentOptions, "state"> = {},
516
+ ): Promise<AgentRunResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
517
+ if (ports.state === undefined) throw new Error("agent: resume requires a state store")
518
+ const state = await ports.state.load(turnId)
519
+ if (state === undefined) throw new Error("agent: no saved state for turn")
520
+ return runAgent(definition, input, ports, { ...options, state })
521
+ }
522
+
523
+ export async function replayAgent<
524
+ InputSchema extends StandardSchemaV1,
525
+ OutputSchema extends StandardSchemaV1,
526
+ >(
527
+ definition: AgentDefinition<InputSchema, OutputSchema>,
528
+ input: AgentTurnInput,
529
+ ports: Omit<AgentPorts, "model">,
530
+ transcript: AgentTranscript,
531
+ options: RunAgentOptions,
532
+ ): Promise<AgentRunResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
533
+ let index = 0
534
+ const model: AgentModelPort = {
535
+ complete: () => {
536
+ const response = transcript.responses[index++]
537
+ if (response === undefined)
538
+ throw new Error("agent replay: transcript ended before the model response")
539
+ return response
540
+ },
541
+ }
542
+ return runAgent(definition, input, { ...ports, model, dryRun: true }, options)
543
+ }
544
+
545
+ async function executeToolStep<OutputSchema extends StandardSchemaV1>(
546
+ state: AgentTurnState,
547
+ tool: AgentToolContract,
548
+ input: unknown,
549
+ ports: AgentPorts,
550
+ evidence: AgentStepEvidence[],
551
+ responses: AgentModelResponse[],
552
+ add: (
553
+ kind: AgentStepEvidence["kind"],
554
+ outcome: AgentStepEvidence["outcome"],
555
+ fields?: Omit<AgentStepEvidence, "seq" | "at" | "kind" | "outcome">,
556
+ ) => Promise<AgentStepEvidence>,
557
+ save: (state: AgentTurnState) => Promise<void>,
558
+ suspended: (
559
+ state: AgentTurnState,
560
+ pending: AgentPendingContinuation,
561
+ reason: "approval" | "budget" | "model" | "cancelled",
562
+ ) => Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>>,
563
+ effectIdOverride?: string,
564
+ ): Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
565
+ const effectId = effectIdOverride ?? (ports.idFactory ?? (() => crypto.randomUUID()))()
566
+ const toolMs = costMilliseconds(tool.cost)
567
+ if (
568
+ ports.deadline !== undefined &&
569
+ !canAttempt(ports.deadline, toolMs, AGENT_SETTLE_RESERVE_MS)
570
+ ) {
571
+ await add("budget", "denied", {
572
+ name: tool.name,
573
+ effectId,
574
+ code: "deadline_exceeded",
575
+ })
576
+ return suspended(state, { kind: "budget", tool: tool.name, input, effectId }, "budget")
577
+ }
578
+ let approval: ToolApproval | undefined
579
+ if (tool.approval.kind !== "none") {
580
+ if (ports.approval === undefined)
581
+ return suspended(state, { kind: "approval", tool: tool.name, input, effectId }, "approval")
582
+ const result = await ports.approval.request({
583
+ turnId: state.turnId,
584
+ effectId,
585
+ tool: tool.name,
586
+ capability: tool.capability,
587
+ signal: ports.signal ?? new AbortController().signal,
588
+ ...(ports.deadline === undefined
589
+ ? {}
590
+ : {
591
+ deadline: ports.deadline.child(toolMs + AGENT_SETTLE_RESERVE_MS),
592
+ }),
593
+ })
594
+ if (result.status === "pending")
595
+ return suspended(
596
+ state,
597
+ { kind: "approval", tool: tool.name, input, effectId: result.effectId },
598
+ "approval",
599
+ )
600
+ if (result.status === "denied")
601
+ approval = {
602
+ granted: false,
603
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
604
+ }
605
+ else approval = result.approval
606
+ await add("approval", result.status === "approved" ? "passed" : "denied", {
607
+ name: tool.name,
608
+ effectId,
609
+ ...(result.status === "denied" ? { code: "approval_denied" } : {}),
610
+ })
611
+ }
612
+ if (
613
+ ports.deadline !== undefined &&
614
+ !canAttempt(ports.deadline, toolMs, AGENT_SETTLE_RESERVE_MS)
615
+ ) {
616
+ await add("budget", "denied", {
617
+ name: tool.name,
618
+ effectId,
619
+ code: "deadline_exceeded",
620
+ })
621
+ return suspended(state, { kind: "budget", tool: tool.name, input, effectId }, "budget")
622
+ }
623
+ const result = await executeTool(tool, input, {
624
+ effectId,
625
+ capabilities: ports.capabilities,
626
+ ...(approval === undefined ? {} : { approval }),
627
+ ...(ports.budget === undefined ? {} : { budget: ports.budget }),
628
+ ...(ports.idempotency === undefined ? {} : { idempotency: ports.idempotency }),
629
+ ...(ports.executionPolicy === undefined ? {} : { executionPolicy: ports.executionPolicy }),
630
+ ...(ports.signal === undefined ? {} : { signal: ports.signal }),
631
+ ...(ports.deadline === undefined
632
+ ? {}
633
+ : { deadline: ports.deadline.child(AGENT_SETTLE_RESERVE_MS) }),
634
+ ...(ports.dryRun === undefined ? {} : { dryRun: ports.dryRun }),
635
+ ...(ports.clock === undefined ? {} : { clock: ports.clock }),
636
+ })
637
+ const toolResult = toolResultOf(tool.name, result)
638
+ await add("tool", result.ok ? "committed" : "failed", {
639
+ name: tool.name,
640
+ effectId,
641
+ ...(result.ok ? {} : { code: result.error.code }),
642
+ ...(result.ledger.chain?.head === undefined ? {} : { ledgerHead: result.ledger.chain.head }),
643
+ })
644
+ if (!result.ok && result.error.code === "budget_exceeded") {
645
+ await add("budget", "denied", { name: tool.name, effectId, code: "budget_exceeded" })
646
+ return suspended(state, { kind: "budget", tool: tool.name, input, effectId }, "budget")
647
+ }
648
+ return continueWithToolError(state, evidence, responses, toolResult, save)
649
+ }
650
+
651
+ async function resumeTool<
652
+ InputSchema extends StandardSchemaV1,
653
+ OutputSchema extends StandardSchemaV1,
654
+ >(
655
+ definition: AgentDefinition<InputSchema, OutputSchema>,
656
+ state: AgentTurnState,
657
+ ports: AgentPorts,
658
+ resume: NonNullable<AgentTurnInput["resume"]>,
659
+ evidence: AgentStepEvidence[],
660
+ responses: AgentModelResponse[],
661
+ add: (
662
+ kind: AgentStepEvidence["kind"],
663
+ outcome: AgentStepEvidence["outcome"],
664
+ fields?: Omit<AgentStepEvidence, "seq" | "at" | "kind" | "outcome">,
665
+ ) => Promise<AgentStepEvidence>,
666
+ save: (state: AgentTurnState) => Promise<void>,
667
+ suspended: (
668
+ state: AgentTurnState,
669
+ pending: AgentPendingContinuation,
670
+ reason: "approval" | "budget" | "model" | "cancelled",
671
+ ) => Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>>,
672
+ ): Promise<AgentTurnResult<NonNullable<OutputSchema["~standard"]["types"]>["output"]>> {
673
+ const tool =
674
+ resume.continuation.tool === undefined
675
+ ? undefined
676
+ : definition.tools.find((candidate) => candidate.name === resume.continuation.tool)
677
+ if (tool === undefined || !Object.hasOwn(resume.continuation, "input"))
678
+ return continueWithToolError(
679
+ state,
680
+ evidence,
681
+ responses,
682
+ {
683
+ name: resume.continuation.tool ?? "unknown",
684
+ ok: false,
685
+ error: { code: "unknown_tool", stage: "execution" },
686
+ },
687
+ save,
688
+ )
689
+ const approvalPorts: AgentPorts =
690
+ resume.approval === undefined
691
+ ? ports
692
+ : {
693
+ ...ports,
694
+ approval: { request: () => ({ status: "approved", approval: resume.approval! }) },
695
+ }
696
+ return executeToolStep(
697
+ { ...state, status: "ready" },
698
+ tool,
699
+ resume.continuation.input,
700
+ approvalPorts,
701
+ evidence,
702
+ responses,
703
+ add,
704
+ save,
705
+ suspended,
706
+ resume.continuation.effectId,
707
+ )
708
+ }
709
+
710
+ async function continueWithToolError<Output>(
711
+ state: AgentTurnState,
712
+ evidence: readonly AgentStepEvidence[],
713
+ responses: readonly AgentModelResponse[],
714
+ toolResult: AgentToolResult,
715
+ save: (state: AgentTurnState) => Promise<void>,
716
+ ): Promise<AgentTurnResult<Output>> {
717
+ const { pending: _pending, ...withoutPending } = state
718
+ const next = freezeState({ ...withoutPending, status: "ready", step: state.step + 1, evidence })
719
+ await save(next)
720
+ return {
721
+ status: "continue",
722
+ state: next,
723
+ toolResult,
724
+ evidence: next.evidence,
725
+ transcript: {
726
+ version: 1,
727
+ turnId: state.turnId,
728
+ responses: Object.freeze([...responses]),
729
+ evidence: next.evidence,
730
+ },
731
+ }
732
+ }
733
+
734
+ function toolResultOf(name: string, result: ToolCallResult<unknown>): AgentToolResult {
735
+ return result.ok
736
+ ? { name, ok: true, ...(result.output === undefined ? {} : { output: result.output }) }
737
+ : { name, ok: false, error: result.error }
738
+ }
739
+
740
+ function completedError(
741
+ state: AgentTurnState,
742
+ responses: readonly AgentModelResponse[],
743
+ evidence: readonly AgentStepEvidence[],
744
+ code: "input_invalid" | "output_invalid",
745
+ issues: readonly StandardIssue[],
746
+ ): AgentTurnBaseResult & {
747
+ readonly status: "completed"
748
+ readonly error: AgentTurnError
749
+ readonly output?: never
750
+ } {
751
+ return {
752
+ status: "completed",
753
+ error: { code, issues },
754
+ state,
755
+ evidence,
756
+ transcript: {
757
+ version: 1,
758
+ turnId: state.turnId,
759
+ responses: Object.freeze([...responses]),
760
+ evidence,
761
+ },
762
+ }
763
+ }
764
+
765
+ function parseModelResponse(value: unknown): AgentModelResponse | undefined {
766
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
767
+ const record = Object.fromEntries(Object.entries(value))
768
+ if (record.kind === "output" && Object.hasOwn(record, "value")) {
769
+ return Object.freeze({ kind: "output", value: record.value })
770
+ }
771
+ if (record.kind === "tool" && typeof record.name === "string" && Object.hasOwn(record, "input")) {
772
+ return Object.freeze({ kind: "tool", name: record.name, input: record.input })
773
+ }
774
+ return undefined
775
+ }
776
+
777
+ function readyState(state: AgentTurnState): AgentTurnState {
778
+ const { pending: _pending, ...withoutPending } = state
779
+ return freezeState({ ...withoutPending, status: "ready" })
780
+ }
781
+
782
+ function withTranscript<Output>(
783
+ result: AgentTurnResult<Output>,
784
+ responses: readonly AgentModelResponse[],
785
+ ): AgentTurnResult<Output> {
786
+ return {
787
+ ...result,
788
+ transcript: {
789
+ version: 1,
790
+ turnId: result.state.turnId,
791
+ responses: Object.freeze([...responses]),
792
+ evidence: result.evidence,
793
+ },
794
+ }
795
+ }
796
+
797
+ function freezeState(state: AgentTurnState): AgentTurnState {
798
+ return Object.freeze({
799
+ ...state,
800
+ evidence: Object.freeze([...state.evidence]),
801
+ ...(state.pending === undefined ? {} : { pending: Object.freeze({ ...state.pending }) }),
802
+ })
803
+ }