agent-accelerator 0.3.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;
@@ -24,6 +33,57 @@ export interface AgentLoopConfig {
24
33
  bypassInputFileModality?: boolean;
25
34
  /** Called after every turn for durable persistence checkpoints (never fails a turn). */
26
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;
45
+ }
46
+
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]";
58
+ }
59
+ }
60
+
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;
27
87
  }
28
88
 
29
89
  function emitTurn(
@@ -66,48 +126,214 @@ function normalizeMaxTurns(value?: number): number {
66
126
  function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendingTools: string[]): void {
67
127
  const who = agentName ? `"${agentName}"` : "Agent";
68
128
  const tools = pendingTools.length > 0 ? ` Pending tool calls dropped: ${pendingTools.join(", ")}.` : "";
69
- // Leading newline: runs often end mid-line on streamed output; without it
70
- // the warning glues onto the last streamed chunk.
71
- console.warn(`\n[Agent Accelerator] WARNING [agent] ${who} hit maxTurns (${maxTurns}) with unfinished tool calls.${tools} Increase maxTurns or split the task.`);
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.
139
+ }
72
140
  }
73
141
 
74
- export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): TokenUsage["cost"] {
75
- if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
76
- return usage.cost;
142
+ // ---------------------------------------------------------------------------
143
+ // Q-19: tool-call/result pairing repair.
144
+ // ---------------------------------------------------------------------------
145
+
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
+ }
210
+
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);
77
234
  }
78
- if (!spec) return usage.cost;
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 };
241
+ }
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
+ }
79
252
 
80
- const costData = spec.cost || {};
81
- const pricingData = spec.pricing || {};
82
- const inputPrice = pricingData.inputPerMillion ?? costData.input ?? 0;
83
- const outputPrice = pricingData.outputPerMillion ?? costData.output ?? 0;
84
- const cacheReadPrice = pricingData.cacheReadPerMillion ?? costData.cache_read ?? 0;
85
- const cacheWritePrice = pricingData.cacheWritePerMillion ?? costData.cache_write ?? 0;
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
+ }
86
266
 
87
- if (inputPrice === 0 && outputPrice === 0 && cacheReadPrice === 0 && cacheWritePrice === 0) {
88
- return usage.cost;
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"));
89
273
  }
274
+ }
90
275
 
91
- const cachedTokens = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
92
- const nonCachedInputTokens = Math.max(0, (usage.inputTokens || 0) - cachedTokens);
93
- const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
94
- const outputTokens = usage.outputTokens ?? 0;
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
+ }
95
281
 
96
- const inputCost = (nonCachedInputTokens / 1_000_000) * inputPrice;
97
- const cacheReadCost = (cachedTokens / 1_000_000) * cacheReadPrice;
98
- const cacheWriteCost = (cacheWriteTokens / 1_000_000) * cacheWritePrice;
99
- const outputCost = (outputTokens / 1_000_000) * outputPrice;
100
- const totalCost = inputCost + cacheReadCost + cacheWriteCost + outputCost;
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
+ }
101
299
 
102
- return {
103
- inputCost,
104
- outputCost,
105
- cacheReadCost,
106
- cacheWriteCost,
107
- totalCost,
108
- };
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
+ }
109
323
  }
110
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
+ // ---------------------------------------------------------------------------
111
337
  function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
112
338
  target.inputTokens += source.inputTokens || 0;
113
339
  target.outputTokens += source.outputTokens || 0;
@@ -121,14 +347,25 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
121
347
  // input. Some providers occasionally report cached > input on long chained
122
348
  // runs, which surfaces as >100% hit rates downstream. Clamp the aggregate
123
349
  // so no consumer can observe an impossible ratio.
124
- target.cachedTokens = Math.min(target.cachedTokens ?? 0, target.inputTokens);
125
- target.cacheReadTokens = Math.min(target.cacheReadTokens ?? 0, target.inputTokens);
126
-
127
- const sourceCost = computeCostFromPricing(source, spec);
128
- if (sourceCost) {
129
- 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
+ });
130
362
  }
