bazis 0.138.0 → 0.140.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.
Files changed (44) hide show
  1. package/README.md +13 -5
  2. package/core/access/Access.module.ts +6 -1
  3. package/core/access/catalog/AccessCatalog.ts +56 -7
  4. package/core/access/catalog/IAccessCatalog.service.ts +28 -2
  5. package/core/access/catalog/PermissionSourceLoader.ts +43 -0
  6. package/core/access/catalog/PermissionSources.ts +222 -0
  7. package/core/access/catalog/moduleFields.ts +60 -6
  8. package/core/access/catalog/permissions.ts +46 -1
  9. package/core/access/check/Access.ts +55 -28
  10. package/core/access/check/accessConfig.ts +9 -1
  11. package/core/access/check/can.ts +2 -2
  12. package/core/access/check/conditions.ts +69 -11
  13. package/core/access/check/decimal.ts +61 -0
  14. package/core/access/check/filter.ts +39 -8
  15. package/core/access/contracts/PermissionSource.ts +32 -0
  16. package/core/access/contracts/types.ts +12 -2
  17. package/core/access/generated/deps.ts +5 -1
  18. package/core/access/http/AccessCatalog.controller.ts +9 -2
  19. package/core/access/index.ts +12 -1
  20. package/core/agent/AgentContextBuilder.ts +119 -16
  21. package/core/agent/AgentRuntime.ts +8 -0
  22. package/core/agent/AgentToolExecutor.ts +7 -0
  23. package/core/agent/ModelProvider.ts +6 -0
  24. package/core/agent/README.md +35 -3
  25. package/core/agent/errors.ts +32 -0
  26. package/core/agent/index.ts +7 -0
  27. package/core/agent/internal/AgentExecutionDriver.ts +598 -24
  28. package/core/agent/internal/AgentExecutionState.ts +26 -3
  29. package/core/agent/semantic.ts +60 -1
  30. package/core/cache/SPEC.md +3 -3
  31. package/core/di/README.md +10 -3
  32. package/core/infra/anthropic.ts +11 -0
  33. package/core/infra/connectors/anthropic.ts +440 -0
  34. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  35. package/core/infra/connectors/llm.ts +12 -0
  36. package/core/infra/connectors/llmRouter.ts +12 -0
  37. package/core/infra/connectors/openaiCompatible.ts +128 -63
  38. package/core/infra/connectors/openaiTextStream.ts +5 -2
  39. package/core/infra/connectors/toolNames.ts +49 -0
  40. package/core/scripts/di-generate.ts +34 -4
  41. package/core/websocket/DESIGN.md +2 -2
  42. package/index.ts +5 -0
  43. package/library/orm/Repository/README.md +14 -11
  44. package/package.json +7 -2
@@ -4,13 +4,15 @@ import type { ServiceProvider } from "../../di";
4
4
  import { getGeneratedOpenApiMetadata } from "../../http/OpenApi/generatedOpenApiRegistry";
5
5
  import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
6
6
  import type { Logger } from "../../kernel/logging/Logger";
7
- import { AgentModelTimeoutError, AgentRuntimeError } from "../errors";
7
+ import { AgentModelContextOverflowError, AgentModelTimeoutError, AgentModelUnavailableError, AgentRuntimeError } from "../errors";
8
8
  import { validateAgentJsonSchema } from "./AgentJsonSchema";
9
9
  import type { AgentDefinition, AgentRegistry } from "../AgentRegistry";
10
10
  import {
11
+ ApproximateAgentContextTokenEstimator,
11
12
  DefaultAgentContextBuilder,
12
13
  mergeAgentContextLimits,
13
14
  normalizeAgentContextLimits,
15
+ type AgentContext,
14
16
  type AgentContextBuilder,
15
17
  type AgentContextLimits,
16
18
  } from "../AgentContextBuilder";
@@ -46,13 +48,74 @@ import {
46
48
  type AgentToolError,
47
49
  type AgentToolContract,
48
50
  type AgentToolResult,
51
+ type AgentUsage,
49
52
  type JsonObject,
50
53
  type JsonValue,
51
54
  } from "../semantic";
52
55
 
53
- const DEFAULT_MAX_STEPS = 8;
56
+ const DEFAULT_MAX_STEPS = 30;
54
57
  const DEFAULT_MAX_TOOL_CALLS_PER_STEP = 16;
55
58
  const DEFAULT_MODEL_TIMEOUT_MS = 60_000;
59
+ const DEFAULT_MODEL_ATTEMPTS = 3;
60
+ const DEFAULT_MODEL_RETRY_DELAY_MS = 500;
61
+ const DEFAULT_MODEL_RETRY_MAX_DELAY_MS = 20_000;
62
+ const DEFAULT_MAX_OUTPUT_CONTINUATIONS = 3;
63
+ const DEFAULT_MAX_OUTPUT_REPAIRS = 2;
64
+ const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 8;
65
+
66
+ /** `message.metadata.runtime` of the messages the runtime writes itself. */
67
+ const RUNTIME_CONTINUE = "continue-output";
68
+ const RUNTIME_REPAIR = "repair-output";
69
+ const CONTINUE_TEXT = "Your previous message was cut off by the output limit. Continue exactly where it stopped: do not repeat, apologize or summarize what is already written.";
70
+
71
+ /** How a model request that the provider could not serve right now is repeated. */
72
+ export interface AgentModelRetryPolicy {
73
+ /** How many times one model request is sent, the first one included. 3 by default; 1 turns repeats off. */
74
+ readonly maxAttempts?: number;
75
+ /** The wait before the second attempt, doubled before each next one. 500 ms by default. */
76
+ readonly delayMs?: number;
77
+ /** The longest single wait, a provider's `Retry-After` included. 20 seconds by default. */
78
+ readonly maxDelayMs?: number;
79
+ }
80
+
81
+ /** `message.metadata.runtime` of the message that stands for the compacted part of a dialog. */
82
+ const RUNTIME_SUMMARY = "summary";
83
+ /** Compaction starts at this share of the model's context window, leaving room for the answer. */
84
+ const COMPACTION_WINDOW_SHARE = 0.8;
85
+ const SUMMARY_PREFIX = "Summary of the earlier part of this conversation (the original messages were replaced by it):";
86
+ const SUMMARY_INSTRUCTIONS = [
87
+ "You summarize a conversation between a user and an AI assistant that uses tools, so that the assistant can continue the work without the original messages.",
88
+ "Keep everything needed to continue:",
89
+ "1. What the user asked for and wants: every request, in the user's own words where the wording matters.",
90
+ "2. Facts, names, identifiers, numbers and results obtained from tools that may be needed later.",
91
+ "3. What has been done and decided, and why.",
92
+ "4. What is in progress and what remains to be done.",
93
+ "5. Constraints, preferences and corrections the user gave.",
94
+ "Write plainly, in the language of the conversation. Do not invent anything, do not address the user, do not call tools. Output only the summary.",
95
+ ].join("\n");
96
+
97
+ /** When and how the older part of a long dialog is replaced by a summary. */
98
+ export interface AgentCompactionOptions {
99
+ /**
100
+ * The dialog size, in tokens, above which the older part is summarized.
101
+ * By default 80% of the model's context window, when the LLM connector
102
+ * states it (`contextTokens`); without both, only a provider's refusal of a
103
+ * request as too long starts compaction.
104
+ */
105
+ readonly maxTokens?: number;
106
+ /** How much of the newest dialog stays as written, in tokens. A quarter of `maxTokens` by default. */
107
+ readonly keepTokens?: number;
108
+ /** The model profile that writes the summary; the run's own by default. */
109
+ readonly modelProfile?: string;
110
+ /** Instructions for the summary instead of the built-in ones. */
111
+ readonly instructions?: string;
112
+ }
113
+
114
+ interface ModelRetrySettings {
115
+ readonly maxAttempts: number;
116
+ readonly delayMs: number;
117
+ readonly maxDelayMs: number;
118
+ }
56
119
 
