@bitkyc08/opencodex 2.87.0 → 2.88.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/gui/dist/assets/{App-ZfYbLHkB.js → App-CHC89EXQ.js} +3 -3
- package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-B5bC7AWL.js} +1 -1
- package/gui/dist/assets/index-B5bsvxor.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-D8y2wTsB.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +76 -45
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google.ts +17 -1
- package/src/bridge/internal.ts +6 -2
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +7 -6
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/outbound.ts +13 -5
- package/src/cli/cross-home-owner.ts +16 -2
- package/src/cli/loopback-family.ts +31 -0
- package/src/codex/catalog/routed-gather.ts +3 -1
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/config/opencode-zen-upgrade.ts +9 -8
- package/src/generated/compatibility-version.json +88 -52
- package/src/images/loop.ts +72 -21
- package/src/lib/request-failure-model.ts +2 -0
- package/src/oauth/generic-account-failover.ts +7 -4
- package/src/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- package/src/providers/opencode-zen-rate-limit.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +50 -4
- package/src/routing/analytics.ts +7 -15
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- package/src/server/messages-native.ts +16 -3
- package/src/server/responses/adapter-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +27 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-stream-preflight.ts +3 -2
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/core-codex-account.ts +2 -2
- package/src/server/responses/core-combo.ts +3 -2
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/sidecar-execution.ts +43 -7
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/ws-bridge.ts +2 -0
- package/src/types/opencode-zen.ts +9 -3
- package/src/types/request.ts +10 -2
- package/src/types/wire.ts +4 -2
- package/src/types.ts +1 -0
- package/src/usage/summary.ts +9 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +181 -68
- package/src/web-search/progress-stream.ts +11 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
package/src/web-search/loop.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo";
|
|
2
|
+
import { anthropicDeclaredTools } from "../adapters/anthropic-tool-catalog";
|
|
3
|
+
import { enableAnthropicReplayCapture, takeAnthropicReplay, bindAnthropicReplay, type AnthropicReplaySnapshot } from "../adapters/anthropic-replay";
|
|
4
|
+
import { anthropicSearchAssistantContent } from "./anthropic-history";
|
|
5
|
+
import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery";
|
|
1
6
|
import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
|
|
2
7
|
import type { AdapterEvent, OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
|
|
3
8
|
import { namespacedToolName, toolChoiceToolPredicate } from "../types";
|
|
@@ -18,7 +23,7 @@ import type { WebSearchBackendId } from "./index";
|
|
|
18
23
|
import { pacingHeaderDeadline } from "./header-deadline";
|
|
19
24
|
import { redactSecretString } from "../lib/redact";
|
|
20
25
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
21
|
-
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
|
|
26
|
+
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
|
|
22
27
|
import { rateLimitRetryDelayMs } from "../providers/key-failover";
|
|
23
28
|
import {
|
|
24
29
|
isTranslatorBudgetExceededError,
|
|
@@ -26,7 +31,7 @@ import {
|
|
|
26
31
|
TranslatorBudgetExceededError,
|
|
27
32
|
} from "../lib/translator-budget";
|
|
28
33
|
import { formatWebSearchResults } from "./format-result";
|
|
29
|
-
import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream";
|
|
34
|
+
import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "./progress-stream";
|
|
30
35
|
import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
|
|
31
36
|
|
|
32
37
|
const SSE_HEADERS = {
|
|
@@ -204,6 +209,9 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
|
|
|
204
209
|
};
|
|
205
210
|
|
|
206
211
|
for (const e of events) {
|
|
212
|
+
// Match the bridge: consecutive signature snapshots update one block. The next
|
|
213
|
+
// semantic event (including an empty thinking-block start) closes that block.
|
|
214
|
+
if (signature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") flushVisible();
|
|
207
215
|
if (e.type === "text_delta" && e.anthropicThinkingSignature) {
|
|
208
216
|
flushVisible();
|
|
209
217
|
flushRaw();
|
|
@@ -216,7 +224,6 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
|
|
|
216
224
|
rawReasoning += e.text;
|
|
217
225
|
} else if (e.type === "thinking_signature") {
|
|
218
226
|
signature = e.signature;
|
|
219
|
-
flushVisible();
|
|
220
227
|
} else if (e.type === "redacted_thinking") {
|
|
221
228
|
flushVisible();
|
|
222
229
|
flushRaw();
|
|
@@ -234,8 +241,8 @@ function normalizeQuery(q: string): string {
|
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
244
|
+
* Developer nudge for the forced-answer pass. Anthropic keeps its first sent position in the
|
|
245
|
+
* request-local history; other providers append it only for that iteration. It tells the model to ground its final answer in the web results already
|
|
239
246
|
* gathered this turn. Citation wording is conditional — a failed/empty search still wants an answer,
|
|
240
247
|
* just without fabricated sources.
|
|
241
248
|
*/
|
|
@@ -252,10 +259,8 @@ function forcedAnswerNudge(): OcxMessage {
|
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge}
|
|
258
|
-
* it is iteration-local and never touches the persisted `messages`.
|
|
262
|
+
* Developer nudge for the ONE recovery pass after an empty forced answer. Tool calls are disabled;
|
|
263
|
+
* Anthropic retains its declared catalog and this nudge in request-local history for prefix fidelity.
|
|
259
264
|
*/
|
|
260
265
|
function forcedAnswerRetryNudge(): OcxMessage {
|
|
261
266
|
return {
|
|
@@ -269,17 +274,17 @@ function forcedAnswerRetryNudge(): OcxMessage {
|
|
|
269
274
|
};
|
|
270
275
|
}
|
|
271
276
|
|
|
272
|
-
function jsonError(status: number, message: string): Response {
|
|
273
|
-
return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
|
|
277
|
+
function jsonError(status: number, message: string, event?: Extract<AdapterEvent, { type: "error" }>): Response {
|
|
278
|
+
return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, ...(event?.retryable === false ? { retryable: false } : {}) } }), {
|
|
274
279
|
status,
|
|
275
|
-
headers: { "Content-Type": "application/json" },
|
|
280
|
+
headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) },
|
|
276
281
|
});
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
/** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
|
|
280
285
|
* non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
|
|
281
286
|
class LoopError extends Error {
|
|
282
|
-
constructor(readonly status: number, message: string) {
|
|
287
|
+
constructor(readonly status: number, message: string, readonly event?: Extract<AdapterEvent, { type: "error" }>) {
|
|
283
288
|
super(message);
|
|
284
289
|
this.name = "LoopError";
|
|
285
290
|
}
|
|
@@ -351,7 +356,8 @@ export interface WebSearchLoopDeps {
|
|
|
351
356
|
/** Observe the exact adapter request selected for each routed-model iteration. */
|
|
352
357
|
onRequestBuilt?: (request: AdapterRequest) => void;
|
|
353
358
|
/** Request-scoped executor retains the core's selection binding across loop retries. */
|
|
354
|
-
|
|
359
|
+
thinkingRecovery?: ReturnType<typeof createAnthropicThinkingRecovery>;
|
|
360
|
+
fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch;
|
|
355
361
|
/** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */
|
|
356
362
|
onAttemptSend?: (recovery?: AttemptRecoveryKind) => void;
|
|
357
363
|
/**
|
|
@@ -412,8 +418,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
412
418
|
: 300_000;
|
|
413
419
|
|
|
414
420
|
const messages: OcxMessage[] = [...parsed.context.messages];
|
|
421
|
+
const replaySnapshots = new Set<AnthropicReplaySnapshot>();
|
|
415
422
|
const loopT0 = Date.now();
|
|
416
|
-
const allTools = parsed.context.tools ?? [];
|
|
423
|
+
const allTools = adapter.name === "anthropic" ? anthropicDeclaredTools(parsed) : parsed.context.tools ?? [];
|
|
424
|
+
let forcedNudgeAdded = false;
|
|
425
|
+
let emptyNudgeAdded = false;
|
|
417
426
|
let searchesExecuted = 0;
|
|
418
427
|
let executedSearchCount = 0;
|
|
419
428
|
// Queries whose search already failed this turn — repeats are short-circuited so a model that keeps
|
|
@@ -448,8 +457,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
448
457
|
interface IterationResponse {
|
|
449
458
|
response: Response;
|
|
450
459
|
responseAdapter: ProviderAdapter;
|
|
460
|
+
recover?: (error: WebSearchAdapterEventError) => Promise<IterationResponse | undefined>;
|
|
451
461
|
}
|
|
452
462
|
type IterationSplit = ReturnType<typeof scanEventsForWebSearch> & {
|
|
463
|
+
events: AdapterEvent[];
|
|
464
|
+
anthropicReplay?: AnthropicReplaySnapshot;
|
|
453
465
|
/**
|
|
454
466
|
* How many leading passthrough events were already delivered live this iteration. They are
|
|
455
467
|
* exactly the first N passthrough entries (live delivery stops before the first event that
|
|
@@ -464,6 +476,38 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
464
476
|
const rateLimitRetryPolicy = deps.retryOn429Policy ?? null;
|
|
465
477
|
let rateLimitRetries = 0;
|
|
466
478
|
|
|
479
|
+
const requireSuccessfulHeaders = async (prepared: IterationResponse): Promise<void> => {
|
|
480
|
+
if (!prepared.response.ok) {
|
|
481
|
+
if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") {
|
|
482
|
+
const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal);
|
|
483
|
+
throw new LoopError(event.status ?? prepared.response.status, event.message, event);
|
|
484
|
+
}
|
|
485
|
+
let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
|
|
486
|
+
try {
|
|
487
|
+
body = await readBoundedResponseBody(prepared.response, { signal });
|
|
488
|
+
} catch {
|
|
489
|
+
// The response status is authoritative even when its untrusted error body fails while
|
|
490
|
+
// being read (including a synchronous getReader() failure). Never route that failure
|
|
491
|
+
// through the adapter formatter or the generic transport error, which could expose its
|
|
492
|
+
// raw message. A parent/client cancellation still owns the request lifecycle as 499.
|
|
493
|
+
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
494
|
+
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
|
|
495
|
+
}
|
|
496
|
+
let formatted = "";
|
|
497
|
+
if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
|
|
498
|
+
try {
|
|
499
|
+
formatted = prepared.responseAdapter.formatErrorBody(
|
|
500
|
+
prepared.response.status,
|
|
501
|
+
prepared.response.headers,
|
|
502
|
+
body.text,
|
|
503
|
+
).trim();
|
|
504
|
+
} catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
|
|
505
|
+
}
|
|
506
|
+
const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
|
|
507
|
+
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
467
511
|
// Acquire one iteration's final response headers. The first call is drained eagerly so an initial
|
|
468
512
|
// connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately
|
|
469
513
|
// left unread until the downstream Responses SSE bridge exists.
|
|
@@ -473,24 +517,30 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
473
517
|
* restart) before the `on429` key rotation.
|
|
474
518
|
*/
|
|
475
519
|
const prepareIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, IterationResponse> {
|
|
476
|
-
// On the forced-answer pass the model is asked to answer from the results already in
|
|
477
|
-
// `messages`. A weak model can still produce a thin answer that
|
|
478
|
-
// ignores what the search found, which reads to the user as "the search did nothing". Nudge it
|
|
479
|
-
// (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
|
|
480
|
-
// Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
|
|
481
|
-
let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
|
|
482
|
-
? [...messages, forcedAnswerNudge()]
|
|
483
|
-
: messages;
|
|
484
|
-
// #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the
|
|
485
|
-
// model nothing to call, and the extra developer turn asks it for the text it just failed to
|
|
486
|
-
// produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry
|
|
487
|
-
// cannot repeat the same empty or tool-shaped response.
|
|
488
520
|
const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
|
|
489
|
-
|
|
521
|
+
const preserveAnthropicPrefix = adapter.name === "anthropic";
|
|
522
|
+
let iterMessages = messages;
|
|
523
|
+
if (preserveAnthropicPrefix) {
|
|
524
|
+
// Once sent, each nudge is part of subsequent signatures' bound prefix. Keep its
|
|
525
|
+
// original position even when a forced-answer response makes another tool call.
|
|
526
|
+
if (forceAnswer && executedSearchCount > 0 && !forcedNudgeAdded) {
|
|
527
|
+
const nudge = forcedAnswerNudge();
|
|
528
|
+
if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
|
|
529
|
+
messages.push(nudge); forcedNudgeAdded = true;
|
|
530
|
+
}
|
|
531
|
+
if (recoveringEmptyAnswer && !emptyNudgeAdded) {
|
|
532
|
+
const nudge = forcedAnswerRetryNudge();
|
|
533
|
+
if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
|
|
534
|
+
messages.push(nudge); emptyNudgeAdded = true;
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
if (forceAnswer && executedSearchCount > 0) iterMessages = [...messages, forcedAnswerNudge()];
|
|
538
|
+
if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
|
|
539
|
+
}
|
|
490
540
|
const iterParsed: OcxParsedRequest = {
|
|
491
541
|
...parsed, stream: true,
|
|
492
542
|
...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
|
|
493
|
-
context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools },
|
|
543
|
+
context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer && !preserveAnthropicPrefix ? [] : allTools },
|
|
494
544
|
};
|
|
495
545
|
// One cumulative header deadline spans key rotations and reset sends, excluding local pacing.
|
|
496
546
|
// clear() stops only its timer after final headers; the direct turn signal remains attached to
|
|
@@ -528,7 +578,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
528
578
|
cachedRequest = request;
|
|
529
579
|
cachedAdapter = requestAdapter;
|
|
530
580
|
}
|
|
531
|
-
const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter) ?? routedProviderFetch);
|
|
581
|
+
const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter, recovery) ?? routedProviderFetch);
|
|
532
582
|
let response: Response;
|
|
533
583
|
try {
|
|
534
584
|
if (requestAdapter.fetchResponse) {
|
|
@@ -571,9 +621,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
571
621
|
} finally {
|
|
572
622
|
request.releaseBodyObservation?.();
|
|
573
623
|
}
|
|
624
|
+
bindAnthropicRejectionResponse(request, response);
|
|
574
625
|
return { response, responseAdapter: requestAdapter };
|
|
575
626
|
};
|
|
576
627
|
|
|
628
|
+
const recover = async (error: WebSearchAdapterEventError): Promise<IterationResponse | undefined> => {
|
|
629
|
+
if (signal.aborted || !error.replaySafe || isNonReplayableResponse(prepared.response)
|
|
630
|
+
|| !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed, prepared.response)) return;
|
|
631
|
+
cachedRequest = undefined;
|
|
632
|
+
cachedAdapter = undefined;
|
|
633
|
+
// The successful headers ended the previous timer; the replacement owns a fresh deadline.
|
|
634
|
+
headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal);
|
|
635
|
+
try {
|
|
636
|
+
const replacement = await fetchOnce(prepared.responseAdapter, "opaque-blob-rejection");
|
|
637
|
+
headerDeadline.clear();
|
|
638
|
+
await requireSuccessfulHeaders(replacement);
|
|
639
|
+
return replacement;
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
642
|
+
if (headerDeadline.didExpire()) throw new LoopError(504,
|
|
643
|
+
`Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
|
|
644
|
+
if (error instanceof LoopError || error instanceof SendBudgetExhaustedError || isTranslatorBudgetExceededError(error)) throw error;
|
|
645
|
+
throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
} finally {
|
|
647
|
+
headerDeadline.clear();
|
|
648
|
+
deps.onIterationEnd?.();
|
|
649
|
+
}
|
|
650
|
+
};
|
|
577
651
|
let prepared = await fetchOnce(adapter);
|
|
578
652
|
// Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key
|
|
579
653
|
// rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust.
|
|
@@ -644,36 +718,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
644
718
|
prepared = replacement;
|
|
645
719
|
}
|
|
646
720
|
|
|
721
|
+
if (await deps.thinkingRecovery?.prepareHttp(prepared.response, prepared.responseAdapter.name, iterParsed)) {
|
|
722
|
+
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* closed */ }
|
|
723
|
+
cachedRequest = undefined;
|
|
724
|
+
cachedAdapter = undefined;
|
|
725
|
+
prepared = await fetchOnce(adapter, "opaque-blob-rejection");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
|
|
647
729
|
// Final headers have arrived. Clear only the deadline timer before ANY body read.
|
|
648
730
|
headerDeadline.clear();
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
body = await readBoundedResponseBody(prepared.response, { signal });
|
|
653
|
-
} catch {
|
|
654
|
-
// The response status is authoritative even when its untrusted error body fails while
|
|
655
|
-
// being read (including a synchronous getReader() failure). Never route that failure
|
|
656
|
-
// through the adapter formatter or the generic transport error, which could expose its
|
|
657
|
-
// raw message. A parent/client cancellation still owns the request lifecycle as 499.
|
|
658
|
-
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
659
|
-
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
|
|
660
|
-
}
|
|
661
|
-
let formatted = "";
|
|
662
|
-
if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
|
|
663
|
-
try {
|
|
664
|
-
formatted = prepared.responseAdapter.formatErrorBody(
|
|
665
|
-
prepared.response.status,
|
|
666
|
-
prepared.response.headers,
|
|
667
|
-
body.text,
|
|
668
|
-
).trim();
|
|
669
|
-
} catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
|
|
670
|
-
}
|
|
671
|
-
const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
|
|
672
|
-
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
|
|
673
|
-
}
|
|
674
|
-
return prepared;
|
|
731
|
+
await requireSuccessfulHeaders(prepared);
|
|
732
|
+
return { ...prepared, recover };
|
|
675
733
|
} catch (error) {
|
|
676
|
-
if (isTranslatorBudgetExceededError(error)) throw error;
|
|
734
|
+
if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
|
|
677
735
|
if (headerDeadline.didExpire()) {
|
|
678
736
|
throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
|
|
679
737
|
}
|
|
@@ -710,15 +768,25 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
710
768
|
const events: AdapterEvent[] = [];
|
|
711
769
|
let liveWindowOpen = deps.streamRoutedModelOutput === true;
|
|
712
770
|
let streamedPassthroughCount = 0;
|
|
771
|
+
let anthropicReplay: AnthropicReplaySnapshot | undefined;
|
|
772
|
+
let releaseCapture = () => {};
|
|
713
773
|
try {
|
|
714
|
-
const parse =
|
|
774
|
+
const parse: ProviderAdapter["parseStream"] = (response, budget) => {
|
|
775
|
+
if (prepared.responseAdapter.name === "anthropic") releaseCapture = enableAnthropicReplayCapture(response);
|
|
776
|
+
return prepared.responseAdapter.parseStream(response, budget);
|
|
777
|
+
};
|
|
715
778
|
for await (const event of parseStreamWithProgress(prepared.response, parse, {
|
|
716
779
|
signal,
|
|
717
780
|
inactivityTimeoutMs: routedModelStallTimeoutMs,
|
|
718
781
|
translatorBudget,
|
|
782
|
+
preserveAdapterError: deps.thinkingRecovery !== undefined,
|
|
719
783
|
// A pass that fails was still billed; the error path reports it with the hidden passes.
|
|
720
784
|
onErrorUsage: usage => { hiddenUsage = mergeUsage(hiddenUsage, usage); },
|
|
721
785
|
})) {
|
|
786
|
+
if (event.type === "done") {
|
|
787
|
+
anthropicReplay = takeAnthropicReplay(event);
|
|
788
|
+
if (anthropicReplay) replaySnapshots.add(anthropicReplay);
|
|
789
|
+
}
|
|
722
790
|
if (event.type === "heartbeat") yield event;
|
|
723
791
|
// Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
|
|
724
792
|
// it performs a bounded final-answer retry. That text is safe to surface immediately and
|
|
@@ -740,12 +808,21 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
740
808
|
}
|
|
741
809
|
}
|
|
742
810
|
} catch (error) {
|
|
743
|
-
if (isTranslatorBudgetExceededError(error)) throw error;
|
|
811
|
+
if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
|
|
744
812
|
if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
|
|
813
|
+
if (error instanceof WebSearchAdapterEventError) {
|
|
814
|
+
const replacement = await prepared.recover?.(error);
|
|
815
|
+
if (replacement) return yield* consumeIterationEvents(replacement);
|
|
816
|
+
const event = isNonReplayableResponse(prepared.response)
|
|
817
|
+
? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false as const }
|
|
818
|
+
: error.event;
|
|
819
|
+
throw new LoopError(event.status ?? 502, event.message, event);
|
|
820
|
+
}
|
|
821
|
+
if (error instanceof LoopError) throw error;
|
|
745
822
|
if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
|
|
746
823
|
if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
|
|
747
824
|
throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
|
|
748
|
-
}
|
|
825
|
+
} finally { releaseCapture(); }
|
|
749
826
|
|
|
750
827
|
const terminalIndexes = events.flatMap((event, index) =>
|
|
751
828
|
event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []);
|
|
@@ -759,7 +836,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
759
836
|
}
|
|
760
837
|
throw new LoopError(502, terminal.message);
|
|
761
838
|
}
|
|
762
|
-
|
|
839
|
+
deps.thinkingRecovery?.observeIteration(events);
|
|
840
|
+
return { ...scanEventsForWebSearch(events), streamedPassthroughCount, events, anthropicReplay };
|
|
763
841
|
};
|
|
764
842
|
|
|
765
843
|
// Execute one model-requested web_search call. The call may batch several queries (native
|
|
@@ -768,7 +846,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
768
846
|
// valid, and surface as ONE search cell carrying every attempted query. A real search (one that
|
|
769
847
|
// hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
|
|
770
848
|
// emit a cell (matching the prior single-query behavior).
|
|
771
|
-
async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
|
|
849
|
+
async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = [], appendAssistant = true): AsyncGenerator<AdapterEvent> {
|
|
772
850
|
const results: { query: string; outcome: SidecarOutcome }[] = [];
|
|
773
851
|
let beganCell = false;
|
|
774
852
|
if (call.queries.length === 0) {
|
|
@@ -845,7 +923,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
845
923
|
const callArgs: Record<string, unknown> = call.queries.length > 1
|
|
846
924
|
? { queries: call.queries }
|
|
847
925
|
: { query: call.queries[0] ?? "" };
|
|
848
|
-
messages.push({
|
|
926
|
+
if (appendAssistant) messages.push({
|
|
849
927
|
role: "assistant",
|
|
850
928
|
content: [
|
|
851
929
|
// Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
|
|
@@ -904,7 +982,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
904
982
|
firstPrepared = await prepareIterationDrained(false);
|
|
905
983
|
} catch (e) {
|
|
906
984
|
if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
|
|
907
|
-
if (e instanceof LoopError) return jsonError(e.status, e.message);
|
|
985
|
+
if (e instanceof LoopError) return jsonError(e.status, e.message, e.event);
|
|
986
|
+
if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
|
|
987
|
+
{ type: "error", message: e.message, errorType: "rate_limit_error", code: e.code, retryable: false });
|
|
908
988
|
throw e;
|
|
909
989
|
}
|
|
910
990
|
|
|
@@ -987,7 +1067,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
987
1067
|
hiddenUsage = mergeUsage(hiddenUsage, terminalEvent.usage);
|
|
988
1068
|
if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
|
|
989
1069
|
emptyAnswerRetries++;
|
|
990
|
-
console.warn("[web-search-loop] empty forced answer — retrying once
|
|
1070
|
+
console.warn("[web-search-loop] empty forced answer — retrying once with tool calls disabled");
|
|
991
1071
|
yield { type: "heartbeat" };
|
|
992
1072
|
continue;
|
|
993
1073
|
}
|
|
@@ -1012,10 +1092,36 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
1012
1092
|
for (const event of split.passthrough.slice(split.streamedPassthroughCount)) {
|
|
1013
1093
|
if (event.type === "text_delta" && event.anthropicThinkingSignature) yield event;
|
|
1014
1094
|
}
|
|
1015
|
-
const
|
|
1095
|
+
const preserveAnthropicHistory = prepared.responseAdapter.name === "anthropic";
|
|
1096
|
+
if (preserveAnthropicHistory) {
|
|
1097
|
+
if (!split.anthropicReplay) throw new LoopError(502, "Anthropic search history cannot be replayed losslessly");
|
|
1098
|
+
const message = { role: "assistant" as const,
|
|
1099
|
+
content: anthropicSearchAssistantContent(split.events), timestamp: Date.now() };
|
|
1100
|
+
const originalCalls = split.anthropicReplay.content.filter(block => block.type === "tool_use");
|
|
1101
|
+
const priorIds = new Set(messages.flatMap(message => message.role === "assistant"
|
|
1102
|
+
? message.content.filter(part => part.type === "toolCall").map(part => part.id)
|
|
1103
|
+
: message.role === "toolResult" ? [message.toolCallId] : []));
|
|
1104
|
+
if (originalCalls.some(block => priorIds.has(block.id as string))) {
|
|
1105
|
+
throw new LoopError(502, "Anthropic search replay tool identity changed");
|
|
1106
|
+
}
|
|
1107
|
+
const calls = message.content.filter(part => part.type === "toolCall");
|
|
1108
|
+
if (originalCalls.length !== calls.length || originalCalls.some((block, index) => block.id !== calls[index]?.id)) {
|
|
1109
|
+
throw new LoopError(502, "Anthropic search replay tool identity changed");
|
|
1110
|
+
}
|
|
1111
|
+
// The raw input is authoritative, including non-empty content_block_start input.
|
|
1112
|
+
for (const [index, call] of calls.entries()) call.arguments = originalCalls[index]!.input as Record<string, unknown>;
|
|
1113
|
+
for (const call of split.calls) {
|
|
1114
|
+
const original = originalCalls.find(block => block.id === call.id);
|
|
1115
|
+
if (!original) throw new LoopError(502, "Anthropic search replay tool identity changed");
|
|
1116
|
+
call.queries = parseQueries(JSON.stringify(original.input));
|
|
1117
|
+
}
|
|
1118
|
+
bindAnthropicReplay(message, split.anthropicReplay);
|
|
1119
|
+
messages.push(message);
|
|
1120
|
+
}
|
|
1121
|
+
const iterationThinking = preserveAnthropicHistory ? [] : extractIterationThinking(split.passthrough);
|
|
1016
1122
|
for (const [callIndex, call] of split.calls.entries()) {
|
|
1017
1123
|
accountRefusalOutputStarted = true;
|
|
1018
|
-
yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
|
|
1124
|
+
yield* runSearchCall(call, callIndex === 0 ? iterationThinking : [], !preserveAnthropicHistory);
|
|
1019
1125
|
}
|
|
1020
1126
|
} catch (e) {
|
|
1021
1127
|
// The bridge reports no usage for either error; the iterations already sent still count.
|
|
@@ -1029,7 +1135,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
1029
1135
|
message: "upstream translation buffer exceeded the safe limit",
|
|
1030
1136
|
};
|
|
1031
1137
|
} else {
|
|
1032
|
-
yield { type: "error",
|
|
1138
|
+
if (e instanceof SendBudgetExhaustedError) yield { type: "error", status: 429,
|
|
1139
|
+
errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false };
|
|
1140
|
+
else yield e instanceof LoopError && e.event ? e.event
|
|
1141
|
+
: e instanceof LoopError && deps.thinkingRecovery
|
|
1142
|
+
? { type: "error", status: e.status, errorType: "upstream_error", message: e.message }
|
|
1143
|
+
: { type: "error", message: e instanceof Error ? e.message : String(e) };
|
|
1033
1144
|
}
|
|
1034
1145
|
return;
|
|
1035
1146
|
}
|
|
@@ -1037,6 +1148,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
1037
1148
|
if (hiddenUsage) deps.onUsage?.(hiddenUsage);
|
|
1038
1149
|
yield { type: "error", message: "web-search loop exceeded its iteration cap" };
|
|
1039
1150
|
} finally {
|
|
1151
|
+
for (const snapshot of replaySnapshots) snapshot.release();
|
|
1152
|
+
replaySnapshots.clear();
|
|
1040
1153
|
if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
|
|
1041
1154
|
}
|
|
1042
1155
|
}
|
|
@@ -26,7 +26,15 @@ export class WebSearchStreamProtocolError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Opt-in preserves an adapter refusal before it becomes an untyped parser exception. */
|
|
30
|
+
export class WebSearchAdapterEventError extends Error {
|
|
31
|
+
constructor(readonly event: Extract<AdapterEvent, { type: "error" }>, readonly replaySafe: boolean) {
|
|
32
|
+
super(event.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
export interface ParseStreamWithProgressOptions {
|
|
37
|
+
preserveAdapterError?: boolean;
|
|
30
38
|
inactivityTimeoutMs: number;
|
|
31
39
|
translatorBudget: TranslatorBudget;
|
|
32
40
|
signal?: AbortSignal;
|
|
@@ -249,6 +257,7 @@ export async function* parseStreamWithProgress(
|
|
|
249
257
|
|
|
250
258
|
resetInactivity();
|
|
251
259
|
|
|
260
|
+
let replaySafe = true;
|
|
252
261
|
const parserPump = (async (): Promise<void> => {
|
|
253
262
|
let heldTerminal: Extract<AdapterEvent, { type: "done" | "incomplete" }> | undefined;
|
|
254
263
|
try {
|
|
@@ -301,9 +310,10 @@ export async function* parseStreamWithProgress(
|
|
|
301
310
|
if (event.usage) options.onErrorUsage?.(event.usage);
|
|
302
311
|
fail(event.code === "translation_buffer_limit"
|
|
303
312
|
? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES)
|
|
304
|
-
: new Error(event.message));
|
|
313
|
+
: options.preserveAdapterError ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message));
|
|
305
314
|
return;
|
|
306
315
|
}
|
|
316
|
+
replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true;
|
|
307
317
|
if (event.type === "done" || event.type === "incomplete") {
|
|
308
318
|
heldTerminal = event;
|
|
309
319
|
// Response-byte inactivity ends once the adapter has produced a terminal event.
|