131
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
+
132
369
  if (sourceCost || target.cost) {
133
370
  target.cost = {
134
371
  inputCost: (target.cost?.inputCost ?? 0) + (sourceCost?.inputCost ?? 0),
@@ -253,20 +490,17 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
253
490
  runOptions,
254
491
  maxTurns: rawMaxTurns = Infinity,
255
492
  } = config;
256
- const maxTurns = normalizeMaxTurns(rawMaxTurns);
257
-
258
- if (config.bypassInputFileModality) {
259
- await preprocessFilePartsForBypass(config.context.messages, {
260
- providerId: provider.id,
261
- modelId,
262
- signal: runOptions?.signal,
263
- });
264
- }
265
-
266
- const standardTools = toStandardToolDeclarations(tools);
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);
499
+
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.
267
502
  const startTime = Date.now();
268
-
269
- let accumulatedUsage: TokenUsage = {
503
+ const accumulatedUsage: TokenUsage = {
270
504
  inputTokens: 0,
271
505
  outputTokens: 0,
272
506
  totalTokens: 0,
@@ -275,7 +509,6 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
275
509
  cacheWriteTokens: 0,
276
510
  thinkingTokens: 0,
277
511
  };
278
-
279
512
  const allToolCalls: ToolCallRecord[] = [];
280
513
  const allToolResults: ToolResultRecord[] = [];
281
514
  const allSubagents: SubAgentExecutionMetadata[] = [];
@@ -283,7 +516,23 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
283
516
  let turns = 0;
284
517
  let previousToolCallFingerprints = new Set<string>();
285
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
+
286
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);
287
536
  turns++;
288
537
 
289
538
  const spec = getModelFromCatalog(provider.id, modelId);
@@ -296,15 +545,33 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
296
545
  tools: standardTools.length > 0 ? standardTools : undefined,
297
546
  signal: runOptions?.signal,
298
547
  };
299
-
300
- const genResult = await provider.generate(
301
- modelId,
302
- {
303
- systemPrompt: context.systemPrompt,
304
- messages: context.messages,
305
- cachedContentId: context.cachedContentId,
306
- },
307
- providerOptions
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 {}
563
+
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
308
575
  );
309
576
 
310
577
  finalResult = genResult;
@@ -315,15 +582,20 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
315
582
  provider.id
316
583
  );
317
584
 
318
- // 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.
319
589
  if ((!genResult.text || genResult.text.trim() === "") && (!genResult.toolCalls || genResult.toolCalls.length === 0) && genResult.thinking) {
320
- if (genResult.thinking.includes("</think>")) {
321
- const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
322
- genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
323
- genResult.text = parts.slice(1).join("").trim();
324
- } else {
325
- genResult.text = genResult.thinking;
326
- 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
+ }
327
599
  }
328
600
  }
329
601
 
@@ -337,15 +609,42 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
337
609
  genResult.thoughtSignature
338
610
  );
339
611
 
340
- // 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).
341
621
  if (!genResult.toolCalls || genResult.toolCalls.length === 0) {
342
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
+ }
343
633
  emitProgress(config);
634
+ if (noToolSteered.length > 0) continue;
344
635
  break;
345
636
  }
346
637
 
347
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 });
348
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
+ }
349
648
  const guarded = await executeToolCallsWithRepeatGuard({
350
649
  tools,
351
650
  toolCalls: genResult.toolCalls,
@@ -377,6 +676,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
377
676
 
378
677
  allToolResults.push(...sanitizedResults);
379
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 });
380
682
  emitTurn(runOptions, {
381
683
  turns,
382
684
  text: genResult.text,
@@ -384,9 +686,25 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
384
686
  toolCalls: genResult.toolCalls,
385
687
  toolResults: sanitizedResults,
386
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
+ }
387
703
  emitProgress(config);
388
704
 
389
- if (runOptions?.signal?.aborted) break;
705
+ // Q-21: abort always throws AbortError with partial attached (never
706
+ // silently breaks).
707
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
390
708
  }
