agent-accelerator 0.4.5 → 0.5.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 +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +401 -120
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +253 -98
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +39 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +21 -2
- package/src/providers/google.ts +282 -74
- package/src/providers/openai-compat.ts +61 -41
- package/src/providers/openai.ts +46 -31
- package/src/providers/openrouter.ts +42 -31
- package/src/providers/shared.ts +110 -16
- package/src/providers.ts +119 -6
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +211 -104
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +57 -20
- package/src/types/model.ts +18 -0
- package/src/types/response.ts +2 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/errors.ts +11 -0
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/agent/loop.ts
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
|
-
import type { Provider, ProviderRequestOptions, ModelSpec } from "../types/model.ts";
|
|
1
|
+
import type { Provider, ProviderRequestOptions, ModelSpec, ProviderRawData } from "../types/model.ts";
|
|
2
2
|
import type { ToolDefinition, ToolCallRecord, ToolResultRecord } from "../types/tool.ts";
|
|
3
3
|
import type { TokenUsage } from "../types/core.ts";
|
|
4
4
|
import type { AgentRunOptions } from "../types/agent.ts";
|
|
5
5
|
import type { ContentPart } from "../types/message.ts";
|
|
6
6
|
import { AgentResponse, normalizeFinishReason, type FinishReason, type SubAgentExecutionMetadata, type StreamEvent } from "../types/response.ts";
|
|
7
|
-
import { priceUsage, reportUsageAnomaly
|
|
7
|
+
import { priceUsage, reportUsageAnomaly } from "../session/store.ts";
|
|
8
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
8
9
|
export { telemetryBus };
|
|
9
10
|
import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
10
11
|
import { AgentContext } from "./context.ts";
|
|
11
12
|
import { toStandardToolDeclarations } from "../tools/tool.ts";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
executeToolCalls,
|
|
15
|
+
findToolDefinition,
|
|
16
|
+
isRepeatableTool,
|
|
17
|
+
makeRepeatBlockedResult,
|
|
18
|
+
shouldBlockRepeat,
|
|
19
|
+
toolCallFingerprint,
|
|
20
|
+
traceToolEvent,
|
|
21
|
+
} from "../tools/executor.ts";
|
|
13
22
|
import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } from "../models/catalog.ts";
|
|
14
23
|
import { preprocessFilePartsForBypass } from "../utils/documents.ts";
|
|
24
|
+
import { enforceFileAccess } from "../utils/safe-io.ts";
|
|
25
|
+
import { isSubagentResult, normalizeSubagentMetadata } from "../tools/subagent-result.ts";
|
|
26
|
+
import { BudgetExceededError } from "../types/errors.ts";
|
|
27
|
+
import { applyCaptureRaw, type CaptureRawMode } from "../utils/raw-capture.ts";
|
|
15
28
|
import { noteProviderTurn, normalizeStructuredOutput, parseStructuredOutput } from "../providers.ts";
|
|
16
29
|
|
|
17
30
|
/** One mid-session steer request buffered while a run is active. */
|
|
18
31
|
export interface SteerEntry {
|
|
19
32
|
prompt: string | ContentPart[];
|
|
20
33
|
options?: AgentRunOptions;
|
|
34
|
+
/**
|
|
35
|
+
* @internal Settles the promise handed to the `steer()` caller. If the run
|
|
36
|
+
* ends before the prompt is injected, the entry becomes a queued follow-up
|
|
37
|
+
* (`deferredToQueue`) and the caller's promise resolves with THAT run's
|
|
38
|
+
* response instead of the finished one.
|
|
39
|
+
*/
|
|
40
|
+
settle?: { resolve: (res: AgentResponse) => void; reject: (err: unknown) => void; deferredToQueue?: boolean };
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export interface AgentLoopConfig {
|
|
@@ -31,6 +51,26 @@ export interface AgentLoopConfig {
|
|
|
31
51
|
maxTurns?: number;
|
|
32
52
|
/** Convert `file` parts client-side for pdf-incapable models (see Agent flag). */
|
|
33
53
|
bypassInputFileModality?: boolean;
|
|
54
|
+
/** Local-file policy: media paths must resolve inside `roots` (see `AgentConfig.fileAccess`). */
|
|
55
|
+
fileAccess?: { roots?: string[] };
|
|
56
|
+
/**
|
|
57
|
+
* What to do with the conversation when a run fails (not aborts):
|
|
58
|
+
* - `"auto"` (default): roll back to the pre-run checkpoint, UNLESS a tool
|
|
59
|
+
* already executed. Rolling back then would erase the only record of real
|
|
60
|
+
* side effects (emails sent, rows written) and a retry would repeat them.
|
|
61
|
+
* - `"rollback"`: always restore the checkpoint.
|
|
62
|
+
* - `"keep"`: never restore it.
|
|
63
|
+
*/
|
|
64
|
+
onFailure?: "auto" | "rollback" | "keep";
|
|
65
|
+
/** Wire-exchange capture policy for `AgentResponse.raw` (default `"redacted"`: no bodies). */
|
|
66
|
+
captureRaw?: CaptureRawMode;
|
|
67
|
+
/**
|
|
68
|
+
* Budget hook. Called with no argument at the start of every turn (time /
|
|
69
|
+
* count checks) and with a priced usage snapshot after each model turn and
|
|
70
|
+
* each finished sub-agent (charge + check). Throws `BudgetExceededError`
|
|
71
|
+
* to stop the run.
|
|
72
|
+
*/
|
|
73
|
+
enforceBudget?: (usage?: TokenUsage) => void;
|
|
34
74
|
/** Called after every turn for durable persistence checkpoints (never fails a turn). */
|
|
35
75
|
onProgress?: () => void;
|
|
36
76
|
/**
|
|
@@ -107,6 +147,33 @@ function emitTurn(
|
|
|
107
147
|
} catch {}
|
|
108
148
|
}
|
|
109
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Per-turn `toolChoice`. `"none"` holds for the whole run; a forced choice
|
|
152
|
+
* (`"required"` or a pinned function) applies to the FIRST model turn only:
|
|
153
|
+
* forcing a tool call on every turn would keep the model calling tools until
|
|
154
|
+
* `maxTurns`, so the run could never produce a final answer.
|
|
155
|
+
*/
|
|
156
|
+
function toolChoiceForTurn(
|
|
157
|
+
choice: ProviderRequestOptions["toolChoice"],
|
|
158
|
+
turn: number
|
|
159
|
+
): ProviderRequestOptions["toolChoice"] {
|
|
160
|
+
if (choice === undefined || choice === "auto") return undefined;
|
|
161
|
+
if (choice === "none") return "none";
|
|
162
|
+
return turn === 1 ? choice : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether a failed run should restore its pre-run checkpoint (see `AgentLoopConfig.onFailure`). */
|
|
166
|
+
function shouldRollbackOnFailure(
|
|
167
|
+
policy: "auto" | "rollback" | "keep" | undefined,
|
|
168
|
+
isAbort: boolean,
|
|
169
|
+
toolsExecuted: boolean
|
|
170
|
+
): boolean {
|
|
171
|
+
if (isAbort) return false;
|
|
172
|
+
if (policy === "keep") return false;
|
|
173
|
+
if (policy === "rollback") return true;
|
|
174
|
+
return !toolsExecuted;
|
|
175
|
+
}
|
|
176
|
+
|
|
110
177
|
function emitProgress(config: { onProgress?: () => void }): void {
|
|
111
178
|
try { config.onProgress?.(); } catch {}
|
|
112
179
|
}
|
|
@@ -174,67 +241,67 @@ function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendin
|
|
|
174
241
|
// ---------------------------------------------------------------------------
|
|
175
242
|
|
|
176
243
|
/**
|
|
177
|
-
* Repairs
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
244
|
+
* Repairs tool call/result pairing before each provider request (Q-19).
|
|
245
|
+
*
|
|
246
|
+
* Providers reject a `tool_result` without its `tool_call`, a `tool_call`
|
|
247
|
+
* without its result, AND (Chat-Completions style APIs) results that are not
|
|
248
|
+
* directly after the assistant message that issued the calls. A prior abort or
|
|
249
|
+
* crash can leave any of these in history, so the repair:
|
|
250
|
+
* - re-emits every result immediately after its assistant message, in call order;
|
|
251
|
+
* - synthesizes an error result for calls that never got one (e.g. `interrupt()`
|
|
252
|
+
* fired before the tools ran), at that same position;
|
|
253
|
+
* - drops results whose id the model never issued.
|
|
254
|
+
* Open-weight models that reuse one id for several calls keep one result per call.
|
|
181
255
|
*/
|
|
182
256
|
export function ensureToolPairing(context: AgentContext): void {
|
|
183
257
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
if (callIds.size === 0) {
|
|
194
|
-
// No calls: drop any stray tool results.
|
|
195
|
-
const hasOrphan = context.messages.some(
|
|
196
|
-
(m: any) => m?.role === "tool" && Array.isArray(m.content)
|
|
197
|
-
);
|
|
198
|
-
if (!hasOrphan) return;
|
|
199
|
-
context.messages = context.messages.filter((m: any) => m?.role !== "tool");
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
const resultIds = new Set<string>();
|
|
203
|
-
for (const m of context.messages) {
|
|
204
|
-
if ((m as any)?.role !== "tool" || !Array.isArray((m as any).content)) continue;
|
|
205
|
-
for (const p of (m as any).content as Array<any>) {
|
|
258
|
+
type ToolResultPart = Extract<ContentPart, { type: "tool_result" }>;
|
|
259
|
+
const messages = context.messages;
|
|
260
|
+
// Pass 1: queue every existing result part by id (document order).
|
|
261
|
+
const results = new Map<string, Array<{ part: ToolResultPart; message: (typeof messages)[number] }>>();
|
|
262
|
+
for (const m of messages) {
|
|
263
|
+
if (m?.role !== "tool" || !Array.isArray(m.content)) continue;
|
|
264
|
+
for (const p of m.content) {
|
|
206
265
|
if (p && p.type === "tool_result" && typeof p.id === "string" && p.id) {
|
|
207
|
-
|
|
266
|
+
const q = results.get(p.id) ?? [];
|
|
267
|
+
q.push({ part: p, message: m });
|
|
268
|
+
results.set(p.id, q);
|
|
208
269
|
}
|
|
209
270
|
}
|
|
210
271
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
272
|
+
// Pass 2: rebuild, placing each assistant's results right behind it.
|
|
273
|
+
const rebuilt: typeof messages = [];
|
|
274
|
+
for (const m of messages) {
|
|
275
|
+
if (m?.role === "tool") continue; // re-emitted next to their calls (orphans dropped)
|
|
276
|
+
rebuilt.push(m);
|
|
277
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
278
|
+
for (const p of m.content) {
|
|
279
|
+
if (!p || p.type !== "tool_call" || typeof p.id !== "string" || !p.id) continue;
|
|
280
|
+
const hit = results.get(p.id)?.shift();
|
|
281
|
+
if (hit) {
|
|
282
|
+
// Reuse the original message object when it carried exactly this result.
|
|
283
|
+
rebuilt.push(hit.message.content.length === 1 ? hit.message : { role: "tool", name: hit.part.name, content: [hit.part] });
|
|
284
|
+
} else {
|
|
285
|
+
const name = typeof p.name === "string" ? p.name : "tool";
|
|
286
|
+
traceToolEvent("call_interrupted", "warn", { id: p.id, name }, {});
|
|
287
|
+
rebuilt.push({
|
|
288
|
+
role: "tool",
|
|
289
|
+
name,
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "tool_result",
|
|
293
|
+
id: p.id,
|
|
294
|
+
name,
|
|
295
|
+
result: `Error: tool_call '${name}' (${p.id}) has no result because the run was interrupted before it completed (recovered by ensureToolPairing). It may have partially executed: check the tool trace before repeating side-effecting work.`,
|
|
296
|
+
isError: true,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
221
301
|
}
|
|
222
302
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
for (const r of missing) resultIds.add(r.id);
|
|
226
|
-
}
|
|
227
|
-
// Drop orphan results (result id never issued by the model).
|
|
228
|
-
const filtered = context.messages.filter((m: any) => {
|
|
229
|
-
if (m?.role !== "tool" || !Array.isArray(m.content)) return true;
|
|
230
|
-
const parts = m.content as Array<any>;
|
|
231
|
-
const keep = parts.filter((p) => p?.type !== "tool_result" || callIds.has(p.id));
|
|
232
|
-
if (keep.length === parts.length) return true;
|
|
233
|
-
if (keep.length === 0) return false;
|
|
234
|
-
try { (m as any).content = keep; } catch {}
|
|
235
|
-
return true;
|
|
236
|
-
});
|
|
237
|
-
context.messages = filtered as typeof context.messages;
|
|
303
|
+
const unchanged = rebuilt.length === messages.length && rebuilt.every((m, i) => m === messages[i]);
|
|
304
|
+
if (!unchanged) context.messages = rebuilt;
|
|
238
305
|
} catch {}
|
|
239
306
|
}
|
|
240
307
|
|
|
@@ -246,13 +313,14 @@ export interface LoopLimits {
|
|
|
246
313
|
maxTurns?: number;
|
|
247
314
|
deadlineMs?: number;
|
|
248
315
|
maxToolCallsPerRun?: number;
|
|
316
|
+
maxTotalTokens?: number;
|
|
249
317
|
requestTimeoutMs: number;
|
|
250
318
|
firstByteTimeoutMs: number;
|
|
251
319
|
streamIdleTimeoutMs: number;
|
|
252
320
|
}
|
|
253
321
|
|
|
254
322
|
/** Resolves `(config as any).limits` with Q-22 defaults (timeouts tolerate reasoning). */
|
|
255
|
-
function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): LoopLimits & { maxTurns: number; deadlineMs: number; maxToolCallsPerRun: number } {
|
|
323
|
+
function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): LoopLimits & { maxTurns: number; deadlineMs: number; maxToolCallsPerRun: number; maxTotalTokens: number } {
|
|
256
324
|
const raw = ((config as unknown as { limits?: Record<string, unknown> }).limits ?? {}) as Record<string, unknown>;
|
|
257
325
|
const num = (v: unknown): number | undefined =>
|
|
258
326
|
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -264,10 +332,11 @@ function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): L
|
|
|
264
332
|
}
|
|
265
333
|
const deadlineMs = num(raw.deadlineMs) ?? Infinity;
|
|
266
334
|
const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
|
|
335
|
+
const maxTotalTokens = num(raw.maxTotalTokens) ?? Infinity;
|
|
267
336
|
const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
|
|
268
337
|
const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
|
|
269
338
|
const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
|
|
270
|
-
return { maxTurns, deadlineMs, maxToolCallsPerRun, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
|
|
339
|
+
return { maxTurns, deadlineMs, maxToolCallsPerRun, maxTotalTokens, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
|
|
271
340
|
}
|
|
272
341
|
|
|
273
342
|
function loopSessionId(config: AgentLoopConfig): string | undefined {
|
|
@@ -303,6 +372,15 @@ function throwIfAborted(
|
|
|
303
372
|
}
|
|
304
373
|
}
|
|
305
374
|
|
|
375
|
+
function checkTokenLimit(usage: TokenUsage, maxTotalTokens: number): void {
|
|
376
|
+
if (Number.isFinite(maxTotalTokens) && usage.totalTokens > maxTotalTokens) {
|
|
377
|
+
throw new BudgetExceededError(
|
|
378
|
+
`Token limit exceeded: ${usage.totalTokens} > limits.maxTotalTokens ${maxTotalTokens}.`,
|
|
379
|
+
{ context: { partial: { usage, turns: 0 } } }
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
306
384
|
function checkDeadline(startTime: number, deadlineMs: number): void {
|
|
307
385
|
if (Number.isFinite(deadlineMs) && deadlineMs !== Infinity && Date.now() - startTime > deadlineMs) {
|
|
308
386
|
throw new Error(`[Agent Accelerator] Run deadline exceeded after ${deadlineMs}ms.`);
|
|
@@ -408,46 +486,38 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
|
|
|
408
486
|
}
|
|
409
487
|
|
|
410
488
|
function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord; metas: SubAgentExecutionMetadata[] } {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
489
|
+
// Only SDK-branded delegation envelopes are trusted. A plain object that
|
|
490
|
+
// merely carries a `_subagentMetadata` key (e.g. scraped JSON) is ordinary
|
|
491
|
+
// tool output: it must not replace what the model sees or touch billing.
|
|
492
|
+
if (isSubagentResult(r.result)) {
|
|
493
|
+
const xml = r.result.xml || r.result.toString?.() || String(r.result);
|
|
414
494
|
return {
|
|
415
495
|
sanitized: { ...r, result: xml },
|
|
416
|
-
metas:
|
|
496
|
+
metas: normalizeSubagentMetadata(r.result._subagentMetadata),
|
|
417
497
|
};
|
|
418
498
|
}
|
|
419
499
|
return { sanitized: r, metas: [] };
|
|
420
500
|
}
|
|
421
501
|
|
|
422
|
-
function toolCallFingerprint(call: ToolCallRecord): string {
|
|
423
|
-
const stableSerialize = (value: any, seen = new Set<any>()): string => {
|
|
424
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
425
|
-
if (seen.has(value)) return "[Circular]";
|
|
426
|
-
seen.add(value);
|
|
427
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item, seen)).join(",")}]`;
|
|
428
|
-
const output = `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key], seen)}`).join(",")}}`;
|
|
429
|
-
seen.delete(value);
|
|
430
|
-
return output;
|
|
431
|
-
};
|
|
432
|
-
const args = stableSerialize(call.arguments ?? {});
|
|
433
|
-
return `${call.name}\u0000${args}`;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
502
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
503
|
+
* Stops a model from hammering the same call. Per fingerprint (name + stable
|
|
504
|
+
* args) the guard counts CONSECUTIVE turns: the first two identical calls run,
|
|
505
|
+
* the third in a row is answered with a synthetic `repeat_blocked` error so
|
|
506
|
+
* the model reuses the earlier result or changes approach. Tools declared
|
|
507
|
+
* `repeatable` (or `idempotent`, e.g. status polling) are never blocked.
|
|
508
|
+
* A fingerprint that skips a turn starts over.
|
|
440
509
|
*/
|
|
441
510
|
async function executeToolCallsWithRepeatGuard(options: {
|
|
442
511
|
tools: Record<string, ToolDefinition>;
|
|
443
512
|
toolCalls: ToolCallRecord[];
|
|
444
513
|
previousFingerprints: Set<string>;
|
|
514
|
+
repeatCounts: Map<string, number>;
|
|
445
515
|
agentName?: string;
|
|
446
516
|
signal?: AbortSignal;
|
|
447
517
|
sessionId?: string;
|
|
448
518
|
onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
|
|
449
519
|
}): Promise<{ results: ToolResultRecord[]; fingerprints: Set<string> }> {
|
|
450
|
-
const { toolCalls, previousFingerprints } = options;
|
|
520
|
+
const { toolCalls, previousFingerprints, repeatCounts } = options;
|
|
451
521
|
const currentFingerprints = new Set<string>();
|
|
452
522
|
const blocked = new Map<string, ToolResultRecord>();
|
|
453
523
|
const executable: ToolCallRecord[] = [];
|
|
@@ -455,20 +525,21 @@ async function executeToolCallsWithRepeatGuard(options: {
|
|
|
455
525
|
for (const call of toolCalls) {
|
|
456
526
|
const fingerprint = toolCallFingerprint(call);
|
|
457
527
|
currentFingerprints.add(fingerprint);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
});
|
|
528
|
+
const def = findToolDefinition(options.tools, call.name);
|
|
529
|
+
if (def && isRepeatableTool(def)) {
|
|
530
|
+
executable.push(call);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!previousFingerprints.has(fingerprint)) repeatCounts.delete(fingerprint); // streak broken
|
|
534
|
+
if (shouldBlockRepeat(previousFingerprints, call, repeatCounts)) {
|
|
535
|
+
traceToolEvent("call_blocked", "warn", call, { agentName: options.agentName, sessionId: options.sessionId }, { reason: "repeat_blocked" });
|
|
536
|
+
blocked.set(call.id, makeRepeatBlockedResult(call));
|
|
468
537
|
} else {
|
|
469
538
|
executable.push(call);
|
|
470
539
|
}
|
|
471
540
|
}
|
|
541
|
+
// Streaks only survive while the fingerprint keeps appearing every turn.
|
|
542
|
+
for (const fp of [...repeatCounts.keys()]) if (!currentFingerprints.has(fp)) repeatCounts.delete(fp);
|
|
472
543
|
|
|
473
544
|
const executed = executable.length > 0
|
|
474
545
|
? await executeToolCalls({
|
|
@@ -546,7 +617,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
546
617
|
const allSubagents: SubAgentExecutionMetadata[] = [];
|
|
547
618
|
let finalResult: any = null;
|
|
548
619
|
let turns = 0;
|
|
620
|
+
let toolsExecuted = false;
|
|
549
621
|
let previousToolCallFingerprints = new Set<string>();
|
|
622
|
+
const repeatCounts = new Map<string, number>();
|
|
550
623
|
|
|
551
624
|
try {
|
|
552
625
|
if (config.bypassInputFileModality) {
|
|
@@ -554,6 +627,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
554
627
|
providerId: provider.id,
|
|
555
628
|
modelId,
|
|
556
629
|
signal: runOptions?.signal,
|
|
630
|
+
roots: config.fileAccess?.roots,
|
|
557
631
|
});
|
|
558
632
|
}
|
|
559
633
|
|
|
@@ -563,8 +637,10 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
563
637
|
// Q-21: fail fast on abort (throws AbortError with partial).
|
|
564
638
|
throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
|
|
565
639
|
checkDeadline(startTime, limits.deadlineMs);
|
|
640
|
+
config.enforceBudget?.();
|
|
566
641
|
// Q-19: repair orphan tool pairs before every request.
|
|
567
642
|
ensureToolPairing(context);
|
|
643
|
+
if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
|
|
568
644
|
turns++;
|
|
569
645
|
|
|
570
646
|
const spec = getModelFromCatalog(provider.id, modelId);
|
|
@@ -576,6 +652,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
576
652
|
cache: options?.cache,
|
|
577
653
|
tools: standardTools.length > 0 ? standardTools : undefined,
|
|
578
654
|
signal: runOptions?.signal,
|
|
655
|
+
toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
|
|
579
656
|
...(loopOutput.spec ? { output: loopOutput.spec } : {}),
|
|
580
657
|
};
|
|
581
658
|
// Q-22: per-request timeout pass-through (providers honor timeoutMs).
|
|
@@ -593,6 +670,15 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
593
670
|
(config as unknown as { googleStore?: unknown }).googleStore;
|
|
594
671
|
if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
|
|
595
672
|
} catch {}
|
|
673
|
+
// Google interaction storage tier for the chain-TTL gate
|
|
674
|
+
// (`free`: 23h local gate; `paid`: 54d). Defaults to `free` in adapter.
|
|
675
|
+
try {
|
|
676
|
+
const gTier =
|
|
677
|
+
(runOptions as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
678
|
+
(options as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
679
|
+
(config as unknown as { googleTier?: unknown }).googleTier;
|
|
680
|
+
if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
|
|
681
|
+
} catch {}
|
|
596
682
|
|
|
597
683
|
const genResult = await generateWithTimeout(
|
|
598
684
|
() => provider.generate(
|
|
@@ -633,6 +719,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
633
719
|
}
|
|
634
720
|
|
|
635
721
|
accumulateUsage(accumulatedUsage, genResult.usage, spec);
|
|
722
|
+
config.enforceBudget?.({ ...genResult.usage, cost: computeCostFromPricing(genResult.usage, spec) });
|
|
723
|
+
checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
|
|
636
724
|
|
|
637
725
|
// Record assistant turn in context
|
|
638
726
|
context.addAssistantMessage(
|
|
@@ -642,6 +730,26 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
642
730
|
genResult.thoughtSignature
|
|
643
731
|
);
|
|
644
732
|
|
|
733
|
+
// Thinking-trace telemetry (observability-only, full text, no limit):
|
|
734
|
+
// one bus event per thinking turn carrying the text + thought signature.
|
|
735
|
+
// Never touches provider payloads, history, or persistence formats.
|
|
736
|
+
if (genResult.thinking) {
|
|
737
|
+
try {
|
|
738
|
+
telemetryBus.emit({
|
|
739
|
+
code: "turn.thinking",
|
|
740
|
+
message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
|
|
741
|
+
data: {
|
|
742
|
+
turns,
|
|
743
|
+
model: modelId,
|
|
744
|
+
provider: provider.id,
|
|
745
|
+
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
746
|
+
thinking: genResult.thinking,
|
|
747
|
+
...(genResult.thoughtSignature ? { thoughtSignature: genResult.thoughtSignature } : {}),
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
|
|
645
753
|
// Mid-session steer: if new queries arrived during this provider turn,
|
|
646
754
|
// inject them now. Tool turns still execute first (see below) so the
|
|
647
755
|
// current action finishes before redirection.
|
|
@@ -660,6 +768,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
660
768
|
providerId: provider.id,
|
|
661
769
|
modelId,
|
|
662
770
|
signal: runOptions?.signal,
|
|
771
|
+
roots: config.fileAccess?.roots,
|
|
663
772
|
});
|
|
664
773
|
}
|
|
665
774
|
}
|
|
@@ -678,10 +787,12 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
678
787
|
`[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${limits.maxToolCallsPerRun}).`
|
|
679
788
|
);
|
|
680
789
|
}
|
|
790
|
+
toolsExecuted = true;
|
|
681
791
|
const guarded = await executeToolCallsWithRepeatGuard({
|
|
682
792
|
tools,
|
|
683
793
|
toolCalls: genResult.toolCalls,
|
|
684
794
|
previousFingerprints: previousToolCallFingerprints,
|
|
795
|
+
repeatCounts,
|
|
685
796
|
agentName,
|
|
686
797
|
signal: runOptions?.signal,
|
|
687
798
|
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
@@ -697,11 +808,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
697
808
|
if (metas.length > 0) {
|
|
698
809
|
allSubagents.push(...metas);
|
|
699
810
|
for (const s of metas) {
|
|
811
|
+
if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
|
|
700
812
|
const subagentSpec = getModelFromCatalog(s.provider, s.model);
|
|
701
813
|
if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
|
|
702
814
|
s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
|
|
703
815
|
}
|
|
704
816
|
accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
|
|
817
|
+
config.enforceBudget?.(s.usage);
|
|
705
818
|
}
|
|
706
819
|
}
|
|
707
820
|
sanitizedResults.push(sanitized);
|
|
@@ -709,6 +822,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
709
822
|
|
|
710
823
|
allToolResults.push(...sanitizedResults);
|
|
711
824
|
context.addToolResults(sanitizedResults);
|
|
825
|
+
checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
|
|
712
826
|
// Q-19: results are stored BEFORE the cancellation check so an abort
|
|
713
827
|
// never drops executed tool work from history.
|
|
714
828
|
throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
|
|
@@ -730,6 +844,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
730
844
|
providerId: provider.id,
|
|
731
845
|
modelId,
|
|
732
846
|
signal: runOptions?.signal,
|
|
847
|
+
roots: config.fileAccess?.roots,
|
|
733
848
|
});
|
|
734
849
|
}
|
|
735
850
|
}
|
|
@@ -775,18 +890,17 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
775
890
|
// Q-04: canonical finish reason + raw vendor passthrough.
|
|
776
891
|
...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
|
|
777
892
|
durationMs: Date.now() - startTime,
|
|
778
|
-
raw: finalResult?.raw,
|
|
893
|
+
raw: applyCaptureRaw(finalResult?.raw, config.captureRaw) as ProviderRawData,
|
|
779
894
|
turns,
|
|
780
895
|
});
|
|
781
896
|
} catch (err) {
|
|
782
897
|
// Q-19: run-level onFailure rollback (default restores pre-run
|
|
783
898
|
// checkpoint; opt out with (config as any).onFailure === "keep").
|
|
784
899
|
// Aborts are excluded so partial history survives interruption.
|
|
785
|
-
const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
|
|
786
900
|
const isAbort =
|
|
787
901
|
(err as { name?: string } | null)?.name === "AbortError" ||
|
|
788
902
|
!!runOptions?.signal?.aborted;
|
|
789
|
-
if (onFailure
|
|
903
|
+
if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
|
|
790
904
|
try { context.rollback(preRunCp, sessionIdForRollback); } catch {
|
|
791
905
|
// why: rollback is best-effort — the original error takes precedence.
|
|
792
906
|
}
|
|
@@ -874,6 +988,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
874
988
|
const allToolCalls: ToolCallRecord[] = [];
|
|
875
989
|
const allToolResults: ToolResultRecord[] = [];
|
|
876
990
|
const allSubagents: SubAgentExecutionMetadata[] = [];
|
|
991
|
+
let toolsExecuted = false;
|
|
877
992
|
try {
|
|
878
993
|
await ensureModelCatalogFresh();
|
|
879
994
|
// Re-validate after refresh: Agent.stream() validates before the catalog
|
|
@@ -895,6 +1010,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
895
1010
|
providerId: config.provider.id,
|
|
896
1011
|
modelId: config.modelId,
|
|
897
1012
|
signal: config.runOptions?.signal,
|
|
1013
|
+
roots: config.fileAccess?.roots,
|
|
898
1014
|
});
|
|
899
1015
|
}
|
|
900
1016
|
const {
|
|
@@ -937,11 +1053,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
937
1053
|
lastResponse = null;
|
|
938
1054
|
turns = 0;
|
|
939
1055
|
let previousToolCallFingerprints = new Set<string>();
|
|
1056
|
+
const repeatCounts = new Map<string, number>();
|
|
940
1057
|
|
|
941
1058
|
while (turns < maxTurns) {
|
|
942
1059
|
throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
|
|
943
1060
|
checkDeadline(streamStartTime, streamLimits.deadlineMs);
|
|
1061
|
+
config.enforceBudget?.();
|
|
944
1062
|
ensureToolPairing(context);
|
|
1063
|
+
if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
|
|
945
1064
|
turns++;
|
|
946
1065
|
|
|
947
1066
|
const spec2 = getModelFromCatalog(provider.id, modelId);
|
|
@@ -952,6 +1071,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
952
1071
|
cache: options?.cache,
|
|
953
1072
|
tools: standardTools.length > 0 ? standardTools : undefined,
|
|
954
1073
|
signal: linked.signal,
|
|
1074
|
+
toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
|
|
955
1075
|
...(streamOutputSpec ? { output: streamOutputSpec } : {}),
|
|
956
1076
|
};
|
|
957
1077
|
try {
|
|
@@ -966,6 +1086,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
966
1086
|
(config as unknown as { googleStore?: unknown }).googleStore;
|
|
967
1087
|
if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
|
|
968
1088
|
} catch {}
|
|
1089
|
+
try {
|
|
1090
|
+
const gTier =
|
|
1091
|
+
(runOptions as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
1092
|
+
(options as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
1093
|
+
(config as unknown as { googleTier?: unknown }).googleTier;
|
|
1094
|
+
if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
|
|
1095
|
+
} catch {}
|
|
969
1096
|
|
|
970
1097
|
const innerStream = provider.stream(
|
|
971
1098
|
modelId,
|
|
@@ -1010,7 +1137,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1010
1137
|
throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
|
|
1011
1138
|
const event = res.value as StreamEvent;
|
|
1012
1139
|
if ((event as StreamEvent)?.type !== "done") {
|
|
1013
|
-
|
|
1140
|
+
// The provider's `start` event carries the request audit record: apply the capture policy.
|
|
1141
|
+
outerStream.push(
|
|
1142
|
+
event.type === "start" && event.raw
|
|
1143
|
+
? { ...event, raw: applyCaptureRaw(event.raw, config.captureRaw) }
|
|
1144
|
+
: event
|
|
1145
|
+
);
|
|
1014
1146
|
}
|
|
1015
1147
|
} catch (e) {
|
|
1016
1148
|
if (timer) clearTimeout(timer);
|
|
@@ -1050,6 +1182,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1050
1182
|
}
|
|
1051
1183
|
|
|
1052
1184
|
accumulateUsage(accumulatedUsage, turnResponse.usage, spec2);
|
|
1185
|
+
config.enforceBudget?.({ ...turnResponse.usage, cost: computeCostFromPricing(turnResponse.usage, spec2) });
|
|
1186
|
+
checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
|
|
1053
1187
|
|
|
1054
1188
|
context.addAssistantMessage(
|
|
1055
1189
|
turnResponse.text,
|
|
@@ -1058,6 +1192,25 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1058
1192
|
turnResponse.thoughtSignature
|
|
1059
1193
|
);
|
|
1060
1194
|
|
|
1195
|
+
// Thinking-trace telemetry (observability-only, full text, no limit):
|
|
1196
|
+
// mirrors the non-streaming loop above; bus-only, never wire/history.
|
|
1197
|
+
if (turnResponse.thinking) {
|
|
1198
|
+
try {
|
|
1199
|
+
telemetryBus.emit({
|
|
1200
|
+
code: "turn.thinking",
|
|
1201
|
+
message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
|
|
1202
|
+
data: {
|
|
1203
|
+
turns,
|
|
1204
|
+
model: modelId,
|
|
1205
|
+
provider: provider.id,
|
|
1206
|
+
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
1207
|
+
thinking: turnResponse.thinking,
|
|
1208
|
+
...(turnResponse.thoughtSignature ? { thoughtSignature: turnResponse.thoughtSignature } : {}),
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
} catch {}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1061
1214
|
const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
|
|
1062
1215
|
? drainSteerInbox(context, config.steerInbox)
|
|
1063
1216
|
: [];
|
|
@@ -1093,10 +1246,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1093
1246
|
} catch {
|
|
1094
1247
|
// why: stream push never throws, but guard anyway to protect the loop.
|
|
1095
1248
|
}
|
|
1249
|
+
toolsExecuted = true;
|
|
1096
1250
|
const guarded = await executeToolCallsWithRepeatGuard({
|
|
1097
1251
|
tools,
|
|
1098
1252
|
toolCalls: turnResponse.toolCalls,
|
|
1099
1253
|
previousFingerprints: previousToolCallFingerprints,
|
|
1254
|
+
repeatCounts,
|
|
1100
1255
|
agentName,
|
|
1101
1256
|
signal: linked.signal,
|
|
1102
1257
|
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
@@ -1125,11 +1280,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1125
1280
|
if (metas.length > 0) {
|
|
1126
1281
|
allSubagents.push(...metas);
|
|
1127
1282
|
for (const s of metas) {
|
|
1283
|
+
if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
|
|
1128
1284
|
const subagentSpec = getModelFromCatalog(s.provider, s.model);
|
|
1129
1285
|
if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
|
|
1130
1286
|
s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
|
|
1131
1287
|
}
|
|
1132
1288
|
accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
|
|
1289
|
+
config.enforceBudget?.(s.usage);
|
|
1133
1290
|
outerStream.push({
|
|
1134
1291
|
type: "subagent_complete",
|
|
1135
1292
|
subagent: s,
|
|
@@ -1224,7 +1381,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1224
1381
|
// Q-04: canonical finish reason + raw vendor passthrough.
|
|
1225
1382
|
...truncatedFinish,
|
|
1226
1383
|
durationMs: Date.now() - startTime,
|
|
1227
|
-
raw: lastResponse?.raw as
|
|
1384
|
+
raw: applyCaptureRaw(lastResponse?.raw, config.captureRaw) as ProviderRawData,
|
|
1228
1385
|
turns,
|
|
1229
1386
|
});
|
|
1230
1387
|
|
|
@@ -1243,13 +1400,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1243
1400
|
const isAbort =
|
|
1244
1401
|
linked.signal.aborted ||
|
|
1245
1402
|
outerStream.isCancelled() ||
|
|
1246
|
-
(raw as any)?.name === "AbortError"
|
|
1247
|
-
/abort|cancell?ed/i.test(String((raw as any)?.message ?? raw));
|
|
1403
|
+
(raw as any)?.name === "AbortError";
|
|
1248
1404
|
// Q-19: streaming onFailure rollback (same policy as runAgentLoop;
|
|
1249
1405
|
// aborts excluded so partial history survives).
|
|
1250
1406
|
try {
|
|
1251
|
-
|
|
1252
|
-
if (onFailure !== "keep" && !isAbort) {
|
|
1407
|
+
if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
|
|
1253
1408
|
try { config.context.rollback(streamPreCp, streamSessionId); } catch {
|
|
1254
1409
|
// why: rollback is best-effort — the original error takes precedence.
|
|
1255
1410
|
}
|