agent-accelerator 0.4.0 → 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 (46) 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 +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  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 +229 -113
  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 +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -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/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. 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";
15
- import { noteProviderTurn } from "../providers.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";
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
  /**
@@ -42,6 +82,12 @@ export interface AgentLoopConfig {
42
82
  steerInbox?: SteerEntry[];
43
83
  /** Called after steer entries are injected (streaming loops also push `steer_injected` events). */
44
84
  onSteerInjected?: (injectedPrompts: string[]) => void;
85
+ /**
86
+ * Canonical structured-output request (raw `AgentConfig.output`).
87
+ * Normalized per turn for the provider wire; validated once at the end
88
+ * into `AgentResponse.parsed`. Agent-level only.
89
+ */
90
+ output?: unknown;
45
91
  }
46
92
 
47
93
  /** Short preview for `steer_injected` / `queued` event payloads (bounded, display-only). */
@@ -101,10 +147,61 @@ function emitTurn(
101
147
  } catch {}
102
148
  }
103
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
+
104
177
  function emitProgress(config: { onProgress?: () => void }): void {
105
178
  try { config.onProgress?.(); } catch {}
106
179
  }
107
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // Structured outputs (canonical output wiring for the loop).
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function resolveLoopOutput(config: AgentLoopConfig): { raw: unknown; spec: import("../types/model.ts").StructuredOutputSpec | undefined } {
186
+ const raw = (config as { output?: unknown }).output;
187
+ const fromOptions = (config.options as { output?: import("../types/model.ts").StructuredOutputSpec } | undefined)?.output;
188
+ if (raw !== undefined && raw !== null) {
189
+ // Throws ValidationError for invalid shapes — caught by run/stream catch
190
+ // with partial attached, same as other config validation.
191
+ const spec = normalizeStructuredOutput(raw);
192
+ return { raw, spec };
193
+ }
194
+ if (fromOptions) return { raw: fromOptions, spec: fromOptions };
195
+ return { raw: undefined, spec: undefined };
196
+ }
197
+
198
+ function parseFinalStructuredOutput(text: string, raw: unknown, spec: import("../types/model.ts").StructuredOutputSpec | undefined): unknown | undefined {
199
+ if (raw === undefined && spec === undefined) return undefined;
200
+ // Strict-fail when opted in: invalid JSON/schema throws ValidationError.
201
+ // Tool turns skip this (called only for final no-tool text).
202
+ return parseStructuredOutput(text, raw ?? spec);
203
+ }
204
+
108
205
  /**
109
206
  * Normalizes `maxTurns` to a safe positive integer (default Infinity).
110
207
  * `0` means infinite (no limit), matching the `timeout: 0` convention.
@@ -144,67 +241,67 @@ function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendin
144
241
  // ---------------------------------------------------------------------------
145
242
 
146
243
  /**
147
- * Repairs orphaned tool call/result pairs before each provider request
148
- * (Q-19). Providers reject a `tool_result` without its `tool_call` and
149
- * vice versa; a prior abort or crash can leave such orphans in history.
150
- * 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.
151
255
  */
152
256
  export function ensureToolPairing(context: AgentContext): void {
153
257
  try {
154
- const callIds = new Map<string, string>();
155
- for (const m of context.messages) {
156
- if ((m as any)?.role !== "assistant" || !Array.isArray((m as any).content)) continue;
157
- for (const p of (m as any).content as Array<any>) {
158
- if (p && p.type === "tool_call" && typeof p.id === "string" && p.id) {
159
- if (!callIds.has(p.id)) callIds.set(p.id, typeof p.name === "string" ? p.name : "tool");
160
- }
161
- }
162
- }
163
- if (callIds.size === 0) {
164
- // No calls: drop any stray tool results.
165
- const hasOrphan = context.messages.some(
166
- (m: any) => m?.role === "tool" && Array.isArray(m.content)
167
- );
168
- if (!hasOrphan) return;
169
- context.messages = context.messages.filter((m: any) => m?.role !== "tool");
170
- return;
171
- }
172
- const resultIds = new Set<string>();
173
- for (const m of context.messages) {
174
- if ((m as any)?.role !== "tool" || !Array.isArray((m as any).content)) continue;
175
- 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) {
176
265
  if (p && p.type === "tool_result" && typeof p.id === "string" && p.id) {
177
- resultIds.add(p.id);
266
+ const q = results.get(p.id) ?? [];
267
+ q.push({ part: p, message: m });
268
+ results.set(p.id, q);
178
269
  }
179
270
  }
180
271
  }
181
- const missing: ToolResultRecord[] = [];
182
- for (const [id, name] of callIds) {
183
- if (!resultIds.has(id)) {
184
- missing.push({
185
- id,
186
- name,
187
- result: `Error: orphan tool_call '${name}' (${id}) without a matching tool result (recovered by ensureToolPairing).`,
188
- isError: true,
189
- durationMs: 0,
190
- });
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
+ }
191
301
  }
192
302
  }