391
709
 
392
710
  // maxTurns exhausted while the model still requested tools: the tool
@@ -413,11 +731,47 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
413
731
  responseId: finalResult?.responseId,
414
732
  model: modelId,
415
733
  provider: provider.id,
416
- finishReason: truncatedByMaxTurns ? "max_turns" : finalResult?.finishReason,
734
+ // Q-04: canonical finish reason + raw vendor passthrough.
735
+ ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
417
736
  durationMs: Date.now() - startTime,
418
737
  raw: finalResult?.raw,
419
738
  turns,
420
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
+ }
421
775
  }
422
776
 
423
777
  /**
@@ -445,12 +799,40 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
445
799
  linked.signal.addEventListener("abort", () => {
446
800
  try { currentInner?.cancel(); } catch {}
447
801
  });
448
- const throwIfCancelled = () => {
449
- if (linked.signal.aborted || outerStream.isCancelled())
450
- 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
+ }
451
810
  };
452
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
+
453
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[] = [];
454
836
  try {
455
837
  await ensureModelCatalogFresh();
456
838
  // Re-validate after refresh: Agent.stream() validates before the catalog
@@ -484,11 +866,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
484
866
  runOptions,
485
867
  maxTurns: rawMaxTurns = Infinity,
486
868
  } = config;
487
- const maxTurns = normalizeMaxTurns(rawMaxTurns);
869
+ const maxTurns = streamLimits.maxTurns;
488
870
 
489
871
  const standardTools = toStandardToolDeclarations(tools);
490
872
 
491
- let accumulatedUsage: TokenUsage = {
873
+ accumulatedUsage = {
492
874
  inputTokens: 0,
493
875
  outputTokens: 0,
494
876
  totalTokens: 0,
@@ -498,15 +880,16 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
498
880
  thinkingTokens: 0,
499
881
  };
500
882
 
501
- const allToolCalls: ToolCallRecord[] = [];
502
- const allToolResults: ToolResultRecord[] = [];
503
- const allSubagents: SubAgentExecutionMetadata[] = [];
504
- let lastResponse: AgentResponse | null = null;
505
- 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;
506
887
  let previousToolCallFingerprints = new Set<string>();
507
888
 
508
889
  while (turns < maxTurns) {
509
- throwIfCancelled();
890
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
891
+ checkDeadline(streamStartTime, streamLimits.deadlineMs);
892
+ ensureToolPairing(context);
510
893
  turns++;
511
894
 
512
895
  const spec2 = getModelFromCatalog(provider.id, modelId);
@@ -518,6 +901,18 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
518
901
  tools: standardTools.length > 0 ? standardTools : undefined,
519
902
  signal: linked.signal,
520
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 {}
521
916
 
522
917
  const innerStream = provider.stream(
523
918
  modelId,
@@ -530,16 +925,54 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
530
925
  );
531
926
  currentInner = innerStream as AssistantMessageEventStream;
532
927
 
533
- for await (const event of innerStream) {
534
- throwIfCancelled();
535
- if (event.type !== "done") {
536
- 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
+ }
537
970
  }
538
971
  }
539
972
 
540
973
  const turnResponse = await innerStream.result();
541
974
  currentInner = null;
542
- throwIfCancelled();
975
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: (turnResponse as { text?: string })?.text });
543
976
  lastResponse = turnResponse;
544
977
  // Canonical session routing: lets adapters detect provider switches.
545
978
  noteProviderTurn(
@@ -547,15 +980,19 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
547
980
  provider.id
548
981
  );
549
982
 
550
- // 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.
551
986
  if ((!turnResponse.text || turnResponse.text.trim() === "") && (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) && turnResponse.thinking) {
552
- if (turnResponse.thinking.includes("</think>")) {
553
- const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
554
- (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
555
- (turnResponse as any).text = parts.slice(1).join("").trim();
556
- } else {
557
- (turnResponse as any).text = turnResponse.thinking;
558
- (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
+ }
559
996
  }
560
997
  }
561
998
 
@@ -568,13 +1005,41 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
568
1005
  turnResponse.thoughtSignature
569
1006
  );
570
1007
 
1008
+ const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
1009
+ ? drainSteerInbox(context, config.steerInbox)
1010
+ : [];
571
1011
  if (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) {
572
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
+ }
573
1025
  emitProgress(config);
1026
+ if (noToolSteered.length > 0) continue;
574
1027
  break;
575
1028
  }
576
1029
 
1030
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
577
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
+ }
578
1043
  const guarded = await executeToolCallsWithRepeatGuard({
579
1044
  tools,
580
1045
  toolCalls: turnResponse.toolCalls,
@@ -592,10 +1057,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
592
1057
  partialText: ev.partialText,
593
1058
  partialThinking: ev.partialThinking,
594
1059
  });
595
- } catch {}
1060
+ } catch {
1061
+ // why: stream push never throws; keep subagent progress flowing.
1062
+ }
596
1063
  },
597
1064
  });
598
- throwIfCancelled();
599
1065
  const results = guarded.results;
600
1066
  previousToolCallFingerprints = guarded.fingerprints;
601
1067
 
@@ -626,9 +1092,17 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
626
1092
  toolResult: res,
627
1093
  });
628
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
+ }
629
1101
 
1102
+ // Q-19: store tool results BEFORE the cancellation check.
630
1103
  allToolResults.push(...sanitizedResults);
631
1104
  context.addToolResults(sanitizedResults);
1105
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
632
1106
  emitTurn(runOptions, {
633
1107
  turns,
634
1108
  text: turnResponse.text,
@@ -636,6 +1110,19 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
636
1110
  toolCalls: turnResponse.toolCalls,
637
1111
  toolResults: sanitizedResults,
638
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
+ }
639
1126
  emitProgress(config);
640
1127
  }
641
1128
 
@@ -648,7 +1135,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
648
1135
  lastResponse!.toolCalls.map((c) => c.name)
649
1136
  );
650
1137
  }
651
- const truncatedFinishReason = truncatedByMaxTurns ? "max_turns" : lastResponse?.finishReason;
1138
+ const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
652
1139
 
653
1140
  const finalAgentResponse = new AgentResponse({
654
1141
  text: lastResponse?.text || "",
@@ -661,7 +1148,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
661
1148
  responseId: lastResponse?.responseId,
662
1149
  model: modelId,
663
1150
  provider: provider.id,
664
- finishReason: truncatedFinishReason,
1151
+ // Q-04: canonical finish reason + raw vendor passthrough.
1152
+ ...truncatedFinish,
665
1153
  durationMs: Date.now() - startTime,
666
1154
  raw: lastResponse?.raw as any,
667
1155
  turns,
@@ -671,7 +1159,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
671
1159
  type: "done",
672
1160
  delta: "",
673
1161
  usage: accumulatedUsage,
674
- finishReason: truncatedFinishReason,
1162
+ finishReason: truncatedFinish.finishReason ?? lastResponse?.finishReason,
1163
+ rawFinishReason: truncatedFinish.rawFinishReason,
675
1164
  responseId: lastResponse?.responseId,
676
1165
  });
677
1166
 
@@ -683,17 +1172,130 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
683
1172
  outerStream.isCancelled() ||
684
1173
  (raw as any)?.name === "AbortError" ||
685
1174
  /abort|cancell?ed/i.test(String((raw as any)?.message ?? raw));
686
- outerStream.fail(
687
- isAbort
688
- ? Object.assign(raw.name === "AbortError" ? raw : new Error("Stream aborted"), { name: "AbortError" })
689
- : raw
690
- );
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
+ }
691
1210
  } finally {
692
- try { currentInner?.cancel(); } catch {}
693
- try { userSignal?.removeEventListener("abort", forwardUserAbort); } catch {}
694
- 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
+ }
695
1220
  }
696
1221
  })();
697
1222
 
698
1223
  return outerStream;
699
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
+ }