agent-accelerator 0.4.0 → 0.5.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 +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
package/src/agent/loop.ts
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
|
-
import type { Provider, ProviderRequestOptions, ModelSpec } from "../types/model.ts";
|
|
1
|
+
import type { Provider, ProviderRequestOptions, ModelSpec, ProviderRawData } from "../types/model.ts";
|
|
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
5
|
import type { ContentPart } from "../types/message.ts";
|
|
6
6
|
import { AgentResponse, normalizeFinishReason, type FinishReason, type SubAgentExecutionMetadata, type StreamEvent } from "../types/response.ts";
|
|
7
|
-
import { priceUsage, reportUsageAnomaly
|
|
7
|
+
import { priceUsage, reportUsageAnomaly } from "../session/store.ts";
|
|
8
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
8
9
|
export { telemetryBus };
|
|
9
10
|
import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
10
11
|
import { AgentContext } from "./context.ts";
|
|
11
12
|
import { toStandardToolDeclarations } from "../tools/tool.ts";
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
executeToolCalls,
|
|
15
|
+
findToolDefinition,
|
|
16
|
+
isRepeatableTool,
|
|
17
|
+
makeRepeatBlockedResult,
|
|
18
|
+
shouldBlockRepeat,
|
|
19
|
+
toolCallFingerprint,
|
|
20
|
+
traceToolEvent,
|
|
21
|
+
} from "../tools/executor.ts";
|
|
13
22
|
import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } from "../models/catalog.ts";
|
|
14
23
|
import { preprocessFilePartsForBypass } from "../utils/documents.ts";
|
|
15
|
-
import {
|
|
24
|
+
import { enforceFileAccess } from "../utils/safe-io.ts";
|
|
25
|
+
import { isSubagentResult, normalizeSubagentMetadata } from "../tools/subagent-result.ts";
|
|
26
|
+
import { BudgetExceededError } from "../types/errors.ts";
|
|
27
|
+
import { applyCaptureRaw, type CaptureRawMode } from "../utils/raw-capture.ts";
|
|
28
|
+
import { noteProviderTurn, normalizeStructuredOutput, parseStructuredOutput } from "../providers.ts";
|
|
16
29
|
|
|
17
30
|
/** One mid-session steer request buffered while a run is active. */
|
|
18
31
|
export interface SteerEntry {
|
|
19
32
|
prompt: string | ContentPart[];
|
|
20
33
|
options?: AgentRunOptions;
|
|
34
|
+
/**
|
|
35
|
+
* @internal Settles the promise handed to the `steer()` caller. If the run
|
|
36
|
+
* ends before the prompt is injected, the entry becomes a queued follow-up
|
|
37
|
+
* (`deferredToQueue`) and the caller's promise resolves with THAT run's
|
|
38
|
+
* response instead of the finished one.
|
|
39
|
+
*/
|
|
40
|
+
settle?: { resolve: (res: AgentResponse) => void; reject: (err: unknown) => void; deferredToQueue?: boolean };
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
export interface AgentLoopConfig {
|
|
@@ -31,6 +51,26 @@ export interface AgentLoopConfig {
|
|
|
31
51
|
maxTurns?: number;
|
|
32
52
|
/** Convert `file` parts client-side for pdf-incapable models (see Agent flag). */
|
|
33
53
|
bypassInputFileModality?: boolean;
|
|
54
|
+
/** Local-file policy: media paths must resolve inside `roots` (see `AgentConfig.fileAccess`). */
|
|
55
|
+
fileAccess?: { roots?: string[] };
|
|
56
|
+
/**
|
|
57
|
+
* What to do with the conversation when a run fails (not aborts):
|
|
58
|
+
* - `"auto"` (default): roll back to the pre-run checkpoint, UNLESS a tool
|
|
59
|
+
* already executed. Rolling back then would erase the only record of real
|
|
60
|
+
* side effects (emails sent, rows written) and a retry would repeat them.
|
|
61
|
+
* - `"rollback"`: always restore the checkpoint.
|
|
62
|
+
* - `"keep"`: never restore it.
|
|
63
|
+
*/
|
|
64
|
+
onFailure?: "auto" | "rollback" | "keep";
|
|
65
|
+
/** Wire-exchange capture policy for `AgentResponse.raw` (default `"redacted"`: no bodies). */
|
|
66
|
+
captureRaw?: CaptureRawMode;
|
|
67
|
+
/**
|
|
68
|
+
* Budget hook. Called with no argument at the start of every turn (time /
|
|
69
|
+
* count checks) and with a priced usage snapshot after each model turn and
|
|
70
|
+
* each finished sub-agent (charge + check). Throws `BudgetExceededError`
|
|
71
|
+
* to stop the run.
|
|
72
|
+
*/
|
|
73
|
+
enforceBudget?: (usage?: TokenUsage) => void;
|
|
34
74
|
/** Called after every turn for durable persistence checkpoints (never fails a turn). */
|
|
35
75
|
onProgress?: () => void;
|
|
36
76
|
/**
|
|
@@ -42,6 +82,12 @@ export interface AgentLoopConfig {
|
|
|
42
82
|
steerInbox?: SteerEntry[];
|
|
43
83
|
/** Called after steer entries are injected (streaming loops also push `steer_injected` events). */
|
|
44
84
|
onSteerInjected?: (injectedPrompts: string[]) => void;
|
|
85
|
+
/**
|
|
86
|
+
* Canonical structured-output request (raw `AgentConfig.output`).
|
|
87
|
+
* Normalized per turn for the provider wire; validated once at the end
|
|
88
|
+
* into `AgentResponse.parsed`. Agent-level only.
|
|
89
|
+
*/
|
|
90
|
+
output?: unknown;
|
|
45
91
|
}
|
|
46
92
|
|
|
47
93
|
/** Short preview for `steer_injected` / `queued` event payloads (bounded, display-only). */
|
|
@@ -101,10 +147,61 @@ function emitTurn(
|
|
|
101
147
|
} catch {}
|
|
102
148
|
}
|
|
103
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Per-turn `toolChoice`. `"none"` holds for the whole run; a forced choice
|
|
152
|
+
* (`"required"` or a pinned function) applies to the FIRST model turn only:
|
|
153
|
+
* forcing a tool call on every turn would keep the model calling tools until
|
|
154
|
+
* `maxTurns`, so the run could never produce a final answer.
|
|
155
|
+
*/
|
|
156
|
+
function toolChoiceForTurn(
|
|
157
|
+
choice: ProviderRequestOptions["toolChoice"],
|
|
158
|
+
turn: number
|
|
159
|
+
): ProviderRequestOptions["toolChoice"] {
|
|
160
|
+
if (choice === undefined || choice === "auto") return undefined;
|
|
161
|
+
if (choice === "none") return "none";
|
|
162
|
+
return turn === 1 ? choice : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether a failed run should restore its pre-run checkpoint (see `AgentLoopConfig.onFailure`). */
|
|
166
|
+
function shouldRollbackOnFailure(
|
|
167
|
+
policy: "auto" | "rollback" | "keep" | undefined,
|
|
168
|
+
isAbort: boolean,
|
|
169
|
+
toolsExecuted: boolean
|
|
170
|
+
): boolean {
|
|
171
|
+
if (isAbort) return false;
|
|
172
|
+
if (policy === "keep") return false;
|
|
173
|
+
if (policy === "rollback") return true;
|
|
174
|
+
return !toolsExecuted;
|
|
175
|
+
}
|
|
176
|
+
|
|
104
177
|
function emitProgress(config: { onProgress?: () => void }): void {
|
|
105
178
|
try { config.onProgress?.(); } catch {}
|
|
106
179
|
}
|
|
107
180
|
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Structured outputs (canonical output wiring for the loop).
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function resolveLoopOutput(config: AgentLoopConfig): { raw: unknown; spec: import("../types/model.ts").StructuredOutputSpec | undefined } {
|
|
186
|
+
const raw = (config as { output?: unknown }).output;
|
|
187
|
+
const fromOptions = (config.options as { output?: import("../types/model.ts").StructuredOutputSpec } | undefined)?.output;
|
|
188
|
+
if (raw !== undefined && raw !== null) {
|
|
189
|
+
// Throws ValidationError for invalid shapes — caught by run/stream catch
|
|
190
|
+
// with partial attached, same as other config validation.
|
|
191
|
+
const spec = normalizeStructuredOutput(raw);
|
|
192
|
+
return { raw, spec };
|
|
193
|
+
}
|
|
194
|
+
if (fromOptions) return { raw: fromOptions, spec: fromOptions };
|
|
195
|
+
return { raw: undefined, spec: undefined };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseFinalStructuredOutput(text: string, raw: unknown, spec: import("../types/model.ts").StructuredOutputSpec | undefined): unknown | undefined {
|
|
199
|
+
if (raw === undefined && spec === undefined) return undefined;
|
|
200
|
+
// Strict-fail when opted in: invalid JSON/schema throws ValidationError.
|
|
201
|
+
// Tool turns skip this (called only for final no-tool text).
|
|
202
|
+
return parseStructuredOutput(text, raw ?? spec);
|
|
203
|
+
}
|
|
204
|
+
|
|
108
205
|
/**
|
|
109
206
|
* Normalizes `maxTurns` to a safe positive integer (default Infinity).
|
|
110
207
|
* `0` means infinite (no limit), matching the `timeout: 0` convention.
|
|
@@ -144,67 +241,67 @@ function warnMaxTurnsHit(agentName: string | undefined, maxTurns: number, pendin
|
|
|
144
241
|
// ---------------------------------------------------------------------------
|
|
145
242
|
|
|
146
243
|
/**
|
|
147
|
-
* Repairs
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
244
|
+
* Repairs tool call/result pairing before each provider request (Q-19).
|
|
245
|
+
*
|
|
246
|
+
* Providers reject a `tool_result` without its `tool_call`, a `tool_call`
|
|
247
|
+
* without its result, AND (Chat-Completions style APIs) results that are not
|
|
248
|
+
* directly after the assistant message that issued the calls. A prior abort or
|
|
249
|
+
* crash can leave any of these in history, so the repair:
|
|
250
|
+
* - re-emits every result immediately after its assistant message, in call order;
|
|
251
|
+
* - synthesizes an error result for calls that never got one (e.g. `interrupt()`
|
|
252
|
+
* fired before the tools ran), at that same position;
|
|
253
|
+
* - drops results whose id the model never issued.
|
|
254
|
+
* Open-weight models that reuse one id for several calls keep one result per call.
|
|
151
255
|
*/
|
|
152
256
|
export function ensureToolPairing(context: AgentContext): void {
|
|
153
257
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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>) {
|
|
258
|
+
type ToolResultPart = Extract<ContentPart, { type: "tool_result" }>;
|
|
259
|
+
const messages = context.messages;
|
|
260
|
+
// Pass 1: queue every existing result part by id (document order).
|
|
261
|
+
const results = new Map<string, Array<{ part: ToolResultPart; message: (typeof messages)[number] }>>();
|
|
262
|
+
for (const m of messages) {
|
|
263
|
+
if (m?.role !== "tool" || !Array.isArray(m.content)) continue;
|
|
264
|
+
for (const p of m.content) {
|
|
176
265
|
if (p && p.type === "tool_result" && typeof p.id === "string" && p.id) {
|
|
177
|
-
|
|
266
|
+
const q = results.get(p.id) ?? [];
|
|
267
|
+
q.push({ part: p, message: m });
|
|
268
|
+
results.set(p.id, q);
|
|
178
269
|
}
|
|
179
270
|
}
|
|
180
271
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
272
|
+
// Pass 2: rebuild, placing each assistant's results right behind it.
|
|
273
|
+
const rebuilt: typeof messages = [];
|
|
274
|
+
for (const m of messages) {
|
|
275
|
+
if (m?.role === "tool") continue; // re-emitted next to their calls (orphans dropped)
|
|
276
|
+
rebuilt.push(m);
|
|
277
|
+
if (m?.role !== "assistant" || !Array.isArray(m.content)) continue;
|
|
278
|
+
for (const p of m.content) {
|
|
279
|
+
if (!p || p.type !== "tool_call" || typeof p.id !== "string" || !p.id) continue;
|
|
280
|
+
const hit = results.get(p.id)?.shift();
|
|
281
|
+
if (hit) {
|
|
282
|
+
// Reuse the original message object when it carried exactly this result.
|
|
283
|
+
rebuilt.push(hit.message.content.length === 1 ? hit.message : { role: "tool", name: hit.part.name, content: [hit.part] });
|
|
284
|
+
} else {
|
|
285
|
+
const name = typeof p.name === "string" ? p.name : "tool";
|
|
286
|
+
traceToolEvent("call_interrupted", "warn", { id: p.id, name }, {});
|
|
287
|
+
rebuilt.push({
|
|
288
|
+
role: "tool",
|
|
289
|
+
name,
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "tool_result",
|
|
293
|
+
id: p.id,
|
|
294
|
+
name,
|
|
295
|
+
result: `Error: tool_call '${name}' (${p.id}) has no result because the run was interrupted before it completed (recovered by ensureToolPairing). It may have partially executed: check the tool trace before repeating side-effecting work.`,
|
|
296
|
+
isError: true,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
}
|
|
191
301
|
}
|
|
192
302
|
}
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
303
|
+
const unchanged = rebuilt.length === messages.length && rebuilt.every((m, i) => m === messages[i]);
|
|
304
|
+
if (!unchanged) context.messages = rebuilt;
|
|
208
305
|
} catch {}
|
|
209
306
|
}
|
|
210
307
|
|
|
@@ -216,13 +313,14 @@ export interface LoopLimits {
|
|
|
216
313
|
maxTurns?: number;
|
|
217
314
|
deadlineMs?: number;
|
|
218
315
|
maxToolCallsPerRun?: number;
|
|
316
|
+
maxTotalTokens?: number;
|
|
219
317
|
requestTimeoutMs: number;
|
|
220
318
|
firstByteTimeoutMs: number;
|
|
221
319
|
streamIdleTimeoutMs: number;
|
|
222
320
|
}
|
|
223
321
|
|
|
224
322
|
/** 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 } {
|
|
323
|
+
function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): LoopLimits & { maxTurns: number; deadlineMs: number; maxToolCallsPerRun: number; maxTotalTokens: number } {
|
|
226
324
|
const raw = ((config as unknown as { limits?: Record<string, unknown> }).limits ?? {}) as Record<string, unknown>;
|
|
227
325
|
const num = (v: unknown): number | undefined =>
|
|
228
326
|
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -234,10 +332,11 @@ function resolveLoopLimits(config: AgentLoopConfig, fallbackMaxTurns: number): L
|
|
|
234
332
|
}
|
|
235
333
|
const deadlineMs = num(raw.deadlineMs) ?? Infinity;
|
|
236
334
|
const maxToolCallsPerRun = num(raw.maxToolCallsPerRun) ?? Infinity;
|
|
335
|
+
const maxTotalTokens = num(raw.maxTotalTokens) ?? Infinity;
|
|
237
336
|
const requestTimeoutMs = num(raw.requestTimeoutMs) ?? 300_000;
|
|
238
337
|
const firstByteTimeoutMs = num(raw.firstByteTimeoutMs) ?? 120_000;
|
|
239
338
|
const streamIdleTimeoutMs = num(raw.streamIdleTimeoutMs) ?? 300_000;
|
|
240
|
-
return { maxTurns, deadlineMs, maxToolCallsPerRun, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
|
|
339
|
+
return { maxTurns, deadlineMs, maxToolCallsPerRun, maxTotalTokens, requestTimeoutMs, firstByteTimeoutMs, streamIdleTimeoutMs };
|
|
241
340
|
}
|
|
242
341
|
|
|
243
342
|
function loopSessionId(config: AgentLoopConfig): string | undefined {
|
|
@@ -273,6 +372,15 @@ function throwIfAborted(
|
|
|
273
372
|
}
|
|
274
373
|
}
|
|
275
374
|
|
|
375
|
+
function checkTokenLimit(usage: TokenUsage, maxTotalTokens: number): void {
|
|
376
|
+
if (Number.isFinite(maxTotalTokens) && usage.totalTokens > maxTotalTokens) {
|
|
377
|
+
throw new BudgetExceededError(
|
|
378
|
+
`Token limit exceeded: ${usage.totalTokens} > limits.maxTotalTokens ${maxTotalTokens}.`,
|
|
379
|
+
{ context: { partial: { usage, turns: 0 } } }
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
276
384
|
function checkDeadline(startTime: number, deadlineMs: number): void {
|
|
277
385
|
if (Number.isFinite(deadlineMs) && deadlineMs !== Infinity && Date.now() - startTime > deadlineMs) {
|
|
278
386
|
throw new Error(`[Agent Accelerator] Run deadline exceeded after ${deadlineMs}ms.`);
|
|
@@ -378,46 +486,38 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
|
|
|
378
486
|
}
|
|
379
487
|
|
|
380
488
|
function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord; metas: SubAgentExecutionMetadata[] } {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
489
|
+
// Only SDK-branded delegation envelopes are trusted. A plain object that
|
|
490
|
+
// merely carries a `_subagentMetadata` key (e.g. scraped JSON) is ordinary
|
|
491
|
+
// tool output: it must not replace what the model sees or touch billing.
|
|
492
|
+
if (isSubagentResult(r.result)) {
|
|
493
|
+
const xml = r.result.xml || r.result.toString?.() || String(r.result);
|
|
384
494
|
return {
|
|
385
495
|
sanitized: { ...r, result: xml },
|
|
386
|
-
metas:
|
|
496
|
+
metas: normalizeSubagentMetadata(r.result._subagentMetadata),
|
|
387
497
|
};
|
|
388
498
|
}
|
|
389
499
|
return { sanitized: r, metas: [] };
|
|
390
500
|
}
|
|
391
501
|
|
|
392
|
-
function toolCallFingerprint(call: ToolCallRecord): string {
|
|
393
|
-
const stableSerialize = (value: any, seen = new Set<any>()): string => {
|
|
394
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
395
|
-
if (seen.has(value)) return "[Circular]";
|
|
396
|
-
seen.add(value);
|
|
397
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableSerialize(item, seen)).join(",")}]`;
|
|
398
|
-
const output = `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key], seen)}`).join(",")}}`;
|
|
399
|
-
seen.delete(value);
|
|
400
|
-
return output;
|
|
401
|
-
};
|
|
402
|
-
const args = stableSerialize(call.arguments ?? {});
|
|
403
|
-
return `${call.name}\u0000${args}`;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
502
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
503
|
+
* Stops a model from hammering the same call. Per fingerprint (name + stable
|
|
504
|
+
* args) the guard counts CONSECUTIVE turns: the first two identical calls run,
|
|
505
|
+
* the third in a row is answered with a synthetic `repeat_blocked` error so
|
|
506
|
+
* the model reuses the earlier result or changes approach. Tools declared
|
|
507
|
+
* `repeatable` (or `idempotent`, e.g. status polling) are never blocked.
|
|
508
|
+
* A fingerprint that skips a turn starts over.
|
|
410
509
|
*/
|
|
411
510
|
async function executeToolCallsWithRepeatGuard(options: {
|
|
412
511
|
tools: Record<string, ToolDefinition>;
|
|
413
512
|
toolCalls: ToolCallRecord[];
|
|
414
513
|
previousFingerprints: Set<string>;
|
|
514
|
+
repeatCounts: Map<string, number>;
|
|
415
515
|
agentName?: string;
|
|
416
516
|
signal?: AbortSignal;
|
|
417
517
|
sessionId?: string;
|
|
418
518
|
onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
|
|
419
519
|
}): Promise<{ results: ToolResultRecord[]; fingerprints: Set<string> }> {
|
|
420
|
-
const { toolCalls, previousFingerprints } = options;
|
|
520
|
+
const { toolCalls, previousFingerprints, repeatCounts } = options;
|
|
421
521
|
const currentFingerprints = new Set<string>();
|
|
422
522
|
const blocked = new Map<string, ToolResultRecord>();
|
|
423
523
|
const executable: ToolCallRecord[] = [];
|
|
@@ -425,20 +525,21 @@ async function executeToolCallsWithRepeatGuard(options: {
|
|
|
425
525
|
for (const call of toolCalls) {
|
|
426
526
|
const fingerprint = toolCallFingerprint(call);
|
|
427
527
|
currentFingerprints.add(fingerprint);
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
});
|
|
528
|
+
const def = findToolDefinition(options.tools, call.name);
|
|
529
|
+
if (def && isRepeatableTool(def)) {
|
|
530
|
+
executable.push(call);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (!previousFingerprints.has(fingerprint)) repeatCounts.delete(fingerprint); // streak broken
|
|
534
|
+
if (shouldBlockRepeat(previousFingerprints, call, repeatCounts)) {
|
|
535
|
+
traceToolEvent("call_blocked", "warn", call, { agentName: options.agentName, sessionId: options.sessionId }, { reason: "repeat_blocked" });
|
|
536
|
+
blocked.set(call.id, makeRepeatBlockedResult(call));
|
|
438
537
|
} else {
|
|
439
538
|
executable.push(call);
|
|
440
539
|
}
|
|
441
540
|
}
|
|
541
|
+
// Streaks only survive while the fingerprint keeps appearing every turn.
|
|
542
|
+
for (const fp of [...repeatCounts.keys()]) if (!currentFingerprints.has(fp)) repeatCounts.delete(fp);
|
|
442
543
|
|
|
443
544
|
const executed = executable.length > 0
|
|
444
545
|
? await executeToolCalls({
|
|
@@ -493,6 +594,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
493
594
|
const fallbackMaxTurns = normalizeMaxTurns(rawMaxTurns);
|
|
494
595
|
const limits = resolveLoopLimits(config, fallbackMaxTurns);
|
|
495
596
|
const maxTurns = limits.maxTurns;
|
|
597
|
+
// Structured outputs: normalize once (throws ValidationError for bad shapes).
|
|
598
|
+
const loopOutput = resolveLoopOutput(config);
|
|
496
599
|
// Q-19: pre-run checkpoint for onFailure rollback.
|
|
497
600
|
const preRunCp = context.checkpoint();
|
|
498
601
|
const sessionIdForRollback = loopSessionId(config);
|
|
@@ -514,7 +617,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
514
617
|
const allSubagents: SubAgentExecutionMetadata[] = [];
|
|
515
618
|
let finalResult: any = null;
|
|
516
619
|
let turns = 0;
|
|
620
|
+
let toolsExecuted = false;
|
|
517
621
|
let previousToolCallFingerprints = new Set<string>();
|
|
622
|
+
const repeatCounts = new Map<string, number>();
|
|
518
623
|
|
|
519
624
|
try {
|
|
520
625
|
if (config.bypassInputFileModality) {
|
|
@@ -522,6 +627,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
522
627
|
providerId: provider.id,
|
|
523
628
|
modelId,
|
|
524
629
|
signal: runOptions?.signal,
|
|
630
|
+
roots: config.fileAccess?.roots,
|
|
525
631
|
});
|
|
526
632
|
}
|
|
527
633
|
|
|
@@ -531,8 +637,10 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
531
637
|
// Q-21: fail fast on abort (throws AbortError with partial).
|
|
532
638
|
throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
|
|
533
639
|
checkDeadline(startTime, limits.deadlineMs);
|
|
640
|
+
config.enforceBudget?.();
|
|
534
641
|
// Q-19: repair orphan tool pairs before every request.
|
|
535
642
|
ensureToolPairing(context);
|
|
643
|
+
if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
|
|
536
644
|
turns++;
|
|
537
645
|
|
|
538
646
|
const spec = getModelFromCatalog(provider.id, modelId);
|
|
@@ -544,6 +652,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
544
652
|
cache: options?.cache,
|
|
545
653
|
tools: standardTools.length > 0 ? standardTools : undefined,
|
|
546
654
|
signal: runOptions?.signal,
|
|
655
|
+
toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
|
|
656
|
+
...(loopOutput.spec ? { output: loopOutput.spec } : {}),
|
|
547
657
|
};
|
|
548
658
|
// Q-22: per-request timeout pass-through (providers honor timeoutMs).
|
|
549
659
|
try {
|
|
@@ -560,6 +670,15 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
560
670
|
(config as unknown as { googleStore?: unknown }).googleStore;
|
|
561
671
|
if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
|
|
562
672
|
} catch {}
|
|
673
|
+
// Google interaction storage tier for the chain-TTL gate
|
|
674
|
+
// (`free`: 23h local gate; `paid`: 54d). Defaults to `free` in adapter.
|
|
675
|
+
try {
|
|
676
|
+
const gTier =
|
|
677
|
+
(runOptions as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
678
|
+
(options as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
679
|
+
(config as unknown as { googleTier?: unknown }).googleTier;
|
|
680
|
+
if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
|
|
681
|
+
} catch {}
|
|
563
682
|
|
|
564
683
|
const genResult = await generateWithTimeout(
|
|
565
684
|
() => provider.generate(
|
|
@@ -600,6 +719,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
600
719
|
}
|
|
601
720
|
|
|
602
721
|
accumulateUsage(accumulatedUsage, genResult.usage, spec);
|
|
722
|
+
config.enforceBudget?.({ ...genResult.usage, cost: computeCostFromPricing(genResult.usage, spec) });
|
|
723
|
+
checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
|
|
603
724
|
|
|
604
725
|
// Record assistant turn in context
|
|
605
726
|
context.addAssistantMessage(
|
|
@@ -609,6 +730,26 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
609
730
|
genResult.thoughtSignature
|
|
610
731
|
);
|
|
611
732
|
|
|
733
|
+
// Thinking-trace telemetry (observability-only, full text, no limit):
|
|
734
|
+
// one bus event per thinking turn carrying the text + thought signature.
|
|
735
|
+
// Never touches provider payloads, history, or persistence formats.
|
|
736
|
+
if (genResult.thinking) {
|
|
737
|
+
try {
|
|
738
|
+
telemetryBus.emit({
|
|
739
|
+
code: "turn.thinking",
|
|
740
|
+
message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
|
|
741
|
+
data: {
|
|
742
|
+
turns,
|
|
743
|
+
model: modelId,
|
|
744
|
+
provider: provider.id,
|
|
745
|
+
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
746
|
+
thinking: genResult.thinking,
|
|
747
|
+
...(genResult.thoughtSignature ? { thoughtSignature: genResult.thoughtSignature } : {}),
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
|
|
612
753
|
// Mid-session steer: if new queries arrived during this provider turn,
|
|
613
754
|
// inject them now. Tool turns still execute first (see below) so the
|
|
614
755
|
// current action finishes before redirection.
|
|
@@ -627,6 +768,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
627
768
|
providerId: provider.id,
|
|
628
769
|
modelId,
|
|
629
770
|
signal: runOptions?.signal,
|
|
771
|
+
roots: config.fileAccess?.roots,
|
|
630
772
|
});
|
|
631
773
|
}
|
|
632
774
|
}
|
|
@@ -645,10 +787,12 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
645
787
|
`[Agent Accelerator] maxToolCallsPerRun exceeded (${allToolCalls.length} > ${limits.maxToolCallsPerRun}).`
|
|
646
788
|
);
|
|
647
789
|
}
|
|
790
|
+
toolsExecuted = true;
|
|
648
791
|
const guarded = await executeToolCallsWithRepeatGuard({
|
|
649
792
|
tools,
|
|
650
793
|
toolCalls: genResult.toolCalls,
|
|
651
794
|
previousFingerprints: previousToolCallFingerprints,
|
|
795
|
+
repeatCounts,
|
|
652
796
|
agentName,
|
|
653
797
|
signal: runOptions?.signal,
|
|
654
798
|
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
@@ -664,11 +808,13 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
664
808
|
if (metas.length > 0) {
|
|
665
809
|
allSubagents.push(...metas);
|
|
666
810
|
for (const s of metas) {
|
|
811
|
+
if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
|
|
667
812
|
const subagentSpec = getModelFromCatalog(s.provider, s.model);
|
|
668
813
|
if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
|
|
669
814
|
s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
|
|
670
815
|
}
|
|
671
816
|
accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
|
|
817
|
+
config.enforceBudget?.(s.usage);
|
|
672
818
|
}
|
|
673
819
|
}
|
|
674
820
|
sanitizedResults.push(sanitized);
|
|
@@ -676,6 +822,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
676
822
|
|
|
677
823
|
allToolResults.push(...sanitizedResults);
|
|
678
824
|
context.addToolResults(sanitizedResults);
|
|
825
|
+
checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
|
|
679
826
|
// Q-19: results are stored BEFORE the cancellation check so an abort
|
|
680
827
|
// never drops executed tool work from history.
|
|
681
828
|
throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
|
|
@@ -697,6 +844,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
697
844
|
providerId: provider.id,
|
|
698
845
|
modelId,
|
|
699
846
|
signal: runOptions?.signal,
|
|
847
|
+
roots: config.fileAccess?.roots,
|
|
700
848
|
});
|
|
701
849
|
}
|
|
702
850
|
}
|
|
@@ -720,8 +868,16 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
720
868
|
);
|
|
721
869
|
}
|
|
722
870
|
|
|
871
|
+
// Structured outputs: validate final text only (tool turns skip this).
|
|
872
|
+
// Strict-fail throws ValidationError with partial attached via catch below.
|
|
873
|
+
let parsedOutput: unknown | undefined;
|
|
874
|
+
if (loopOutput.raw !== undefined && !truncatedByMaxTurns) {
|
|
875
|
+
parsedOutput = parseFinalStructuredOutput(finalResult?.text || "", loopOutput.raw, loopOutput.spec);
|
|
876
|
+
}
|
|
877
|
+
|
|
723
878
|
return new AgentResponse({
|
|
724
879
|
text: finalResult?.text || "",
|
|
880
|
+
...(parsedOutput !== undefined ? { parsed: parsedOutput } : {}),
|
|
725
881
|
thinking: finalResult?.thinking,
|
|
726
882
|
thoughtSignature: finalResult?.thoughtSignature,
|
|
727
883
|
toolCalls: allToolCalls,
|
|
@@ -734,18 +890,17 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
|
|
|
734
890
|
// Q-04: canonical finish reason + raw vendor passthrough.
|
|
735
891
|
...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
|
|
736
892
|
durationMs: Date.now() - startTime,
|
|
737
|
-
raw: finalResult?.raw,
|
|
893
|
+
raw: applyCaptureRaw(finalResult?.raw, config.captureRaw) as ProviderRawData,
|
|
738
894
|
turns,
|
|
739
895
|
});
|
|
740
896
|
} catch (err) {
|
|
741
897
|
// Q-19: run-level onFailure rollback (default restores pre-run
|
|
742
898
|
// checkpoint; opt out with (config as any).onFailure === "keep").
|
|
743
899
|
// Aborts are excluded so partial history survives interruption.
|
|
744
|
-
const onFailure = (config as unknown as { onFailure?: unknown }).onFailure;
|
|
745
900
|
const isAbort =
|
|
746
901
|
(err as { name?: string } | null)?.name === "AbortError" ||
|
|
747
902
|
!!runOptions?.signal?.aborted;
|
|
748
|
-
if (onFailure
|
|
903
|
+
if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
|
|
749
904
|
try { context.rollback(preRunCp, sessionIdForRollback); } catch {
|
|
750
905
|
// why: rollback is best-effort — the original error takes precedence.
|
|
751
906
|
}
|
|
@@ -833,6 +988,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
833
988
|
const allToolCalls: ToolCallRecord[] = [];
|
|
834
989
|
const allToolResults: ToolResultRecord[] = [];
|
|
835
990
|
const allSubagents: SubAgentExecutionMetadata[] = [];
|
|
991
|
+
let toolsExecuted = false;
|
|
836
992
|
try {
|
|
837
993
|
await ensureModelCatalogFresh();
|
|
838
994
|
// Re-validate after refresh: Agent.stream() validates before the catalog
|
|
@@ -854,6 +1010,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
854
1010
|
providerId: config.provider.id,
|
|
855
1011
|
modelId: config.modelId,
|
|
856
1012
|
signal: config.runOptions?.signal,
|
|
1013
|
+
roots: config.fileAccess?.roots,
|
|
857
1014
|
});
|
|
858
1015
|
}
|
|
859
1016
|
const {
|
|
@@ -869,6 +1026,17 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
869
1026
|
const maxTurns = streamLimits.maxTurns;
|
|
870
1027
|
|
|
871
1028
|
const standardTools = toStandardToolDeclarations(tools);
|
|
1029
|
+
// Structured outputs: normalize once for the whole stream run.
|
|
1030
|
+
let streamOutputRaw: unknown;
|
|
1031
|
+
let streamOutputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
1032
|
+
try {
|
|
1033
|
+
const resolved = resolveLoopOutput(config);
|
|
1034
|
+
streamOutputRaw = resolved.raw;
|
|
1035
|
+
streamOutputSpec = resolved.spec;
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
outerStream.fail(err instanceof Error ? err : new Error(String(err)));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
872
1040
|
|
|
873
1041
|
accumulatedUsage = {
|
|
874
1042
|
inputTokens: 0,
|
|
@@ -885,11 +1053,14 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
885
1053
|
lastResponse = null;
|
|
886
1054
|
turns = 0;
|
|
887
1055
|
let previousToolCallFingerprints = new Set<string>();
|
|
1056
|
+
const repeatCounts = new Map<string, number>();
|
|
888
1057
|
|
|
889
1058
|
while (turns < maxTurns) {
|
|
890
1059
|
throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
|
|
891
1060
|
checkDeadline(streamStartTime, streamLimits.deadlineMs);
|
|
1061
|
+
config.enforceBudget?.();
|
|
892
1062
|
ensureToolPairing(context);
|
|
1063
|
+
if (config.fileAccess?.roots) await enforceFileAccess(context.messages, config.fileAccess.roots);
|
|
893
1064
|
turns++;
|
|
894
1065
|
|
|
895
1066
|
const spec2 = getModelFromCatalog(provider.id, modelId);
|
|
@@ -900,6 +1071,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
900
1071
|
cache: options?.cache,
|
|
901
1072
|
tools: standardTools.length > 0 ? standardTools : undefined,
|
|
902
1073
|
signal: linked.signal,
|
|
1074
|
+
toolChoice: toolChoiceForTurn(options?.toolChoice, turns),
|
|
1075
|
+
...(streamOutputSpec ? { output: streamOutputSpec } : {}),
|
|
903
1076
|
};
|
|
904
1077
|
try {
|
|
905
1078
|
if (Number.isFinite(streamLimits.requestTimeoutMs) && streamLimits.requestTimeoutMs !== Infinity) {
|
|
@@ -913,6 +1086,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
913
1086
|
(config as unknown as { googleStore?: unknown }).googleStore;
|
|
914
1087
|
if (gStore === false) (providerOptions as unknown as { store?: boolean }).store = false;
|
|
915
1088
|
} catch {}
|
|
1089
|
+
try {
|
|
1090
|
+
const gTier =
|
|
1091
|
+
(runOptions as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
1092
|
+
(options as unknown as { googleTier?: unknown } | undefined)?.googleTier ??
|
|
1093
|
+
(config as unknown as { googleTier?: unknown }).googleTier;
|
|
1094
|
+
if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
|
|
1095
|
+
} catch {}
|
|
916
1096
|
|
|
917
1097
|
const innerStream = provider.stream(
|
|
918
1098
|
modelId,
|
|
@@ -957,7 +1137,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
957
1137
|
throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
|
|
958
1138
|
const event = res.value as StreamEvent;
|
|
959
1139
|
if ((event as StreamEvent)?.type !== "done") {
|
|
960
|
-
|
|
1140
|
+
// The provider's `start` event carries the request audit record: apply the capture policy.
|
|
1141
|
+
outerStream.push(
|
|
1142
|
+
event.type === "start" && event.raw
|
|
1143
|
+
? { ...event, raw: applyCaptureRaw(event.raw, config.captureRaw) }
|
|
1144
|
+
: event
|
|
1145
|
+
);
|
|
961
1146
|
}
|
|
962
1147
|
} catch (e) {
|
|
963
1148
|
if (timer) clearTimeout(timer);
|
|
@@ -997,6 +1182,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
997
1182
|
}
|
|
998
1183
|
|
|
999
1184
|
accumulateUsage(accumulatedUsage, turnResponse.usage, spec2);
|
|
1185
|
+
config.enforceBudget?.({ ...turnResponse.usage, cost: computeCostFromPricing(turnResponse.usage, spec2) });
|
|
1186
|
+
checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
|
|
1000
1187
|
|
|
1001
1188
|
context.addAssistantMessage(
|
|
1002
1189
|
turnResponse.text,
|
|
@@ -1005,6 +1192,25 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1005
1192
|
turnResponse.thoughtSignature
|
|
1006
1193
|
);
|
|
1007
1194
|
|
|
1195
|
+
// Thinking-trace telemetry (observability-only, full text, no limit):
|
|
1196
|
+
// mirrors the non-streaming loop above; bus-only, never wire/history.
|
|
1197
|
+
if (turnResponse.thinking) {
|
|
1198
|
+
try {
|
|
1199
|
+
telemetryBus.emit({
|
|
1200
|
+
code: "turn.thinking",
|
|
1201
|
+
message: `[Agent Accelerator] turn ${turns} thinking (${provider.id}/${modelId})`,
|
|
1202
|
+
data: {
|
|
1203
|
+
turns,
|
|
1204
|
+
model: modelId,
|
|
1205
|
+
provider: provider.id,
|
|
1206
|
+
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
1207
|
+
thinking: turnResponse.thinking,
|
|
1208
|
+
...(turnResponse.thoughtSignature ? { thoughtSignature: turnResponse.thoughtSignature } : {}),
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
} catch {}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1008
1214
|
const noToolSteered = (!turnResponse.toolCalls || turnResponse.toolCalls.length === 0)
|
|
1009
1215
|
? drainSteerInbox(context, config.steerInbox)
|
|
1010
1216
|
: [];
|
|
@@ -1040,10 +1246,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1040
1246
|
} catch {
|
|
1041
1247
|
// why: stream push never throws, but guard anyway to protect the loop.
|
|
1042
1248
|
}
|
|
1249
|
+
toolsExecuted = true;
|
|
1043
1250
|
const guarded = await executeToolCallsWithRepeatGuard({
|
|
1044
1251
|
tools,
|
|
1045
1252
|
toolCalls: turnResponse.toolCalls,
|
|
1046
1253
|
previousFingerprints: previousToolCallFingerprints,
|
|
1254
|
+
repeatCounts,
|
|
1047
1255
|
agentName,
|
|
1048
1256
|
signal: linked.signal,
|
|
1049
1257
|
sessionId: runOptions?.sessionId || options?.sessionId || options?.cache?.sessionId,
|
|
@@ -1072,11 +1280,13 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1072
1280
|
if (metas.length > 0) {
|
|
1073
1281
|
allSubagents.push(...metas);
|
|
1074
1282
|
for (const s of metas) {
|
|
1283
|
+
if (s.raw) s.raw = applyCaptureRaw(s.raw, config.captureRaw);
|
|
1075
1284
|
const subagentSpec = getModelFromCatalog(s.provider, s.model);
|
|
1076
1285
|
if (subagentSpec && (!s.usage?.cost || !s.usage.cost.totalCost)) {
|
|
1077
1286
|
s.usage.cost = computeCostFromPricing(s.usage, subagentSpec);
|
|
1078
1287
|
}
|
|
1079
1288
|
accumulateUsage(accumulatedUsage, s.usage, subagentSpec);
|
|
1289
|
+
config.enforceBudget?.(s.usage);
|
|
1080
1290
|
outerStream.push({
|
|
1081
1291
|
type: "subagent_complete",
|
|
1082
1292
|
subagent: s,
|
|
@@ -1137,8 +1347,28 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1137
1347
|
}
|
|
1138
1348
|
const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
|
|
1139
1349
|
|
|
1350
|
+
let streamParsed: unknown | undefined;
|
|
1351
|
+
if (streamOutputRaw !== undefined && !truncatedByMaxTurns) {
|
|
1352
|
+
try {
|
|
1353
|
+
streamParsed = parseFinalStructuredOutput(lastResponse?.text || "", streamOutputRaw, streamOutputSpec);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
const raw = err instanceof Error ? err : new Error(String(err));
|
|
1356
|
+
try { (raw as unknown as { partial?: unknown }).partial ??= {
|
|
1357
|
+
usage: accumulatedUsage,
|
|
1358
|
+
cost: accumulatedUsage.cost,
|
|
1359
|
+
turns,
|
|
1360
|
+
toolCalls: allToolCalls,
|
|
1361
|
+
toolResults: allToolResults,
|
|
1362
|
+
text: lastResponse?.text,
|
|
1363
|
+
}; } catch {}
|
|
1364
|
+
outerStream.fail(raw);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1140
1369
|
const finalAgentResponse = new AgentResponse({
|
|
1141
1370
|
text: lastResponse?.text || "",
|
|
1371
|
+
...(streamParsed !== undefined ? { parsed: streamParsed } : {}),
|
|
1142
1372
|
thinking: lastResponse?.thinking,
|
|
1143
1373
|
thoughtSignature: lastResponse?.thoughtSignature,
|
|
1144
1374
|
toolCalls: allToolCalls,
|
|
@@ -1151,7 +1381,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1151
1381
|
// Q-04: canonical finish reason + raw vendor passthrough.
|
|
1152
1382
|
...truncatedFinish,
|
|
1153
1383
|
durationMs: Date.now() - startTime,
|
|
1154
|
-
raw: lastResponse?.raw as
|
|
1384
|
+
raw: applyCaptureRaw(lastResponse?.raw, config.captureRaw) as ProviderRawData,
|
|
1155
1385
|
turns,
|
|
1156
1386
|
});
|
|
1157
1387
|
|
|
@@ -1170,13 +1400,11 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
|
|
|
1170
1400
|
const isAbort =
|
|
1171
1401
|
linked.signal.aborted ||
|
|
1172
1402
|
outerStream.isCancelled() ||
|
|
1173
|
-
(raw as any)?.name === "AbortError"
|
|
1174
|
-
/abort|cancell?ed/i.test(String((raw as any)?.message ?? raw));
|
|
1403
|
+
(raw as any)?.name === "AbortError";
|
|
1175
1404
|
// Q-19: streaming onFailure rollback (same policy as runAgentLoop;
|
|
1176
1405
|
// aborts excluded so partial history survives).
|
|
1177
1406
|
try {
|
|
1178
|
-
|
|
1179
|
-
if (onFailure !== "keep" && !isAbort) {
|
|
1407
|
+
if (shouldRollbackOnFailure(config.onFailure, isAbort, toolsExecuted)) {
|
|
1180
1408
|
try { config.context.rollback(streamPreCp, streamSessionId); } catch {
|
|
1181
1409
|
// why: rollback is best-effort — the original error takes precedence.
|
|
1182
1410
|
}
|