agent-accelerator 0.4.5 → 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 +401 -120
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +253 -98
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +39 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +21 -2
- package/src/providers/google.ts +282 -74
- package/src/providers/openai-compat.ts +61 -41
- package/src/providers/openai.ts +46 -31
- package/src/providers/openrouter.ts +42 -31
- package/src/providers/shared.ts +110 -16
- package/src/providers.ts +119 -6
- 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 +211 -104
- 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 +57 -20
- package/src/types/model.ts +18 -0
- package/src/types/response.ts +2 -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/errors.ts +11 -0
- 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/providers/google.ts
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* - Stateful chaining via `previous_interaction_id` keyed by session; falls
|
|
17
17
|
* back to stateless full-history (`store: false`) when the session mixed
|
|
18
18
|
* providers, keeping the canonical conversation provider-agnostic.
|
|
19
|
-
* - Unlike the legacy transport path, Gemini 2.x is
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
19
|
+
* - Unlike the legacy transport path, Gemini 2.x is rejected here —
|
|
20
|
+
* the minimum supported generation is Gemini 3 (deprecated 1.x/2.x fail
|
|
21
|
+
* fast before any network call); the catalog still governs thinking
|
|
22
|
+
* validation upstream.
|
|
22
23
|
*/
|
|
23
24
|
import type {
|
|
24
25
|
Provider,
|
|
@@ -43,14 +44,21 @@ import { withRetries } from "../utils/retry.ts";
|
|
|
43
44
|
import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
|
|
44
45
|
import {
|
|
45
46
|
newToolCallId,
|
|
47
|
+
parseArguments,
|
|
46
48
|
redactedHeaders,
|
|
47
49
|
readErrorPayload,
|
|
48
50
|
incompleteStreamError,
|
|
51
|
+
isNotFoundChainError,
|
|
49
52
|
combinedSignal,
|
|
50
53
|
timeoutFor,
|
|
51
54
|
hasAuthHeader,
|
|
52
55
|
clampToolCallId,
|
|
56
|
+
openStreamResponse,
|
|
57
|
+
retryOptionsFrom,
|
|
58
|
+
headersToRecord,
|
|
59
|
+
parseRetryAfterMs,
|
|
53
60
|
} from "./shared.ts";
|
|
61
|
+
import { telemetryBus } from "../telemetry.ts";
|
|
54
62
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
55
63
|
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
56
64
|
import {
|
|
@@ -61,7 +69,9 @@ import {
|
|
|
61
69
|
lastProviderFor,
|
|
62
70
|
noteProviderTurn,
|
|
63
71
|
parseStreamedToolArguments,
|
|
72
|
+
selectRawArguments,
|
|
64
73
|
mapOutputToGoogle,
|
|
74
|
+
mapGenerationParamsToGoogle,
|
|
65
75
|
normalizeStructuredOutput,
|
|
66
76
|
augmentSystemPromptWithStructuredOutput,
|
|
67
77
|
emitProviderWarning,
|
|
@@ -80,7 +90,7 @@ type InteractionContentBlock =
|
|
|
80
90
|
|
|
81
91
|
type InteractionInputStep =
|
|
82
92
|
| { type: "user_input"; content: string | InteractionContentBlock[] }
|
|
83
|
-
| { type: "thought"; signature
|
|
93
|
+
| { type: "thought"; signature?: string; summary?: Array<{ type: string; text?: string }> }
|
|
84
94
|
| { type: "function_call"; id: string; name: string; arguments: Record<string, unknown> }
|
|
85
95
|
| {
|
|
86
96
|
type: "function_result";
|
|
@@ -122,6 +132,7 @@ interface InteractionObject {
|
|
|
122
132
|
created?: string;
|
|
123
133
|
updated?: string;
|
|
124
134
|
service_tier?: string;
|
|
135
|
+
error?: { message?: string; code?: string | number } | null;
|
|
125
136
|
steps?: InteractionStep[];
|
|
126
137
|
usage?: {
|
|
127
138
|
total_tokens?: number;
|
|
@@ -149,9 +160,34 @@ const INTERNAL_HEADERS = new Set([
|
|
|
149
160
|
interface ChainEntry {
|
|
150
161
|
interactionId: string;
|
|
151
162
|
model: string;
|
|
163
|
+
/** Wall-clock ms of the turn that established this chain (drives the TTL gate). */
|
|
164
|
+
at: number;
|
|
152
165
|
}
|
|
153
166
|
const interactionChains = new Map<string, ChainEntry>();
|
|
154
167
|
|
|
168
|
+
/** Interaction storage TTLs: free 24h / paid 55d; local gate fires 1h/1d early. */
|
|
169
|
+
const FREE_CHAIN_TTL_MS = 23 * 3600 * 1000;
|
|
170
|
+
const PAID_CHAIN_TTL_MS = 54 * 24 * 3600 * 1000;
|
|
171
|
+
|
|
172
|
+
/** Last-turn total tokens per session for the rehydration invariant (Rule 3). */
|
|
173
|
+
const lastTurnTotals = new Map<string, number>();
|
|
174
|
+
|
|
175
|
+
function resolveInteractionTier(options?: ProviderRequestOptions): "free" | "paid" {
|
|
176
|
+
return options?.googleTier === "paid" ? "paid" : "free";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function chainTtlMs(options?: ProviderRequestOptions): number {
|
|
180
|
+
return resolveInteractionTier(options) === "paid" ? PAID_CHAIN_TTL_MS : FREE_CHAIN_TTL_MS;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Emits a chain anomaly on the telemetry bus (+ legacy global hook). Never throws. */
|
|
184
|
+
function reportChainAnomaly(detail: { type: string; [k: string]: unknown }): void {
|
|
185
|
+
telemetryBus.emitWarning(detail.type, `[Agent Accelerator] [google] ${String(detail.type)}`, detail);
|
|
186
|
+
try {
|
|
187
|
+
(globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
function chainFor(sessionId: string | undefined): ChainEntry | undefined {
|
|
156
192
|
if (!sessionId) return undefined;
|
|
157
193
|
return interactionChains.get(sessionId);
|
|
@@ -162,10 +198,57 @@ function setChain(sessionId: string | undefined, entry: ChainEntry): void {
|
|
|
162
198
|
interactionChains.set(sessionId, entry);
|
|
163
199
|
}
|
|
164
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Drops a chain entry whose storage TTL elapsed locally (Rule 1: zero wasted
|
|
203
|
+
* RTT). Returns undefined when gated so the caller falls through to the
|
|
204
|
+
* full-history seeding branch. Also resets the token baseline so the reseed
|
|
205
|
+
* never trips the rehydration invariant.
|
|
206
|
+
*/
|
|
207
|
+
function liveChainFor(sessionId: string | undefined, options?: ProviderRequestOptions): ChainEntry | undefined {
|
|
208
|
+
const entry = chainFor(sessionId);
|
|
209
|
+
if (!entry || !sessionId) return entry;
|
|
210
|
+
if (typeof entry.at === "number" && Date.now() - entry.at >= chainTtlMs(options)) {
|
|
211
|
+
interactionChains.delete(sessionId);
|
|
212
|
+
lastTurnTotals.delete(sessionId);
|
|
213
|
+
reportChainAnomaly({ type: "interaction_chain_ttl_expired", sessionId, model: entry.model });
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
return entry;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Records a successful turn's token total and alerts when input tokens drop
|
|
221
|
+
* below the previous turn's total (Rule 3: a caller dropped the chain without
|
|
222
|
+
* rehydrating history). Never throws; observability-only.
|
|
223
|
+
*/
|
|
224
|
+
function checkTurnTokens(sessionId: string | undefined, usage: { inputTokens: number; outputTokens: number; cachedTokens?: number }): void {
|
|
225
|
+
try {
|
|
226
|
+
if (!sessionId) return;
|
|
227
|
+
const total = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
|
228
|
+
const prev = lastTurnTotals.get(sessionId);
|
|
229
|
+
lastTurnTotals.set(sessionId, total);
|
|
230
|
+
if (prev !== undefined && prev > 0 && (usage.inputTokens ?? 0) < prev) {
|
|
231
|
+
const input = usage.inputTokens ?? 0;
|
|
232
|
+
reportChainAnomaly({
|
|
233
|
+
type: "interaction_history_shrink",
|
|
234
|
+
sessionId,
|
|
235
|
+
inputTokens: input,
|
|
236
|
+
previousTotalTokens: prev,
|
|
237
|
+
cacheHitRate: input > 0 ? (usage.cachedTokens ?? 0) / input : 0,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
|
|
165
243
|
/** Clears chained interaction state (mainly for tests). */
|
|
166
244
|
export function clearInteractionChains(sessionId?: string): void {
|
|
167
|
-
if (sessionId)
|
|
168
|
-
|
|
245
|
+
if (sessionId) {
|
|
246
|
+
interactionChains.delete(sessionId);
|
|
247
|
+
lastTurnTotals.delete(sessionId);
|
|
248
|
+
} else {
|
|
249
|
+
interactionChains.clear();
|
|
250
|
+
lastTurnTotals.clear();
|
|
251
|
+
}
|
|
169
252
|
}
|
|
170
253
|
|
|
171
254
|
// ---------------------------------------------------------------------------
|
|
@@ -193,6 +276,33 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
193
276
|
return rawId.replace(/^(google\/|gemini\/|models\/)/i, "");
|
|
194
277
|
}
|
|
195
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Enforces the Gemini 3 minimum generation floor: Gemini 1.x/2.x are
|
|
281
|
+
* deprecated and unsupported — fail fast before any network call.
|
|
282
|
+
* Non-`gemini-` model ids are left to the catalog/provider verdict.
|
|
283
|
+
*/
|
|
284
|
+
function assertSupportedGeneration(clean: string): void {
|
|
285
|
+
const match = clean.match(/^gemini-(\d+)(?:[.\-]|$)/i);
|
|
286
|
+
if (match && Number(match[1]) < 3) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`[Agent Accelerator] [google/${clean}] Unsupported generation: Gemini ${match[1]}.x and older are strictly rejected by this SDK. ` +
|
|
289
|
+
`Use a Gemini 3 or later model for full support and feature coverage.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Throws a concise error for failed/cancelled interactions (never silent empty success). */
|
|
295
|
+
function throwIfFailedInteraction(interaction: InteractionObject, modelId: string): void {
|
|
296
|
+
if (interaction.status !== "failed" && interaction.status !== "cancelled") return;
|
|
297
|
+
const message =
|
|
298
|
+
interaction.error && typeof interaction.error.message === "string" && interaction.error.message
|
|
299
|
+
? interaction.error.message
|
|
300
|
+
: `Google interaction ${interaction.status}`;
|
|
301
|
+
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
302
|
+
if (interaction.error?.code !== undefined) failure["code"] = interaction.error.code;
|
|
303
|
+
throw toConciseProviderError(failure, "google", modelId);
|
|
304
|
+
}
|
|
305
|
+
|
|
196
306
|
function toGoogleTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
197
307
|
if (!tools || tools.length === 0) return undefined;
|
|
198
308
|
return tools.map((t) => ({
|
|
@@ -260,19 +370,40 @@ function resultToText(result: unknown): string {
|
|
|
260
370
|
return safeStringify(result);
|
|
261
371
|
}
|
|
262
372
|
|
|
263
|
-
/**
|
|
373
|
+
/**
|
|
374
|
+
* Latest-turn input for stateful chaining (the server already holds history):
|
|
375
|
+
* everything AFTER the last assistant message, in order. That is normally one
|
|
376
|
+
* user prompt (sent as plain text/blocks), but after tool execution it is the
|
|
377
|
+
* `function_result` steps, and a mid-run `steer()` can append user messages
|
|
378
|
+
* behind them. Sending only the last message would drop the tool results and
|
|
379
|
+
* leave the chained interaction waiting on its function calls.
|
|
380
|
+
*/
|
|
264
381
|
async function latestTurnInput(context: ProviderContext): Promise<string | InteractionContentBlock[] | InteractionInputStep[]> {
|
|
265
382
|
const messages = context.messages;
|
|
266
|
-
|
|
267
|
-
const trailingTool: Message[] = [];
|
|
383
|
+
let lastAssistant = -1;
|
|
268
384
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
385
|
+
if (messages[i]!.role === "assistant") {
|
|
386
|
+
lastAssistant = i;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
272
389
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
390
|
+
const tail = messages.slice(lastAssistant + 1).filter((m) => m.role !== "system");
|
|
391
|
+
if (tail.length === 0) return "";
|
|
392
|
+
|
|
393
|
+
const hasToolResults = tail.some((m) => m.role === "tool");
|
|
394
|
+
if (!hasToolResults && tail.length === 1) {
|
|
395
|
+
const only = tail[0]!;
|
|
396
|
+
if (typeof only.content === "string") return only.content;
|
|
397
|
+
const blocks = await contentPartsToBlocks(only.content);
|
|
398
|
+
if (blocks.length === 1 && blocks[0]!.type === "text") {
|
|
399
|
+
return (blocks[0] as { type: "text"; text: string }).text;
|
|
400
|
+
}
|
|
401
|
+
return blocks;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const steps: InteractionInputStep[] = [];
|
|
405
|
+
for (const m of tail) {
|
|
406
|
+
if (m.role === "tool") {
|
|
276
407
|
if (!Array.isArray(m.content)) continue;
|
|
277
408
|
for (const part of m.content) {
|
|
278
409
|
if (part.type === "tool_result") {
|
|
@@ -284,17 +415,16 @@ async function latestTurnInput(context: ProviderContext): Promise<string | Inter
|
|
|
284
415
|
});
|
|
285
416
|
}
|
|
286
417
|
}
|
|
418
|
+
} else if (m.role === "user") {
|
|
419
|
+
if (typeof m.content === "string") {
|
|
420
|
+
steps.push({ type: "user_input", content: [{ type: "text", text: m.content }] });
|
|
421
|
+
} else {
|
|
422
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
423
|
+
if (blocks.length > 0) steps.push({ type: "user_input", content: blocks });
|
|
424
|
+
}
|
|
287
425
|
}
|
|
288
|
-
return steps;
|
|
289
|
-
}
|
|
290
|
-
const last = messages[messages.length - 1];
|
|
291
|
-
if (!last) return "";
|
|
292
|
-
if (typeof last.content === "string") return last.content;
|
|
293
|
-
const blocks = await contentPartsToBlocks(last.content);
|
|
294
|
-
if (blocks.length === 1 && blocks[0]!.type === "text") {
|
|
295
|
-
return (blocks[0] as { type: "text"; text: string }).text;
|
|
296
426
|
}
|
|
297
|
-
return
|
|
427
|
+
return steps;
|
|
298
428
|
}
|
|
299
429
|
|
|
300
430
|
/** Full-history Steps for stateless (provider-switch) turns. */
|
|
@@ -324,16 +454,18 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
324
454
|
if (part.type === "thinking" && part.thinking) {
|
|
325
455
|
// Q-18: preserve namespaced signatures only — foreign-provider
|
|
326
456
|
// signatures never echo here; untagged legacy echoes (compat).
|
|
457
|
+
// Absent signatures are omitted (never sent as empty strings).
|
|
327
458
|
const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
328
459
|
const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
329
460
|
const sig = shouldEchoSignature(partProvider ?? msgProvider, "google")
|
|
330
461
|
? part.thoughtSignature || m.thoughtSignature || ""
|
|
331
462
|
: "";
|
|
332
|
-
|
|
463
|
+
const thoughtStep: { type: "thought"; signature?: string; summary: Array<{ type: string; text?: string }> } = {
|
|
333
464
|
type: "thought",
|
|
334
|
-
signature: sig,
|
|
335
465
|
summary: [{ type: "text", text: part.thinking }],
|
|
336
|
-
}
|
|
466
|
+
};
|
|
467
|
+
if (sig) thoughtStep.signature = sig;
|
|
468
|
+
steps.push(thoughtStep);
|
|
337
469
|
} else if (part.type === "tool_call") {
|
|
338
470
|
steps.push({
|
|
339
471
|
type: "function_call",
|
|
@@ -382,7 +514,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
382
514
|
|
|
383
515
|
function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
|
|
384
516
|
const input = raw?.total_input_tokens ?? 0;
|
|
385
|
-
|
|
517
|
+
// Google reports thought tokens separately from `total_output_tokens`, but bills them as output
|
|
518
|
+
// ("response pricing is the sum of output tokens and thinking tokens"). Fold them into output so
|
|
519
|
+
// pricing is right and `thinkingTokens` stays a subset of `outputTokens`.
|
|
520
|
+
const thought = raw?.total_thought_tokens ?? 0;
|
|
521
|
+
const output = (raw?.total_output_tokens ?? 0) + thought;
|
|
386
522
|
// Canonical invariant: cached hits are a SUBSET of input. Google usually
|
|
387
523
|
// honors this, but long chained runs have been observed reporting
|
|
388
524
|
// total_cached_tokens > total_input_tokens (which yields >100% hit rates
|
|
@@ -395,13 +531,14 @@ function mapUsage(raw?: InteractionObject["usage"]): TokenUsage {
|
|
|
395
531
|
cachedTokens: cached,
|
|
396
532
|
cacheReadTokens: cached,
|
|
397
533
|
cacheWriteTokens: 0,
|
|
398
|
-
thinkingTokens:
|
|
534
|
+
thinkingTokens: thought,
|
|
399
535
|
};
|
|
400
536
|
}
|
|
401
537
|
|
|
402
538
|
function mapFinishReason(status?: string): string {
|
|
403
539
|
if (status === "requires_action") return "tool_calls";
|
|
404
540
|
if (status === "incomplete") return "length";
|
|
541
|
+
if (status === "failed" || status === "cancelled" || status === "error") return "error";
|
|
405
542
|
return "stop";
|
|
406
543
|
}
|
|
407
544
|
|
|
@@ -411,6 +548,9 @@ function parseInteraction(
|
|
|
411
548
|
durationMs: number,
|
|
412
549
|
raw: ProviderRawData
|
|
413
550
|
): ProviderGenerateResult {
|
|
551
|
+
// Failed/cancelled interactions must surface loudly — never resolve as
|
|
552
|
+
// empty successful stops.
|
|
553
|
+
throwIfFailedInteraction(interaction, modelId);
|
|
414
554
|
let text = "";
|
|
415
555
|
const thinkingParts: string[] = [];
|
|
416
556
|
let thoughtSignature: string | undefined;
|
|
@@ -434,8 +574,15 @@ function parseInteraction(
|
|
|
434
574
|
if (block.type === "text" && block.text) text += block.text;
|
|
435
575
|
}
|
|
436
576
|
} else if (step.type === "function_call") {
|
|
577
|
+
// Object args as-is; a JSON string is parsed (unparseable text becomes the
|
|
578
|
+
// truncated marker); absent args mean a zero-argument call.
|
|
579
|
+
const rawArgs: unknown = step.arguments;
|
|
437
580
|
const args =
|
|
438
|
-
|
|
581
|
+
rawArgs && typeof rawArgs === "object"
|
|
582
|
+
? rawArgs
|
|
583
|
+
: typeof rawArgs === "string"
|
|
584
|
+
? parseArguments(rawArgs)
|
|
585
|
+
: {};
|
|
439
586
|
toolCalls.push({
|
|
440
587
|
id: step.id || newToolCallId(),
|
|
441
588
|
name: step.name || "unknown",
|
|
@@ -520,6 +667,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
520
667
|
options?.thinking?.enabled !== false && (options?.thinking?.level ?? "dynamic") !== "none";
|
|
521
668
|
if (thinkingActive) generationConfig["thinking_summaries"] = "auto";
|
|
522
669
|
if (toolChoice) generationConfig["tool_choice"] = toolChoice;
|
|
670
|
+
// max_output_tokens only; stop and seed warn+drop.
|
|
671
|
+
Object.assign(generationConfig, mapGenerationParamsToGoogle(options, `google/${modelId}`));
|
|
523
672
|
|
|
524
673
|
const body: InteractionRequestBody = {
|
|
525
674
|
model: modelId,
|
|
@@ -566,10 +715,15 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
566
715
|
|
|
567
716
|
const prevProvider = lastProviderFor(sessionId);
|
|
568
717
|
const switched = !!prevProvider && prevProvider !== "google";
|
|
569
|
-
|
|
718
|
+
// Proactive TTL gate (Rule 1): expired chains are dropped locally so
|
|
719
|
+
// this turn seeds fresh instead of wasting an RTT on a dead pointer.
|
|
720
|
+
const chain = liveChainFor(sessionId, options);
|
|
570
721
|
// Interaction ids are model-scoped server-side: chaining a 2.5 id into
|
|
571
722
|
// a 3.5 request 400s, so a same-provider model switch resumes stateless.
|
|
572
723
|
const continuing = !!chain && !switched && chain.model === modelId && context.messages.length > 1;
|
|
724
|
+
// A model switch reseeds from full history and tokenizes differently: drop the token
|
|
725
|
+
// baseline so the new model's first turn is not mistaken for a shrunken history.
|
|
726
|
+
if (chain && sessionId && chain.model !== modelId) lastTurnTotals.delete(sessionId);
|
|
573
727
|
|
|
574
728
|
if (switched) {
|
|
575
729
|
// Cross-provider turn: the Google server never saw the other
|
|
@@ -620,7 +774,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
620
774
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
621
775
|
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
622
776
|
try {
|
|
623
|
-
const res = await fetch(url, this.requestInit(body, options, apiKey, effective));
|
|
777
|
+
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, effective));
|
|
624
778
|
const text = await res.text();
|
|
625
779
|
const headers: Record<string, string> = {};
|
|
626
780
|
res.headers.forEach((v, k) => {
|
|
@@ -637,7 +791,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
637
791
|
statusText: string,
|
|
638
792
|
url: string,
|
|
639
793
|
bodyText: string,
|
|
640
|
-
modelId: string
|
|
794
|
+
modelId: string,
|
|
795
|
+
headers?: Record<string, string>
|
|
641
796
|
): void {
|
|
642
797
|
if (status >= 200 && status < 300) return;
|
|
643
798
|
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
@@ -655,6 +810,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
655
810
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
656
811
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
657
812
|
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
813
|
+
const retryAfterMs = parseRetryAfterMs(headers);
|
|
814
|
+
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
658
815
|
throw toConciseProviderError(err, "google", modelId);
|
|
659
816
|
}
|
|
660
817
|
|
|
@@ -665,6 +822,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
665
822
|
): Promise<ProviderGenerateResult> {
|
|
666
823
|
const startTime = Date.now();
|
|
667
824
|
const clean = cleanModelId(model);
|
|
825
|
+
// Hard generation floor: Gemini 1.x/2.x fail fast before any network call.
|
|
826
|
+
assertSupportedGeneration(clean);
|
|
668
827
|
const apiKey = resolveApiKey(options);
|
|
669
828
|
if (!apiKey) {
|
|
670
829
|
throw new Error(
|
|
@@ -679,9 +838,9 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
679
838
|
const url = `${baseUrl}/interactions`;
|
|
680
839
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
681
840
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
682
|
-
|
|
841
|
+
let body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
683
842
|
|
|
684
|
-
const
|
|
843
|
+
const makeRawRequest = (b: InteractionRequestBody): ProviderRawData["request"] => ({
|
|
685
844
|
url,
|
|
686
845
|
method: "POST",
|
|
687
846
|
headers: redactedHeaders({
|
|
@@ -691,12 +850,13 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
691
850
|
),
|
|
692
851
|
"x-goog-api-key": "[REDACTED]",
|
|
693
852
|
}),
|
|
694
|
-
body,
|
|
695
|
-
};
|
|
853
|
+
body: b,
|
|
854
|
+
});
|
|
855
|
+
let rawRequest = makeRawRequest(body);
|
|
696
856
|
|
|
697
857
|
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
698
858
|
const res = await this.doFetch(url, body, options, apiKey, options?.signal);
|
|
699
|
-
this.throwIfError(res.status, res.statusText, url, res.text, clean);
|
|
859
|
+
this.throwIfError(res.status, res.statusText, url, res.text, clean, res.headers);
|
|
700
860
|
let interaction: InteractionObject;
|
|
701
861
|
try {
|
|
702
862
|
interaction = JSON.parse(res.text) as InteractionObject;
|
|
@@ -712,28 +872,41 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
712
872
|
);
|
|
713
873
|
}
|
|
714
874
|
if (interaction.id && sessionId && body.store !== false) {
|
|
715
|
-
setChain(sessionId, { interactionId: interaction.id, model: clean });
|
|
875
|
+
setChain(sessionId, { interactionId: interaction.id, model: clean, at: Date.now() });
|
|
716
876
|
} else if (body.store === false && sessionId) {
|
|
717
877
|
interactionChains.delete(sessionId);
|
|
878
|
+
lastTurnTotals.delete(sessionId);
|
|
718
879
|
}
|
|
719
880
|
noteProviderTurn(sessionId, "google");
|
|
720
|
-
|
|
881
|
+
const parsed = parseInteraction(
|
|
721
882
|
interaction,
|
|
722
883
|
clean,
|
|
723
884
|
Date.now() - startTime,
|
|
724
885
|
{ request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: interaction } }
|
|
725
886
|
);
|
|
887
|
+
checkTurnTokens(sessionId, parsed.usage);
|
|
888
|
+
return parsed;
|
|
726
889
|
};
|
|
727
890
|
|
|
891
|
+
const retryOptions = {
|
|
892
|
+
...retryOptionsFrom(options),
|
|
893
|
+
signal: options?.signal,
|
|
894
|
+
label: { providerId: "google", modelId: clean },
|
|
895
|
+
};
|
|
728
896
|
try {
|
|
729
|
-
return await withRetries(doCall,
|
|
730
|
-
maxRetries: options?.maxRetries,
|
|
731
|
-
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
732
|
-
signal: options?.signal,
|
|
733
|
-
label: { providerId: "google", modelId: clean },
|
|
734
|
-
});
|
|
897
|
+
return await withRetries(doCall, retryOptions);
|
|
735
898
|
} catch (err) {
|
|
736
899
|
if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
|
|
900
|
+
// Reactive self-heal (Rule 2): a chained pointer the server no longer
|
|
901
|
+
// has is retried once against the full canonical history with the dead
|
|
902
|
+
// chain cleared, so the turn succeeds instead of wedging the session.
|
|
903
|
+
if (sessionId && body.previous_interaction_id && isNotFoundChainError(err)) {
|
|
904
|
+
reportChainAnomaly({ type: "interaction_chain_evicted", sessionId, model: clean });
|
|
905
|
+
clearInteractionChains(sessionId);
|
|
906
|
+
body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
907
|
+
rawRequest = makeRawRequest(body);
|
|
908
|
+
return await withRetries(doCall, retryOptions);
|
|
909
|
+
}
|
|
737
910
|
throw err;
|
|
738
911
|
}
|
|
739
912
|
}
|
|
@@ -766,7 +939,13 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
766
939
|
});
|
|
767
940
|
|
|
768
941
|
(async () => {
|
|
942
|
+
// Hoisted for the catch-block self-heal (consts inside try are not
|
|
943
|
+
// visible in catch).
|
|
944
|
+
let evictSessionId: string | undefined;
|
|
945
|
+
let usedChain = false;
|
|
769
946
|
try {
|
|
947
|
+
// Hard generation floor (mirrors generate): no network on legacy ids.
|
|
948
|
+
assertSupportedGeneration(clean);
|
|
770
949
|
const apiKey = resolveApiKey(options);
|
|
771
950
|
if (!apiKey) {
|
|
772
951
|
throw new Error(
|
|
@@ -779,6 +958,8 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
779
958
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
780
959
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
781
960
|
const body = await this.buildBody(clean, context, options, sessionId, tools, true);
|
|
961
|
+
evictSessionId = sessionId;
|
|
962
|
+
usedChain = body.previous_interaction_id !== undefined;
|
|
782
963
|
|
|
783
964
|
// Audit trail mirrors generate (custom headers included); the
|
|
784
965
|
// Interactions API sends no session-affinity headers (implicit
|
|
@@ -797,25 +978,30 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
797
978
|
};
|
|
798
979
|
|
|
799
980
|
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
981
|
+
// Transient failures (429/5xx/network) are retried here, before the
|
|
982
|
+
// first byte; once the stream is live it is never retried.
|
|
983
|
+
const res = await openStreamResponse({
|
|
984
|
+
fetchOnce: async () => {
|
|
985
|
+
const timer =
|
|
986
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
987
|
+
? setTimeout(() => {
|
|
988
|
+
try {
|
|
989
|
+
linked.abort();
|
|
990
|
+
} catch {}
|
|
991
|
+
}, streamTimeoutMs)
|
|
992
|
+
: undefined;
|
|
993
|
+
try {
|
|
994
|
+
return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, linked.signal));
|
|
995
|
+
} finally {
|
|
996
|
+
if (timer) clearTimeout(timer);
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, url, text, clean, headersToRecord(r.headers)),
|
|
1000
|
+
noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
|
|
1001
|
+
retry: retryOptionsFrom(options),
|
|
1002
|
+
signal: linked.signal,
|
|
1003
|
+
label: { providerId: "google", modelId: clean },
|
|
1004
|
+
});
|
|
819
1005
|
const responseHeaders: Record<string, string> = {};
|
|
820
1006
|
res.headers.forEach((v, k) => {
|
|
821
1007
|
responseHeaders[k] = v;
|
|
@@ -824,7 +1010,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
824
1010
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
825
1011
|
|
|
826
1012
|
const parser = new SSEParser();
|
|
827
|
-
const reader = res.body
|
|
1013
|
+
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
828
1014
|
const decoder = new TextDecoder();
|
|
829
1015
|
let text = "";
|
|
830
1016
|
let thinking = "";
|
|
@@ -921,6 +1107,9 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
921
1107
|
}
|
|
922
1108
|
} else if (type === "interaction.completed") {
|
|
923
1109
|
const interaction = (msg["interaction"] as InteractionObject) ?? {};
|
|
1110
|
+
// Failed/cancelled interactions must surface loudly — never
|
|
1111
|
+
// resolve as empty successful stops.
|
|
1112
|
+
throwIfFailedInteraction(interaction, clean);
|
|
924
1113
|
completedBody = msg["interaction"];
|
|
925
1114
|
if (interaction.id) responseId = interaction.id;
|
|
926
1115
|
if (interaction.usage) {
|
|
@@ -961,17 +1150,21 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
961
1150
|
id: c.id,
|
|
962
1151
|
name: c.name,
|
|
963
1152
|
arguments: args,
|
|
964
|
-
rawArguments: c.startArgs
|
|
1153
|
+
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
965
1154
|
};
|
|
966
1155
|
toolCalls.push(record);
|
|
967
1156
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
968
1157
|
}
|
|
969
1158
|
|
|
970
1159
|
if (sessionId) {
|
|
971
|
-
if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean });
|
|
972
|
-
else if (body.store === false)
|
|
1160
|
+
if (responseId && body.store !== false) setChain(sessionId, { interactionId: responseId, model: clean, at: Date.now() });
|
|
1161
|
+
else if (body.store === false) {
|
|
1162
|
+
interactionChains.delete(sessionId);
|
|
1163
|
+
lastTurnTotals.delete(sessionId);
|
|
1164
|
+
}
|
|
973
1165
|
}
|
|
974
1166
|
noteProviderTurn(sessionId, "google");
|
|
1167
|
+
checkTurnTokens(sessionId, usage);
|
|
975
1168
|
|
|
976
1169
|
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
977
1170
|
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
@@ -1015,11 +1208,26 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
1015
1208
|
const isAbort =
|
|
1016
1209
|
linked.signal.aborted ||
|
|
1017
1210
|
eventStream.isCancelled() ||
|
|
1018
|
-
(raw as { name?: string }).name === "AbortError"
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1211
|
+
(raw as { name?: string }).name === "AbortError";
|
|
1212
|
+
if (isAbort) {
|
|
1213
|
+
eventStream.fail(
|
|
1214
|
+
Object.assign(new Error("Stream aborted"), { name: "AbortError" })
|
|
1215
|
+
);
|
|
1216
|
+
} else if (evictSessionId && usedChain && isNotFoundChainError(raw)) {
|
|
1217
|
+
// Reactive self-heal, fail-clean variant (Rule 2): the `start`
|
|
1218
|
+
// event is already delivered, so a transparent in-stream retry
|
|
1219
|
+
// would duplicate it. Clear the dead pointer now so the next turn
|
|
1220
|
+
// reseeds from full history; mark retryable so a plain caller retry
|
|
1221
|
+
// of this turn succeeds.
|
|
1222
|
+
reportChainAnomaly({ type: "interaction_chain_evicted", sessionId: evictSessionId, model: clean });
|
|
1223
|
+
clearInteractionChains(evictSessionId);
|
|
1224
|
+
const retryable = toConciseProviderError(raw, "google", clean);
|
|
1225
|
+
(retryable as unknown as Record<string, unknown>)["retryable"] = true;
|
|
1226
|
+
(retryable as unknown as Record<string, unknown>)["transient"] = true;
|
|
1227
|
+
eventStream.fail(retryable);
|
|
1228
|
+
} else {
|
|
1229
|
+
eventStream.fail(raw);
|
|
1230
|
+
}
|
|
1023
1231
|
} finally {
|
|
1024
1232
|
try {
|
|
1025
1233
|
options?.signal?.removeEventListener("abort", forwardUserAbort);
|