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.
Files changed (45) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +211 -104
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. 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, telemetryBus } from "../session/store.ts";
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 { executeToolCalls } from "../tools/executor.ts";
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 orphaned tool call/result pairs before each provider request
178
- * (Q-19). Providers reject a `tool_result` without its `tool_call` and
179
- * vice versa; a prior abort or crash can leave such orphans in history.
180
- * Synthesizes error results for orphan calls and drops orphan results.
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
- const callIds = new Map<string, string>();
185
- for (const m of context.messages) {
186
- if ((m as any)?.role !== "assistant" || !Array.isArray((m as any).content)) continue;
187
- for (const p of (m as any).content as Array<any>) {
188
- if (p && p.type === "tool_call" && typeof p.id === "string" && p.id) {
189
- if (!callIds.has(p.id)) callIds.set(p.id, typeof p.name === "string" ? p.name : "tool");
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
- resultIds.add(p.id);
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
- const missing: ToolResultRecord[] = [];
212
- for (const [id, name] of callIds) {
213
- if (!resultIds.has(id)) {
214
- missing.push({
215
- id,
216
- name,
217
- result: `Error: orphan tool_call '${name}' (${id}) without a matching tool result (recovered by ensureToolPairing).`,
218
- isError: true,
219
- durationMs: 0,
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
- if (missing.length > 0) {
224
- try { context.addToolResults(missing); } catch {}
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
- if (r.result && typeof r.result === "object" && "_subagentMetadata" in (r.result as any)) {
412
- const metaList = (r.result as any)._subagentMetadata as SubAgentExecutionMetadata[];
413
- const xml = (r.result as any).xml || (r.result as any).toString?.() || String(r.result);
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: Array.isArray(metaList) ? metaList : [],
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
- * Prevents an identical tool call from being executed in consecutive model
438
- * turns. The synthetic error is sent back to the model so it can reuse the
439
- * previous result or choose a different action.
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
- if (previousFingerprints.has(fingerprint)) {
459
- blocked.set(call.id, {
460
- id: call.id,
461
- name: call.name,
462
- result:
463
- `Error: Tool '${call.name}' was called again immediately with identical arguments. ` +
464
- "The previous result is already available; reuse it or call the tool with different arguments.",
465
- isError: true,
466
- durationMs: 0,
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 !== "keep" && !isAbort) {
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
- outerStream.push(event);
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 any,
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
- const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
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
  }