agent-accelerator 0.3.0 → 0.4.5

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,14 +2,23 @@ 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";
9
12
  import { executeToolCalls } from "../tools/executor.ts";
10
13
  import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } from "../models/catalog.ts";
11
14
  import { preprocessFilePartsForBypass } from "../utils/documents.ts";
12
- import { noteProviderTurn } from "../providers.ts";
15
+ import { noteProviderTurn, normalizeStructuredOutput, parseStructuredOutput } from "../providers.ts";
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
+ }
13
22
 
14
23
  export interface AgentLoopConfig {
15
24
  agentName?: string;
@@ -24,6 +33,63 @@ 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
+ * Canonical structured-output request (raw `AgentConfig.output`).
47
+ * Normalized per turn for the provider wire; validated once at the end
48
+ * into `AgentResponse.parsed`. Agent-level only.
49
+ */
50
+ output?: unknown;
51
+ }
52
+
53
+ /** Short preview for `steer_injected` / `queued` event payloads (bounded, display-only). */
54
+ function steerPreview(prompt: string | ContentPart[]): string {
55
+ if (typeof prompt === "string") return prompt.slice(0, 500);
56
+ try {
57
+ const texts = prompt
58
+ .filter((p) => (p as { type?: unknown }).type === "text")
59
+ .map((p) => (p as { text?: string }).text ?? "")
60
+ .join("\n");
61
+ return (texts || "[multipart]").slice(0, 500);
62
+ } catch {
63
+ return "[multipart]";
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Drains pending steer entries into the conversation as new user messages.
69
+ * Returns display previews for events/telemetry. Never throws: invalid
70
+ * entries are skipped so one bad steer cannot fail the active turn.
71
+ */
72
+ function drainSteerInbox(context: AgentContext, inbox?: SteerEntry[]): string[] {
73
+ if (!inbox || inbox.length === 0) return [];
74
+ const injected: string[] = [];
75
+ while (inbox.length > 0) {
76
+ const entry = inbox.shift()!;
77
+ try {
78
+ let prompt = entry.prompt as string | ContentPart[];
79
+ const opts = entry.options;
80
+ if (Array.isArray(prompt) && prompt.length === 0) continue;
81
+ if (opts?.additionalContext) {
82
+ const prefix = `[Additional Context]\n${opts.additionalContext}\n\n`;
83
+ if (typeof prompt === "string") prompt = prefix + prompt;
84
+ else prompt = [{ type: "text", text: prefix } as ContentPart, ...prompt];
85
+ }
86
+ context.addUserMessage(prompt);
87
+ injected.push(steerPreview(entry.prompt));
88
+ } catch {
89
+ continue;
90
+ }
91
+ }
92
+ return injected;
27
93
  }
28
94
 
29
95
  function emitTurn(
@@ -45,6 +111,30 @@ function emitProgress(config: { onProgress?: () => void }): void {
45
111
  try { config.onProgress?.(); } catch {}
46
112
  }
47
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // Structured outputs (canonical output wiring for the loop).
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function resolveLoopOutput(config: AgentLoopConfig): { raw: unknown; spec: import("../types/model.ts").StructuredOutputSpec | undefined } {
119
+ const raw = (config as { output?: unknown }).output;
120
+ const fromOptions = (config.options as { output?: import("../types/model.ts").StructuredOutputSpec } | undefined)?.output;
121
+ if (raw !== undefined && raw !== null) {
122
+ // Throws ValidationError for invalid shapes — caught by run/stream catch
123
+ // with partial attached, same as other config validation.
124
+ const spec = normalizeStructuredOutput(raw);
125
+ return { raw, spec };
126
+ }
127
+ if (fromOptions) return { raw: fromOptions, spec: fromOptions };
128
+ return { raw: undefined, spec: undefined };
129
+ }
130
+
131
+ function parseFinalStructuredOutput(text: string, raw: unknown, spec: import("../types/model.ts").StructuredOutputSpec | undefined): unknown | undefined {
132
+ if (raw === undefined && spec === undefined) return undefined;
133
+ // Strict-fail when opted in: invalid JSON/schema throws ValidationError.
134
+ // Tool turns skip this (called only for final no-tool text).
135
+ return parseStructuredOutput(text, raw ?? spec);
136
+ }
137
+
48
138
  /**
49
139
  * Normalizes `maxTurns` to a safe positive integer (default Infinity).
50
140
  * `0` means infinite (no limit), matching the `timeout: 0` convention.
@@ -66,48 +156,214 @@ function normalizeMaxTurns(value?: number): number {
66
156
  function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendingTools: string[]): void {
67
157
  const who = agentName ? `"${agentName}"` : "Agent";
68
158
  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.`);
159
+ // Q-41: no console.* in library code — route via the telemetry bus
160
+ // (default no-op; CLIs attach a pretty logger via telemetryBus.onEvent).
161
+ try {
162
+ telemetryBus.emitWarning(
163
+ "agent.max_turns_truncated",
164
+ `[Agent Accelerator] "${who}" hit maxTurns (${maxTurns}) with unfinished tool calls.${tools} Increase maxTurns or split the task.`,
165
+ { agentName, maxTurns, pendingTools }
166
+ );
167
+ } catch {
168
+ // why: telemetry emission is observability-only and must never throw.
169
+ }
72
170
  }
73
171
 
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;
172
+ // ---------------------------------------------------------------------------
173
+ // Q-19: tool-call/result pairing repair.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Repairs orphaned tool call/result pairs before each provider request
178
+ * (Q-19). Providers reject a `tool_result` without its `tool_call` and
179
+ * vice versa; a prior abort or crash can leave such orphans in history.
180
+ * Synthesizes error results for orphan calls and drops orphan results.
181
+ */
182
+ export function ensureToolPairing(context: AgentContext): void {
183
+ try {
184
+ const callIds = new Map<string, string>();
185
+ for (const m of context.messages) {
186
+ if ((m as any)?.role !== "assistant" || !Array.isArray((m as any).content)) continue;
187
+ for (const p of (m as any).content as Array<any>) {
188
+ if (p && p.type === "tool_call" && typeof p.id === "string" && p.id) {
189
+ if (!callIds.has(p.id)) callIds.set(p.id, typeof p.name === "string" ? p.name : "tool");
190
+ }
191
+ }
192
+ }
193
+ if (callIds.size === 0) {
194
+ // No calls: drop any stray tool results.
195
+ const hasOrphan = context.messages.some(
196
+ (m: any) => m?.role === "tool" && Array.isArray(m.content)
197
+ );
198
+ if (!hasOrphan) return;
199
+ context.messages = context.messages.filter((m: any) => m?.role !== "tool");
200
+ return;
201
+ }
202
+ const resultIds = new Set<string>();
203
+ for (const m of context.messages) {
204
+ if ((m as any)?.role !== "tool" || !Array.isArray((m as any).content)) continue;
205
+ for (const p of (m as any).content as Array<any>) {
206
+ if (p && p.type === "tool_result" && typeof p.id === "string" && p.id) {
207
+ resultIds.add(p.id);
208
+ }
209
+ }
210
+ }
211
+ const missing: ToolResultRecord[] = [];
212
+ for (const [id, name] of callIds) {
213
+ if (!resultIds.has(id)) {
214
+ missing.push({
215
+ id,
216
+ name,
217
+ result: `Error: orphan tool_call '${name}' (${id}) without a matching tool result (recovered by ensureToolPairing).`,
218
+ isError: true,
219
+ durationMs: 0,
220
+ });
221
+ }
222
+ }
223
+ if (missing.length > 0) {
224
+ try { context.addToolResults(missing); } catch {}
225
+ for (const r of missing) resultIds.add(r.id);
226
+ }
227
+ // Drop orphan results (result id never issued by the model).
228
+ const filtered = context.messages.filter((m: any) => {
229
+ if (m?.role !== "tool" || !Array.isArray(m.content)) return true;
230
+ const parts = m.content as Array<any>;
231
+ const keep = parts.filter((p) => p?.type !== "tool_result" || callIds.has(p.id));
232
+ if (keep.length === parts.length) return true;
233
+ if (keep.length === 0) return false;
234
+ try { (m as any).content = keep; } catch {}
235
+ return true;
236
+ });
237
+ context.messages = filtered as typeof context.messages;
238
+ } catch {}
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Q-21/Q-22: abort + limits helpers.
243
+ // ---------------------------------------------------------------------------
244
+
245
+ export interface LoopLimits {
246
+ maxTurns?: number;
247
+ deadlineMs?: number;
248
+ maxToolCallsPerRun?: number;
249
+ requestTimeoutMs: number;
250
+ firstByteTimeoutMs: number;
251
+ streamIdleTimeoutMs: number;
252
+ }
253
+
254
+ /** Resolves `(config as any).limits` with Q-22 defaults (timeouts tolerate reasoning). */
255
+ function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): LoopLimits & { maxTurns: number; deadlineMs: number; maxToolCallsPerRun: number } {
256
+ const raw = ((config as unknown as { limits?: Record<string, unknown> }).limits ?? {}) as Record<string, unknown>;
257
+ const num = (v: unknown): number | undefined =>
258
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
259
+ let maxTurns = fallbackMaxTurns;
260
+ const limMax = num(raw.maxTurns);
261
+ if (limMax !== undefined) {
262
+ const normLim = normalizeMaxTurns(limMax);
263
+ maxTurns = Math.min(maxTurns, normLim);
77
264
  }
78
- if (!spec) return usage.cost;
265
+ const deadlineMs = num(raw.deadlineMs) ?? Infinity;
266
+ const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
267
+ const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
268
+ const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
269
+ const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
270
+ return { maxTurns, deadlineMs, maxToolCallsPerRun, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
271
+ }
79
272
 
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;
273
+ function loopSessionId(config: AgentLoopConfig): string | undefined {
274
+ try {
275
+ return (
276
+ (config.runOptions as { sessionId?: string } | undefined)?.sessionId ||
277
+ (config.options as { sessionId?: string } | undefined)?.sessionId ||
278
+ (config.options as { cache?: { sessionId?: string } } | undefined)?.cache?.sessionId
279
+ );
280
+ } catch { return undefined; }
281
+ }
86
282
 
87
- if (inputPrice === 0 && outputPrice === 0 && cacheReadPrice === 0 && cacheWritePrice === 0) {
88
- return usage.cost;
283
+ function makeAbortError(
284
+ partial: { usage: TokenUsage; turns: number; text?: string },
285
+ reason?: unknown
286
+ ): Error {
287
+ let base: Error;
288
+ if (reason instanceof Error && reason.name === "AbortError") base = reason;
289
+ else if (reason instanceof Error) base = new Error(reason.message || "Aborted");
290
+ else if (typeof reason === "string" && reason) base = new Error(reason);
291
+ else base = new Error("Aborted");
292
+ try { (base as { name: string }).name = "AbortError"; } catch {}
293
+ try { (base as unknown as { partial: unknown }).partial = partial; } catch {}
294
+ return base;
295
+ }
296
+
297
+ function throwIfAborted(
298
+ signal: AbortSignal | undefined,
299
+ partial: { usage: TokenUsage; turns: number; text?: string }
300
+ ): void {
301
+ if (signal?.aborted) {
302
+ throw makeAbortError(partial, (signal as unknown as { reason?: unknown })?.reason ?? new Error("Aborted"));
89
303
  }
304
+ }
90
305
 
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;
306
+ function checkDeadline(startTime: number, deadlineMs: number): void {
307
+ if (Number.isFinite(deadlineMs) && deadlineMs !== Infinity && Date.now() - startTime > deadlineMs) {
308
+ throw new Error(`[Agent Accelerator] Run deadline exceeded after ${deadlineMs}ms.`);
309
+ }
310
+ }
95
311
 
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;
312
+ async function generateWithTimeout<T>(task: () => Promise<T>, timeoutMs: number): Promise<T> {
313
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs === Infinity) return task();
314
+ let timer: ReturnType<typeof setTimeout> | undefined;
315
+ try {
316
+ const timeoutP = new Promise<never>((_, reject) => {
317
+ timer = setTimeout(() => {
318
+ const e = new Error(`[Agent Accelerator] Request timed out after ${timeoutMs}ms.`);
319
+ e.name = "TimeoutError";
320
+ reject(e);
321
+ }, timeoutMs);
322
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
323
+ });
324
+ return await Promise.race([task(), timeoutP]);
325
+ } finally {
326
+ if (timer) clearTimeout(timer);
327
+ }
328
+ }
101
329
 
102
- return {
103
- inputCost,
104
- outputCost,
105
- cacheReadCost,
106
- cacheWriteCost,
107
- totalCost,
108
- };
330
+ /** Compat wrapper around the one cost model (`priceUsage` in session/store.ts, Q-36).
331
+ * Prefers provider-reported totals, else catalog pricing with integer
332
+ * micro-USD accumulation. Returns undefined when unknown (never a 0 object). */
333
+ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): TokenUsage["cost"] {
334
+ try {
335
+ if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
336
+ return usage.cost;
337
+ }
338
+ if (!spec) return usage.cost;
339
+ const priced = priceUsage(usage, spec);
340
+ return priced
341
+ ? {
342
+ inputCost: priced.inputCost,
343
+ outputCost: priced.outputCost,
344
+ cacheReadCost: priced.cacheReadCost,
345
+ cacheWriteCost: priced.cacheWriteCost,
346
+ totalCost: priced.totalCost,
347
+ }
348
+ : usage.cost;
349
+ } catch {
350
+ // why: cost is observability-only — pricing failures must never break turns.
351
+ return usage.cost;
352
+ }
109
353
  }
110
354
 
355
+ // ---------------------------------------------------------------------------
356
+ // Q-35: canonical usage accumulation (pure w.r.t. source).
357
+ // Invariants (see assertUsageInvariants in session/store.ts):
358
+ // - token counts are finite integers >= 0;
359
+ // - cachedTokens/cacheReadTokens are a SUBSET of inputTokens (clamped below
360
+ // so no consumer observes >100% hit rates);
361
+ // - thinkingTokens is a SUBSET of outputTokens when both are reported
362
+ // (reasoning is generated output; enforced in the test helper, documented
363
+ // here because providers vary on which side they omit).
364
+ // This function never mutates `source` (Q-35): the priced cost is cloned
365
+ // into a local before folding into the target.
366
+ // ---------------------------------------------------------------------------
111
367
  function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
112
368
  target.inputTokens += source.inputTokens || 0;
113
369
  target.outputTokens += source.outputTokens || 0;
@@ -121,14 +377,25 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
121
377
  // input. Some providers occasionally report cached > input on long chained
122
378
  // runs, which surfaces as >100% hit rates downstream. Clamp the aggregate
123
379
  // 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;
380
+ const preCached = target.cachedTokens ?? 0;
381
+ const preRead = target.cacheReadTokens ?? 0;
382
+ target.cachedTokens = Math.min(preCached, target.inputTokens);
383
+ target.cacheReadTokens = Math.min(preRead, target.inputTokens);
384
+ if ((target.cachedTokens ?? 0) < preCached || (target.cacheReadTokens ?? 0) < preRead) {
385
+ // Q-35: no console — report via the anomaly hook + telemetry bus.
386
+ reportUsageAnomaly({
387
+ type: "cache_clamp",
388
+ inputTokens: target.inputTokens,
389
+ cachedTokens: preCached,
390
+ cacheReadTokens: preRead,
391
+ });
130
392
  }
131
393
 
394
+ const priced = computeCostFromPricing(source, spec);
395
+ // Q-35: pure — read `source` only; never assign back to `source.cost`.
396
+ // Clone the priced cost so later target folds cannot alias source state.
397
+ const sourceCost = priced ? { ...priced } : undefined;
398
+
132
399
  if (sourceCost || target.cost) {
133
400
  target.cost = {
134
401
  inputCost: (target.cost?.inputCost ?? 0) + (sourceCost?.inputCost ?? 0),
@@ -253,20 +520,19 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
253
520
  runOptions,
254
521
  maxTurns: rawMaxTurns = Infinity,
255
522
  } = 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);
523
+ const fallbackMaxTurns = normalizeMaxTurns(rawMaxTurns);
524
+ const limits = resolveLoopLimits(config, fallbackMaxTurns);
525
+ const maxTurns = limits.maxTurns;
526
+ // Structured outputs: normalize once (throws ValidationError for bad shapes).
527
+ const loopOutput = resolveLoopOutput(config);
528
+ // Q-19: pre-run checkpoint for onFailure rollback.
529
+ const preRunCp = context.checkpoint();
530
+ const sessionIdForRollback = loopSessionId(config);
531
+
532
+ // Q-37: partial-run state hoisted outside try so the catch block can attach
533
+ // {usage,cost,turns,toolCalls,toolResults,text} to ANY thrown error.
267
534
  const startTime = Date.now();
268
-
269
- let accumulatedUsage: TokenUsage = {
535
+ const accumulatedUsage: TokenUsage = {
270
536
  inputTokens: 0,
271
537
  outputTokens: 0,
272
538
  totalTokens: 0,
@@ -275,7 +541,6 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
275
541
  cacheWriteTokens: 0,
276
542
  thinkingTokens: 0,
277
543
  };
278
-
279
544
  const allToolCalls: ToolCallRecord[] = [];
280
545
  const allToolResults: ToolResultRecord[] = [];
281
546
  const allSubagents: SubAgentExecutionMetadata[] = [];
@@ -283,7 +548,23 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
283
548
  let turns = 0;
284
549
  let previousToolCallFingerprints = new Set<string>();
285
550
 
551
+ try {
552
+ if (config.bypassInputFileModality) {
553
+ await preprocessFilePartsForBypass(config.context.messages, {
554
+ providerId: provider.id,
555
+ modelId,
556
+ signal: runOptions?.signal,
557
+ });
558
+ }
559
+
560
+ const standardTools = toStandardToolDeclarations(tools);
561
+
286
562
  while (turns < maxTurns) {
563
+ // Q-21: fail fast on abort (throws AbortError with partial).
564
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
565
+ checkDeadline(startTime, limits.deadlineMs);
566
+ // Q-19: repair orphan tool pairs before every request.
567
+ ensureToolPairing(context);
287
568
  turns++;
288
569
 
289
570
  const spec = getModelFromCatalog(provider.id, modelId);
@@ -295,16 +576,35 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
295
576
  cache: options?.cache,
296
577
  tools: standardTools.length > 0 ? standardTools : undefined,
297
578
  signal: runOptions?.signal,
579
+ ...(loopOutput.spec ? { output: loopOutput.spec } : {}),
298
580
  };
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
581
+ // Q-22: per-request timeout pass-through (providers honor timeoutMs).
582
+ try {
583
+ if (Number.isFinite(limits.requestTimeoutMs) && limits.requestTimeoutMs !== Infinity) {
584
+ (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = limits.requestTimeoutMs;
585
+ }
586
+ } catch {}
587
+ // Q-25-partial: google:{store:false} opt-out pass-through (provider
588
+ // already respects body.store===false for chaining).
589
+ try {
590
+ const gStore =
591
+ (runOptions as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
592
+ (options as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
593
+ (config as unknown as { googleStore?: unknown }).googleStore;
594
+ if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
595
+ } catch {}
596
+
597
+ const genResult = await generateWithTimeout(
598
+ () => provider.generate(
599
+ modelId,
600
+ {
601
+ systemPrompt: context.systemPrompt,
602
+ messages: context.messages,
603
+ cachedContentId: context.cachedContentId,
604
+ },
605
+ providerOptions
606
+ ),
607
+ limits.requestTimeoutMs
308
608
  );
309
609
 
310
610
  finalResult = genResult;
@@ -315,15 +615,20 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
315
615
  provider.id
316
616
  );
317
617
 
318
- // Safety fallback: if no tool calls and text is empty, rescue answer from thinking
618
+ // Thinking is exposed via events/traces and onTurn (see shouldExposeThinking,
619
+ // default true). Promoting thinking to answer text is gated (Q-15, default
620
+ // OFF): without the explicit opt-in the run returns the provider's empty
621
+ // text with its finishReason instead of fabricating an answer from reasoning.
319
622
  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;
623
+ if (shouldPromoteThinkingToAnswer(config, runOptions)) {
624
+ if (genResult.thinking.includes("</think>")) {
625
+ const parts = genResult.thinking.split(/<\/(?:think|thought)>/i);
626
+ genResult.thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
627
+ genResult.text = parts.slice(1).join("").trim();
628
+ } else {
629
+ genResult.text = genResult.thinking;
630
+ genResult.thinking = undefined;
631
+ }
327
632
  }
328
633
  }
329
634
 
@@ -337,15 +642,42 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
337
642
  genResult.thoughtSignature
338
643
  );
339
644
 
340
- // If no tool calls, generation is complete!
645
+ // Mid-session steer: if new queries arrived during this provider turn,
646
+ // inject them now. Tool turns still execute first (see below) so the
647
+ // current action finishes before redirection.
648
+ const noToolSteered = (!genResult.toolCalls || genResult.toolCalls.length === 0)
649
+ ? drainSteerInbox(context, config.steerInbox)
650
+ : [];
651
+
652
+ // If no tool calls, generation is complete — unless steered, in which
653
+ // case the injected message extends the SAME run (no new response).
341
654
  if (!genResult.toolCalls || genResult.toolCalls.length === 0) {
342
655
  emitTurn(runOptions, { turns, text: genResult.text, thinking: genResult.thinking });
656
+ if (noToolSteered.length > 0) {
657
+ try { config.onSteerInjected?.(noToolSteered); } catch {}
658
+ if (config.bypassInputFileModality) {
659
+ await preprocessFilePartsForBypass(config.context.messages, {
660
+ providerId: provider.id,
661
+ modelId,
662
+ signal: runOptions?.signal,
663
+ });
664
+ }
665
+ }
343
666
  emitProgress(config);
667
+ if (noToolSteered.length > 0) continue;
344
668
  break;
345
669
  }
346
670
 
347
671
  // Execute tool calls — always parallel (model-driven, bloatfree DX7)
672
+ // Q-21: check abort before tools (throws AbortError with partial).
673
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
348
674
  allToolCalls.push(...genResult.toolCalls);
675
+ // Q-22: per-run tool-call budget.
676
+ if (allToolCalls.length > limits.maxToolCallsPerRun) {
677
+ throw new Error(
678
+ `[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${limits.maxToolCallsPerRun}).`
679
+ );
680
+ }
349
681
  const guarded = await executeToolCallsWithRepeatGuard({
350
682
  tools,
351
683
  toolCalls: genResult.toolCalls,
@@ -377,6 +709,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
377
709
 
378
710
  allToolResults.push(...sanitizedResults);
379
711
  context.addToolResults(sanitizedResults);
712
+ // Q-19: results are stored BEFORE the cancellation check so an abort
713
+ // never drops executed tool work from history.
714
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
380
715
  emitTurn(runOptions, {
381
716
  turns,
382
717
  text: genResult.text,
@@ -384,9 +719,25 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
384
719
  toolCalls: genResult.toolCalls,
385
720
  toolResults: sanitizedResults,
386
721
  });
722
+ // Steer drain AFTER tool results so call/result pairs stay adjacent.
723
+ // The next loop iteration sends the injected user message to the model
724
+ // within the same run (same usage/turn budget).
725
+ const toolSteered = drainSteerInbox(context, config.steerInbox);
726
+ if (toolSteered.length > 0) {
727
+ try { config.onSteerInjected?.(toolSteered); } catch {}
728
+ if (config.bypassInputFileModality) {
729
+ await preprocessFilePartsForBypass(config.context.messages, {
730
+ providerId: provider.id,
731
+ modelId,
732
+ signal: runOptions?.signal,
733
+ });
734
+ }
735
+ }
387
736
  emitProgress(config);
388
737
 
389
- if (runOptions?.signal?.aborted) break;
738
+ // Q-21: abort always throws AbortError with partial attached (never
739
+ // silently breaks).
740
+ throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
390
741
  }
391
742
 
392
743
  // maxTurns exhausted while the model still requested tools: the tool
@@ -402,8 +753,16 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
402
753
  );
403
754
  }
404
755
 
756
+ // Structured outputs: validate final text only (tool turns skip this).
757
+ // Strict-fail throws ValidationError with partial attached via catch below.
758
+ let parsedOutput: unknown | undefined;
759
+ if (loopOutput.raw !== undefined && !truncatedByMaxTurns) {
760
+ parsedOutput = parseFinalStructuredOutput(finalResult?.text || "", loopOutput.raw, loopOutput.spec);
761
+ }
762
+
405
763
  return new AgentResponse({
406
764
  text: finalResult?.text || "",
765
+ ...(parsedOutput !== undefined ? { parsed: parsedOutput } : {}),
407
766
  thinking: finalResult?.thinking,
408
767
  thoughtSignature: finalResult?.thoughtSignature,
409
768
  toolCalls: allToolCalls,
@@ -413,11 +772,47 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
413
772
  responseId: finalResult?.responseId,
414
773
  model: modelId,
415
774
  provider: provider.id,
416
- finishReason: truncatedByMaxTurns ? "max_turns" : finalResult?.finishReason,
775
+ // Q-04: canonical finish reason + raw vendor passthrough.
776
+ ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
417
777
  durationMs: Date.now() - startTime,
418
778
  raw: finalResult?.raw,
419
779
  turns,
420
780
  });
781
+ } catch (err) {
782
+ // Q-19: run-level onFailure rollback (default restores pre-run
783
+ // checkpoint; opt out with (config as any).onFailure === "keep").
784
+ // Aborts are excluded so partial history survives interruption.
785
+ const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
786
+ const isAbort =
787
+ (err as { name?: string } | null)?.name === "AbortError" ||
788
+ !!runOptions?.signal?.aborted;
789
+ if (onFailure !== "keep" && !isAbort) {
790
+ try { context.rollback(preRunCp, sessionIdForRollback); } catch {
791
+ // why: rollback is best-effort — the original error takes precedence.
792
+ }
793
+ }
794
+ // Q-21: ensure aborts surface as AbortError with partial attached.
795
+ if (isAbort && (err as { name?: string } | null)?.name !== "AbortError") {
796
+ throw makeAbortError(
797
+ { usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, turns: 0 },
798
+ err
799
+ );
800
+ }
801
+ // Q-37: every failure carries its partial progress for observability.
802
+ try {
803
+ (err as unknown as { partial?: unknown }).partial ??= {
804
+ usage: accumulatedUsage,
805
+ cost: accumulatedUsage.cost,
806
+ turns,
807
+ toolCalls: allToolCalls,
808
+ toolResults: allToolResults,
809
+ text: finalResult?.text,
810
+ };
811
+ } catch {
812
+ // why: partial attachment is observability-only and must never mask the error.
813
+ }
814
+ throw err;
815
+ }
421
816
  }
422
817
 
423
818
  /**
@@ -445,12 +840,40 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
445
840
  linked.signal.addEventListener("abort", () => {
446
841
  try { currentInner?.cancel(); } catch {}
447
842
  });
448
- const throwIfCancelled = () => {
449
- if (linked.signal.aborted || outerStream.isCancelled())
450
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
843
+ const throwIfCancelled = (partial?: { usage: TokenUsage; turns: number; text?: string }) => {
844
+ if (linked.signal.aborted || outerStream.isCancelled()) {
845
+ const fallback: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
846
+ throw makeAbortError(
847
+ partial ?? { usage: fallback, turns: 0 },
848
+ (linked.signal as unknown as { reason?: unknown })?.reason ?? new Error("Stream aborted")
849
+ );
850
+ }
451
851
  };
452
852
 
853
+ // Q-19/Q-22: per-run checkpoint + limits for the streaming loop.
854
+ const streamPreCp = config.context.checkpoint();
855
+ const streamSessionId = loopSessionId(config);
856
+ const streamFallbackMax = normalizeMaxTurns((config as { maxTurns?: number }).maxTurns);
857
+ const streamLimits = resolveLoopLimits(config, streamFallbackMax);
858
+ const streamStartTime = startTime;
859
+
453
860
  (async () => {
861
+ // Hoisted for catch-block partial reporting (Q-21/Q-37).
862
+ let accumulatedUsage: TokenUsage = {
863
+ inputTokens: 0,
864
+ outputTokens: 0,
865
+ totalTokens: 0,
866
+ cachedTokens: 0,
867
+ cacheReadTokens: 0,
868
+ cacheWriteTokens: 0,
869
+ thinkingTokens: 0,
870
+ };
871
+ let lastResponse: AgentResponse | null = null;
872
+ let turns = 0;
873
+ // Q-37: tool progress hoisted so failures carry toolCalls/toolResults.
874
+ const allToolCalls: ToolCallRecord[] = [];
875
+ const allToolResults: ToolResultRecord[] = [];
876
+ const allSubagents: SubAgentExecutionMetadata[] = [];
454
877
  try {
455
878
  await ensureModelCatalogFresh();
456
879
  // Re-validate after refresh: Agent.stream() validates before the catalog
@@ -484,11 +907,22 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
484
907
  runOptions,
485
908
  maxTurns: rawMaxTurns = Infinity,
486
909
  } = config;
487
- const maxTurns = normalizeMaxTurns(rawMaxTurns);
910
+ const maxTurns = streamLimits.maxTurns;
488
911
 
489
912
  const standardTools = toStandardToolDeclarations(tools);
913
+ // Structured outputs: normalize once for the whole stream run.
914
+ let streamOutputRaw: unknown;
915
+ let streamOutputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
916
+ try {
917
+ const resolved = resolveLoopOutput(config);
918
+ streamOutputRaw = resolved.raw;
919
+ streamOutputSpec = resolved.spec;
920
+ } catch (err) {
921
+ outerStream.fail(err instanceof Error ? err : new Error(String(err)));
922
+ return;
923
+ }
490
924
 
491
- let accumulatedUsage: TokenUsage = {
925
+ accumulatedUsage = {
492
926
  inputTokens: 0,
493
927
  outputTokens: 0,
494
928
  totalTokens: 0,
@@ -498,15 +932,16 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
498
932
  thinkingTokens: 0,
499
933
  };
500
934
 
501
- const allToolCalls: ToolCallRecord[] = [];
502
- const allToolResults: ToolResultRecord[] = [];
503
- const allSubagents: SubAgentExecutionMetadata[] = [];
504
- let lastResponse: AgentResponse | null = null;
505
- let turns = 0;
935
+ // Q-37: allToolCalls/allToolResults/allSubagents are hoisted above (fresh
936
+ // per stream run); just reset turn counters here.
937
+ lastResponse = null;
938
+ turns = 0;
506
939
  let previousToolCallFingerprints = new Set<string>();
507
940
 
508
941
  while (turns < maxTurns) {
509
- throwIfCancelled();
942
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
943
+ checkDeadline(streamStartTime, streamLimits.deadlineMs);
944
+ ensureToolPairing(context);
510
945
  turns++;
511
946
 
512
947
  const spec2 = getModelFromCatalog(provider.id, modelId);
@@ -517,7 +952,20 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
517
952
  cache: options?.cache,
518
953
  tools: standardTools.length > 0 ? standardTools : undefined,
519
954
  signal: linked.signal,
955
+ ...(streamOutputSpec ? { output: streamOutputSpec } : {}),
520
956
  };
957
+ try {
958
+ if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
959
+ (providerOptions as unknown as { timeoutMs?: number }).timeoutMs = streamLimits.requestTimeoutMs;
960
+ }
961
+ } catch {}
962
+ try {
963
+ const gStore =
964
+ (runOptions as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
965
+ (options as unknown as { googleStore?: unknown } | undefined)?.googleStore ??
966
+ (config as unknown as { googleStore?: unknown }).googleStore;
967
+ if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
968
+ } catch {}
521
969
 
522
970
  const innerStream = provider.stream(
523
971
  modelId,
@@ -530,16 +978,54 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
530
978
  );
531
979
  currentInner = innerStream as AssistantMessageEventStream;
532
980
 
533
- for await (const event of innerStream) {
534
- throwIfCancelled();
535
- if (event.type !== "done") {
536
- outerStream.push(event);
981
+ // Q-22: first-byte + idle timeouts around SSE (tolerate reasoning).
982
+ // Implemented as races on the inner iterator so a hung provider
983
+ // surfaces as TimeoutError instead of hanging the run forever.
984
+ {
985
+ const it = (innerStream as AsyncIterable<StreamEvent>)[Symbol.asyncIterator]();
986
+ let first = true;
987
+ let doneIter = false;
988
+ while (!doneIter) {
989
+ const waitMs = first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
990
+ let timer: ReturnType<typeof setTimeout> | undefined;
991
+ const timeoutP =
992
+ Number.isFinite(waitMs) && waitMs !== Infinity && waitMs > 0
993
+ ? new Promise<never>((_, reject) => {
994
+ timer = setTimeout(() => {
995
+ const e = new Error(
996
+ `[Agent Accelerator] Stream ${first ? "first-byte" : "idle"} timed out after ${waitMs}ms.`
997
+ );
998
+ e.name = "TimeoutError";
999
+ reject(e);
1000
+ }, waitMs);
1001
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
1002
+ })
1003
+ : null;
1004
+ try {
1005
+ const nextP = it.next();
1006
+ const res = timeoutP ? await Promise.race([nextP, timeoutP]) : await nextP;
1007
+ if (timer) clearTimeout(timer);
1008
+ if (res.done) { doneIter = true; break; }
1009
+ first = false;
1010
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
1011
+ const event = res.value as StreamEvent;
1012
+ if ((event as StreamEvent)?.type !== "done") {
1013
+ outerStream.push(event);
1014
+ }
1015
+ } catch (e) {
1016
+ if (timer) clearTimeout(timer);
1017
+ // Timeout: cancel the hung inner stream before surfacing.
1018
+ if ((e as Error)?.name === "TimeoutError") {
1019
+ try { currentInner?.cancel(); } catch {}
1020
+ }
1021
+ throw e;
1022
+ }
537
1023
  }
538
1024
  }
539
1025
 
540
1026
  const turnResponse = await innerStream.result();
541
1027
  currentInner = null;
542
- throwIfCancelled();
1028
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: (turnResponse as { text?: string })?.text });
543
1029
  lastResponse = turnResponse;
544
1030
  // Canonical session routing: lets adapters detect provider switches.
545
1031
  noteProviderTurn(
@@ -547,15 +1033,19 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
547
1033
  provider.id
548
1034
  );
549
1035
 
550
- // Safety fallback: if no tool calls and text is empty, rescue answer from thinking
1036
+ // Thinking stays on events/traces + onTurn (default exposed). Promoting
1037
+ // it to answer text is gated (Q-15, default OFF): without the opt-in
1038
+ // the turn keeps the provider's empty text and finishReason.
551
1039
  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;
1040
+ if (shouldPromoteThinkingToAnswer(config, runOptions)) {
1041
+ if (turnResponse.thinking.includes("</think>")) {
1042
+ const parts = turnResponse.thinking.split(/<\/(?:think|thought)>/i);
1043
+ (turnResponse as any).thinking = parts[0]!.replace(/<(?:think|thought)>/i, "").trim() || undefined;
1044
+ (turnResponse as any).text = parts.slice(1).join("").trim();
1045
+ } else {
1046
+ (turnResponse as any).text = turnResponse.thinking;
1047
+ (turnResponse as any).thinking = undefined;
1048
+ }
559
1049
  }
560
1050
  }
561
1051
 
@@ -568,13 +1058,41 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
568
1058
  turnResponse.thoughtSignature
569
1059
  );
570
1060
 
1061
+ const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
1062
+ ? drainSteerInbox(context, config.steerInbox)
1063
+ : [];
571
1064
  if (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0) {
572
1065
  emitTurn(runOptions, { turns, text: turnResponse.text, thinking: turnResponse.thinking });
1066
+ if (noToolSteered.length > 0) {
1067
+ for (const preview of noToolSteered) {
1068
+ try {
1069
+ outerStream.push({ type: "steer_injected", injectedPrompt: preview });
1070
+ } catch {
1071
+ // why: stream push never throws, but guard anyway to protect the loop.
1072
+ }
1073
+ }
1074
+ try { config.onSteerInjected?.(noToolSteered); } catch {
1075
+ // why: user callback — a throw must not fail the run.
1076
+ }
1077
+ }
573
1078
  emitProgress(config);
1079
+ if (noToolSteered.length > 0) continue;
574
1080
  break;
575
1081
  }
576
1082
 
1083
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
577
1084
  allToolCalls.push(...turnResponse.toolCalls);
1085
+ if (allToolCalls.length > streamLimits.maxToolCallsPerRun) {
1086
+ throw new Error(
1087
+ `[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${streamLimits.maxToolCallsPerRun}).`
1088
+ );
1089
+ }
1090
+ // Q-40: typed tool lifecycle for streaming consumers.
1091
+ try {
1092
+ outerStream.push({ type: "tool_start", toolCalls: turnResponse.toolCalls });
1093
+ } catch {
1094
+ // why: stream push never throws, but guard anyway to protect the loop.
1095
+ }
578
1096
  const guarded = await executeToolCallsWithRepeatGuard({
579
1097
  tools,
580
1098
  toolCalls: turnResponse.toolCalls,
@@ -592,10 +1110,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
592
1110
  partialText: ev.partialText,
593
1111
  partialThinking: ev.partialThinking,
594
1112
  });
595
- } catch {}
1113
+ } catch {
1114
+ // why: stream push never throws; keep subagent progress flowing.
1115
+ }
596
1116
  },
597
1117
  });
598
- throwIfCancelled();
599
1118
  const results = guarded.results;
600
1119
  previousToolCallFingerprints = guarded.fingerprints;
601
1120
 
@@ -626,9 +1145,17 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
626
1145
  toolResult: res,
627
1146
  });
628
1147
  }
1148
+ // Q-40: close the tool lifecycle opened by `tool_start` above.
1149
+ try {
1150
+ outerStream.push({ type: "tool_end", toolResults: sanitizedResults });
1151
+ } catch {
1152
+ // why: stream push never throws, but guard anyway to protect the loop.
1153
+ }
629
1154
 
1155
+ // Q-19: store tool results BEFORE the cancellation check.
630
1156
  allToolResults.push(...sanitizedResults);
631
1157
  context.addToolResults(sanitizedResults);
1158
+ throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
632
1159
  emitTurn(runOptions, {
633
1160
  turns,
634
1161
  text: turnResponse.text,
@@ -636,6 +1163,19 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
636
1163
  toolCalls: turnResponse.toolCalls,
637
1164
  toolResults: sanitizedResults,
638
1165
  });
1166
+ const toolSteered = drainSteerInbox(context, config.steerInbox);
1167
+ if (toolSteered.length > 0) {
1168
+ for (const preview of toolSteered) {
1169
+ try {
1170
+ outerStream.push({ type: "steer_injected", injectedPrompt: preview });
1171
+ } catch {
1172
+ // why: stream push never throws, but guard anyway to protect the loop.
1173
+ }
1174
+ }
1175
+ try { config.onSteerInjected?.(toolSteered); } catch {
1176
+ // why: user callback — a throw must not fail the run.
1177
+ }
1178
+ }
639
1179
  emitProgress(config);
640
1180
  }
641
1181
 
@@ -648,10 +1188,30 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
648
1188
  lastResponse!.toolCalls.map((c) => c.name)
649
1189
  );
650
1190
  }
651
- const truncatedFinishReason = truncatedByMaxTurns ? "max_turns" : lastResponse?.finishReason;
1191
+ const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1192
+
1193
+ let streamParsed: unknown | undefined;
1194
+ if (streamOutputRaw !== undefined && !truncatedByMaxTurns) {
1195
+ try {
1196
+ streamParsed = parseFinalStructuredOutput(lastResponse?.text || "", streamOutputRaw, streamOutputSpec);
1197
+ } catch (err) {
1198
+ const raw = err instanceof Error ? err : new Error(String(err));
1199
+ try { (raw as unknown as { partial?: unknown }).partial ??= {
1200
+ usage: accumulatedUsage,
1201
+ cost: accumulatedUsage.cost,
1202
+ turns,
1203
+ toolCalls: allToolCalls,
1204
+ toolResults: allToolResults,
1205
+ text: lastResponse?.text,
1206
+ }; } catch {}
1207
+ outerStream.fail(raw);
1208
+ return;
1209
+ }
1210
+ }
652
1211
 
653
1212
  const finalAgentResponse = new AgentResponse({
654
1213
  text: lastResponse?.text || "",
1214
+ ...(streamParsed !== undefined ? { parsed: streamParsed } : {}),
655
1215
  thinking: lastResponse?.thinking,
656
1216
  thoughtSignature: lastResponse?.thoughtSignature,
657
1217
  toolCalls: allToolCalls,
@@ -661,7 +1221,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
661
1221
  responseId: lastResponse?.responseId,
662
1222
  model: modelId,
663
1223
  provider: provider.id,
664
- finishReason: truncatedFinishReason,
1224
+ // Q-04: canonical finish reason + raw vendor passthrough.
1225
+ ...truncatedFinish,
665
1226
  durationMs: Date.now() - startTime,
666
1227
  raw: lastResponse?.raw as any,
667
1228
  turns,
@@ -671,7 +1232,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
671
1232
  type: "done",
672
1233
  delta: "",
673
1234
  usage: accumulatedUsage,
674
- finishReason: truncatedFinishReason,
1235
+ finishReason: truncatedFinish.finishReason ?? lastResponse?.finishReason,
1236
+ rawFinishReason: truncatedFinish.rawFinishReason,
675
1237
  responseId: lastResponse?.responseId,
676
1238
  });
677
1239
 
@@ -683,17 +1245,130 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
683
1245
  outerStream.isCancelled() ||
684
1246
  (raw as any)?.name === "AbortError" ||
685
1247
  /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
- );
1248
+ // Q-19: streaming onFailure rollback (same policy as runAgentLoop;
1249
+ // aborts excluded so partial history survives).
1250
+ try {
1251
+ const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
1252
+ if (onFailure !== "keep" && !isAbort) {
1253
+ try { config.context.rollback(streamPreCp, streamSessionId); } catch {
1254
+ // why: rollback is best-effort — the original error takes precedence.
1255
+ }
1256
+ }
1257
+ } catch {
1258
+ // why: rollback policy itself must never mask the original error.
1259
+ }
1260
+ // Q-37: every failure carries its partial progress for observability.
1261
+ const partial = {
1262
+ usage: accumulatedUsage,
1263
+ cost: accumulatedUsage.cost,
1264
+ turns,
1265
+ toolCalls: allToolCalls,
1266
+ toolResults: allToolResults,
1267
+ text: lastResponse?.text,
1268
+ };
1269
+ // Q-21: aborts always surface as AbortError with partial attached.
1270
+ if (isAbort) {
1271
+ const abortErr =
1272
+ raw.name === "AbortError" ? raw : makeAbortError(partial, raw);
1273
+ try { (abortErr as unknown as { partial?: unknown }).partial ??= partial; } catch {
1274
+ // why: partial attachment is observability-only and must never mask the error.
1275
+ }
1276
+ outerStream.fail(abortErr);
1277
+ } else {
1278
+ try { (raw as unknown as { partial?: unknown }).partial ??= partial; } catch {
1279
+ // why: partial attachment is observability-only and must never mask the error.
1280
+ }
1281
+ outerStream.fail(raw);
1282
+ }
691
1283
  } finally {
692
- try { currentInner?.cancel(); } catch {}
693
- try { userSignal?.removeEventListener("abort", forwardUserAbort); } catch {}
694
- try { removeOuterCancel(); } catch {}
1284
+ try { currentInner?.cancel(); } catch {
1285
+ // why: inner streams tolerate double-cancel; cleanup must not throw.
1286
+ }
1287
+ try { userSignal?.removeEventListener("abort", forwardUserAbort); } catch {
1288
+ // why: listener removal is best-effort cleanup.
1289
+ }
1290
+ try { removeOuterCancel(); } catch {
1291
+ // why: unsubscribe is best-effort cleanup.
1292
+ }
695
1293
  }
696
1294
  })();
697
1295
 
698
1296
  return outerStream;
699
1297
  }
1298
+
1299
+ // ---------------------------------------------------------------------------
1300
+ // Q-15: single thinking-exposure policy + Q-04 canonical finish helper.
1301
+ // (Appended here to avoid conflicts with other loop sections.)
1302
+ // ---------------------------------------------------------------------------
1303
+
1304
+ /**
1305
+ * Single expose-thinking policy (Q-15).
1306
+ * - Events/traces/onTurn always carry thinking separately (default true).
1307
+ * - Promoting thinking to answer *text* is a separate opt-in
1308
+ * (`thinkingAsAnswerFallback`, default false) — see
1309
+ * `shouldPromoteThinkingToAnswer`.
1310
+ *
1311
+ * Reads `exposeThinking` from the loop config, run options, or provider
1312
+ * options; any explicit `false` hides thinking from events/traces.
1313
+ */
1314
+ export function shouldExposeThinking(config?: unknown): boolean {
1315
+ try {
1316
+ const c = config as {
1317
+ exposeThinking?: unknown;
1318
+ runOptions?: { exposeThinking?: unknown };
1319
+ options?: { exposeThinking?: unknown };
1320
+ } | null | undefined;
1321
+ const v = c?.exposeThinking ?? c?.runOptions?.exposeThinking ?? c?.options?.exposeThinking;
1322
+ if (v === undefined) return true;
1323
+ return Boolean(v);
1324
+ } catch {
1325
+ // why: policy lookup is best-effort — default to exposing thinking.
1326
+ return true;
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * Whether an empty-text turn may promote `thinking` to answer `text` (Q-15).
1332
+ * Default false: the run returns the provider's empty text with its
1333
+ * finishReason instead of fabricating an answer from reasoning. Opt in via
1334
+ * `(runOptions as any).thinkingAsAnswerFallback === true` or
1335
+ * `(config as any).thinkingAsAnswerFallback === true`.
1336
+ */
1337
+ export function shouldPromoteThinkingToAnswer(config?: unknown, runOptions?: unknown): boolean {
1338
+ try {
1339
+ if ((runOptions as { thinkingAsAnswerFallback?: unknown } | null | undefined)?.thinkingAsAnswerFallback === true) {
1340
+ return true;
1341
+ }
1342
+ const c = config as {
1343
+ thinkingAsAnswerFallback?: unknown;
1344
+ runOptions?: { thinkingAsAnswerFallback?: unknown };
1345
+ } | null | undefined;
1346
+ if (c?.thinkingAsAnswerFallback === true) return true;
1347
+ if (c?.runOptions?.thinkingAsAnswerFallback === true) return true;
1348
+ return false;
1349
+ } catch {
1350
+ // why: policy lookup is best-effort — default to NOT promoting thinking.
1351
+ return false;
1352
+ }
1353
+ }
1354
+
1355
+ /**
1356
+ * Canonicalizes a provider finish reason for AgentResponse (Q-04).
1357
+ * When `truncated` (maxTurns exhausted with pending tools), forces
1358
+ * `"max_turns"` and preserves the provider's reason in `rawFinishReason`.
1359
+ * Otherwise delegates to `normalizeFinishReason` (unknown vendor strings map
1360
+ * to `"error"` with the original in `rawFinishReason`).
1361
+ */
1362
+ export function canonicalFinishReason(
1363
+ providerReason: unknown,
1364
+ truncated: boolean
1365
+ ): { finishReason?: FinishReason; rawFinishReason?: string } {
1366
+ if (truncated) {
1367
+ if (typeof providerReason === "string" && providerReason && providerReason !== "max_turns") {
1368
+ return { finishReason: "max_turns", rawFinishReason: providerReason };
1369
+ }
1370
+ return { finishReason: "max_turns" };
1371
+ }
1372
+ if (typeof providerReason !== "string" || providerReason === "") return {};
1373
+ return normalizeFinishReason(providerReason);
1374
+ }