57
120
  export type AgentRuntimeStatus = "completed" | "failed";
58
121
 
@@ -78,6 +141,33 @@ export interface AgentRuntimeOptions {
78
141
  readonly modelProfile?: string;
79
142
  /** Provider-call timeout. Defaults to 60 seconds; 0 explicitly disables it. */
80
143
  readonly timeoutMs?: number;
144
+ /** The deadline of a whole run: every model call, Tool call and wait. No deadline by default. */
145
+ readonly runTimeoutMs?: number;
146
+ /** Repeats of a model request the provider could not serve right now (429, 5xx, a broken connection). */
147
+ readonly modelRetry?: AgentModelRetryPolicy;
148
+ /**
149
+ * How many times the model is asked to go on when the output limit cut its
150
+ * text answer (`finishReason: "length"`). 3 by default; 0 fails the run at once.
151
+ */
152
+ readonly maxOutputContinuations?: number;
153
+ /**
154
+ * How many times the model is asked to correct a structured answer that is
155
+ * not valid JSON or does not match the schema. 2 by default; 0 fails the run at once.
156
+ */
157
+ readonly maxOutputRepairs?: number;
158
+ /**
159
+ * How many Tools of one model step run at the same time. Only Tools with
160
+ * `sideEffect: "none"` or `"read"` standing next to each other run together;
161
+ * `write` and `external` always run one by one, in the order the model wrote
162
+ * them. 8 by default; 1 runs every Tool one by one.
163
+ */
164
+ readonly maxParallelToolCalls?: number;
165
+ /**
166
+ * Compaction of a long dialog: its older part is replaced by a summary the
167
+ * model writes, the newest messages stay as written. On by default;
168
+ * `false` turns it off, and a dialog that does not fit then fails the run.
169
+ */
170
+ readonly compaction?: AgentCompactionOptions | false;
81
171
  readonly contextBuilder?: AgentContextBuilder;
82
172
  readonly contextLimits?: AgentContextLimits;
83
173
  /**
@@ -93,6 +183,8 @@ export interface AgentRuntimeOptions {
93
183
  */
94
184
  export interface AgentExecutionDriverOptions extends AgentRuntimeOptions {
95
185
  readonly toolExecutorOptions?: AgentToolExecutorOptions;
186
+ /** Real tokens per estimated token, by model profile: one map for every run of a runtime. */
187
+ readonly tokenScales?: Map<string, number>;
96
188
  }
97
189
 
98
190
  export interface AgentRuntimeInvokeOptions {
@@ -109,6 +201,10 @@ export interface AgentRuntimeInvokeOptions {
109
201
  readonly temperature?: number;
110
202
  /** Per-provider-call timeout override; 0 explicitly disables it. */
111
203
  readonly timeoutMs?: number;
204
+ /** The deadline of this whole run instead of the runtime's; 0 explicitly disables it. */
205
+ readonly runTimeoutMs?: number;
206
+ /** Compaction settings of this run over the runtime's; `false` keeps the dialog as written. */
207
+ readonly compaction?: AgentCompactionOptions | false;
112
208
  readonly contextLimits?: AgentContextLimits;
113
209
  readonly signal?: AbortSignal;
114
210
  readonly toolExecution?: AgentRuntimeToolExecutionOptions;
@@ -127,6 +223,14 @@ export interface AgentRuntimeResult {
127
223
  readonly finalMessage?: AgentMessage;
128
224
  /** Validated structured value when `output` requested JSON. */
129
225
  readonly output?: JsonValue;
226
+ /** The sum over every model call of the run, summaries included, when the provider reports usage. */
227
+ readonly usage?: AgentUsage;
228
+ /**
229
+ * How many times the older part of the dialog was replaced by a summary
230
+ * during this run. `messages` then start with the summary, a user message
231
+ * with `metadata.runtime: "summary"`, not with the original dialog.
232
+ */
233
+ readonly compactions?: number;
130
234
  readonly error?: AgentToolError;
131
235
  readonly metadata: JsonObject;
132
236
  }
@@ -138,6 +242,15 @@ type PreparedAgentPlan = {
138
242
  readonly maxSteps: number;
139
243
  readonly maxToolCallsPerStep: number;
140
244
  readonly timeoutMs: number | undefined;
245
+ readonly runTimeoutMs: number | undefined;
246
+ readonly modelRetry: ModelRetrySettings;
247
+ readonly maxOutputContinuations: number;
248
+ readonly maxOutputRepairs: number;
249
+ readonly maxParallelToolCalls: number;
250
+ /** `undefined`: the dialog is never compacted. */
251
+ readonly compaction: AgentCompactionOptions | undefined;
252
+ /** What the run did besides its steps; filled while it runs. */
253
+ readonly side: { readonly usage: AgentUsage[]; compactions: number };
141
254
  readonly modelProfile: string | undefined;
142
255
  readonly contextLimits: AgentContextLimits | undefined;
143
256
  readonly messages: AgentMessage[];
@@ -403,6 +516,7 @@ function normalizeProviderResponse(value: unknown): AgentModelResponse {
403
516
  id: call.id as string,
404
517
  name: call.name as string,
405
518
  input: call.input,
519
+ inputError: call.inputError as string | undefined,
406
520
  metadata: call.metadata,
407
521
  });
408
522
  });
@@ -424,6 +538,146 @@ function runtimeError(code: string, message: string, details?: JsonValue): Agent
424
538
  });
425
539
  }
426
540
 