193
- if (missing.length > 0) {
194
- try { context.addToolResults(missing); } catch {}
195
- for (const r of missing) resultIds.add(r.id);
196
- }
197
- // Drop orphan results (result id never issued by the model).
198
- const filtered = context.messages.filter((m: any) => {
199
- if (m?.role !== "tool" || !Array.isArray(m.content)) return true;
200
- const parts = m.content as Array<any>;
201
- const keep = parts.filter((p) => p?.type !== "tool_result" || callIds.has(p.id));
202
- if (keep.length === parts.length) return true;
203
- if (keep.length === 0) return false;
204
- try { (m as any).content = keep; } catch {}
205
- return true;
206
- });
207
- 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;
208
305
  } catch {}
209
306
  }
210
307
 
@@ -216,13 +313,14 @@ export interface LoopLimits {
216
313
  maxTurns?: number;
217
314
  deadlineMs?: number;
218
315
  maxToolCallsPerRun?: number;
316
+ maxTotalTokens?: number;
219
317
  requestTimeoutMs: number;
220
318
  firstByteTimeoutMs: number;
221
319
  streamIdleTimeoutMs: number;
222
320
  }
223
321
 
224
322
  /** Resolves `(config as any).limits` with Q-22 defaults (timeouts tolerate reasoning). */
225
- 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 } {
226
324
  const raw = ((config as unknown as { limits?: Record<string, unknown> }).limits ?? {}) as Record<string, unknown>;
227
325
  const num = (v: unknown): number | undefined =>
228
326
  typeof v === "number" && Number.isFinite(v) ? v : undefined;
@@ -234,10 +332,11 @@ function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): L
234
332
  }
235
333
  const deadlineMs = num(raw.deadlineMs) ?? Infinity;
236
334
  const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
335
+ const maxTotalTokens = num(raw.maxTotalTokens) ?? Infinity;
237
336
  const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
238
337
  const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
239
338
  const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
240
- return { maxTurns, deadlineMs, maxToolCallsPerRun, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
339
+ return { maxTurns, deadlineMs, maxToolCallsPerRun, maxTotalTokens, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
241
340
  }
242
341
 
243
342
  function loopSessionId(config: AgentLoopConfig): string | undefined {
@@ -273,6 +372,15 @@ function throwIfAborted(
273
372
  }
274
373
  }
275
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
+
276
384
  function checkDeadline(startTime: number, deadlineMs: number): void {
277
385
  if (Number.isFinite(deadlineMs) && deadlineMs !== Infinity && Date.now() - startTime > deadlineMs) {
278
386
  throw new Error(`[Agent Accelerator] Run deadline exceeded after ${deadlineMs}ms.`);
@@ -378,46 +486,38 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
378
486
  }
379
487
 
380
488
  function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord; metas: SubAgentExecutionMetadata[] } {
381
- if (r.result && typeof r.result === "object" && "_subagentMetadata" in (r.result as any)) {
382
- const metaList = (r.result as any)._subagentMetadata as SubAgentExecutionMetadata[];
383
- 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);
384
494
  return {
385
495
  sanitized: { ...r, result: xml },
386
- metas: Array.isArray(metaList) ? metaList : [],
496
+ metas: normalizeSubagentMetadata(r.result._subagentMetadata),
387
497
  };
388
498
  }
389
499
  return { sanitized: r, metas: [] };
390
500
  }
391
501
 
