agent-accelerator 0.2.0 → 0.4.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/src/agent/loop.ts CHANGED
@@ -2,7 +2,10 @@ import type { Provider, ProviderRequestOptions, ModelSpec } from "../types/model
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
- import { AgentResponse, type SubAgentExecutionMetadata, type StreamEvent } from "../types/response.ts";
5
+ import type { ContentPart } from "../types/message.ts";
6
+ import { AgentResponse, normalizeFinishReason, type FinishReason, type SubAgentExecutionMetadata, type StreamEvent } from "../types/response.ts";
7
+ import { priceUsage, reportUsageAnomaly, telemetryBus } from "../session/store.ts";
8
+ export { telemetryBus };
6
9
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
7
10
  import { AgentContext } from "./context.ts";
8
11
  import { toStandardToolDeclarations } from "../tools/tool.ts";
@@ -11,6 +14,12 @@ import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } f
11
14
  import { preprocessFilePartsForBypass } from "../utils/documents.ts";
12
15
  import { noteProviderTurn } from "../providers.ts";
13
16
 
17
+ /** One mid-session steer request buffered while a run is active. */
18
+ export interface SteerEntry {
19
+ prompt: string | ContentPart[];
20
+ options?: AgentRunOptions;
21
+ }
22
+
14
23
  export interface AgentLoopConfig {
15
24
  agentName?: string;
16
25
  provider: Provider;
@@ -22,45 +31,309 @@ export interface AgentLoopConfig {
22
31
  maxTurns?: number;
23
32
  /** Convert `file` parts client-side for pdf-incapable models (see Agent flag). */
24
33
  bypassInputFileModality?: boolean;
34
+ /** Called after every turn for durable persistence checkpoints (never fails a turn). */
35
+ onProgress?: () => void;
36
+ /**
37
+ * Shared steer inbox for mid-session redirection. Entries are drained
38
+ * between turns (after the current provider/tool work finishes) and
39
+ * injected into the *current* turn — same response, same turn budget.
40
+ * Mutated in place by `agent.steer()` / `run(..., { enqueue: "steer" })`.
41
+ */
42
+ steerInbox?: SteerEntry[];
43
+ /** Called after steer entries are injected (streaming loops also push `steer_injected` events). */
44
+ onSteerInjected?: (injectedPrompts: string[]) => void;
25
45
  }
26
46
 
27
- export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): TokenUsage["cost"] {
28
- if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
29
- return usage.cost;
47
+ /** Short preview for `steer_injected` / `queued` event payloads (bounded, display-only). */
48
+ function steerPreview(prompt: string | ContentPart[]): string {
49
+ if (typeof prompt === "string") return prompt.slice(0, 500);
50
+ try {
51
+ const texts = prompt
52
+ .filter((p) => (p as { type?: unknown }).type === "text")
53
+ .map((p) => (p as { text?: string }).text ?? "")
54
+ .join("\n");
55
+ return (texts || "[multipart]").slice(0, 500);
56
+ } catch {
57
+ return "[multipart]";
30
58
  }
31
- if (!spec) return usage.cost;
59
+ }
32
60
 
33
- const costData = spec.cost || {};
34
- const pricingData = spec.pricing || {};
35
- const inputPrice = pricingData.inputPerMillion ?? costData.input ?? 0;
36
- const outputPrice = pricingData.outputPerMillion ?? costData.output ?? 0;
37
- const cacheReadPrice = pricingData.cacheReadPerMillion ?? costData.cache_read ?? 0;
38
- const cacheWritePrice = pricingData.cacheWritePerMillion ?? costData.cache_write ?? 0;
61
+ /**
62
+ * Drains pending steer entries into the conversation as new user messages.
63
+ * Returns display previews for events/telemetry. Never throws: invalid
64
+ * entries are skipped so one bad steer cannot fail the active turn.
65
+ */
66
+ function drainSteerInbox(context: AgentContext, inbox?: SteerEntry[]): string[] {
67
+ if (!inbox || inbox.length === 0) return [];
68
+ const injected: string[] = [];
69
+ while (inbox.length > 0) {
70
+ const entry = inbox.shift()!;
71
+ try {
72
+ let prompt = entry.prompt as string | ContentPart[];
73
+ const opts = entry.options;
74
+ if (Array.isArray(prompt) && prompt.length === 0) continue;
75
+ if (opts?.additionalContext) {
76
+ const prefix = `[Additional Context]\n${opts.additionalContext}\n\n`;
77
+ if (typeof prompt === "string") prompt = prefix + prompt;
78
+ else prompt = [{ type: "text", text: prefix } as ContentPart, ...prompt];
79
+ }
80
+ context.addUserMessage(prompt);
81
+ injected.push(steerPreview(entry.prompt));
82
+ } catch {
83
+ continue;
84
+ }
85
+ }
86
+ return injected;
87
+ }
39
88
 
40
- if (inputPrice === 0 && outputPrice === 0 && cacheReadPrice === 0 && cacheWritePrice === 0) {
41
- return usage.cost;
89
+ function emitTurn(
90
+ runOptions: AgentRunOptions | undefined,
91
+ turn: { turns: number; text?: string; thinking?: string; toolCalls?: ToolCallRecord[]; toolResults?: ToolResultRecord[] }
92
+ ): void {
93
+ try {
94
+ runOptions?.onTurn?.({
95
+ turns: turn.turns,
96
+ text: turn.text,
97
+ thinking: turn.thinking,
98
+ toolCalls: turn.toolCalls?.map((c) => ({ id: c.id, name: c.name })),
99
+ toolResults: turn.toolResults?.map((r) => ({ id: r.id, name: r.name, isError: r.isError })),
100
+ });
101
+ } catch {}
102
+ }
103
+
104
+ function emitProgress(config: { onProgress?: () => void }): void {
105
+ try { config.onProgress?.(); } catch {}
106
+ }
107
+
108
+ /**
109
+ * Normalizes `maxTurns` to a safe positive integer (default Infinity).
110
+ * `0` means infinite (no limit), matching the `timeout: 0` convention.
111
+ * Prevents silent zero-turn runs from negative values (clamped to 1).
112
+ */
113
+ function normalizeMaxTurns(value?: number): number {
114
+ if (value === undefined) return Infinity;
115
+ if (value === 0 || value === Infinity) return Infinity;
116
+ if (!Number.isFinite(value as number)) return Infinity;
117
+ return Math.max(1, Math.floor(value as number));
118
+ }
119
+
120
+ /**
121
+ * Warns when an agent run stops because `maxTurns` was exhausted while
122
+ * the model still wanted to call tools. Without this, the loop simply
123
+ * returns the last tool-turn text (often empty) with `finishReason:
124
+ * "tool_calls"`, which looks like success while dropping pending work.
125
+ */
126
+ function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendingTools: string[]): void {
127
+ const who = agentName ? `"${agentName}"` : "Agent";
128
+ const tools = pendingTools.length > 0 ? ` Pending tool calls dropped: ${pendingTools.join(", ")}.` : "";
129
+ // Q-41: no console.* in library code — route via the telemetry bus
130
+ // (default no-op; CLIs attach a pretty logger via telemetryBus.onEvent).
131
+ try {
132
+ telemetryBus.emitWarning(
133
+ "agent.max_turns_truncated",
134
+ `[Agent Accelerator] "${who}" hit maxTurns (${maxTurns}) with unfinished tool calls.${tools} Increase maxTurns or split the task.`,
135
+ { agentName, maxTurns, pendingTools }
136
+ );
137
+ } catch {
138
+ // why: telemetry emission is observability-only and must never throw.
42
139
  }
140
+ }
43
141
 
44
- const cachedTokens = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
45
- const nonCachedInputTokens = Math.max(0, (usage.inputTokens || 0) - cachedTokens);
46
- const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
47
- const outputTokens = usage.outputTokens ?? 0;
142
+ // ---------------------------------------------------------------------------
143
+ // Q-19: tool-call/result pairing repair.
144
+ // ---------------------------------------------------------------------------
48
145
 
49
- const inputCost = (nonCachedInputTokens / 1_000_000) * inputPrice;
50
- const cacheReadCost = (cachedTokens / 1_000_000) * cacheReadPrice;
51
- const cacheWriteCost = (cacheWriteTokens / 1_000_000) * cacheWritePrice;
52
- const outputCost = (outputTokens / 1_000_000) * outputPrice;
53
- const totalCost = inputCost + cacheReadCost + cacheWriteCost + outputCost;
146
+ /**
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.
151
+ */
152
+ export function ensureToolPairing(context: AgentContext): void {
153
+ 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>) {
176
+ if (p && p.type === "tool_result" && typeof p.id === "string" && p.id) {
177
+ resultIds.add(p.id);
178
+ }
179
+ }
180
+ }
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
+ });
191
+ }
192
+ }
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;
208
+ } catch {}
209
+ }
54
210
 