541
+ /** The sum of what the provider reported for every model call. */
542
+ function totalUsage(usages: readonly (AgentUsage | undefined)[]): AgentUsage | undefined {
543
+ const total: { inputTokens?: number; outputTokens?: number; totalTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number; latencyMs?: number } = {};
544
+ let amount: number | undefined;
545
+ let currency: string | undefined;
546
+ let mixedCurrency = false;
547
+ let reported = false;
548
+ for (const usage of usages) {
549
+ if (usage === undefined) continue;
550
+ reported = true;
551
+ for (const field of ["inputTokens", "outputTokens", "totalTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "latencyMs"] as const) {
552
+ if (usage[field] !== undefined) total[field] = (total[field] ?? 0) + usage[field];
553
+ }
554
+ if (usage.cost !== undefined) {
555
+ if (currency !== undefined && currency !== usage.cost.currency) mixedCurrency = true;
556
+ currency = usage.cost.currency;
557
+ amount = (amount ?? 0) + usage.cost.amount;
558
+ }
559
+ }
560
+ if (!reported) return undefined;
561
+ return Object.freeze({
562
+ ...total,
563
+ ...(amount !== undefined && currency !== undefined && !mixedCurrency ? { cost: Object.freeze({ amount, currency }) } : {}),
564
+ });
565
+ }
566
+
567
+ /** The drafts at the end of a dialog that the runtime asked the model to continue or correct. */
568
+ function runtimeChain(messages: readonly AgentMessage[]): { readonly start: number; readonly drafts: readonly AgentMessage[]; readonly continues: number; readonly repairs: number } {
569
+ let start = messages.length;
570
+ let continues = 0;
571
+ let repairs = 0;
572
+ const drafts: AgentMessage[] = [];
573
+ while (start >= 2) {
574
+ const ask = messages[start - 1]!;
575
+ const draft = messages[start - 2]!;
576
+ const kind = ask.role === "user" ? ask.metadata.runtime : undefined;
577
+ if ((kind !== RUNTIME_CONTINUE && kind !== RUNTIME_REPAIR) || draft.role !== "assistant" || draft.content.some((part) => part.kind === "tool-call")) break;
578
+ drafts.unshift(draft);
579
+ if (kind === RUNTIME_CONTINUE) continues += 1;
580
+ else repairs += 1;
581
+ start -= 2;
582
+ }
583
+ return { start, drafts, continues, repairs };
584
+ }
585
+
586
+ /** An answer in words: text, with or without the reasoning that led to it. */
587
+ function isTextOnly(message: AgentMessage): boolean {
588
+ return message.content.every((part) => part.kind === "text" || part.kind === "reasoning");
589
+ }
590
+
591
+ /**
592
+ * A message without its reasoning parts. A provider ties reasoning to the
593
+ * dialog it was written in, so it is dropped wherever the runtime rewrites
594
+ * that dialog; the words and Tool calls stay.
595
+ */
596
+ function withoutReasoning(message: AgentMessage): AgentMessage {
597
+ if (!message.content.some((part) => part.kind === "reasoning")) return message;
598
+ const content = message.content.filter((part) => part.kind !== "reasoning");
599
+ if (content.length === 0) return message;
600
+ return agentMessage(message.role, content, { id: message.id, name: message.name, toolCallId: message.toolCallId, createdAtUnixMs: message.createdAtUnixMs, metadata: message.metadata });
601
+ }
602
+
603
+ /** One answer out of the pieces the output limit cut it into. */
604
+ function joinedAnswer(pieces: readonly AgentMessage[]): AgentMessage {
605
+ const last = pieces[pieces.length - 1]!;
606
+ const text = pieces.map((piece) => piece.content.map((part) => part.kind === "text" ? part.text : "").join("")).join("");
607
+ return agentMessage("assistant", text, { id: last.id, name: last.name, createdAtUnixMs: last.createdAtUnixMs, metadata: last.metadata });
608
+ }
609
+
610
+ function repairRequest(error: AgentToolError): string {
611
+ const details = error.details;
612
+ const issues = details !== undefined && details !== null && typeof details === "object" && !Array.isArray(details) ? (details as JsonObject).issues : undefined;
613
+ const listed = issues === undefined ? "" : ` Issues: ${JSON.stringify(issues).slice(0, 2_000)}.`;
614
+ return `Your answer was not accepted: ${error.message}${listed} Reply again with the corrected JSON only, without any other text.`;
615
+ }
616
+
617
+ function isInlineData(uri: string): boolean {
618
+ return uri.startsWith("data:");
619
+ }
620
+
621
+ function clip(text: string, maxChars: number): string {
622
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars)}… [${text.length - maxChars} more characters]`;
623
+ }
624
+
625
+ /** A dialog as plain text for the model that writes its summary. */
626
+ function renderDialog(messages: readonly AgentMessage[], maxChars: number): string {
627
+ const lines: string[] = [];
628
+ for (const message of messages) {
629
+ const speaker = message.metadata.runtime === RUNTIME_SUMMARY ? "Earlier summary" : message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : message.role === "tool" ? "Tool" : "Instructions";
630
+ for (const part of message.content) {
631
+ switch (part.kind) {
632
+ case "text": lines.push(`${speaker}: ${clip(part.text, 8_000)}`); break;
633
+ case "data": lines.push(`${speaker} (data${part.name === undefined ? "" : ` "${part.name}"`}): ${clip(JSON.stringify(part.value), 4_000)}`); break;
634
+ case "image": lines.push(`${speaker}: [image${isInlineData(part.uri) ? "" : ` ${clip(part.uri, 200)}`}]`); break;
635
+ case "file": lines.push(`${speaker}: [file ${part.name ?? (isInlineData(part.uri) ? "" : clip(part.uri, 200))}]`); break;
636
+ // Reasoning is the model's own working; the summary is written from what was said and done.
637
+ case "reasoning": break;
638
+ case "tool-call": lines.push(`Assistant called ${part.call.name}: ${clip(JSON.stringify(part.call.input), 2_000)}`); break;
639
+ case "tool-result": lines.push(`Result of ${part.result.name} (${part.result.status}): ${clip(JSON.stringify(part.result.status === "success" ? part.result.output : part.result.error), 4_000)}`); break;
640
+ }
641
+ }
642
+ }
643
+ const text = lines.join("\n");
644
+ // The end of the part being summarized matters most: it is what the dialog continues from.
645
+ return text.length <= maxChars ? text : `[The beginning of the conversation is omitted.]\n${text.slice(text.length - maxChars)}`;
646
+ }
647
+
648
+ /**
649
+ * Where the dialog is cut: messages before the index are summarized, the rest
650
+ * stay as written. A Tool call is never separated from its results.
651
+ * `undefined` when there is nothing worth summarizing.
652
+ */
653
+ function compactionCut(messages: readonly AgentMessage[], tokens: readonly number[], keepTokens: number): number | undefined {
654
+ let cut = messages.length;
655
+ let kept = 0;
656
+ while (cut > 0 && kept + tokens[cut - 1]! <= keepTokens) {
657
+ kept += tokens[cut - 1]!;
658
+ cut -= 1;
659
+ }
660
+ // The kept part starts with a question or an answer, never with a Tool result.
661
+ while (cut < messages.length && messages[cut]!.role === "tool") cut += 1;
662
+ if (cut >= messages.length) {
663
+ // The newest exchange alone is larger than what may be kept: it stays whole anyway.
664
+ cut = messages.length - 1;
665
+ while (cut > 0 && messages[cut]!.role === "tool") cut -= 1;
666
+ }
667
+ return cut >= 2 ? cut : undefined;
668
+ }
669
+
670
+ /** Waits unless the run is cancelled first; resolves `false` when it was. */
671
+ function pause(ms: number, signal: AbortSignal | undefined): Promise<boolean> {
672
+ if (signal?.aborted) return Promise.resolve(false);
673
+ if (ms <= 0) return Promise.resolve(true);
674
+ return new Promise<boolean>((resolve) => {
675
+ const onAbort = (): void => { clearTimeout(timer); resolve(false); };
676
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(true); }, ms);
677
+ signal?.addEventListener("abort", onAbort, { once: true });
678
+ });
679
+ }
680
+
427
681
  function runtimeResult(options: {
428
682
  readonly invocationId: string;
429
683
  readonly agentName: string;
@@ -437,6 +691,7 @@ function runtimeResult(options: {
437
691
  readonly error?: AgentToolError;
438
692
  readonly metadata: JsonObject;
439
693
  }): AgentRuntimeResult {
694
+ const usage = totalUsage(options.responses.map((response) => response.usage));
440
695
  return Object.freeze({
441
696
  invocationId: options.invocationId,
442
697
  agentName: options.agentName,
@@ -447,6 +702,7 @@ function runtimeResult(options: {
447
702
  toolResults: Object.freeze([...options.toolResults]),
448
703
  ...(options.finalMessage !== undefined ? { finalMessage: options.finalMessage } : {}),
449
704
  ...(options.output !== undefined ? { output: options.output } : {}),
705
+ ...(usage !== undefined ? { usage } : {}),
450
706
  ...(options.error !== undefined ? { error: options.error } : {}),
451
707
  metadata: options.metadata,
452
708
  });
@@ -508,12 +764,33 @@ function attachAbortForwarding(source: AbortSignal | undefined, target: AbortCon
508
764
  return () => source.removeEventListener("abort", abort);
509
765
  }
510
766
 
767
+ /** The size of a whole dialog as the context builder counted it, dropped messages included. */
768
+ function contextTokens(context: AgentContext): number {
769
+ return context.trace.reduce((sum, entry) => sum + entry.tokens, 0);
770
+ }
771
+
772
+ const fallbackEstimator = new ApproximateAgentContextTokenEstimator();
773
+
774
+ /** Tokens of every dialog message: the builder's own count, or an estimate for a builder that keeps no such trace. */
775
+ function messageTokens(messages: readonly AgentMessage[], context: AgentContext, tokenScale: number): readonly number[] {
776
+ const counted = context.trace.filter((entry) => entry.section.startsWith("conversation["));
777
+ if (counted.length === messages.length) return counted.map((entry) => entry.tokens);
778
+ return messages.map((message) => Math.ceil(fallbackEstimator.estimateMessage(message) * tokenScale));
779
+ }
780
+
511
781
  export class AgentExecutionDriver {
512
782
  private readonly toolExecutor: AgentToolExecutor;
513
783
  private readonly defaultMaxSteps: number;
514
784
  private readonly defaultMaxToolCallsPerStep: number;
515
785
  private readonly defaultModelProfile?: string;
516
786
  readonly defaultTimeoutMs?: number;
787
+ private readonly defaultRunTimeoutMs?: number;
788
+ private readonly modelRetry: ModelRetrySettings;
789
+ private readonly maxOutputContinuations: number;
790
+ private readonly maxOutputRepairs: number;
791
+ private readonly maxParallelToolCalls: number;
792
+ private readonly compaction: AgentCompactionOptions | undefined;
793
+ private readonly tokenScales: Map<string, number>;
517
794
  private readonly defaultContextLimits?: AgentContextLimits;
518
795
  private readonly contextBuilder: AgentContextBuilder;
519
796
  private readonly logger?: Logger;
@@ -527,6 +804,24 @@ export class AgentExecutionDriver {
527
804
  assertPositiveInteger(options.maxSteps, "AgentRuntime.maxSteps");
528
805
  assertPositiveInteger(options.maxToolCallsPerStep, "AgentRuntime.maxToolCallsPerStep");
529
806
  assertNonNegativeInteger(options.timeoutMs, "AgentRuntime.timeoutMs");
807
+ assertNonNegativeInteger(options.runTimeoutMs, "AgentRuntime.runTimeoutMs");
808
+ assertPositiveInteger(options.modelRetry?.maxAttempts, "AgentRuntime.modelRetry.maxAttempts");
809
+ assertNonNegativeInteger(options.modelRetry?.delayMs, "AgentRuntime.modelRetry.delayMs");
810
+ assertNonNegativeInteger(options.modelRetry?.maxDelayMs, "AgentRuntime.modelRetry.maxDelayMs");
811
+ assertNonNegativeInteger(options.maxOutputContinuations, "AgentRuntime.maxOutputContinuations");
812
+ assertNonNegativeInteger(options.maxOutputRepairs, "AgentRuntime.maxOutputRepairs");
813
+ assertPositiveInteger(options.maxParallelToolCalls, "AgentRuntime.maxParallelToolCalls");
814
+ this.defaultRunTimeoutMs = options.runTimeoutMs === 0 ? undefined : options.runTimeoutMs;
815
+ this.modelRetry = Object.freeze({
816
+ maxAttempts: options.modelRetry?.maxAttempts ?? DEFAULT_MODEL_ATTEMPTS,
817
+ delayMs: options.modelRetry?.delayMs ?? DEFAULT_MODEL_RETRY_DELAY_MS,
818
+ maxDelayMs: options.modelRetry?.maxDelayMs ?? DEFAULT_MODEL_RETRY_MAX_DELAY_MS,
819
+ });
820
+ this.maxOutputContinuations = options.maxOutputContinuations ?? DEFAULT_MAX_OUTPUT_CONTINUATIONS;
821
+ this.maxOutputRepairs = options.maxOutputRepairs ?? DEFAULT_MAX_OUTPUT_REPAIRS;
822
+ this.maxParallelToolCalls = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS;
823
+ this.compaction = this.compactionSettings(options.compaction, "AgentRuntime.compaction") ?? (options.compaction === false ? undefined : Object.freeze({}));
824
+ this.tokenScales = options.tokenScales ?? new Map<string, number>();
530
825
  this.defaultMaxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
531
826
  this.defaultMaxToolCallsPerStep = options.maxToolCallsPerStep ?? DEFAULT_MAX_TOOL_CALLS_PER_STEP;
532
827
  this.defaultModelProfile = optionalText(options.modelProfile, "AgentRuntime.modelProfile");
@@ -667,6 +962,15 @@ export class AgentExecutionDriver {
667
962
  maxSteps,
668
963
  maxToolCallsPerStep,
669
964
  timeoutMs: providerTimeoutMs,
965
+ // A Session owns its run deadline and runs Tools one by one behind its durable boundary.
966
+ runTimeoutMs: undefined,
967
+ modelRetry: this.modelRetry,
968
+ maxOutputContinuations: this.maxOutputContinuations,
969
+ maxOutputRepairs: this.maxOutputRepairs,
970
+ maxParallelToolCalls: 1,
971
+ // A Session keeps its dialog as written: a checkpoint ties it to the recorded responses.
972
+ compaction: undefined,
973
+ side: { usage: [], compactions: 0 },
670
974
  modelProfile,
671
975
  contextLimits,
672
976
  messages: [...checkpoint.messages],
@@ -691,6 +995,8 @@ export class AgentExecutionDriver {
691
995
  let maxSteps: number;
692
996
  let maxToolCallsPerStep: number;
693
997
  let timeoutMs: number | undefined;
998
+ let runTimeoutMs: number | undefined;
999
+ let compaction: AgentCompactionOptions | undefined;
694
1000
  let modelProfile: string | undefined;
695
1001
  let contextLimits: AgentContextLimits | undefined;
696
1002
  try {
@@ -698,6 +1004,12 @@ export class AgentExecutionDriver {
698
1004
  assertPositiveInteger(options.maxSteps, "agentRuntime.maxSteps");
699
1005
  assertPositiveInteger(options.maxToolCallsPerStep, "agentRuntime.maxToolCallsPerStep");
700
1006
  assertNonNegativeInteger(options.timeoutMs, "agentRuntime.timeoutMs");
1007
+ assertNonNegativeInteger(options.runTimeoutMs, "agentRuntime.runTimeoutMs");
1008
+ runTimeoutMs = options.runTimeoutMs === 0 ? undefined : options.runTimeoutMs ?? this.defaultRunTimeoutMs;
1009
+ const ownCompaction = this.compactionSettings(options.compaction, "agentRuntime.compaction");
1010
+ compaction = options.compaction === false ? undefined
1011
+ : ownCompaction === undefined ? this.compaction
1012
+ : Object.freeze({ ...(this.compaction ?? {}), ...ownCompaction });
701
1013
  maxSteps = options.maxSteps ?? this.defaultMaxSteps;
702
1014
  maxToolCallsPerStep = options.maxToolCallsPerStep ?? this.defaultMaxToolCallsPerStep;
703
1015
  timeoutMs = options.timeoutMs === 0 ? undefined : options.timeoutMs ?? this.defaultTimeoutMs;
@@ -733,6 +1045,13 @@ export class AgentExecutionDriver {
733
1045
  maxSteps,
734
1046
  maxToolCallsPerStep,
735
1047
  timeoutMs,
1048
+ runTimeoutMs,
1049
+ modelRetry: this.modelRetry,
1050
+ maxOutputContinuations: this.maxOutputContinuations,
1051
+ maxOutputRepairs: this.maxOutputRepairs,
1052
+ maxParallelToolCalls: this.maxParallelToolCalls,
1053
+ compaction,
1054
+ side: { usage: [], compactions: 0 },
736
1055
  modelProfile,
737
1056
  contextLimits,
738
1057
  messages,
@@ -791,6 +1110,14 @@ export class AgentExecutionDriver {
791
1110
  }
792
1111
  };
793
1112
  const terminal = (state: TState, result: AgentRuntimeResult): EngineOutcome<TState> => ({ kind: "terminal", state, result });
1113
+ // A dialog is compacted at most once before a step and once after a refusal of that step.
1114
+ const canCompact = !session && plan.compaction !== undefined;
1115
+ let sizeCheckedAtStep = 0;
1116
+ let overflowHandledAtStep = 0;
1117
+ const withMessages = (messages: readonly AgentMessage[]): TState => bridge.evolve(checkpoint, {
1118
+ phase: checkpoint.phase, steps: checkpoint.steps, messages, responses: checkpoint.responses, toolResults: checkpoint.toolResults,
1119
+ seenToolCallIds: checkpoint.seenToolCallIds, pendingToolBatch: undefined, terminal: undefined,
1120
+ });
794
1121
 
795
1122
  for (;;) {
796
1123
  if (session && signal?.aborted) return suspend("aborted");
@@ -800,6 +1127,42 @@ export class AgentExecutionDriver {
800
1127
  const pending = checkpoint.pendingToolBatch!;
801
1128
  const response = checkpoint.responses[pending.responseIndex]!;
802
1129
  const call = response.toolCalls[pending.nextToolIndex]!;
1130
+ const together = session ? [] : this.readTogether(plan, response.toolCalls, pending.nextToolIndex);
1131
+ if (together.length > 1) {
1132
+ const results = await Promise.all(together.map((item) => this.toolExecutor.execute(item.call, {
1133
+ ...plan.executorToolOptions,
1134
+ agentName: plan.agent.name,
1135
+ invocationId: plan.invocationId,
1136
+ metadata: plan.metadata,
1137
+ signal,
1138
+ idempotencyKey: item.idempotencyKey,
1139
+ })));
1140
+ const unknown = results.findIndex((result) => result.error?.code?.endsWith("_OUTCOME_UNKNOWN") === true);
1141
+ const nextToolIndex = pending.nextToolIndex + together.length;
1142
+ const finished = nextToolIndex === response.toolCalls.length;
1143
+ const candidate = bridge.evolve(checkpoint, {
1144
+ phase: unknown >= 0 ? "terminal" : finished ? "ready-model" : "pending-tools",
1145
+ steps: checkpoint.steps,
1146
+ // Results stand in the order the model wrote the calls, whichever finished first.
1147
+ messages: [...checkpoint.messages, ...results.map((result, index) => agentMessage("tool", agentToolResultPart(result), {
1148
+ toolCallId: together[index]!.call.id, metadata: { toolName: together[index]!.call.name },
1149
+ }))],
1150
+ responses: checkpoint.responses,
1151
+ toolResults: [...checkpoint.toolResults, ...results],
1152
+ seenToolCallIds: checkpoint.seenToolCallIds,
1153
+ pendingToolBatch: finished ? undefined : { ...pending, nextToolIndex },
1154
+ terminal: unknown >= 0 ? {
1155
+ status: "failed",
1156
+ error: runtimeError("AGENT_TOOL_OUTCOME_UNKNOWN", `Tool "${together[unknown]!.call.name}" finished with an unknown retry-safe side-effect outcome; automatic model continuation is blocked.`, {
1157
+ callId: together[unknown]!.call.id, toolName: together[unknown]!.call.name, idempotencyKey: together[unknown]!.idempotencyKey, toolErrorCode: results[unknown]!.error?.code ?? null,
1158
+ }),
1159
+ } : undefined,
1160
+ });
1161
+ const stopped = await committedControl(candidate);
1162
+ if (stopped !== undefined) return stopped;
1163
+ if (candidate.phase === "terminal") return terminal(candidate, this.resultFromCheckpoint(plan, candidate));
1164
+ continue;
1165
+ }
803
1166
  let idempotencyKey: string;
804
1167
  try {
805
1168
  idempotencyKey = !session
@@ -873,8 +1236,22 @@ export class AgentExecutionDriver {
873
1236
  const readyControl = await control(checkpoint);
874
1237
  if (readyControl !== undefined) return readyControl;
875
1238
  let modelRequest: AgentModelRequest;
1239
+ let context: AgentContext;
1240
+ const tokenScale = this.tokenScales.get(plan.modelProfile ?? "") ?? 1;
1241
+ const build = (): AgentContext => this.contextBuilder.build({ invocationId: plan.invocationId, agent: plan.agent, messages: checkpoint.messages, metadata: plan.metadata, limits: plan.contextLimits, tokenScale });
876
1242
  try {
877
- const context = this.contextBuilder.build({ invocationId: plan.invocationId, agent: plan.agent, messages: checkpoint.messages, metadata: plan.metadata, limits: plan.contextLimits });
1243
+ context = build();
1244
+ if (canCompact && sizeCheckedAtStep !== step) {
1245
+ sizeCheckedAtStep = step;
1246
+ const maxTokens = this.compactionThreshold(plan);
1247
+ if (maxTokens !== undefined && contextTokens(context) > maxTokens) {
1248
+ const compacted = await this.compact(plan, checkpoint.messages, context, tokenScale, plan.compaction!.keepTokens ?? Math.floor(maxTokens / 4), "size", signal);
1249
+ if (compacted !== undefined) {
1250
+ checkpoint = withMessages(compacted);
1251
+ context = build();
1252
+ }
1253
+ }
1254
+ }
878
1255
  modelRequest = agentModelRequest({
879
1256
  invocationId: plan.invocationId, messages: context.messages, tools: plan.tools,
880
1257
  output: this.providerOutputContract(plan.options.output),
@@ -896,9 +1273,18 @@ export class AgentExecutionDriver {
896
1273
  } catch {
897
1274
  return suspend("before-model-rejected");
898
1275
  }
899
- const response = await this.completeModel(modelRequest, plan.agent.name, plan.modelProfile, plan.metadata, signal, plan.timeoutMs,
1276
+ const response = await this.completeModel(modelRequest, plan.agent.name, plan.modelProfile, plan.metadata, signal, plan.timeoutMs, plan.modelRetry,
900
1277
  plan.options.onTextDelta ? text => plan.options.onTextDelta!(Object.freeze({ step, text })) : undefined);
901
1278
  if (!response.ok) {
1279
+ // The provider says the request is too long: the dialog is compacted and the step is asked again.
1280
+ if (response.cause instanceof AgentModelContextOverflowError && canCompact && overflowHandledAtStep !== step) {
1281
+ overflowHandledAtStep = step;
1282
+ const compacted = await this.compact(plan, checkpoint.messages, context, tokenScale, plan.compaction!.keepTokens ?? Math.floor(contextTokens(context) / 4), "overflow", signal);
1283
+ if (compacted !== undefined) {
1284
+ checkpoint = withMessages(compacted);
1285
+ continue;
1286
+ }
1287
+ }
902
1288
  const candidate = bridge.evolve(checkpoint, {
903
1289
  phase: "terminal", steps: step, messages: checkpoint.messages, responses: checkpoint.responses, toolResults: checkpoint.toolResults,
904
1290
  seenToolCallIds: checkpoint.seenToolCallIds, pendingToolBatch: undefined, terminal: { status: "failed", error: response.error },
@@ -907,6 +1293,7 @@ export class AgentExecutionDriver {
907
1293
  const stopped = await committedControl(candidate);
908
1294
  return stopped ?? terminal(candidate, this.resultFromCheckpoint(plan, candidate));
909
1295
  }
1296
+ this.learnTokenScale(plan.modelProfile, tokenScale, context, response.value);
910
1297
  const candidate = await this.modelCandidate(plan, checkpoint, step, response.value, bridge);
911
1298
  try { await boundary.afterModelSettlement(checkpoint, candidate.state, step, { response: response.value }); } catch { throw bridge.boundaryError(checkpoint); }
912
1299
  const stopped = await committedControl(candidate.state);
@@ -947,14 +1334,39 @@ export class AgentExecutionDriver {
947
1334
  const state = bridge.evolve(checkpoint, { phase: "pending-tools", steps: step, messages: [...checkpoint.messages, assistant], responses: [...checkpoint.responses, response], toolResults: checkpoint.toolResults, seenToolCallIds: [...seen], pendingToolBatch: { step, responseIndex: checkpoint.responses.length, assistantMessageIndex: checkpoint.messages.length, nextToolIndex: 0 }, terminal: undefined });
948
1335
  return { state };
949
1336
  }
950
- const messages = message === undefined ? checkpoint.messages : [...checkpoint.messages, message];
1337
+ // No tool calls: a model response always carries a message then.
1338
+ const answer = message!;
1339
+ const chain = runtimeChain(checkpoint.messages);
1340
+ const responses = [...checkpoint.responses, response];
1341
+ const json = plan.options.output?.mode === "json";
1342
+ /** The draft stays in the dialog with the runtime's request, and the model is called again. */
1343
+ const askAgain = (kind: string, text: string): { readonly state: TState } => ({ state: bridge.evolve(checkpoint, {
1344
+ phase: "ready-model", steps: step,
1345
+ messages: [...checkpoint.messages, answer, agentMessage("user", text, { metadata: { runtime: kind } })],
1346
+ responses, toolResults: checkpoint.toolResults, seenToolCallIds: checkpoint.seenToolCallIds, pendingToolBatch: undefined, terminal: undefined,
1347
+ }) });
1348
+ const mayAskAgain = step < plan.maxSteps;
1349
+ if (response.finishReason === "length" && !json && isTextOnly(answer) && mayAskAgain && chain.continues < plan.maxOutputContinuations) {
1350
+ return askAgain(RUNTIME_CONTINUE, CONTINUE_TEXT);
1351
+ }
1352
+ // The finished dialog keeps one answer: the pieces joined, without rejected drafts and the runtime's requests.
1353
+ const finalMessage = !json && chain.continues > 0 && chain.repairs === 0 && isTextOnly(answer) && chain.drafts.every(isTextOnly)
1354
+ ? joinedAnswer([...chain.drafts, answer])
1355
+ // Drafts are dropped from the dialog, so the answer's reasoning no longer matches it.
1356
+ : chain.drafts.length > 0 ? withoutReasoning(answer) : answer;
1357
+ const messages = [...checkpoint.messages.slice(0, chain.start), finalMessage];
951
1358
  let result: AgentRuntimeResult;
952
- if (response.finishReason !== "stop" || message === undefined) {
953
- result = runtimeResult({ invocationId: plan.invocationId, agentName: plan.agent.name, status: "failed", steps: step, messages, responses: [...checkpoint.responses, response], toolResults: checkpoint.toolResults, finalMessage: message, metadata: plan.metadata,
1359
+ if (response.finishReason !== "stop") {
1360
+ result = runtimeResult({ invocationId: plan.invocationId, agentName: plan.agent.name, status: "failed", steps: step, messages, responses, toolResults: checkpoint.toolResults, finalMessage, metadata: plan.metadata,
954
1361
  error: runtimeError("AGENT_PROVIDER_STOPPED", `Model provider stopped with finishReason "${response.finishReason}".`) });
955
1362
  } else {
956
- result = runtimeResult({ invocationId: plan.invocationId, agentName: plan.agent.name, status: "completed", steps: step, messages, responses: [...checkpoint.responses, response], toolResults: checkpoint.toolResults, finalMessage: message, metadata: plan.metadata });
957
- if (plan.options.output?.mode === "json") result = this.validateExplicitOutput(result, plan.options.output);
1363
+ result = runtimeResult({ invocationId: plan.invocationId, agentName: plan.agent.name, status: "completed", steps: step, messages, responses, toolResults: checkpoint.toolResults, finalMessage, metadata: plan.metadata });
1364
+ if (plan.options.output?.mode === "json") {
1365
+ result = this.validateExplicitOutput(result, plan.options.output);
1366
+ if (result.error?.code === "AGENT_OUTPUT_INVALID" && mayAskAgain && chain.repairs < plan.maxOutputRepairs) {
1367
+ return askAgain(RUNTIME_REPAIR, repairRequest(result.error));
1368
+ }
1369
+ }
958
1370
  }
959
1371
  const state = bridge.evolve(checkpoint, { phase: "terminal", steps: step, messages: result.messages, responses: result.responses, toolResults: result.toolResults, seenToolCallIds: checkpoint.seenToolCallIds, pendingToolBatch: undefined,
960
1372
  terminal: result.status === "completed" ? { status: "completed", finalMessage: result.finalMessage, output: result.output } : { status: "failed", finalMessage: result.finalMessage, error: result.error! } });
@@ -992,21 +1404,152 @@ export class AgentExecutionDriver {
992
1404
  boundaryError() { return new AgentRuntimeError("in-memory execution boundary rejected a transition."); },
993
1405
  };
994
1406
  const resolvedPlan = { ...plan, modelProfile: plan.modelProfile ?? plan.agent.modelProfile ?? this.defaultModelProfile };
995
- const outcome = await this.drivePhaseEngine(resolvedPlan, initial, inMemoryBoundary, plan.options.signal, inMemoryBridge, false);
996
- if (outcome.kind === "terminal") return outcome.result;
997
- throw new AgentRuntimeError("in-memory execution cannot suspend.");
1407
+ if (plan.runTimeoutMs === undefined) {
1408
+ const outcome = await this.drivePhaseEngine(resolvedPlan, initial, inMemoryBoundary, plan.options.signal, inMemoryBridge, false);
1409
+ if (outcome.kind === "terminal") return this.withSideWork(outcome.result, plan);
1410
+ throw new AgentRuntimeError("in-memory execution cannot suspend.");
1411
+ }
1412
+ // The run deadline cancels whatever is in flight, like the caller's signal does.
1413
+ const deadline = new AbortController();
1414
+ const detach = attachAbortForwarding(plan.options.signal, deadline);
1415
+ let expired = false;
1416
+ const timer = setTimeout(() => { expired = true; deadline.abort(); }, plan.runTimeoutMs);
1417
+ try {
1418
+ const outcome = await this.drivePhaseEngine(resolvedPlan, initial, inMemoryBoundary, deadline.signal, inMemoryBridge, false);
1419
+ if (outcome.kind !== "terminal") throw new AgentRuntimeError("in-memory execution cannot suspend.");
1420
+ return this.withSideWork(expired && outcome.result.error?.code === "AGENT_ABORTED"
1421
+ ? this.failedResult(outcome.result, runtimeError("AGENT_RUN_TIMEOUT", `Agent "${plan.agent.name}" did not finish within runTimeoutMs (${plan.runTimeoutMs}).`, { runTimeoutMs: plan.runTimeoutMs }))
1422
+ : outcome.result, plan);
1423
+ } finally {
1424
+ clearTimeout(timer);
1425
+ detach?.();
1426
+ }
1427
+ }
1428
+
1429
+ /** Adds to a result what the run did besides its steps: summaries and their usage. */
1430
+ private withSideWork(result: AgentRuntimeResult, plan: PreparedAgentPlan): AgentRuntimeResult {
1431
+ if (plan.side.compactions === 0) return result;
1432
+ const usage = totalUsage([result.usage, ...plan.side.usage]);
1433
+ return Object.freeze({ ...result, ...(usage === undefined ? {} : { usage }), compactions: plan.side.compactions });
1434
+ }
1435
+
1436
+ private compactionSettings(value: AgentCompactionOptions | false | undefined, field: string): AgentCompactionOptions | undefined {
1437
+ if (value === undefined || value === false) return undefined;
1438
+ assertPositiveInteger(value.maxTokens, `${field}.maxTokens`);
1439
+ assertPositiveInteger(value.keepTokens, `${field}.keepTokens`);
1440
+ const modelProfile = optionalText(value.modelProfile, `${field}.modelProfile`);
1441
+ const instructions = optionalText(value.instructions, `${field}.instructions`);
1442
+ return Object.freeze({
1443
+ ...(value.maxTokens === undefined ? {} : { maxTokens: value.maxTokens }),
1444
+ ...(value.keepTokens === undefined ? {} : { keepTokens: value.keepTokens }),
1445
+ ...(modelProfile === undefined ? {} : { modelProfile }),
1446
+ ...(instructions === undefined ? {} : { instructions }),
1447
+ });
1448
+ }
1449
+
1450
+ /** The dialog size that starts compaction, when it is set or the model's window is known. */
1451
+ private compactionThreshold(plan: PreparedAgentPlan): number | undefined {
1452
+ if (plan.compaction?.maxTokens !== undefined) return plan.compaction.maxTokens;
1453
+ let window: number | undefined;
1454
+ try { window = this.modelProvider.contextTokens?.(plan.modelProfile); } catch { window = undefined; }
1455
+ return typeof window === "number" && Number.isFinite(window) && window > 0 ? Math.floor(window * COMPACTION_WINDOW_SHARE) : undefined;
1456
+ }
1457
+
1458
+ /**
1459
+ * Replaces the older part of a dialog with a summary the model writes.
1460
+ * `undefined` when there is nothing to summarize or the summary failed: the
1461
+ * dialog then stays as it is.
1462
+ */
1463
+ private async compact(
1464
+ plan: PreparedAgentPlan, messages: readonly AgentMessage[], context: AgentContext, tokenScale: number, keepTokens: number,
1465
+ reason: "size" | "overflow", signal: AbortSignal | undefined,
1466
+ ): Promise<readonly AgentMessage[] | undefined> {
1467
+ const tokens = messageTokens(messages, context, tokenScale);
1468
+ const cut = compactionCut(messages, tokens, keepTokens);
1469
+ if (cut === undefined) return undefined;
1470
+ const settings = plan.compaction!;
1471
+ const modelProfile = settings.modelProfile ?? plan.modelProfile;
1472
+ const before = tokens.reduce((sum, value) => sum + value, 0);
1473
+ const summarized = tokens.slice(0, cut).reduce((sum, value) => sum + value, 0);
1474
+ try {
1475
+ const request = agentModelRequest({
1476
+ invocationId: plan.invocationId,
1477
+ messages: [
1478
+ agentMessage("system", settings.instructions ?? SUMMARY_INSTRUCTIONS),
1479
+ // Plain text, so the request needs neither the Tools nor a valid tool-call order.
1480
+ agentMessage("user", `<conversation>\n${renderDialog(messages.slice(0, cut), Math.max(20_000, summarized * 4))}\n</conversation>\n\nWrite the summary of the conversation above.`),
1481
+ ],
1482
+ modelProfile,
1483
+ metadata: { ...plan.metadata, agentName: plan.agent.name, purpose: "compaction", modelProfile: modelProfile ?? null },
1484
+ });
1485
+ const response = await this.completeModel(request, plan.agent.name, modelProfile, plan.metadata, signal, plan.timeoutMs, plan.modelRetry);
1486
+ if (!response.ok) {
1487
+ if (response.error.code !== "AGENT_ABORTED") this.logger?.warn("Agent context was not compacted", { agent: plan.agent.name, invocationId: plan.invocationId, reason, code: response.error.code });
1488
+ return undefined;
1489
+ }
1490
+ if (response.value.usage !== undefined) plan.side.usage.push(response.value.usage);
1491
+ const summary = (response.value.message?.content ?? []).map((part) => part.kind === "text" ? part.text : "").join("").trim();
1492
+ if (summary.length === 0 || response.value.toolCalls.length > 0) {
1493
+ this.logger?.warn("Agent context was not compacted", { agent: plan.agent.name, invocationId: plan.invocationId, reason, code: "AGENT_SUMMARY_EMPTY" });
1494
+ return undefined;
1495
+ }
1496
+ plan.side.compactions += 1;
1497
+ this.logger?.info("Agent context compacted", { agent: plan.agent.name, invocationId: plan.invocationId, reason, messages: cut, tokensBefore: before, tokensSummarized: summarized });
1498
+ return Object.freeze([
1499
+ agentMessage("user", `${SUMMARY_PREFIX}\n\n${summary}`, { metadata: { runtime: RUNTIME_SUMMARY, messages: cut } }),
1500
+ // The kept messages were reasoned about with the whole dialog in front of the model.
1501
+ ...messages.slice(cut).map(withoutReasoning),
1502
+ ]);
1503
+ } catch (error) {
1504
+ this.logger?.warn("Agent context was not compacted", { agent: plan.agent.name, invocationId: plan.invocationId, reason, error: redactSensitive(error) });
1505
+ return undefined;
1506
+ }
1507
+ }
1508
+
1509
+ /** Remembers how many real tokens an estimated one is for a model, from the usage it reported. */
1510
+ private learnTokenScale(modelProfile: string | undefined, usedScale: number, context: AgentContext, response: AgentModelResponse): void {
1511
+ const reported = response.usage?.inputTokens;
1512
+ const counted = context.metadata.tokenCount;
1513
+ if (typeof reported !== "number" || reported <= 0 || typeof counted !== "number" || counted <= 0) return;
1514
+ const measured = Math.min(6, Math.max(0.5, reported / (counted / usedScale)));
1515
+ const key = modelProfile ?? "";
1516
+ const known = this.tokenScales.get(key);
1517
+ // Half-way to the new measurement: one unusual request does not swing the estimate.
1518
+ this.tokenScales.set(key, known === undefined ? measured : (known + measured) / 2);
1519
+ }
1520
+
1521
+ /**
1522
+ * The calls from `from` on that may run at the same time: Tools that change
1523
+ * nothing, standing next to each other. One call, or a call that changes
1524
+ * something, runs alone through the ordinary path.
1525
+ */
1526
+ private readTogether(plan: PreparedAgentPlan, calls: readonly AgentModelResponse["toolCalls"][number][], from: number): readonly { readonly call: AgentModelResponse["toolCalls"][number]; readonly idempotencyKey: string }[] {
1527
+ const together: { call: AgentModelResponse["toolCalls"][number]; idempotencyKey: string }[] = [];
1528
+ for (let index = from; index < calls.length && together.length < plan.maxParallelToolCalls; index += 1) {
1529
+ const call = calls[index]!;
1530
+ const sideEffect = plan.agent.tools.find((tool) => tool.metadata.name === call.name)?.metadata.sideEffect;
1531
+ if (sideEffect !== "none" && sideEffect !== "read") break;
1532
+ try {
1533
+ together.push({ call, idempotencyKey: this.toolIdempotencyKey(plan.options.toolExecution, { invocationId: plan.invocationId, agentName: plan.agent.name, call }) });
1534
+ } catch {
1535
+ // A wrong idempotency setting is reported by the ordinary one-call path.
1536
+ break;
1537
+ }
1538
+ }
1539
+ return together;
998
1540
  }
999
1541
  private parseJsonOutput(message: AgentMessage | undefined): unknown {
1000
1542
  if (message === undefined) {
1001
1543
  throw new AgentRuntimeError("Model response does not contain a final message.");
1002
1544
  }
1003
- if (message.content.length === 1 && message.content[0]?.kind === "data") {
1004
- return message.content[0].value;
1545
+ const content = message.content.filter((part) => part.kind !== "reasoning");
1546
+ if (content.length === 1 && content[0]?.kind === "data") {
1547
+ return content[0].value;
1005
1548
  }
1006
- if (!message.content.every((part) => part.kind === "text")) {
1549
+ if (!content.every((part) => part.kind === "text")) {
1007
1550
  throw new AgentRuntimeError("JSON output must contain only text or one data part.");
1008
1551
  }
1009
- const text = message.content.map((part) => part.kind === "text" ? part.text : "").join("\n").trim();
1552
+ const text = content.map((part) => part.kind === "text" ? part.text : "").join("\n").trim();
1010
1553
  if (text.length === 0) {
1011
1554
  throw new AgentRuntimeError("JSON output is empty.");
1012
1555
  }
@@ -1099,6 +1642,7 @@ export class AgentExecutionDriver {
1099
1642
  return agentOutputContract({ ...output, schema: agentJsonSchema(output.schema.name, inlineGeneratedSchema(generated, schemas), { strict: output.schema.strict }) });
1100
1643
  }
1101
1644
 
1645
+ /** One model request, repeated while the provider cannot serve it right now. */
1102
1646
  private async completeModel(
1103
1647
  request: AgentModelRequest,
1104
1648
  agentName: string,
@@ -1106,11 +1650,42 @@ export class AgentExecutionDriver {
1106
1650
  metadata: JsonObject,
1107
1651
  sourceSignal: AbortSignal | undefined,
1108
1652
  timeoutMs: number | undefined,
1653
+ retry: ModelRetrySettings,
1109
1654
  onTextDelta?: (text: string) => void,
1110
- ): Promise<{ readonly ok: true; readonly value: AgentModelResponse } | { readonly ok: false; readonly error: AgentToolError }> {
1655
+ ): Promise<{ readonly ok: true; readonly value: AgentModelResponse } | { readonly ok: false; readonly error: AgentToolError; readonly cause?: unknown }> {
1656
+ for (let attempt = 1; ; attempt += 1) {
1657
+ const outcome = await this.completeModelOnce(request, agentName, modelProfile, metadata, sourceSignal, timeoutMs, onTextDelta);
1658
+ if (outcome.ok) return outcome;
1659
+ // Text already shown to the user is not followed by a second answer.
1660
+ const repeat = outcome.cause instanceof AgentModelUnavailableError && attempt < retry.maxAttempts && !outcome.textEmitted && sourceSignal?.aborted !== true;
1661
+ if (!repeat) {
1662
+ if (outcome.error.code !== "AGENT_ABORTED" && !outcome.logged) {
1663
+ this.logger?.error("Agent model call failed", { agent: agentName, invocationId: request.invocationId, code: outcome.error.code, ...(attempt > 1 ? { attempts: attempt } : {}), error: redactSensitive(outcome.cause) });
1664
+ }
1665
+ return { ok: false, error: outcome.error, cause: outcome.cause };
1666
+ }
1667
+ const asked = (outcome.cause as AgentModelUnavailableError).retryAfterMs ?? 0;
1668
+ const delayMs = Math.min(retry.maxDelayMs, Math.max(asked, retry.delayMs * 2 ** (attempt - 1)));
1669
+ this.logger?.warn("Agent model call failed, repeating", { agent: agentName, invocationId: request.invocationId, attempt, maxAttempts: retry.maxAttempts, delayMs, error: redactSensitive(outcome.cause) });
1670
+ if (!await pause(delayMs, sourceSignal)) {
1671
+ return { ok: false, error: runtimeError("AGENT_ABORTED", "Agent runtime invocation was aborted.") };
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ private async completeModelOnce(
1677
+ request: AgentModelRequest,
1678
+ agentName: string,
1679
+ modelProfile: string | undefined,
1680
+ metadata: JsonObject,
1681
+ sourceSignal: AbortSignal | undefined,
1682
+ timeoutMs: number | undefined,
1683
+ onTextDelta?: (text: string) => void,
1684
+ ): Promise<{ readonly ok: true; readonly value: AgentModelResponse } | { readonly ok: false; readonly error: AgentToolError; readonly cause: unknown; readonly textEmitted: boolean; readonly logged: boolean }> {
1111
1685
  const abortController = new AbortController();
1112
1686
  const detach = attachAbortForwarding(sourceSignal, abortController);
1113
1687
  let acceptingText = true;
1688
+ let textEmitted = false;
1114
1689
  const context: AgentModelProviderContext = Object.freeze({
1115
1690
  invocationId: request.invocationId,
1116
1691
  agentName,
@@ -1120,7 +1695,7 @@ export class AgentExecutionDriver {
1120
1695
  ...(onTextDelta ? { onTextDelta: (text: string) => {
1121
1696
  if (!acceptingText || abortController.signal.aborted) return;
1122
1697
  if (typeof text !== "string") throw new AgentRuntimeError("Model text delta must be a string.");
1123
- if (text) onTextDelta(text);
1698
+ if (text) { textEmitted = true; onTextDelta(text); }
1124
1699
  } } : {}),
1125
1700
  });
1126
1701
 
@@ -1139,7 +1714,7 @@ export class AgentExecutionDriver {
1139
1714
  } catch (error) {
1140
1715
  this.logger?.error("Agent model call failed", { agent: agentName, invocationId: request.invocationId, code: "AGENT_PROVIDER_RESPONSE_INVALID", error: redactSensitive(error) });
1141
1716
  return {
1142
- ok: false,
1717
+ ok: false, cause: error, textEmitted, logged: true,
1143
1718
  error: runtimeError("AGENT_PROVIDER_RESPONSE_INVALID", `Model provider returned an invalid response: ${errorMessageOf(error)}`),
1144
1719
  };
1145
1720
  }
@@ -1148,11 +1723,10 @@ export class AgentExecutionDriver {
1148
1723
  ? "AGENT_PROVIDER_TIMEOUT"
1149
1724
  : error instanceof AgentRuntimeAbortError
1150
1725
  ? "AGENT_ABORTED"
1151
- : "AGENT_PROVIDER_FAILED";
1152
- if (code !== "AGENT_ABORTED") {
1153
- this.logger?.error("Agent model call failed", { agent: agentName, invocationId: request.invocationId, code, error: redactSensitive(error) });
1154
- }
1155
- return { ok: false, error: runtimeError(code, errorMessageOf(error)) };
1726
+ : error instanceof AgentModelContextOverflowError
1727
+ ? "AGENT_CONTEXT_OVERFLOW"
1728
+ : "AGENT_PROVIDER_FAILED";
1729
+ return { ok: false, cause: error, textEmitted, logged: false, error: runtimeError(code, errorMessageOf(error)) };
1156
1730
  } finally {
1157
1731
  acceptingText = false;
1158
1732
  detach?.();