392
- function toolCallFingerprint(call: ToolCallRecord): string {
393
- const stableSerialize = (value: any, seen = new Set<any>()): string => {
394
- if (value === null || typeof value !== "object") return JSON.stringify(value);
395
- if (seen.has(value)) return "[Circular]";
396
- seen.add(value);
397
- if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item, seen)).join(",")}]`;
398
- const output = `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key], seen)}`).join(",")}}`;
399
- seen.delete(value);
400
- return output;
401
- };
402
- const args = stableSerialize(call.arguments ?? {});
403
- return `${call.name}\u0000${args}`;
404
- }
405
-
406
502
  /**
407
- * Prevents an identical tool call from being executed in consecutive model
408
- * turns. The synthetic error is sent back to the model so it can reuse the
409
- * 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.
410
509
  */
411
510
  async function executeToolCallsWithRepeatGuard(options: {
412
511
  tools: Record<string, ToolDefinition>;
413
512
  toolCalls: ToolCallRecord[];
414
513
  previousFingerprints: Set<string>;
514
+ repeatCounts: Map<string, number>;
415
515
  agentName?: string;
416
516
  signal?: AbortSignal;
417
517
  sessionId?: string;
418
518
  onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
419
519
  }): Promise<{ results: ToolResultRecord[]; fingerprints: Set<string> }> {
420
- const { toolCalls, previousFingerprints } = options;
520
+ const { toolCalls, previousFingerprints, repeatCounts } = options;
421
521
  const currentFingerprints = new Set<string>();
422
522
  const blocked = new Map<string, ToolResultRecord>();
423
523
  const executable: ToolCallRecord[] = [];
@@ -425,20 +525,21 @@ async function executeToolCallsWithRepeatGuard(options: {
425
525
  for (const call of toolCalls) {
426
526
  const fingerprint = toolCallFingerprint(call);
427
527
  currentFingerprints.add(fingerprint);
428
- if (previousFingerprints.has(fingerprint)) {
429
- blocked.set(call.id, {
430
- id: call.id,
431
- name: call.name,
432
- result:
433
- `Error: Tool '${call.name}' was called again immediately with identical arguments. ` +
434
- "The previous result is already available; reuse it or call the tool with different arguments.",
435
- isError: true,
436
- durationMs: 0,
437
- });
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));
438
537
  } else {
439
538
  executable.push(call);
440
539
  }
441
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);
442
543
 
443
544
  const executed = executable.length > 0
444
545
  ? await executeToolCalls({
@@ -493,6 +594,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
493
594
  const fallbackMaxTurns = normalizeMaxTurns(rawMaxTurns);
494
595
  const limits = resolveLoopLimits(config, fallbackMaxTurns);
495
596
  const maxTurns = limits.maxTurns;
597
+ // Structured outputs: normalize once (throws ValidationError for bad shapes).
598
+ const loopOutput = resolveLoopOutput(config);
496
599
  // Q-19: pre-run checkpoint for onFailure rollback.
497
600
  const preRunCp = context.checkpoint();
498
601
  const sessionIdForRollback = loopSessionId(config);
@@ -514,7 +617,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
514
617
  const allSubagents: SubAgentExecutionMetadata[] = [];
515
618
  let finalResult: any = null;
516
619
  let turns = 0;
620
+ let toolsExecuted = false;
517
621
  let previousToolCallFingerprints = new Set<string>();
622
+ const repeatCounts = new Map<string, number>();
518
623
 
519
624
  try {
520
625
  if (config.bypassInputFileModality) {
@@ -522,6 +627,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
522
627
  providerId: provider.id,
523
628
  modelId,
524
629
  signal: runOptions?.signal,
630
+ roots: config.fileAccess?.roots,
525
631
  });
526
632
  }
527
633
 
@@ -531,8 +637,10 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
531
637
  // Q-21: fail fast on abort (throws AbortError with partial).
532
638
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
533
639
  checkDeadline(startTime, limits.deadlineMs);
640
+ config.enforceBudget?.();
534
641
  // Q-19: repair orphan tool pairs before every request.
535
642
  ensureToolPairing(context);
643
+ if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
536
644
  turns++;
537
645
 
538
646
  const spec = getModelFromCatalog(provider.id, modelId);
@@ -544,6 +652,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
544
652
  cache: options?.cache,
545
653
  tools: standardTools.length > 0 ? standardTools : undefined,
546
654
  signal: runOptions?.signal,
655
+ toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
656
+ ...(loopOutput.spec ? { output: loopOutput.spec } : {}),
547
657
  };
548
658
  // Q-22: per-request timeout pass-through (providers honor timeoutMs).
549
659
  try {
@@ -560,6 +670,15 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
560
670
  (config as unknown as { googleStore?: unknown }).googleStore;
561
671
  if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
562
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 {}
563
682
 
564
683
  const genResult = await generateWithTimeout(
565
684
  () => provider.generate(
@@ -600,6 +719,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
600
719
  }
601
720
 
602
721
  accumulateUsage(accumulatedUsage, genResult.usage, spec);
722
+ config.enforceBudget?.({ ...genResult.usage, cost: computeCostFromPricing(genResult.usage, spec) });
723
+ checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
603
724
 
604
725
  // Record assistant turn in context
605
726
  context.addAssistantMessage(
@@ -609,6 +730,26 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
609
730
  genResult.thoughtSignature
610
731
  );
611
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
+
612
753
  // Mid-session steer: if new queries arrived during this provider turn,
613
754
  // inject them now. Tool turns still execute first (see below) so the
614
755
  // current action finishes before redirection.
@@ -627,6 +768,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
627
768
  providerId: provider.id,
628
769
  modelId,
629
770
  signal: runOptions?.signal,
771
+ roots: config.fileAccess?.roots,
630
772
  });
631
773
  }
