@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/README.md +26 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +61 -0
- package/dist/events.js.map +1 -0
- package/dist/execution-policy.d.ts +43 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +185 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +409 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/events.ts +85 -0
- package/src/execution-policy.ts +241 -0
- package/src/index.ts +803 -0
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
|
+
}
|