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.
- package/README.md +13 -5
- package/core/access/Access.module.ts +6 -1
- package/core/access/catalog/AccessCatalog.ts +56 -7
- package/core/access/catalog/IAccessCatalog.service.ts +28 -2
- package/core/access/catalog/PermissionSourceLoader.ts +43 -0
- package/core/access/catalog/PermissionSources.ts +222 -0
- package/core/access/catalog/moduleFields.ts +60 -6
- package/core/access/catalog/permissions.ts +46 -1
- package/core/access/check/Access.ts +55 -28
- package/core/access/check/accessConfig.ts +9 -1
- package/core/access/check/can.ts +2 -2
- package/core/access/check/conditions.ts +69 -11
- package/core/access/check/decimal.ts +61 -0
- package/core/access/check/filter.ts +39 -8
- package/core/access/contracts/PermissionSource.ts +32 -0
- package/core/access/contracts/types.ts +12 -2
- package/core/access/generated/deps.ts +5 -1
- package/core/access/http/AccessCatalog.controller.ts +9 -2
- package/core/access/index.ts +12 -1
- package/core/agent/AgentContextBuilder.ts +119 -16
- package/core/agent/AgentRuntime.ts +8 -0
- package/core/agent/AgentToolExecutor.ts +7 -0
- package/core/agent/ModelProvider.ts +6 -0
- package/core/agent/README.md +35 -3
- package/core/agent/errors.ts +32 -0
- package/core/agent/index.ts +7 -0
- package/core/agent/internal/AgentExecutionDriver.ts +598 -24
- package/core/agent/internal/AgentExecutionState.ts +26 -3
- package/core/agent/semantic.ts +60 -1
- package/core/cache/SPEC.md +3 -3
- package/core/di/README.md +10 -3
- package/core/infra/anthropic.ts +11 -0
- package/core/infra/connectors/anthropic.ts +440 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +12 -0
- package/core/infra/connectors/llmRouter.ts +12 -0
- package/core/infra/connectors/openaiCompatible.ts +128 -63
- package/core/infra/connectors/openaiTextStream.ts +5 -2
- package/core/infra/connectors/toolNames.ts +49 -0
- package/core/scripts/di-generate.ts +34 -4
- package/core/websocket/DESIGN.md +2 -2
- package/index.ts +5 -0
- package/library/orm/Repository/README.md +14 -11
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
953
|
-
result = runtimeResult({ invocationId: plan.invocationId, agentName: plan.agent.name, status: "failed", steps: step, messages, responses
|
|
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
|
|
957
|
-
if (plan.options.output?.mode === "json")
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
-
:
|
|
1152
|
-
|
|
1153
|
-
|
|
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?.();
|