632
774
  }
@@ -645,10 +787,12 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
645
787
  `[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${limits.maxToolCallsPerRun}).`
646
788
  );
647
789
  }
790
+ toolsExecuted = true;
648
791
  const guarded = await executeToolCallsWithRepeatGuard({
649
792
  tools,
650
793
  toolCalls: genResult.toolCalls,
651
794
  previousFingerprints: previousToolCallFingerprints,
795
+ repeatCounts,
652
796
  agentName,
653
797
  signal: runOptions?.signal,
654
798
  sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
@@ -664,11 +808,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
664
808
  if (metas.length > 0) {
665
809
  allSubagents.push(...metas);
666
810
  for (const s of metas) {
811
+ if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
667
812
  const subagentSpec = getModelFromCatalog(s.provider, s.model);
668
813
  if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
669
814
  s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
670
815
  }
671
816
  accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
817
+ config.enforceBudget?.(s.usage);
672
818
  }
673
819
  }
674
820
  sanitizedResults.push(sanitized);
@@ -676,6 +822,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
676
822
 
677
823
  allToolResults.push(...sanitizedResults);
678
824
  context.addToolResults(sanitizedResults);
825
+ checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
679
826
  // Q-19: results are stored BEFORE the cancellation check so an abort
680
827
  // never drops executed tool work from history.
681
828
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
@@ -697,6 +844,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
697
844
  providerId: provider.id,
698
845
  modelId,
699
846
  signal: runOptions?.signal,
847
+ roots: config.fileAccess?.roots,
700
848
  });
701
849
  }
702
850
  }
@@ -720,8 +868,16 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
720
868
  );
721
869
  }
722
870
 
871
+ // Structured outputs: validate final text only (tool turns skip this).
872
+ // Strict-fail throws ValidationError with partial attached via catch below.
873
+ let parsedOutput: unknown | undefined;
874
+ if (loopOutput.raw !== undefined && !truncatedByMaxTurns) {
875
+ parsedOutput = parseFinalStructuredOutput(finalResult?.text || "", loopOutput.raw, loopOutput.spec);
876
+ }
877
+
723
878
  return new AgentResponse({
724
879
  text: finalResult?.text || "",
880
+ ...(parsedOutput !== undefined ? { parsed: parsedOutput } : {}),
725
881
  thinking: finalResult?.thinking,
726
882
  thoughtSignature: finalResult?.thoughtSignature,
727
883
  toolCalls: allToolCalls,
@@ -734,18 +890,17 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
734
890
  // Q-04: canonical finish reason + raw vendor passthrough.
735
891
  ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
736
892
  durationMs: Date.now() - startTime,
737
- raw: finalResult?.raw,
893
+ raw: applyCaptureRaw(finalResult?.raw, config.captureRaw) as ProviderRawData,
738
894
  turns,
739
895
  });
