@anteros/core 0.0.44 → 0.0.47

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. package/utils/logger.ts +125 -33
package/lib/agent.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { AppError, fn } from "./error";
2
+ import { DEFAULT_REDACT_KEYS, isSensitiveKey, redactKeys } from "./redact";
2
3
  import { toJsonSchema, validateWithSchema } from "./jsonSchema";
3
4
  import { InMemoryAgentMemory, trimMessages } from "./agentMemory";
4
5
  import { forMemory, resolveAttachments } from "./attachment";
5
- import { normalizeMessages, serializeToolResult } from "./messages";
6
+ import { normalizeMessages, serializeToolResult, toolResultMedia } from "./messages";
6
7
  import { mcpToolsFor } from "./mcpClient";
8
+ import { parsePartialJson } from "./partialJson";
7
9
  import { skillsBlock } from "./skills";
10
+ import { trace } from "./tracing";
8
11
  import {
9
12
  EMPTY_USAGE,
10
13
  resolveProvider,
@@ -29,12 +32,15 @@ import type {
29
32
  AgentMessage,
30
33
  AgentOptions,
31
34
  AgentPendingApproval,
35
+ AgentRunState,
36
+ AgentRunStore,
32
37
  AgentSkill,
33
38
  AgentTextPart,
34
39
  AgentThinkingBlock,
35
40
  AgentThread,
36
41
  AgentProvider,
37
42
  AgentStreamChunk,
43
+ AgentStreamObjectResult,
38
44
  AgentStreamResult,
39
45
  AgentStep,
40
46
  AgentStructuredOutput,
@@ -43,6 +49,10 @@ import type {
43
49
  AgentToolContext,
44
50
  AgentToolMessage,
45
51
  AgentToolResultEntry,
52
+ AgentGuardrail,
53
+ AgentGuardrailContext,
54
+ AgentPricing,
55
+ AgentToolStats,
46
56
  AgentTools,
47
57
  AgentUsage,
48
58
  AgentWorkingMemory,
@@ -264,11 +274,13 @@ async function applyProcessors(
264
274
  function addUsage(base: AgentUsage, next: Partial<AgentUsage>): AgentUsage {
265
275
  const inputTokens = base.inputTokens + (next.inputTokens ?? 0);
266
276
  const outputTokens = base.outputTokens + (next.outputTokens ?? 0);
277
+ const cost = (base.cost ?? 0) + (next.cost ?? 0);
267
278
  return {
268
279
  inputTokens,
269
280
  outputTokens,
270
281
  totalTokens: inputTokens + outputTokens,
271
282
  requests: base.requests + (next.requests ?? 0),
283
+ ...(cost ? { cost } : {}),
272
284
  };
273
285
  }
274
286
 
@@ -328,6 +340,19 @@ function extractJson(text: string): { value?: any; error?: string } {
328
340
  }
329
341
  }
330
342
 
343
+ /**
344
+ * The object of a running answer — `undefined` until the text parses, a partial
345
+ * parse after that. It **drains** the run's text: a caller picks
346
+ * `partialObjectStream` **or** `textStream`, never both.
347
+ */
348
+ async function* partialObjects<T>(text: AsyncIterable<string>): AsyncIterable<T | undefined> {
349
+ let buffer = "";
350
+ for await (const token of text) {
351
+ buffer += token;
352
+ yield parsePartialJson(buffer) as T | undefined;
353
+ }
354
+ }
355
+
331
356
  // ─── Default memory ──────────────────────────────────────────────────────
332
357
  // The stores live in `lib/agentMemory.ts` (`agents.memory.InMemory` / `.Mongo`).
333
358
 
@@ -359,6 +384,142 @@ async function withTimeout<T>(work: Promise<T>, ms: number, name: string): Promi
359
384
  }
360
385
  }
361
386
 
387
+ /** Run `work` over `items` with at most `limit` in flight, preserving order. */
388
+ async function mapLimited<T, R>(items: T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
389
+ const out = new Array<R>(items.length);
390
+ let next = 0;
391
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
392
+ while (true) {
393
+ const index = next++;
394
+ if (index >= items.length) return;
395
+ out[index] = await work(items[index]!, index);
396
+ }
397
+ });
398
+ await Promise.all(workers);
399
+ return out;
400
+ }
401
+
402
+ /** A prompt budget is an order of magnitude: 4 chars ≈ 1 token, no tokenizer dependency. */
403
+ function estimateTokens(message: AgentMessage): number {
404
+ const content = (message as any).content;
405
+ const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
406
+ return Math.ceil((text?.length ?? 0) / 4) + 4;
407
+ }
408
+
409
+ /** Drop the oldest turns until the prompt fits `budget`; the last turn always stays. */
410
+ function trimToTokens(messages: AgentMessage[], budget: number): AgentMessage[] {
411
+ let total = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
412
+ let start = 0;
413
+ while (total > budget && start < messages.length - 1) {
414
+ total -= estimateTokens(messages[start]!);
415
+ start += 1;
416
+ }
417
+ return start ? messages.slice(start) : messages;
418
+ }
419
+
420
+ /** The text a guardrail reads — a message's text parts joined. */
421
+ function messageText(message: AgentMessage | undefined): string {
422
+ if (!message) return "";
423
+ const content = (message as any).content;
424
+ if (typeof content === "string") return content;
425
+ return (content ?? [])
426
+ .filter((part: any) => part?.type === "text")
427
+ .map((part: any) => part.text)
428
+ .join("\n");
429
+ }
430
+
431
+ /**
432
+ * Run one side of the guardrails. A check that throws **blocks the run** with
433
+ * `AGENT_GUARDRAIL_BLOCKED`, naming the guardrail and carrying its own code.
434
+ */
435
+ async function runGuardrails(
436
+ list: AgentGuardrail[] | undefined,
437
+ value: string,
438
+ ctx: AgentGuardrailContext,
439
+ kind: "prompt" | "answer",
440
+ ): Promise<void> {
441
+ for (const guardrail of list ?? []) {
442
+ try {
443
+ await guardrail.check(value, ctx);
444
+ } catch (err: any) {
445
+ throw new AppError(
446
+ `Guardrail '${guardrail.name}' blocked the ${kind}: ${err?.message ?? err}`,
447
+ { status: 400, code: "AGENT_GUARDRAIL_BLOCKED", reason: err?.code ? String(err.code) : undefined },
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Check a **successful** tool result before the model reads it. A guardrail that
455
+ * throws blocks **that result only**: it becomes an error the model sees
456
+ * (`AGENT_GUARDRAIL_BLOCKED`), and the run goes on — a tool result is a turn of
457
+ * the conversation, not the answer. A failed result is not checked.
458
+ */
459
+ async function guardToolResult(
460
+ entry: AgentToolResultEntry,
461
+ list: AgentGuardrail[],
462
+ ctx: AgentGuardrailContext,
463
+ ): Promise<void> {
464
+ if (entry.error || !list.length) return;
465
+
466
+ const value = serializeToolResult(entry);
467
+ const toolCtx: AgentGuardrailContext = { ...ctx, tool: entry.name, toolCallId: entry.id };
468
+
469
+ for (const guardrail of list) {
470
+ try {
471
+ await guardrail.check(value, toolCtx);
472
+ } catch (err: any) {
473
+ entry.error = `Guardrail '${guardrail.name}' blocked the tool result: ${err?.message ?? err}`;
474
+ entry.errorCode = "AGENT_GUARDRAIL_BLOCKED";
475
+ delete entry.result;
476
+ return;
477
+ }
478
+ }
479
+ }
480
+
481
+ /** What a model call cost, in USD, from its token usage. */
482
+ function costOf(usage: AgentUsage, pricing: AgentPricing): number {
483
+ return (usage.inputTokens / 1_000_000) * pricing.input + (usage.outputTokens / 1_000_000) * pricing.output;
484
+ }
485
+
486
+ /** Whether another endpoint may answer — 429, 5xx, network, timeout; never a 4xx. */
487
+ function worthFallback(err: any): boolean {
488
+ // The adapter reports the provider's own status under `reason.status`: a 400 from
489
+ // the upstream endpoint is the request's fault, and another model would fail too.
490
+ const providerStatus = Number(err?.reason?.status ?? 0);
491
+ if (providerStatus) return providerStatus === 429 || providerStatus >= 500;
492
+
493
+ const status = Number(err?.status ?? 0);
494
+ if (status === 429 || status === 504 || status >= 500) return true;
495
+ if (status) return false;
496
+
497
+ const text = String(err?.message ?? err);
498
+ return /network|fetch failed|timed out|timeout|ECONN|socket|ENOTFOUND|EAI_AGAIN/i.test(text);
499
+ }
500
+
501
+ /** Fold one tool result into the run's aggregate (`result.toolStats`). */
502
+ function recordToolStats(stats: AgentToolStats, entry: AgentToolResultEntry): void {
503
+ stats.calls += 1;
504
+ stats.durationMs += entry.durationMs;
505
+ if (!entry.error) return;
506
+ stats.errors += 1;
507
+ const code = entry.errorCode ?? "AGENT_TOOL_FAILED";
508
+ stats.byCode[code] = (stats.byCode[code] ?? 0) + 1;
509
+ if (code === "AGENT_TOOL_TIMEOUT") stats.timeouts += 1;
510
+ }
511
+
512
+ /** Fold a delegated run's stats into its parent's — the calls are the caller's too. */
513
+ function mergeToolStats(base: AgentToolStats, next: AgentToolStats): void {
514
+ base.calls += next.calls;
515
+ base.errors += next.errors;
516
+ base.timeouts += next.timeouts;
517
+ base.durationMs += next.durationMs;
518
+ for (const [code, count] of Object.entries(next.byCode)) {
519
+ base.byCode[code] = (base.byCode[code] ?? 0) + count;
520
+ }
521
+ }
522
+
362
523
  /**
363
524
  * The tool calls the conversation has not answered yet — the ones of its last
364
525
  * assistant turn that carries calls, minus the `tool` turns that follow. That is
@@ -380,11 +541,26 @@ function unansweredToolCalls(messages: AgentMessage[]): AgentToolCall[] {
380
541
 
381
542
  /** The `tool` turn a result becomes in the conversation — one shape, one place. */
382
543
  function toolTurnFor(result: AgentToolResultEntry): AgentMessage {
544
+ const media = toolResultMedia(result);
545
+ if (!media.length) {
546
+ return {
547
+ role: "tool",
548
+ toolCallId: result.id,
549
+ name: result.name,
550
+ content: serializeToolResult(result),
551
+ isError: !!result.error,
552
+ };
553
+ }
554
+ // Media travels **beside** the text: Anthropic accepts image/document blocks in a
555
+ // `tool_result`; a text-only protocol gets the marker `serializeToolResult` built.
556
+ const text = serializeToolResult(result);
557
+ const content: any[] = text ? [{ type: "text", text }] : [];
558
+ content.push(...media);
383
559
  return {
384
560
  role: "tool",
385
561
  toolCallId: result.id,
386
562
  name: result.name,
387
- content: serializeToolResult(result),
563
+ content,
388
564
  isError: !!result.error,
389
565
  };
390
566
  }
@@ -396,10 +572,24 @@ function toolTurnFor(result: AgentToolResultEntry): AgentMessage {
396
572
  * The sub-agent is built per call, bound to the **calling** `rest`: its own tools then
397
573
  * run with the right tenant, session and transaction.
398
574
  */
575
+ /** What a delegated run must inherit from its parent. */
576
+ type Delegation = {
577
+ signal?: AbortSignal;
578
+ maxSteps?: number;
579
+ /**
580
+ * The parent's `emit`, set when the parent **streams**: the child then streams
581
+ * too, and every one of its chunks is re-emitted wrapped as `subagent`.
582
+ */
583
+ emit?: (chunk: AgentStreamChunk) => void;
584
+ /** Folds the child's usage and tool stats into the parent's. */
585
+ onResult?: (result: { usage: AgentUsage; toolStats: AgentToolStats }) => void;
586
+ };
587
+
399
588
  function subAgentTools(
400
589
  declared: AgentConfig | AgentConfig[] | undefined,
401
590
  rest: InstanceType<typeof useRest> | undefined,
402
591
  tenant: string | undefined,
592
+ delegation?: Delegation,
403
593
  ): Array<[string, AgentTool]> {
404
594
  const definitions = !declared ? [] : Array.isArray(declared) ? declared : [declared];
405
595
  const out: Array<[string, AgentTool]> = [];
@@ -421,9 +611,57 @@ function subAgentTools(
421
611
  required: ["input"],
422
612
  additionalProperties: false,
423
613
  },
424
- execute: async ({ input, thread, resource }: any) => {
614
+ execute: async ({ input, thread, resource }: any, ctx) => {
615
+ if (!definition.description) {
616
+ throw ctx.error(`Agent '${id}' has no description — it cannot be delegated to`, {
617
+ status: 500, code: "AGENT_DELEGATION_INVALID",
618
+ });
619
+ }
425
620
  const sub = new Agent(definition, { rest, tenant });
426
- const answer = await sub.generate(input, { thread, resource });
621
+ // The parent's lifetime is the child's: an aborted run must not
622
+ // leave a delegated model call running in the background
623
+ const inherited = {
624
+ thread,
625
+ resource,
626
+ ...(delegation?.signal ? { signal: delegation.signal } : {}),
627
+ ...(delegation?.maxSteps ? { maxSteps: delegation.maxSteps } : {}),
628
+ };
629
+
630
+ // A streaming parent gets a streaming child: the answer is still the
631
+ // tool result, it simply arrives as it is produced too
632
+ if (delegation?.emit) {
633
+ const run = sub.stream(input, inherited);
634
+ for await (const chunk of run.fullStream) {
635
+ delegation.emit({ type: "subagent", name: id, chunk });
636
+ }
637
+ const [text, finishReason, usage, stats] = await Promise.all([
638
+ run.text,
639
+ run.finishReason,
640
+ run.usage,
641
+ run.toolStats,
642
+ ]);
643
+ delegation.onResult?.({ usage, toolStats: stats });
644
+ // A step is all-or-nothing: a child that paused for a decision
645
+ // cannot be resumed through a tool result
646
+ if (finishReason === "approval_required") {
647
+ throw ctx.error(
648
+ `Sub-agent '${id}' paused for approval — delegation cannot carry a decision; call it directly`,
649
+ { status: 409, code: "AGENT_APPROVAL_IN_DELEGATION" },
650
+ );
651
+ }
652
+ return text;
653
+ }
654
+
655
+ const answer = await sub.generate(input, inherited);
656
+ delegation?.onResult?.({ usage: answer.usage, toolStats: answer.toolStats });
657
+ // A step is all-or-nothing: a child that paused for a decision cannot
658
+ // be resumed through a tool result — say so instead of losing the call
659
+ if (answer.pendingApprovals?.length) {
660
+ throw ctx.error(
661
+ `Sub-agent '${id}' paused for approval (${answer.pendingApprovals.map((approval) => approval.name).join(", ")}) — delegation cannot carry a decision; call it directly`,
662
+ { status: 409, code: "AGENT_APPROVAL_IN_DELEGATION" },
663
+ );
664
+ }
427
665
  return answer.text;
428
666
  },
429
667
  }]);
@@ -438,6 +676,8 @@ type RunOptions = {
438
676
  streaming: boolean;
439
677
  controller: AbortController;
440
678
  emit?: (chunk: AgentStreamChunk) => void;
679
+ /** The stored state a `resumeRun()` continues — its conversation and counters. */
680
+ resumeState?: AgentRunState;
441
681
  };
442
682
 
443
683
  class Agent {
@@ -505,10 +745,18 @@ class Agent {
505
745
 
506
746
  // ── Provider ─────────────────────────────────────────────────────────
507
747
 
508
- /** The provider as declared — the API key is **not** included. */
748
+ /** The provider as declared — the API key **and sensitive headers** are not included. */
509
749
  getProvider(): AgentProvider {
510
- const { apiKey, ...rest } = this.#config.provider;
511
- return { ...rest, options: { ...this.#config.provider.options } };
750
+ const { apiKey, headers, ...rest } = this.#config.provider;
751
+ const keys = redactKeys() ?? new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()));
752
+ const safeHeaders = headers
753
+ ? Object.fromEntries(Object.entries(headers).filter(([name]) => !isSensitiveKey(name, keys)))
754
+ : undefined;
755
+ return {
756
+ ...rest,
757
+ ...(safeHeaders && Object.keys(safeHeaders).length ? { headers: safeHeaders } : {}),
758
+ options: { ...this.#config.provider.options },
759
+ };
512
760
  }
513
761
 
514
762
  /** Sampling and transport options as declared (`provider.options`). */
@@ -916,6 +1164,54 @@ class Agent {
916
1164
  return result.object;
917
1165
  }
918
1166
 
1167
+ /**
1168
+ * Continue a run that a crash, a restart or a provider failure interrupted —
1169
+ * from its **last completed step**, without paying for the conversation again.
1170
+ *
1171
+ * ```ts
1172
+ * const answer = await agent.resumeRun('run-42'); // 1 model call, not the whole loop
1173
+ * ```
1174
+ *
1175
+ * The store is the call's `checkpoint`, then the agent's. The resumed run keeps
1176
+ * the same `runId` and store, so its next steps are saved too and its success
1177
+ * clears the state. Completed steps are never replayed through the model: a
1178
+ * checkpoint sits on a step boundary, and the tool results of the last completed
1179
+ * step are already part of the stored conversation — the model is asked for the
1180
+ * **next** step only.
1181
+ */
1182
+ async resumeRun<T = any>(runId: string, options: AgentCallOptions = {}): Promise<AgentGenerateResult<T>> {
1183
+ const store = options.checkpoint ?? this.#config.checkpoint;
1184
+ if (!store) {
1185
+ throw new AppError(
1186
+ `Agent '${this.getId() ?? "?"}' cannot resume run '${runId}': no \`checkpoint\` store`,
1187
+ { status: 400, code: "AGENT_CHECKPOINT_REQUIRED" },
1188
+ );
1189
+ }
1190
+
1191
+ const state = await store.load(runId);
1192
+ if (!state) {
1193
+ throw new AppError(`No checkpoint is stored for run '${runId}'`, {
1194
+ status: 404,
1195
+ code: "AGENT_CHECKPOINT_NOT_FOUND",
1196
+ });
1197
+ }
1198
+
1199
+ // The conversation comes from the state — the input is inert, and so are the
1200
+ // injected turns, the attachments and a paused-run resume of the call
1201
+ const call: AgentCallOptions = { ...options, runId, checkpoint: store };
1202
+ delete call.resume;
1203
+ delete call.messages;
1204
+ delete call.files;
1205
+
1206
+ return this.#run<T>({
1207
+ input: "",
1208
+ call,
1209
+ streaming: false,
1210
+ controller: new AbortController(),
1211
+ resumeState: state,
1212
+ });
1213
+ }
1214
+
919
1215
  /**
920
1216
  * Run the agent and stream the answer token by token.
921
1217
  * The promises of the returned object resolve when the **run** is over
@@ -936,12 +1232,15 @@ class Agent {
936
1232
  const object = deferred<T | undefined>();
937
1233
  const toolCalls = deferred<AgentToolCall[]>();
938
1234
  const toolResults = deferred<AgentToolResultEntry[]>();
1235
+ const toolStats = deferred<AgentToolStats>();
1236
+ const modelUsed = deferred<string | undefined>();
1237
+ const budgetHit = deferred<{ limit: "tokens" | "cost" | "duration"; value: number } | undefined>();
939
1238
  const steps = deferred<AgentStep[]>();
940
1239
  const usage = deferred<AgentUsage>();
941
1240
  const finishReason = deferred<string>();
942
1241
  const messages = deferred<AgentMessage[]>();
943
1242
 
944
- const deferreds = [text, reasoning, object, toolCalls, toolResults, steps, usage, finishReason, messages];
1243
+ const deferreds = [text, reasoning, object, toolCalls, toolResults, toolStats, modelUsed, budgetHit, steps, usage, finishReason, messages];
945
1244
 
946
1245
  // The caller may only consume `textStream` — a rejected promise nobody
947
1246
  // awaits must not become an unhandled rejection.
@@ -957,6 +1256,9 @@ class Agent {
957
1256
  object.resolve(result.object);
958
1257
  toolCalls.resolve(result.toolCalls);
959
1258
  toolResults.resolve(result.toolResults);
1259
+ toolStats.resolve(result.toolStats);
1260
+ modelUsed.resolve(result.model);
1261
+ budgetHit.resolve(result.budget);
960
1262
  steps.resolve(result.steps);
961
1263
  usage.resolve(result.usage);
962
1264
  finishReason.resolve(result.finishReason);
@@ -980,6 +1282,9 @@ class Agent {
980
1282
  object: object.promise,
981
1283
  toolCalls: toolCalls.promise,
982
1284
  toolResults: toolResults.promise,
1285
+ toolStats: toolStats.promise,
1286
+ model: modelUsed.promise,
1287
+ budget: budgetHit.promise,
983
1288
  steps: steps.promise,
984
1289
  usage: usage.promise,
985
1290
  finishReason: finishReason.promise,
@@ -988,6 +1293,36 @@ class Agent {
988
1293
  };
989
1294
  }
990
1295
 
1296
+ /**
1297
+ * Stream the answer into a typed object — `stream()` bounded to a schema.
1298
+ *
1299
+ * Every member of `stream()` is there (`text`, `object`, `usage`,
1300
+ * `fullStream`, `abort`…), plus `partialObjectStream`, which yields a
1301
+ * **best-effort** parse of the answer so far (`undefined` until the partial
1302
+ * JSON parses). The final object is still parsed and validated at the end:
1303
+ * `await result.object` is the one to trust.
1304
+ *
1305
+ * ```ts
1306
+ * const run = agent.streamObject('List 3 cities', { schema });
1307
+ * for await (const partial of run.partialObjectStream) {
1308
+ * if (partial) render(partial);
1309
+ * }
1310
+ * ```
1311
+ *
1312
+ * `partialObjectStream` consumes the run's text: choose it **or**
1313
+ * `textStream`, not both.
1314
+ */
1315
+ streamObject<T = any>(input: AgentInput, options: AgentCallOptions = {}): AgentStreamObjectResult<T> {
1316
+ if (!options.schema && !options.structuredOutput) {
1317
+ throw new AppError("`streamObject` requires a `schema` (or `structuredOutput`)", {
1318
+ status: 500,
1319
+ code: "AGENT_SCHEMA_REQUIRED",
1320
+ });
1321
+ }
1322
+ const run = this.stream<T>(input, options);
1323
+ return { ...run, partialObjectStream: partialObjects<T>(run.textStream) };
1324
+ }
1325
+
991
1326
  // ── Internals ────────────────────────────────────────────────────────
992
1327
 
993
1328
  /** Provider merged with the call-level override — `options` merged too, not replaced. */
@@ -1001,6 +1336,22 @@ class Agent {
1001
1336
  };
1002
1337
  }
1003
1338
 
1339
+ /** The primary provider, then its fallbacks, each with its options merged in. */
1340
+ #providerChain(override?: Partial<AgentProvider>): AgentProvider[] {
1341
+ const primary = this.#provider(override);
1342
+ const fallbacks = primary.fallbacks ?? [];
1343
+ return [
1344
+ primary,
1345
+ ...fallbacks.map((fallback) => ({
1346
+ ...primary,
1347
+ ...fallback,
1348
+ options: { ...primary.options, ...fallback.options },
1349
+ headers: { ...primary.headers, ...fallback.headers },
1350
+ fallbacks: undefined,
1351
+ })),
1352
+ ];
1353
+ }
1354
+
1004
1355
  /**
1005
1356
  * The tools of one run — `true` (all), `false` (none), an array of **names**
1006
1357
  * (an allow-list) or extra tools to merge in.
@@ -1008,14 +1359,14 @@ class Agent {
1008
1359
  * The declared agents and the **remote MCP servers** are resolved here, before the
1009
1360
  * allow-list is applied, so every rule below treats them like any other tool.
1010
1361
  */
1011
- async #collectTools(requested?: boolean | string[] | AgentTools): Promise<Map<string, AgentTool>> {
1362
+ async #collectTools(requested?: boolean | string[] | AgentTools, delegation?: Delegation): Promise<Map<string, AgentTool>> {
1012
1363
  // `tools: false` — the run answers directly (and no remote is even contacted)
1013
1364
  if (requested === false) return new Map();
1014
1365
 
1015
1366
  const tools = new Map(this.#tools);
1016
1367
 
1017
1368
  // The other agents this one may delegate to
1018
- for (const [name, tool] of subAgentTools(this.#config.agents, this.#rest, this._tenant_)) {
1369
+ for (const [name, tool] of subAgentTools(this.#config.agents, this.#rest, this._tenant_, delegation)) {
1019
1370
  if (!tools.has(name)) tools.set(name, tool);
1020
1371
  }
1021
1372
 
@@ -1169,12 +1520,14 @@ class Agent {
1169
1520
  const tool = tools.get(call.name);
1170
1521
  if (!tool) {
1171
1522
  entry.error = `Unknown tool '${call.name}'`;
1523
+ entry.errorCode = "AGENT_TOOL_UNKNOWN";
1172
1524
  return done();
1173
1525
  }
1174
1526
 
1175
1527
  const { value, error } = await validateWithSchema(tool.inputSchema, call.args);
1176
1528
  if (error) {
1177
1529
  entry.error = `Invalid arguments for tool '${call.name}': ${error}`;
1530
+ entry.errorCode = "AGENT_TOOL_ARGS_INVALID";
1178
1531
  return done();
1179
1532
  }
1180
1533
 
@@ -1207,6 +1560,7 @@ class Agent {
1207
1560
  return done();
1208
1561
  } catch (err: any) {
1209
1562
  entry.error = err?.message ?? String(err);
1563
+ entry.errorCode = err?.code ? String(err.code) : "AGENT_TOOL_FAILED";
1210
1564
  return done();
1211
1565
  }
1212
1566
  }
@@ -1214,10 +1568,31 @@ class Agent {
1214
1568
  async #run<T>(run: RunOptions): Promise<AgentGenerateResult<T>> {
1215
1569
  const { input, call, streaming, controller } = run;
1216
1570
  const emit = run.emit;
1571
+ // A `resumeRun()` continues a stored state: its conversation and counters are
1572
+ // the starting point, not the caller's input
1573
+ const resumeState = run.resumeState;
1574
+
1575
+ // The run's signal comes first: the delegated agents inherit it, and the
1576
+ // tools are collected before the first model call
1577
+ const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
1217
1578
 
1218
- const tools = await this.#collectTools(call.tools);
1219
- const provider = this.#provider(call.provider);
1220
- const adapter = resolveProvider(provider);
1579
+ // What delegated runs consume — folded into the parent's usage and stats at
1580
+ // the end. A streaming parent hands its `emit` down, so the child streams too.
1581
+ let delegatedUsage: AgentUsage = { ...EMPTY_USAGE };
1582
+ const delegatedToolStats: AgentToolStats = { calls: 0, errors: 0, byCode: {}, timeouts: 0, durationMs: 0 };
1583
+ const tools = await this.#collectTools(call.tools, {
1584
+ signal,
1585
+ maxSteps: call.maxSteps ?? this.#config.maxSteps,
1586
+ ...(emit ? { emit } : {}),
1587
+ onResult: ({ usage, toolStats: stats }) => {
1588
+ delegatedUsage = addUsage(delegatedUsage, usage);
1589
+ mergeToolStats(delegatedToolStats, stats);
1590
+ },
1591
+ });
1592
+ const chain = this.#providerChain(call.provider);
1593
+ const provider = chain[0]!;
1594
+ // A resumed segment starts from the model that answered before the restart
1595
+ let model = resumeState?.model ?? provider.model;
1221
1596
 
1222
1597
  const structured: AgentStructuredOutput | undefined = call.structuredOutput ?? (call.schema ? { schema: call.schema } : undefined);
1223
1598
  const schema = structured?.schema;
@@ -1233,18 +1608,44 @@ class Agent {
1233
1608
  // `…Id` spellings are still read as aliases.
1234
1609
  const thread = call.memory?.thread ?? call.memory?.threadId ?? call.thread ?? call.threadId;
1235
1610
  const resource = call.memory?.resource ?? call.memory?.resourceId ?? call.resource ?? call.resourceId;
1236
- const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
1237
1611
 
1238
1612
  // Per-tool deadline and tool parallelism: the call wins over the declaration
1239
1613
  const toolTimeout = call.toolTimeoutMs ?? this.#config.toolTimeoutMs;
1240
1614
  const parallel = call.parallelTools ?? this.#config.parallelTools ?? false;
1615
+ const maxParallelToolCalls = Math.max(
1616
+ 1,
1617
+ Math.trunc(call.maxParallelToolCalls ?? this.#config.maxParallelToolCalls ?? 8),
1618
+ );
1241
1619
  const savePartial = call.savePartial ?? this.#config.savePartial ?? false;
1242
1620
 
1621
+ // Durable checkpoint: the call's store wins over the declaration's, and both
1622
+ // the store and a run id are required — a run without identity cannot be
1623
+ // resumed, so it is not saved either
1624
+ const checkpointStore = call.checkpoint ?? this.#config.checkpoint;
1625
+ const runId = call.runId;
1626
+ const checkpointing = !!checkpointStore && !!runId;
1627
+
1628
+ // Run budget — a run that reaches it stops **cleanly**, it does not throw
1629
+ const maxTokens = call.maxTokens ?? this.#config.maxTokens;
1630
+ const maxDurationMs = call.maxDurationMs ?? this.#config.maxDurationMs;
1631
+ const maxCost = call.maxCost ?? this.#config.maxCost;
1632
+ const contextTokens = call.contextTokens ?? this.#config.contextTokens;
1633
+ const startedAt = Date.now();
1634
+ let budget: { limit: "tokens" | "cost" | "duration"; value: number } | undefined;
1635
+
1636
+ // Guardrails of this run: the declaration's, then the call's
1637
+ const inputGuardrails = [...(this.#config.guardrails?.input ?? []), ...(call.guardrails?.input ?? [])];
1638
+ const outputGuardrails = [...(this.#config.guardrails?.output ?? []), ...(call.guardrails?.output ?? [])];
1639
+ const toolGuardrails = [...(this.#config.guardrails?.tool ?? []), ...(call.guardrails?.tool ?? [])];
1640
+
1243
1641
  // Resuming a paused run: the conversation is the caller's — the paused step is
1244
- // resolved from it instead of being asked of the model again
1245
- const resuming = call.resume ? await normalizeMessages(call.resume.messages) : undefined;
1642
+ // resolved from it instead of being asked of the model again. A checkpoint
1643
+ // resume has the same shape, with the stored conversation as the whole
1644
+ // history (already shaped by the processors of the interrupted run).
1645
+ const carried = resumeState?.messages;
1646
+ const resuming = !carried && call.resume ? await normalizeMessages(call.resume.messages) : undefined;
1246
1647
 
1247
- const history = resuming ?? (thread && this.#config.memory
1648
+ const history = carried ?? resuming ?? (thread && this.#config.memory
1248
1649
  ? [...(await this.#config.memory.get(thread, this.#memoryCtx(resource)))]
1249
1650
  : []);
1250
1651
 
@@ -1252,31 +1653,56 @@ class Agent {
1252
1653
  // history and before the question, and saved with the rest of the
1253
1654
  // conversation. A trailing assistant turn is legitimate ("continue from
1254
1655
  // here"), so the block is not forced to end on a user turn.
1255
- const injected = !resuming && call.messages ? await normalizeMessages(call.messages) : [];
1656
+ const injected = !carried && !resuming && call.messages ? await normalizeMessages(call.messages) : [];
1256
1657
 
1257
1658
  // What this run **adds to the thread** — the injected turns and the input,
1258
1659
  // with the attachments on the turn being asked. The history window and the
1259
1660
  // `load` processors only shape the prompt: neither reaches the store.
1260
1661
  // A resumed run adds nothing: its input **is** the conversation it was given.
1261
- const additions: AgentMessage[] = resuming ? [] : [...injected, ...(await normalizeMessages(input))];
1662
+ const additions: AgentMessage[] = carried || resuming ? [] : [...injected, ...(await normalizeMessages(input))];
1262
1663
 
1263
1664
  // Attachments of this run — read now, attached to the turn being asked
1264
- if (!resuming && call.files) {
1665
+ if (!carried && !resuming && call.files) {
1265
1666
  const parts = await resolveAttachments(call.files, { baseDir: call.filesBaseDir });
1266
1667
  if (parts.length) attachToTurn(additions, parts);
1267
1668
  }
1268
1669
 
1269
1670
  // The history window: the thread is what the store keeps, the window is what
1270
- // the prompt replays (a cheap bound on the prompt)
1671
+ // the prompt replays (a cheap bound on the prompt). A carried conversation is
1672
+ // continued as-is — the interrupted run already applied both bounds to it.
1271
1673
  const window = call.lastMessages ?? this.#config.lastMessages;
1272
- const replay = window && window > 0 ? trimMessages(history, window) : history;
1674
+ const replay = carried ?? (window && window > 0 ? trimMessages(history, window) : history);
1273
1675
 
1274
1676
  const memoryCtx = this.#memoryCtx(resource, thread);
1275
1677
  let messages: AgentMessage[] = [...replay, ...additions];
1276
1678
 
1277
- // Memory processors, `load` side: they see exactly what the model will see
1278
- // (history, injected turns, input and attachments included)
1279
- messages = await applyProcessors(this.#config.processors, "load", messages, memoryCtx);
1679
+ if (!carried) {
1680
+ // Memory processors, `load` side: they see exactly what the model will
1681
+ // see (history, injected turns, input and attachments included)
1682
+ messages = await applyProcessors(this.#config.processors, "load", messages, memoryCtx);
1683
+
1684
+ // The prompt budget, in tokens: the oldest turns are dropped first. It runs
1685
+ // after the processors (they may have shortened the thread) and before the
1686
+ // guardrails (which read the caller's prompt, not the window).
1687
+ if (contextTokens && contextTokens > 0) messages = trimToTokens(messages, contextTokens);
1688
+ }
1689
+
1690
+ // Input guardrails — the caller's prompt, before any model call
1691
+ const guardrailCtx: AgentGuardrailContext = { agent: this, tenant: this._tenant_, thread, resource, signal };
1692
+ const lastUser = [...messages].reverse().find((message) => message.role === "user");
1693
+ await runGuardrails(inputGuardrails, messageText(lastUser), guardrailCtx, "prompt");
1694
+
1695
+ // Tracing — the caller's hook (the call's wins over the declaration's). It
1696
+ // sees plain events; turning them into spans is its job, not the run's.
1697
+ const traceHook = call.onTrace ?? this.#config.onTrace;
1698
+ trace(traceHook, {
1699
+ type: "run.start",
1700
+ at: Date.now(),
1701
+ ...(this.#config.id ? { agentId: this.#config.id } : {}),
1702
+ ...(call.runId ? { runId: call.runId } : {}),
1703
+ ...(thread ? { thread } : {}),
1704
+ ...(resource ? { resource } : {}),
1705
+ });
1280
1706
 
1281
1707
  let system = call.instructions ?? await this.resolveInstructions({ thread, resource });
1282
1708
 
@@ -1303,11 +1729,87 @@ class Agent {
1303
1729
  const steps: AgentStep[] = [];
1304
1730
  const allToolCalls: AgentToolCall[] = [];
1305
1731
  const allToolResults: AgentToolResultEntry[] = [];
1306
- let usage: AgentUsage = { ...EMPTY_USAGE };
1732
+ // A resumed segment carries what the run already spent — a budget sees the
1733
+ // whole run, not just the part after the restart
1734
+ const toolStats: AgentToolStats = resumeState
1735
+ ? { ...resumeState.toolStats, byCode: { ...resumeState.toolStats.byCode } }
1736
+ : { calls: 0, errors: 0, byCode: {}, timeouts: 0, durationMs: 0 };
1737
+ let usage: AgentUsage = resumeState ? { ...resumeState.usage } : { ...EMPTY_USAGE };
1307
1738
  let text = "";
1308
1739
  let reasoning = "";
1309
1740
  let finishReason = "stop";
1310
1741
 
1742
+ // The durable checkpoint — written at a **step boundary** only: the assistant
1743
+ // turn and every tool turn it asked for are in, so the stored conversation is
1744
+ // always valid to replay. Best-effort: a store that fails is logged, never
1745
+ // fatal — the run is worth more than its checkpoint.
1746
+ const saveCheckpoint = async (completed: number): Promise<void> => {
1747
+ if (!checkpointing) return;
1748
+ const state: AgentRunState = {
1749
+ runId: runId!,
1750
+ ...(this.#config.id ? { agentId: this.#config.id } : {}),
1751
+ step: completed,
1752
+ messages: messages.map((message) => ({ ...message })),
1753
+ usage: { ...usage },
1754
+ toolStats: { ...toolStats, byCode: { ...toolStats.byCode } },
1755
+ ...(model ? { model } : {}),
1756
+ finishReason,
1757
+ at: new Date().toISOString(),
1758
+ };
1759
+ try {
1760
+ await checkpointStore!.save(runId!, state);
1761
+ } catch (err: any) {
1762
+ console.error(`The checkpoint of run '${runId}' was not saved: ${err?.message ?? err}`);
1763
+ }
1764
+ };
1765
+
1766
+ // The end of the run — traced once, on the answer or on the way out. The
1767
+ // delegated work is folded in first: it is part of what the caller pays for.
1768
+ const endRun = (reason: string, error?: unknown): void => {
1769
+ usage = addUsage(usage, delegatedUsage);
1770
+ mergeToolStats(toolStats, delegatedToolStats);
1771
+ trace(traceHook, {
1772
+ type: "run.end",
1773
+ at: Date.now(),
1774
+ finishReason: reason,
1775
+ durationMs: Date.now() - startedAt,
1776
+ usage,
1777
+ toolStats,
1778
+ ...(error !== undefined
1779
+ ? { error: String((error as any)?.message ?? error).split("\n")[0] }
1780
+ : {}),
1781
+ });
1782
+ };
1783
+
1784
+ // One tool call, traced — the start before it runs, the end after, whichever
1785
+ // path executes it (sequential, parallel, or a resumed step)
1786
+ const runTool = async (toolCall: AgentToolCall, step: number): Promise<AgentToolResultEntry> => {
1787
+ const toolStartedAt = Date.now();
1788
+ trace(traceHook, {
1789
+ type: "tool.start",
1790
+ at: toolStartedAt,
1791
+ step,
1792
+ tool: toolCall.name,
1793
+ toolCallId: toolCall.id,
1794
+ });
1795
+ let result: AgentToolResultEntry | undefined;
1796
+ try {
1797
+ result = await this.#executeTool(toolCall, tools, step, signal, toolTimeout);
1798
+ return result;
1799
+ } finally {
1800
+ const endedAt = Date.now();
1801
+ trace(traceHook, {
1802
+ type: "tool.end",
1803
+ at: endedAt,
1804
+ step,
1805
+ tool: toolCall.name,
1806
+ toolCallId: toolCall.id,
1807
+ durationMs: endedAt - toolStartedAt,
1808
+ ...(result?.errorCode ? { errorCode: result.errorCode } : {}),
1809
+ });
1810
+ }
1811
+ };
1812
+
1311
1813
  // What the model may use, and how — the framework picks no tool itself
1312
1814
  const toolChoice = call.toolChoice ?? this.#config.toolChoice ?? "auto";
1313
1815
 
@@ -1337,7 +1839,10 @@ class Agent {
1337
1839
  }
1338
1840
  const result: AgentToolResultEntry = decision === false
1339
1841
  ? { id: toolCall.id, name: toolCall.name, args: toolCall.args, durationMs: 0, error: "Refused by the caller" }
1340
- : await this.#executeTool(toolCall, tools, 0, signal, toolTimeout);
1842
+ : await runTool(toolCall, 0);
1843
+ // Checked before it enters the conversation — a blocked result is an
1844
+ // error the model reads, and the run goes on
1845
+ if (decision !== false) await guardToolResult(result, toolGuardrails, guardrailCtx);
1341
1846
  const turn = toolTurnFor(result);
1342
1847
  messages.push(turn);
1343
1848
  additions.push({ ...turn });
@@ -1347,13 +1852,63 @@ class Agent {
1347
1852
  }
1348
1853
  }
1349
1854
 
1855
+ // One model call, with the fallbacks: an endpoint that is rate-limited, down or
1856
+ // unreachable hands over to the next one — as long as nothing was streamed yet
1857
+ let streamed = false;
1858
+ const guardedEmit = emit
1859
+ ? (chunk: AgentStreamChunk) => { streamed = true; emit(chunk); }
1860
+ : undefined;
1861
+ const chat = async (request: ModelChatRequest, step: number) => {
1862
+ const callStartedAt = Date.now();
1863
+ let lastError: any;
1864
+ for (const candidate of chain) {
1865
+ const adapter = resolveProvider(candidate);
1866
+ try {
1867
+ const turn = streaming && guardedEmit
1868
+ ? await this.#chatStream(adapter, request, guardedEmit)
1869
+ : await this.#chat(adapter, request);
1870
+ model = candidate.model;
1871
+ if (candidate.pricing) {
1872
+ turn.usage = { ...turn.usage, cost: costOf(turn.usage, candidate.pricing) };
1873
+ }
1874
+ const endedAt = Date.now();
1875
+ trace(traceHook, {
1876
+ type: "model.end",
1877
+ at: endedAt,
1878
+ step,
1879
+ durationMs: endedAt - callStartedAt,
1880
+ // What answered, not what was tried: a fallback is the model
1881
+ model: candidate.model,
1882
+ usage: turn.usage,
1883
+ finishReason: turn.finishReason,
1884
+ });
1885
+ return turn;
1886
+ } catch (err: any) {
1887
+ lastError = err;
1888
+ // A stream that already reached the caller cannot be restarted
1889
+ if (signal.aborted || streamed || !worthFallback(err)) throw err;
1890
+ }
1891
+ }
1892
+ throw lastError;
1893
+ };
1894
+
1350
1895
  try {
1351
- // Nothing to ask the model while a decision is pending
1352
- for (let step = 0; !pending.length && step < maxSteps; step++) {
1896
+ // Nothing to ask the model while a decision is pending. A resumed run
1897
+ // starts at the **next** step: the completed ones are never replayed through
1898
+ // the model, they are simply already in the conversation.
1899
+ for (let step = resumeState?.step ?? 0; !pending.length && step < maxSteps; step++) {
1900
+ trace(traceHook, { type: "step.start", at: Date.now(), step });
1353
1901
  if (signal.aborted) {
1354
1902
  finishReason = "aborted";
1355
1903
  break;
1356
1904
  }
1905
+ // The wall-clock budget is checked between steps: a model call already
1906
+ // started is bounded by its own `provider.options.timeout`
1907
+ if (maxDurationMs !== undefined && Date.now() - startedAt >= maxDurationMs) {
1908
+ finishReason = "budget";
1909
+ budget = { limit: "duration", value: maxDurationMs };
1910
+ break;
1911
+ }
1357
1912
 
1358
1913
  const request: ModelChatRequest = {
1359
1914
  system,
@@ -1370,9 +1925,7 @@ class Agent {
1370
1925
  : {}),
1371
1926
  };
1372
1927
 
1373
- const turn = streaming && emit
1374
- ? await this.#chatStream(adapter, request, emit)
1375
- : await this.#chat(adapter, request);
1928
+ const turn = await chat(request, step);
1376
1929
 
1377
1930
  usage = addUsage(usage, turn.usage);
1378
1931
  text = turn.text;
@@ -1401,10 +1954,45 @@ class Agent {
1401
1954
  additions.push({ ...assistantTurn });
1402
1955
  allToolCalls.push(...turn.toolCalls);
1403
1956
 
1957
+ // The token/cost budget is checked **after** the assistant turn is in
1958
+ // the conversation: the paid answer is kept, and the calls it asked for
1959
+ // are answered with a refusal so the thread stays valid for a replay
1960
+ const overTokens = maxTokens !== undefined && usage.totalTokens > maxTokens;
1961
+ const overCost = maxCost !== undefined && (usage.cost ?? 0) > maxCost;
1962
+ if (overTokens || overCost) {
1963
+ for (const toolCall of turn.toolCalls) {
1964
+ const skipped: AgentToolResultEntry = {
1965
+ id: toolCall.id,
1966
+ name: toolCall.name,
1967
+ args: toolCall.args,
1968
+ durationMs: 0,
1969
+ error: "Skipped: the run budget was exhausted",
1970
+ errorCode: "AGENT_BUDGET_EXHAUSTED",
1971
+ };
1972
+ current.toolResults.push(skipped);
1973
+ allToolResults.push(skipped);
1974
+ recordToolStats(toolStats, skipped);
1975
+ const toolTurn = toolTurnFor(skipped);
1976
+ messages.push(toolTurn);
1977
+ additions.push({ ...toolTurn });
1978
+ emit?.({ type: "tool_result", toolResult: skipped });
1979
+ }
1980
+ budget = overTokens
1981
+ ? { limit: "tokens", value: maxTokens! }
1982
+ : { limit: "cost", value: maxCost! };
1983
+ finishReason = "budget";
1984
+ steps.push(current);
1985
+ await call.onStepFinish?.(current);
1986
+ emit?.({ type: "step", step: current });
1987
+ await saveCheckpoint(step + 1);
1988
+ break;
1989
+ }
1990
+
1404
1991
  if (!turn.toolCalls.length) {
1405
1992
  finishReason = turn.finishReason;
1406
1993
  steps.push(current);
1407
1994
  await call.onStepFinish?.(current);
1995
+ await saveCheckpoint(step + 1);
1408
1996
  break;
1409
1997
  }
1410
1998
 
@@ -1424,23 +2012,33 @@ class Agent {
1424
2012
  }
1425
2013
 
1426
2014
  // The tools of one step: sequential by default (two writes must not
1427
- // interleave), together when the caller asks for it. The results are
1428
- // attached in the model's order either way.
2015
+ // interleave), together when the caller asks for it **or** when every
2016
+ // call is read-only. The results are attached in the model's order
2017
+ // either way, and at most `maxParallelToolCalls` run at once.
1429
2018
  const entries: AgentToolResultEntry[] = [];
1430
- if (parallel) {
1431
- entries.push(...await Promise.all(
1432
- turn.toolCalls.map((toolCall) => this.#executeTool(toolCall, tools, step, signal, toolTimeout)),
2019
+ const readOnlyStep = turn.toolCalls.length > 1
2020
+ && turn.toolCalls.every((toolCall) => tools.get(toolCall.name)?.readOnly === true);
2021
+ if ((parallel || readOnlyStep) && turn.toolCalls.length > 1) {
2022
+ entries.push(...await mapLimited(
2023
+ turn.toolCalls,
2024
+ maxParallelToolCalls,
2025
+ (toolCall) => runTool(toolCall, step),
1433
2026
  ));
1434
2027
  } else {
1435
2028
  for (const toolCall of turn.toolCalls) {
1436
- entries.push(await this.#executeTool(toolCall, tools, step, signal, toolTimeout));
2029
+ entries.push(await runTool(toolCall, step));
1437
2030
  }
1438
2031
  }
1439
2032
 
2033
+ // Tool guardrails: a **successful** result is checked before the model
2034
+ // reads it — a blocked one becomes an error and the run goes on
2035
+ for (const result of entries) await guardToolResult(result, toolGuardrails, guardrailCtx);
2036
+
1440
2037
  turn.toolCalls.forEach((toolCall, index) => {
1441
2038
  const result = entries[index]!;
1442
2039
  current.toolResults.push(result);
1443
2040
  allToolResults.push(result);
2041
+ recordToolStats(toolStats, result);
1444
2042
  const toolTurn = toolTurnFor(result);
1445
2043
  messages.push(toolTurn);
1446
2044
  additions.push({ ...toolTurn });
@@ -1452,6 +2050,7 @@ class Agent {
1452
2050
  emit?.({ type: "step", step: current });
1453
2051
  finishReason = turn.finishReason;
1454
2052
  if (step === maxSteps - 1) finishReason = "max_steps";
2053
+ await saveCheckpoint(step + 1);
1455
2054
  }
1456
2055
  } catch (err: any) {
1457
2056
  if (signal.aborted) {
@@ -1462,6 +2061,7 @@ class Agent {
1462
2061
  if (savePartial && thread && this.#config.memory && !call.memory?.readOnly) {
1463
2062
  await this.#persist(thread, [...history, ...additions], memoryCtx).catch(() => {});
1464
2063
  }
2064
+ endRun("error", err);
1465
2065
  throw err;
1466
2066
  }
1467
2067
  }
@@ -1471,53 +2071,62 @@ class Agent {
1471
2071
 
1472
2072
  // Structured output — one repair turn, then give up with a stable code.
1473
2073
  let object: T | undefined;
1474
- if (schema && !pending.length) {
1475
- const attempt = extractJson(text);
1476
- let candidate: any = attempt.value;
1477
- let failure = attempt.error;
1478
- if (!failure) {
1479
- const validated = await validateWithSchema(schema, candidate);
1480
- if (validated.error) failure = validated.error;
1481
- else candidate = validated.value;
1482
- }
1483
-
1484
- if (failure && text.trim()) {
1485
- // One repair turn — `messages` already ends with the rejected answer.
1486
- const repair = [...messages, {
1487
- role: "user" as const,
1488
- content: `That answer was rejected: ${failure}. Reply with a single valid JSON object matching the schema — no markdown, no commentary.`,
1489
- }];
1490
- const turn = await this.#chat(adapter, {
1491
- system,
1492
- messages: repair,
1493
- temperature: 0,
1494
- signal,
1495
- json: true,
1496
- ...(jsonSchema ? { jsonSchema: { name: schemaName, schema: jsonSchema } } : {}),
1497
- });
1498
-
1499
- usage = addUsage(usage, turn.usage);
1500
- text = turn.text;
1501
- messages.push({ role: "assistant", content: text });
1502
- additions.push({ role: "assistant", content: text });
1503
-
1504
- const retry = extractJson(text);
1505
- failure = retry.error;
2074
+ try {
2075
+ if (schema && !pending.length) {
2076
+ const attempt = extractJson(text);
2077
+ let candidate: any = attempt.value;
2078
+ let failure = attempt.error;
1506
2079
  if (!failure) {
1507
- const validated = await validateWithSchema(schema, retry.value);
2080
+ const validated = await validateWithSchema(schema, candidate);
1508
2081
  if (validated.error) failure = validated.error;
1509
2082
  else candidate = validated.value;
1510
2083
  }
1511
- }
1512
2084
 
1513
- if (failure) {
1514
- throw new AppError(`Agent '${this.#config.id ?? "?"}' did not return valid structured output: ${failure}`, {
1515
- status: 502,
1516
- code: "AGENT_OUTPUT_INVALID",
1517
- reason: text,
1518
- });
2085
+ if (failure && text.trim()) {
2086
+ // One repair turn — `messages` already ends with the rejected answer.
2087
+ const repair = [...messages, {
2088
+ role: "user" as const,
2089
+ content: `That answer was rejected: ${failure}. Reply with a single valid JSON object matching the schema — no markdown, no commentary.`,
2090
+ }];
2091
+ const turn = await chat({
2092
+ system,
2093
+ messages: repair,
2094
+ temperature: 0,
2095
+ signal,
2096
+ json: true,
2097
+ ...(jsonSchema ? { jsonSchema: { name: schemaName, schema: jsonSchema } } : {}),
2098
+ }, Math.max(0, steps.length - 1));
2099
+
2100
+ usage = addUsage(usage, turn.usage);
2101
+ text = turn.text;
2102
+ messages.push({ role: "assistant", content: text });
2103
+ additions.push({ role: "assistant", content: text });
2104
+
2105
+ const retry = extractJson(text);
2106
+ failure = retry.error;
2107
+ if (!failure) {
2108
+ const validated = await validateWithSchema(schema, retry.value);
2109
+ if (validated.error) failure = validated.error;
2110
+ else candidate = validated.value;
2111
+ }
2112
+ }
2113
+
2114
+ if (failure) {
2115
+ throw new AppError(`Agent '${this.#config.id ?? "?"}' did not return valid structured output: ${failure}`, {
2116
+ status: 502,
2117
+ code: "AGENT_OUTPUT_INVALID",
2118
+ reason: text,
2119
+ });
2120
+ }
2121
+ object = candidate as T;
1519
2122
  }
1520
- object = candidate as T;
2123
+
2124
+ // Output guardrails — the answer is checked **before** it is stored or
2125
+ // returned: a blocked answer is not persisted, and the caller gets the reason
2126
+ await runGuardrails(outputGuardrails, text, guardrailCtx, "answer");
2127
+ } catch (err) {
2128
+ endRun("error", err);
2129
+ throw err;
1521
2130
  }
1522
2131
 
1523
2132
  // A paused run stores **nothing**: the caller resumes with the conversation it
@@ -1531,12 +2140,31 @@ class Agent {
1531
2140
  }
1532
2141
  }
1533
2142
 
2143
+ // A run that came back with an answer has nothing to resume: its state is
2144
+ // cleared. A paused or aborted run keeps it — that is what the next call
2145
+ // continues from. Best-effort: a store that fails is logged, never fatal.
2146
+ if (checkpointing && !pending.length && finishReason !== "aborted" && finishReason !== "error") {
2147
+ try {
2148
+ await checkpointStore!.clear(runId!);
2149
+ } catch (err: any) {
2150
+ console.error(`The checkpoint of run '${runId}' was not cleared: ${err?.message ?? err}`);
2151
+ }
2152
+ }
2153
+
2154
+ // A delegated run's tokens and cost are the parent's: they are what the caller
2155
+ // pays for, and what a budget must see — `endRun` folds them in before
2156
+ // emitting the final event.
2157
+ endRun(finishReason);
2158
+
1534
2159
  return {
1535
2160
  text,
2161
+ model,
2162
+ ...(budget ? { budget } : {}),
1536
2163
  ...(object !== undefined ? { object } : {}),
1537
2164
  ...(reasoning ? { reasoning } : {}),
1538
2165
  toolCalls: allToolCalls,
1539
2166
  toolResults: allToolResults,
2167
+ toolStats,
1540
2168
  steps,
1541
2169
  usage,
1542
2170
  finishReason,