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