55
- return {
56
- inputCost,
57
- outputCost,
58
- cacheReadCost,
59
- cacheWriteCost,
60
- totalCost,
61
- };
211
+ // ---------------------------------------------------------------------------
212
+ // Q-21/Q-22: abort + limits helpers.
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export interface LoopLimits {
216
+ maxTurns?: number;
217
+ deadlineMs?: number;
218
+ maxToolCallsPerRun?: number;
219
+ requestTimeoutMs: number;
220
+ firstByteTimeoutMs: number;
221
+ streamIdleTimeoutMs: number;
222
+ }
223
+
224
+ /** 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 } {
226
+ const raw = ((config as unknown as { limits?: Record<string, unknown> }).limits ?? {}) as Record<string, unknown>;
227
+ const num = (v: unknown): number | undefined =>
228
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
229
+ let maxTurns = fallbackMaxTurns;
230
+ const limMax = num(raw.maxTurns);
231
+ if (limMax !== undefined) {
232
+ const normLim = normalizeMaxTurns(limMax);
233
+ maxTurns = Math.min(maxTurns, normLim);
234
+ }
235
+ const deadlineMs = num(raw.deadlineMs) ?? Infinity;
236
+ const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
237
+ const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
238
+ const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
239
+ const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
240
+ return { maxTurns, deadlineMs, maxToolCallsPerRun, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
62
241
  }
63
242
 
243
+ function loopSessionId(config: AgentLoopConfig): string | undefined {
244
+ try {
245
+ return (
246
+ (config.runOptions as { sessionId?: string } | undefined)?.sessionId ||
247
+ (config.options as { sessionId?: string } | undefined)?.sessionId ||
248
+ (config.options as { cache?: { sessionId?: string } } | undefined)?.cache?.sessionId
249
+ );
250
+ } catch { return undefined; }
251
+ }
252
+
253
+ function makeAbortError(
254
+ partial: { usage: TokenUsage; turns: number; text?: string },
255
+ reason?: unknown
256
+ ): Error {
257
+ let base: Error;
258
+ if (reason instanceof Error && reason.name === "AbortError") base = reason;
259
+ else if (reason instanceof Error) base = new Error(reason.message || "Aborted");
260
+ else if (typeof reason === "string" && reason) base = new Error(reason);
261
+ else base = new Error("Aborted");
262
+ try { (base as { name: string }).name = "AbortError"; } catch {}
263
+ try { (base as unknown as { partial: unknown }).partial = partial; } catch {}
264
+ return base;
265
+ }
266
+
267
+ function throwIfAborted(
268
+ signal: AbortSignal | undefined,
269
+ partial: { usage: TokenUsage; turns: number; text?: string }
270
+ ): void {
271
+ if (signal?.aborted) {
272
+ throw makeAbortError(partial, (signal as unknown as { reason?: unknown })?.reason ?? new Error("Aborted"));
273
+ }
274
+ }
275
+
276
+ function checkDeadline(startTime: number, deadlineMs: number): void {
277
+ if (Number.isFinite(deadlineMs) && deadlineMs !== Infinity && Date.now() - startTime > deadlineMs) {
278
+ throw new Error(`[Agent Accelerator] Run deadline exceeded after ${deadlineMs}ms.`);
279
+ }
280
+ }
281
+
282
+ async function generateWithTimeout<T>(task: () => Promise<T>, timeoutMs: number): Promise<T> {
283
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs === Infinity) return task();
284
+ let timer: ReturnType<typeof setTimeout> | undefined;
285
+ try {
286
+ const timeoutP = new Promise<never>((_, reject) => {
287
+ timer = setTimeout(() => {
288
+ const e = new Error(`[Agent Accelerator] Request timed out after ${timeoutMs}ms.`);
289
+ e.name = "TimeoutError";
290
+ reject(e);
291
+ }, timeoutMs);
292
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
293
+ });
294
+ return await Promise.race([task(), timeoutP]);
295
+ } finally {
296
+ if (timer) clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ /** Compat wrapper around the one cost model (`priceUsage` in session/store.ts, Q-36).
301
+ * Prefers provider-reported totals, else catalog pricing with integer
302
+ * micro-USD accumulation. Returns undefined when unknown (never a 0 object). */
303
+ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): TokenUsage["cost"] {
304
+ try {
305
+ if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
306
+ return usage.cost;
307
+ }
308
+ if (!spec) return usage.cost;
309
+ const priced = priceUsage(usage, spec);
310
+ return priced
311
+ ? {
312
+ inputCost: priced.inputCost,
313
+ outputCost: priced.outputCost,
314
+ cacheReadCost: priced.cacheReadCost,
315
+ cacheWriteCost: priced.cacheWriteCost,
316
+ totalCost: priced.totalCost,
317
+ }
318
+ : usage.cost;
319
+ } catch {
320
+ // why: cost is observability-only — pricing failures must never break turns.
321
+ return usage.cost;
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Q-35: canonical usage accumulation (pure w.r.t. source).
327
+ // Invariants (see assertUsageInvariants in session/store.ts):
328
+ // - token counts are finite integers >= 0;
329
+ // - cachedTokens/cacheReadTokens are a SUBSET of inputTokens (clamped below
330
+ // so no consumer observes >100% hit rates);
331
+ // - thinkingTokens is a SUBSET of outputTokens when both are reported
332
+ // (reasoning is generated output; enforced in the test helper, documented
333
+ // here because providers vary on which side they omit).
334
+ // This function never mutates `source` (Q-35): the priced cost is cloned
335
+ // into a local before folding into the target.
336
+ // ---------------------------------------------------------------------------
64
337
  function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
65
338
  target.inputTokens += source.inputTokens || 0;
66
339
  target.outputTokens += source.outputTokens || 0;
@@ -74,14 +347,25 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
74
347
  // input. Some providers occasionally report cached > input on long chained
75
348
  // runs, which surfaces as >100% hit rates downstream. Clamp the aggregate
76
349
  // so no consumer can observe an impossible ratio.
77
- target.cachedTokens = Math.min(target.cachedTokens ?? 0, target.inputTokens);
78
- target.cacheReadTokens = Math.min(target.cacheReadTokens ?? 0, target.inputTokens);
79
-
80
- const sourceCost = computeCostFromPricing(source, spec);
81
- if (sourceCost) {
82
- source.cost = sourceCost;
350
+ const preCached = target.cachedTokens ?? 0;
351
+ const preRead = target.cacheReadTokens ?? 0;
352
+ target.cachedTokens = Math.min(preCached, target.inputTokens);
353
+ target.cacheReadTokens = Math.min(preRead, target.inputTokens);
354
+ if ((target.cachedTokens ?? 0) < preCached || (target.cacheReadTokens ?? 0) < preRead) {
355
+ // Q-35: no console — report via the anomaly hook + telemetry bus.
356
+ reportUsageAnomaly({
357
+ type: "cache_clamp",
358
+ inputTokens: target.inputTokens,
359
+ cachedTokens: preCached,
360
+ cacheReadTokens: preRead,
361
+ });
83
362
  }
84
363
 
364
+ const priced = computeCostFromPricing(source, spec);
365
+ // Q-35: pure — read `source` only; never assign back to `source.cost`.
366
+ // Clone the priced cost so later target folds cannot alias source state.
367
+ const sourceCost = priced ? { ...priced } : undefined;
368
+
85
369
  if (sourceCost || target.cost) {
86
370
  target.cost = {
87
371
  inputCost: (target.cost?.inputCost ?? 0) + (sourceCost?.inputCost ?? 0),
@@ -131,6 +415,7 @@ async function executeToolCallsWithRepeatGuard(options: {
131
415
  agentName?: string;
132
416
  signal?: AbortSignal;
133
417
  sessionId?: string;
418
+ onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
134
419
  }): Promise<{ results: ToolResultRecord[]; fingerprints: Set<string> }> {
135
420
  const { toolCalls, previousFingerprints } = options;
136
421
  const currentFingerprints = new Set<string>();
@@ -163,6 +448,7 @@ async function executeToolCallsWithRepeatGuard(options: {
163
448
  parallel: true,
164
449
  signal: options.signal,
165
450
  sessionId: options.sessionId,
451
+ onSubagentEvent: options.onSubagentEvent,
166
452
  })
167
453
  : [];
168
454
  const byId = new Map<string, ToolResultRecord[]>();
@@ -202,21 +488,19 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
202
488
  tools,
203
489
  options,
204
490
  runOptions,
205
- maxTurns = 10,
491
+ maxTurns: rawMaxTurns = Infinity,
206
492
  } = config;
493
+ const fallbackMaxTurns = normalizeMaxTurns(rawMaxTurns);
494
+ const limits = resolveLoopLimits(config, fallbackMaxTurns);
495
+ const maxTurns = limits.maxTurns;
496
+ // Q-19: pre-run checkpoint for onFailure rollback.
497
+ const preRunCp = context.checkpoint();
498
+ const sessionIdForRollback = loopSessionId(config);
207
499
 
208
- if (config.bypassInputFileModality) {
209
- await preprocessFilePartsForBypass(config.context.messages, {
210
- providerId: provider.id,
211
- modelId,
212
- signal: runOptions?.signal,
213
- });
214
- }
215
-
216
- const standardTools = toStandardToolDeclarations(tools);
500
+ // Q-37: partial-run state hoisted outside try so the catch block can attach
501
+ // {usage,cost,turns,toolCalls,toolResults,text} to ANY thrown error.
217
502
  const startTime = Date.now();
218
-
219
- let accumulatedUsage: TokenUsage = {
503
+ const accumulatedUsage: TokenUsage = {
220
504
  inputTokens: 0,
221
505
  outputTokens: 0,
222
506
  totalTokens: 0,
@@ -225,7 +509,6 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
225
509
  cacheWriteTokens: 0,
226
510
  thinkingTokens: 0,
227
511
  };
228
-
229
512
  const allToolCalls: ToolCallRecord[] = [];
230
513
  const allToolResults: ToolResultRecord[] = [];
231
514
  const allSubagents: SubAgentExecutionMetadata[] = [];
@@ -233,7 +516,23 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
233
516
  let turns = 0;
234
517
  let previousToolCallFingerprints = new Set<string>();
235
518
 
519
+ try {
520
+ if (config.bypassInputFileModality) {
521
+ await preprocessFilePartsForBypass(config.context.messages, {
522
+ providerId: provider.id,
523
+ modelId,
524
+ signal: runOptions?.signal,
525
+ });
526
+ }
527
+
528
+ const standardTools = toStandardToolDeclarations(tools);
529
+
236
530
  while (turns < maxTurns) {
531
+ // Q-21: fail fast on abort (throws AbortError with partial).
532
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
533
+ checkDeadline(startTime, limits.deadlineMs);
534
+ // Q-19: repair orphan tool pairs before every request.
535
+ ensureToolPairing(context);
237
536
  turns++;
238
537
 
239
538
  const spec = getModelFromCatalog(provider.id, modelId);
@@ -246,15 +545,33 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
246
545
  tools: standardTools.length > 0 ? standardTools : undefined,
247
546
  signal: runOptions?.signal,
248
547
  };
548
+ // Q-22: per-request timeout pass-through (providers honor timeoutMs).
549
+ try {
550
+ if (Number.isFinite(limits.requestTimeoutMs) && limits.requestTimeoutMs !== Infinity) {
551
+ (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = limits.requestTimeoutMs;
552
+ }
553
+ } catch {}
554
+ // Q-25-partial: google:{store:false} opt-out pass-through (provider
555
+ // already respects body.store===false for chaining).
556
+ try {
557
+ const gStore =
558
+ (runOptions as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
559
+ (options as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
560
+ (config as unknown as { googleStore?: unknown }).googleStore;
561
+ if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
562
+ } catch {}
249
563
 
250
- const genResult = await provider.generate(
251
- modelId,
252
- {
253
- systemPrompt: context.systemPrompt,
254
- messages: context.messages,
255
- cachedContentId: context.cachedContentId,
256
- },
257
- providerOptions
564
+ const genResult = await generateWithTimeout(
565
+ () => provider.generate(
566
+ modelId,
567
+ {
568
+ systemPrompt: context.systemPrompt,
569
+ messages: context.messages,
570
+ cachedContentId: context.cachedContentId,
571
+ },
572
+ providerOptions
573
+ ),
574
+ limits.requestTimeoutMs
258
575
  );
259
576
 
260
577
  finalResult = genResult;
@@ -265,15 +582,20 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
265
582
  provider.id
266
583
  );
267
584
 
268
- // Safety fallback: if no tool calls and text is empty, rescue answer from thinking
585
+ // Thinking is exposed via events/traces and onTurn (see shouldExposeThinking,
586
+ // default true). Promoting thinking to answer text is gated (Q-15, default
587
+ // OFF): without the explicit opt-in the run returns the provider's empty
588
+ // text with its finishReason instead of fabricating an answer from reasoning.
269
589
  if ((!genResult.text || genResult.text.trim() === "") && (!genResult.toolCalls || genResult.toolCalls.length === 0) && genResult.thinking) {
270
- if (genResult.thinking.includes("</think>")) {
271
- const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
272
- genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
273
- genResult.text = parts.slice(1).join("").trim();
274
- } else {
275
- genResult.text = genResult.thinking;
276
- genResult.thinking = undefined;
590
+ if (shouldPromoteThinkingToAnswer(config, runOptions)) {
591
+ if (genResult.thinking.includes("</think>")) {
592
+ const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
593
+ genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
594
+ genResult.text = parts.slice(1).join("").trim();
595
+ } else {
596
+ genResult.text = genResult.thinking;
597
+ genResult.thinking = undefined;
598
+ }
277
599
  }
278
600
  }
279
601
 
@@ -287,13 +609,42 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
287
609
  genResult.thoughtSignature
288
610
  );
289
611
 
290
- // If no tool calls, generation is complete!
612
+ // Mid-session steer: if new queries arrived during this provider turn,
613
+ // inject them now. Tool turns still execute first (see below) so the
614
+ // current action finishes before redirection.
615
+ const noToolSteered = (!genResult.toolCalls || genResult.toolCalls.length === 0)
616
+ ? drainSteerInbox(context, config.steerInbox)
617
+ : [];
618
+
619
+ // If no tool calls, generation is complete — unless steered, in which
620
+ // case the injected message extends the SAME run (no new response).
291
621
  if (!genResult.toolCalls || genResult.toolCalls.length === 0) {
622
+ emitTurn(runOptions, { turns, text: genResult.text, thinking: genResult.thinking });
623
+ if (noToolSteered.length > 0) {
624
+ try { config.onSteerInjected?.(noToolSteered); } catch {}
625
+ if (config.bypassInputFileModality) {
626
+ await preprocessFilePartsForBypass(config.context.messages, {
627
+ providerId: provider.id,
628
+ modelId,
629
+ signal: runOptions?.signal,
630
+ });
631
+ }
632
+ }
633
+ emitProgress(config);
634
+ if (noToolSteered.length > 0) continue;
292
635
  break;
293
636
  }
294
637
 
295
638
  // Execute tool calls — always parallel (model-driven, bloatfree DX7)
639
+ // Q-21: check abort before tools (throws AbortError with partial).
640
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
296
641
  allToolCalls.push(...genResult.toolCalls);
642
+ // Q-22: per-run tool-call budget.
643
+ if (allToolCalls.length > limits.maxToolCallsPerRun) {
644
+ throw new Error(
645
+ `[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${limits.maxToolCallsPerRun}).`
646
+ );
647
+ }
297
648
  const guarded = await executeToolCallsWithRepeatGuard({
298
649
  tools,
299
650
  toolCalls: genResult.toolCalls,
@@ -325,8 +676,48 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
325
676
 
326
677
  allToolResults.push(...sanitizedResults);
327
678
  context.addToolResults(sanitizedResults);
679
+ // Q-19: results are stored BEFORE the cancellation check so an abort
680
+ // never drops executed tool work from history.
681
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
682
+ emitTurn(runOptions, {
683
+ turns,
684
+ text: genResult.text,
685
+ thinking: genResult.thinking,
686
+ toolCalls: genResult.toolCalls,
687
+ toolResults: sanitizedResults,
688
+ });
689
+ // Steer drain AFTER tool results so call/result pairs stay adjacent.
690
+ // The next loop iteration sends the injected user message to the model
691
+ // within the same run (same usage/turn budget).
692
+ const toolSteered = drainSteerInbox(context, config.steerInbox);
693
+ if (toolSteered.length > 0) {
694
+ try { config.onSteerInjected?.(toolSteered); } catch {}
695
+ if (config.bypassInputFileModality) {
696
+ await preprocessFilePartsForBypass(config.context.messages, {
697
+ providerId: provider.id,
698
+ modelId,
699
+ signal: runOptions?.signal,
700
+ });
701
+ }
702
+ }
703
+ emitProgress(config);
704
+
705
+ // Q-21: abort always throws AbortError with partial attached (never
706
+ // silently breaks).
707
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
708
+ }
328
709
 
329
- if (runOptions?.signal?.aborted) break;
710
+ // maxTurns exhausted while the model still requested tools: the tool
711
+ // results above were never sent back, so the run is truncated — surface
712
+ // it via finishReason + warning instead of silently returning.
713
+ const truncatedByMaxTurns =
714
+ turns >= maxTurns && !!finalResult?.toolCalls && finalResult.toolCalls.length > 0;
715
+ if (truncatedByMaxTurns) {
716
+ warnMaxTurnsHit(
717
+ agentName,
718
+ maxTurns,
719
+ finalResult.toolCalls.map((c: ToolCallRecord) => c.name)
720
+ );
330
721
  }
331
722
 
332
723
  return new AgentResponse({
@@ -340,11 +731,47 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
340
731
  responseId: finalResult?.responseId,
341
732
  model: modelId,
342
733
  provider: provider.id,
343
- finishReason: finalResult?.finishReason,
734
+ // Q-04: canonical finish reason + raw vendor passthrough.
735
+ ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
344
736
  durationMs: Date.now() - startTime,
345
737
  raw: finalResult?.raw,
346
738
  turns,
347
739
  });
740
+ } catch (err) {
741
+ // Q-19: run-level onFailure rollback (default restores pre-run
742
+ // checkpoint; opt out with (config as any).onFailure === "keep").
743
+ // Aborts are excluded so partial history survives interruption.
744
+ const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
745
+ const isAbort =
746
+ (err as { name?: string } | null)?.name === "AbortError" ||
747
+ !!runOptions?.signal?.aborted;
748
+ if (onFailure !== "keep" && !isAbort) {
749
+ try { context.rollback(preRunCp, sessionIdForRollback); } catch {
750
+ // why: rollback is best-effort — the original error takes precedence.
751
+ }
752
+ }
753
+ // Q-21: ensure aborts surface as AbortError with partial attached.
754
+ if (isAbort && (err as { name?: string } | null)?.name !== "AbortError") {
755
+ throw makeAbortError(
756
+ { usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, turns: 0 },
757
+ err
758
+ );
759
+ }
760
+ // Q-37: every failure carries its partial progress for observability.
761
+ try {
762
+ (err as unknown as { partial?: unknown }).partial ??= {
763
+ usage: accumulatedUsage,
764
+ cost: accumulatedUsage.cost,
765
+ turns,
766
+ toolCalls: allToolCalls,
767
+ toolResults: allToolResults,
768
+ text: finalResult?.text,
769
+ };
770
+ } catch {
771
+ // why: partial attachment is observability-only and must never mask the error.
772
+ }
773
+ throw err;
774
+ }
348
775
  }
349
776
 
350
777
  /**
@@ -372,12 +799,40 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
372
799
  linked.signal.addEventListener("abort", () => {
373
800
  try { currentInner?.cancel(); } catch {}
374
801
  });
375
- const throwIfCancelled = () => {
376
- if (linked.signal.aborted || outerStream.isCancelled())
377
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
802
+ const throwIfCancelled = (partial?: { usage: TokenUsage; turns: number; text?: string }) => {
803
+ if (linked.signal.aborted || outerStream.isCancelled()) {
804
+ const fallback: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
805
+ throw makeAbortError(
806
+ partial ?? { usage: fallback, turns: 0 },
807
+ (linked.signal as unknown as { reason?: unknown })?.reason ?? new Error("Stream aborted")
808
+ );
809
+ }
378
810
  };
379
811
 
812
+ // Q-19/Q-22: per-run checkpoint + limits for the streaming loop.
813
+ const streamPreCp = config.context.checkpoint();
814
+ const streamSessionId = loopSessionId(config);
815
+ const streamFallbackMax = normalizeMaxTurns((config as { maxTurns?: number }).maxTurns);
816
+ const streamLimits = resolveLoopLimits(config, streamFallbackMax);
817
+ const streamStartTime = startTime;
818
+
380
819
  (async () => {
820
+ // Hoisted for catch-block partial reporting (Q-21/Q-37).
821
+ let accumulatedUsage: TokenUsage = {
822
+ inputTokens: 0,
823
+ outputTokens: 0,
824
+ totalTokens: 0,
825
+ cachedTokens: 0,
826
+ cacheReadTokens: 0,
827
+ cacheWriteTokens: 0,
828
+ thinkingTokens: 0,
829
+ };
830
+ let lastResponse: AgentResponse | null = null;
831
+ let turns = 0;
832
+ // Q-37: tool progress hoisted so failures carry toolCalls/toolResults.
833
+ const allToolCalls: ToolCallRecord[] = [];
834
+ const allToolResults: ToolResultRecord[] = [];
835
+ const allSubagents: SubAgentExecutionMetadata[] = [];
381
836
  try {
382
837
  await ensureModelCatalogFresh();
383
838
  // Re-validate after refresh: Agent.stream() validates before the catalog
@@ -409,12 +864,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
409
864
  tools,
410
865
  options,
411
866
  runOptions,
412
- maxTurns = 10,
867
+ maxTurns: rawMaxTurns = Infinity,
413
868
  } = config;
869
+ const maxTurns = streamLimits.maxTurns;
414
870
 
415
871
  const standardTools = toStandardToolDeclarations(tools);
416
872
 
417
- let accumulatedUsage: TokenUsage = {
873
+ accumulatedUsage = {
418
874
  inputTokens: 0,
419
875
  outputTokens: 0,
420
876
  totalTokens: 0,
@@ -424,15 +880,16 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
424
880
  thinkingTokens: 0,
425
881
  };
426
882
 
427
- const allToolCalls: ToolCallRecord[] = [];
428
- const allToolResults: ToolResultRecord[] = [];
429
- const allSubagents: SubAgentExecutionMetadata[] = [];
430
- let lastResponse: AgentResponse | null = null;
431
- let turns = 0;
883
+ // Q-37: allToolCalls/allToolResults/allSubagents are hoisted above (fresh
884
+ // per stream run); just reset turn counters here.
885
+ lastResponse = null;
886
+ turns = 0;
432
887
  let previousToolCallFingerprints = new Set<string>();
433
888
 
434
889
  while (turns < maxTurns) {
435
- throwIfCancelled();
890
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
891
+ checkDeadline(streamStartTime, streamLimits.deadlineMs);
892
+ ensureToolPairing(context);
436
893
  turns++;
437
894
 
438
895
  const spec2 = getModelFromCatalog(provider.id, modelId);
@@ -444,6 +901,18 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
444
901
  tools: standardTools.length > 0 ? standardTools : undefined,
445
902
  signal: linked.signal,
446
903
  };
904
+ try {
905
+ if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
906
+ (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = streamLimits.requestTimeoutMs;
907
+ }
908
+ } catch {}
909
+ try {
910
+ const gStore =
911
+ (runOptions as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
912
+ (options as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
913
+ (config as unknown as { googleStore?: unknown }).googleStore;
914
+ if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
915
+ } catch {}
447
916
 
448
917
  const innerStream = provider.stream(
449
918
  modelId,
@@ -456,16 +925,54 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
456
925
  );
457
926
  currentInner = innerStream as AssistantMessageEventStream;
458
927
 
459
- for await (const event of innerStream) {
460
- throwIfCancelled();
461
- if (event.type !== "done") {
462
- outerStream.push(event);
928
+ // Q-22: first-byte + idle timeouts around SSE (tolerate reasoning).
929
+ // Implemented as races on the inner iterator so a hung provider
930
+ // surfaces as TimeoutError instead of hanging the run forever.
931
+ {
932
+ const it = (innerStream as AsyncIterable<StreamEvent>)[Symbol.asyncIterator]();
933
+ let first = true;
934
+ let doneIter = false;
935
+ while (!doneIter) {
936
+ const waitMs = first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
937
+ let timer: ReturnType<typeof setTimeout> | undefined;
938
+ const timeoutP =
939
+ Number.isFinite(waitMs) && waitMs !== Infinity && waitMs > 0
940
+ ? new Promise<never>((_, reject) => {
941
+ timer = setTimeout(() => {
942
+ const e = new Error(
943
+ `[Agent Accelerator] Stream ${first ? "first-byte" : "idle"} timed out after ${waitMs}ms.`
944
+ );
945
+ e.name = "TimeoutError";
946
+ reject(e);
947
+ }, waitMs);
948
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
949
+ })
950
+ : null;
951
+ try {
952
+ const nextP = it.next();
953
+ const res = timeoutP ? await Promise.race([nextP, timeoutP]) : await nextP;
954
+ if (timer) clearTimeout(timer);
955
+ if (res.done) { doneIter = true; break; }
956
+ first = false;
957
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
958
+ const event = res.value as StreamEvent;
959
+ if ((event as StreamEvent)?.type !== "done") {
960
+ outerStream.push(event);
961
+ }
962
+ } catch (e) {
963
+ if (timer) clearTimeout(timer);
964
+ // Timeout: cancel the hung inner stream before surfacing.
965
+ if ((e as Error)?.name === "TimeoutError") {
966
+ try { currentInner?.cancel(); } catch {}
967
+ }
968
+ throw e;
969
+ }
463
970
  }
464
971
  }
465
972
 
466
973
  const turnResponse = await innerStream.result();
467
974
  currentInner = null;
468
- throwIfCancelled();
975
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: (turnResponse as { text?: string })?.text });
469
976
  lastResponse = turnResponse;
470
977
  // Canonical session routing: lets adapters detect provider switches.
471
978
  noteProviderTurn(
@@ -473,15 +980,19 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
473
980
  provider.id
474
981
  );
475
982
 
476
- // Safety fallback: if no tool calls and text is empty, rescue answer from thinking
983
+ // Thinking stays on events/traces + onTurn (default exposed). Promoting
984
+ // it to answer text is gated (Q-15, default OFF): without the opt-in
985
+ // the turn keeps the provider's empty text and finishReason.
477
986
  if ((!turnResponse.text || turnResponse.text.trim() === "") && (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) && turnResponse.thinking) {
478
- if (turnResponse.thinking.includes("</think>")) {
479
- const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
480
- (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
481
- (turnResponse as any).text = parts.slice(1).join("").trim();
482
- } else {
483
- (turnResponse as any).text = turnResponse.thinking;
484
- (turnResponse as any).thinking = undefined;
987
+ if (shouldPromoteThinkingToAnswer(config, runOptions)) {
988
+ if (turnResponse.thinking.includes("</think>")) {
989
+ const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
990
+ (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
991
+ (turnResponse as any).text = parts.slice(1).join("").trim();
992
+ } else {
993
+ (turnResponse as any).text = turnResponse.thinking;
994
+ (turnResponse as any).thinking = undefined;
995
+ }
485
996
  }
486
997
  }
487
998
 
@@ -494,11 +1005,41 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
494
1005
  turnResponse.thoughtSignature
495
1006
  );
496
1007
 
1008
+ const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
1009
+ ? drainSteerInbox(context, config.steerInbox)
1010
+ : [];
497
1011
  if (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) {
1012
+ emitTurn(runOptions, { turns, text: turnResponse.text, thinking: turnResponse.thinking });
1013
+ if (noToolSteered.length > 0) {
1014
+ for (const preview of noToolSteered) {
1015
+ try {
1016
+ outerStream.push({ type: "steer_injected", injectedPrompt: preview });
1017
+ } catch {
1018
+ // why: stream push never throws, but guard anyway to protect the loop.
1019
+ }
1020
+ }
1021
+ try { config.onSteerInjected?.(noToolSteered); } catch {
1022
+ // why: user callback — a throw must not fail the run.
1023
+ }
1024
+ }
1025
+ emitProgress(config);
1026
+ if (noToolSteered.length > 0) continue;
498
1027
  break;
499
1028
  }
500
1029
 
1030
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
501
1031
  allToolCalls.push(...turnResponse.toolCalls);
1032
+ if (allToolCalls.length > streamLimits.maxToolCallsPerRun) {
1033
+ throw new Error(
1034
+ `[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${streamLimits.maxToolCallsPerRun}).`
1035
+ );
1036
+ }
1037
+ // Q-40: typed tool lifecycle for streaming consumers.
1038
+ try {
1039
+ outerStream.push({ type: "tool_start", toolCalls: turnResponse.toolCalls });
1040
+ } catch {
1041
+ // why: stream push never throws, but guard anyway to protect the loop.
1042
+ }
502
1043
  const guarded = await executeToolCallsWithRepeatGuard({
503
1044
  tools,
504
1045
  toolCalls: turnResponse.toolCalls,
@@ -506,8 +1047,21 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
506
1047
  agentName,
507
1048
  signal: linked.signal,
508
1049
  sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
1050
+ onSubagentEvent: (ev) => {
1051
+ try {
1052
+ outerStream.push({
1053
+ type: "subagent_delta",
1054
+ subagentTrackingId: ev.trackingId,
1055
+ delta: ev.delta,
1056
+ thinkingDelta: ev.thinkingDelta,
1057
+ partialText: ev.partialText,
1058
+ partialThinking: ev.partialThinking,
1059
+ });
1060
+ } catch {
1061
+ // why: stream push never throws; keep subagent progress flowing.
1062
+ }
1063
+ },
509
1064
  });
510
- throwIfCancelled();
511
1065
  const results = guarded.results;
512
1066
  previousToolCallFingerprints = guarded.fingerprints;
513
1067
 
@@ -538,11 +1092,51 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
538
1092
  toolResult: res,
539
1093
  });
540
1094
  }
1095
+ // Q-40: close the tool lifecycle opened by `tool_start` above.
1096
+ try {
1097
+ outerStream.push({ type: "tool_end", toolResults: sanitizedResults });
1098
+ } catch {
1099
+ // why: stream push never throws, but guard anyway to protect the loop.
1100
+ }
541
1101
 
1102
+ // Q-19: store tool results BEFORE the cancellation check.
542
1103
  allToolResults.push(...sanitizedResults);
543
1104
  context.addToolResults(sanitizedResults);
1105
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
1106
+ emitTurn(runOptions, {
1107
+ turns,
1108
+ text: turnResponse.text,
1109
+ thinking: turnResponse.thinking,
1110
+ toolCalls: turnResponse.toolCalls,
1111
+ toolResults: sanitizedResults,
1112
+ });
1113
+ const toolSteered = drainSteerInbox(context, config.steerInbox);
1114
+ if (toolSteered.length > 0) {
1115
+ for (const preview of toolSteered) {
1116
+ try {
1117
+ outerStream.push({ type: "steer_injected", injectedPrompt: preview });
1118
+ } catch {
1119
+ // why: stream push never throws, but guard anyway to protect the loop.
1120
+ }
1121
+ }
1122
+ try { config.onSteerInjected?.(toolSteered); } catch {
1123
+ // why: user callback — a throw must not fail the run.
1124
+ }
1125
+ }
1126
+ emitProgress(config);
544
1127
  }
545
1128
 
1129
+ const truncatedByMaxTurns =
1130
+ turns >= maxTurns && !!lastResponse?.toolCalls && lastResponse.toolCalls.length > 0;
1131
+ if (truncatedByMaxTurns) {
1132
+ warnMaxTurnsHit(
1133
+ agentName,
1134
+ maxTurns,
1135
+ lastResponse!.toolCalls.map((c) => c.name)
1136
+ );
1137
+ }
1138
+ const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1139
+
546
1140
  const finalAgentResponse = new AgentResponse({
547
1141
  text: lastResponse?.text || "",
548
1142
  thinking: lastResponse?.thinking,
@@ -554,7 +1148,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
554
1148
  responseId: lastResponse?.responseId,
555
1149
  model: modelId,
556
1150
  provider: provider.id,
557
- finishReason: lastResponse?.finishReason,
1151
+ // Q-04: canonical finish reason + raw vendor passthrough.
1152
+ ...truncatedFinish,
558
1153
  durationMs: Date.now() - startTime,
559
1154
  raw: lastResponse?.raw as any,
560
1155
  turns,
@@ -564,7 +1159,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
564
1159
  type: "done",
565
1160
  delta: "",
566
1161
  usage: accumulatedUsage,
567
- finishReason: lastResponse?.finishReason,
1162
+ finishReason: truncatedFinish.finishReason ?? lastResponse?.finishReason,
1163
+ rawFinishReason: truncatedFinish.rawFinishReason,
568
1164
  responseId: lastResponse?.responseId,
569
1165
  });
570
1166
 
@@ -576,17 +1172,130 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
576
1172
  outerStream.isCancelled() ||
577
1173
  (raw as any)?.name === "AbortError" ||
578
1174
  /abort|cancell?ed/i.test(String((raw as any)?.message ?? raw));
579
- outerStream.fail(
580
- isAbort
581
- ? Object.assign(raw.name === "AbortError" ? raw : new Error("Stream aborted"), { name: "AbortError" })
582
- : raw
583
- );
1175
+ // Q-19: streaming onFailure rollback (same policy as runAgentLoop;
1176
+ // aborts excluded so partial history survives).
1177
+ try {
1178
+ const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
1179
+ if (onFailure !== "keep" && !isAbort) {
1180
+ try { config.context.rollback(streamPreCp, streamSessionId); } catch {
1181
+ // why: rollback is best-effort — the original error takes precedence.
1182
+ }
1183
+ }
1184
+ } catch {
1185
+ // why: rollback policy itself must never mask the original error.
1186
+ }
1187
+ // Q-37: every failure carries its partial progress for observability.
1188
+ const partial = {
1189
+ usage: accumulatedUsage,
1190
+ cost: accumulatedUsage.cost,
1191
+ turns,
1192
+ toolCalls: allToolCalls,
1193
+ toolResults: allToolResults,
1194
+ text: lastResponse?.text,
1195
+ };
1196
+ // Q-21: aborts always surface as AbortError with partial attached.
1197
+ if (isAbort) {
1198
+ const abortErr =
1199
+ raw.name === "AbortError" ? raw : makeAbortError(partial, raw);
1200
+ try { (abortErr as unknown as { partial?: unknown }).partial ??= partial; } catch {
1201
+ // why: partial attachment is observability-only and must never mask the error.
1202
+ }
1203
+ outerStream.fail(abortErr);
1204
+ } else {
1205
+ try { (raw as unknown as { partial?: unknown }).partial ??= partial; } catch {
1206
+ // why: partial attachment is observability-only and must never mask the error.
1207
+ }
1208
+ outerStream.fail(raw);
1209
+ }
584
1210
  } finally {
585
- try { currentInner?.cancel(); } catch {}
586
- try { userSignal?.removeEventListener("abort", forwardUserAbort); } catch {}
587
- try { removeOuterCancel(); } catch {}
1211
+ try { currentInner?.cancel(); } catch {
1212
+ // why: inner streams tolerate double-cancel; cleanup must not throw.
1213
+ }
1214
+ try { userSignal?.removeEventListener("abort", forwardUserAbort); } catch {
1215
+ // why: listener removal is best-effort cleanup.
1216
+ }
1217
+ try { removeOuterCancel(); } catch {
1218
+ // why: unsubscribe is best-effort cleanup.
1219
+ }
588
1220
  }
589
1221
  })();
590
1222
 
591
1223
  return outerStream;
592
1224
  }
1225
+
1226
+ // ---------------------------------------------------------------------------
1227
+ // Q-15: single thinking-exposure policy + Q-04 canonical finish helper.
1228
+ // (Appended here to avoid conflicts with other loop sections.)
1229
+ // ---------------------------------------------------------------------------
1230
+
1231
+ /**
1232
+ * Single expose-thinking policy (Q-15).
1233
+ * - Events/traces/onTurn always carry thinking separately (default true).
1234
+ * - Promoting thinking to answer *text* is a separate opt-in
1235
+ * (`thinkingAsAnswerFallback`, default false) — see
1236
+ * `shouldPromoteThinkingToAnswer`.
1237
+ *
1238
+ * Reads `exposeThinking` from the loop config, run options, or provider
1239
+ * options; any explicit `false` hides thinking from events/traces.
1240
+ */
1241
+ export function shouldExposeThinking(config?: unknown): boolean {
1242
+ try {
1243
+ const c = config as {
1244
+ exposeThinking?: unknown;
1245
+ runOptions?: { exposeThinking?: unknown };
1246
+ options?: { exposeThinking?: unknown };
1247
+ } | null | undefined;
1248
+ const v = c?.exposeThinking ?? c?.runOptions?.exposeThinking ?? c?.options?.exposeThinking;
1249
+ if (v === undefined) return true;
1250
+ return Boolean(v);
1251
+ } catch {
1252
+ // why: policy lookup is best-effort — default to exposing thinking.
1253
+ return true;
1254
+ }
1255
+ }
1256
+
1257
+ /**
1258
+ * Whether an empty-text turn may promote `thinking` to answer `text` (Q-15).
1259
+ * Default false: the run returns the provider's empty text with its
1260
+ * finishReason instead of fabricating an answer from reasoning. Opt in via
1261
+ * `(runOptions as any).thinkingAsAnswerFallback === true` or
1262
+ * `(config as any).thinkingAsAnswerFallback === true`.
1263
+ */
1264
+ export function shouldPromoteThinkingToAnswer(config?: unknown, runOptions?: unknown): boolean {
1265
+ try {
1266
+ if ((runOptions as { thinkingAsAnswerFallback?: unknown } | null | undefined)?.thinkingAsAnswerFallback === true) {
1267
+ return true;
1268
+ }
1269
+ const c = config as {
1270
+ thinkingAsAnswerFallback?: unknown;
1271
+ runOptions?: { thinkingAsAnswerFallback?: unknown };
1272
+ } | null | undefined;
1273
+ if (c?.thinkingAsAnswerFallback === true) return true;
1274
+ if (c?.runOptions?.thinkingAsAnswerFallback === true) return true;
1275
+ return false;
1276
+ } catch {
1277
+ // why: policy lookup is best-effort — default to NOT promoting thinking.
1278
+ return false;
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Canonicalizes a provider finish reason for AgentResponse (Q-04).
1284
+ * When `truncated` (maxTurns exhausted with pending tools), forces
1285
+ * `"max_turns"` and preserves the provider's reason in `rawFinishReason`.
1286
+ * Otherwise delegates to `normalizeFinishReason` (unknown vendor strings map
1287
+ * to `"error"` with the original in `rawFinishReason`).
1288
+ */
1289
+ export function canonicalFinishReason(
1290
+ providerReason: unknown,
1291
+ truncated: boolean
1292
+ ): { finishReason?: FinishReason; rawFinishReason?: string } {
1293
+ if (truncated) {
1294
+ if (typeof providerReason === "string" && providerReason && providerReason !== "max_turns") {
1295
+ return { finishReason: "max_turns", rawFinishReason: providerReason };
1296
+ }
1297
+ return { finishReason: "max_turns" };
1298
+ }
1299
+ if (typeof providerReason !== "string" || providerReason === "") return {};
1300
+ return normalizeFinishReason(providerReason);
1301
+ }