740
896
  } catch (err) {
741
897
  // Q-19: run-level onFailure rollback (default restores pre-run
742
898
  // checkpoint; opt out with (config as any).onFailure === "keep").
743
899
  // Aborts are excluded so partial history survives interruption.
744
- const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
745
900
  const isAbort =
746
901
  (err as { name?: string } | null)?.name === "AbortError" ||
747
902
  !!runOptions?.signal?.aborted;
748
- if (onFailure !== "keep" && !isAbort) {
903
+ if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
749
904
  try { context.rollback(preRunCp, sessionIdForRollback); } catch {
750
905
  // why: rollback is best-effort — the original error takes precedence.
751
906
  }
@@ -833,6 +988,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
833
988
  const allToolCalls: ToolCallRecord[] = [];
834
989
  const allToolResults: ToolResultRecord[] = [];
835
990
  const allSubagents: SubAgentExecutionMetadata[] = [];
991
+ let toolsExecuted = false;
836
992
  try {
837
993
  await ensureModelCatalogFresh();
838
994
  // Re-validate after refresh: Agent.stream() validates before the catalog
@@ -854,6 +1010,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
854
1010
  providerId: config.provider.id,
855
1011
  modelId: config.modelId,
856
1012
  signal: config.runOptions?.signal,
1013
+ roots: config.fileAccess?.roots,
857
1014
  });
858
1015
  }
