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/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
//
|
|
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 (
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
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 (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
+
}
|