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