859
1016
  const {
@@ -869,6 +1026,17 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
869
1026
  const maxTurns = streamLimits.maxTurns;
870
1027
 
871
1028
  const standardTools = toStandardToolDeclarations(tools);
1029
+ // Structured outputs: normalize once for the whole stream run.
1030
+ let streamOutputRaw: unknown;
1031
+ let streamOutputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
1032
+ try {
1033
+ const resolved = resolveLoopOutput(config);
1034
+ streamOutputRaw = resolved.raw;
1035
+ streamOutputSpec = resolved.spec;
1036
+ } catch (err) {
1037
+ outerStream.fail(err instanceof Error ? err : new Error(String(err)));
1038
+ return;
1039
+ }
872
1040
 
873
1041
  accumulatedUsage = {
874
1042
  inputTokens: 0,
@@ -885,11 +1053,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
885
1053
  lastResponse = null;
886
1054
  turns = 0;
887
1055
  let previousToolCallFingerprints = new Set<string>();
1056
+ const repeatCounts = new Map<string, number>();
888
1057
 
889
1058
  while (turns < maxTurns) {
890
1059
  throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
891
1060
  checkDeadline(streamStartTime, streamLimits.deadlineMs);
1061
+ config.enforceBudget?.();
892
1062
  ensureToolPairing(context);
1063
+ if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
893
1064
  turns++;
894
1065
 
895
1066
  const spec2 = getModelFromCatalog(provider.id, modelId);
@@ -900,6 +1071,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
900
1071
  cache: options?.cache,
901
1072
  tools: standardTools.length > 0 ? standardTools : undefined,
902
1073
  signal: linked.signal,
1074
+ toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
1075
+ ...(streamOutputSpec ? { output: streamOutputSpec } : {}),
903
1076
  };
904
1077
  try {
905
1078
  if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
@@ -913,6 +1086,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
913
1086
  (config as unknown as { googleStore?: unknown }).googleStore;
914
1087
  if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
915
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 {}
916
1096
 
917
1097
  const innerStream = provider.stream(
918
1098
  modelId,
@@ -957,7 +1137,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
957
1137
  throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
958
1138
  const event = res.value as StreamEvent;
959
1139
  if ((event as StreamEvent)?.type !== "done") {
960
- 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
+ );
961
1146
  }
962
1147
  } catch (e) {
963
1148
  if (timer) clearTimeout(timer);
@@ -997,6 +1182,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
997
1182
  }
998
1183
 
999
1184
  accumulateUsage(accumulatedUsage, turnResponse.usage, spec2);
1185
+ config.enforceBudget?.({ ...turnResponse.usage, cost: computeCostFromPricing(turnResponse.usage, spec2) });
1186
+ checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
1000
1187
 
1001
1188
  context.addAssistantMessage(
1002
1189
  turnResponse.text,
@@ -1005,6 +1192,25 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1005
1192
  turnResponse.thoughtSignature
1006
1193
  );
1007
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
+
1008
1214
  const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
1009
1215
  ? drainSteerInbox(context, config.steerInbox)
1010
1216
  : [];
@@ -1040,10 +1246,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1040
1246
  } catch {
1041
1247
  // why: stream push never throws, but guard anyway to protect the loop.
1042
1248
  }
1249
+ toolsExecuted = true;
1043
1250
  const guarded = await executeToolCallsWithRepeatGuard({
1044
1251
  tools,
1045
1252
  toolCalls: turnResponse.toolCalls,
1046
1253
  previousFingerprints: previousToolCallFingerprints,
1254
+ repeatCounts,
1047
1255
  agentName,
1048
1256
  signal: linked.signal,
1049
1257
  sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
@@ -1072,11 +1280,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1072
1280
  if (metas.length > 0) {
1073
1281
  allSubagents.push(...metas);
1074
1282
  for (const s of metas) {
1283
+ if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
1075
1284
  const subagentSpec = getModelFromCatalog(s.provider, s.model);
1076
1285
  if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
1077
1286
  s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
1078
1287
  }
1079
1288
  accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
1289
+ config.enforceBudget?.(s.usage);
1080
1290
  outerStream.push({
1081
1291
  type: "subagent_complete",
1082
1292
  subagent: s,
@@ -1137,8 +1347,28 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1137
1347
  }
1138
1348
  const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1139
1349
 
1350
+ let streamParsed: unknown | undefined;
1351
+ if (streamOutputRaw !== undefined && !truncatedByMaxTurns) {
1352
+ try {
1353
+ streamParsed = parseFinalStructuredOutput(lastResponse?.text || "", streamOutputRaw, streamOutputSpec);
1354
+ } catch (err) {
1355
+ const raw = err instanceof Error ? err : new Error(String(err));
1356
+ try { (raw as unknown as { partial?: unknown }).partial ??= {
1357
+ usage: accumulatedUsage,
1358
+ cost: accumulatedUsage.cost,
1359
+ turns,
1360
+ toolCalls: allToolCalls,
1361
+ toolResults: allToolResults,
1362
+ text: lastResponse?.text,
1363
+ }; } catch {}
1364
+ outerStream.fail(raw);
1365
+ return;
1366
+ }
1367
+ }
1368
+
1140
1369
  const finalAgentResponse = new AgentResponse({
1141
1370
  text: lastResponse?.text || "",
1371
+ ...(streamParsed !== undefined ? { parsed: streamParsed } : {}),
1142
1372
  thinking: lastResponse?.thinking,
1143
1373
  thoughtSignature: lastResponse?.thoughtSignature,
1144
1374
  toolCalls: allToolCalls,
@@ -1151,7 +1381,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1151
1381
  // Q-04: canonical finish reason + raw vendor passthrough.
1152
1382
  ...truncatedFinish,
1153
1383
  durationMs: Date.now() - startTime,
1154
- raw: lastResponse?.raw as any,
1384
+ raw: applyCaptureRaw(lastResponse?.raw, config.captureRaw) as ProviderRawData,
1155
1385
  turns,
1156
1386
  });
1157
1387
 
@@ -1170,13 +1400,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1170
1400
  const isAbort =
1171
1401
  linked.signal.aborted ||
1172
1402
  outerStream.isCancelled() ||
1173
- (raw as any)?.name === "AbortError" ||
1174
- /abort|cancell?ed/i.test(String((raw as any)?.message ?? raw));
1403
+ (raw as any)?.name === "AbortError";
1175
1404
  // Q-19: streaming onFailure rollback (same policy as runAgentLoop;
1176
1405
  // aborts excluded so partial history survives).
1177
1406
  try {
1178
- const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
1179
- if (onFailure !== "keep" && !isAbort) {
1407
+ if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
1180
1408
  try { config.context.rollback(streamPreCp, streamSessionId); } catch {
1181
1409
  // why: rollback is best-effort — the original error takes precedence.
1182
1410
  }