@cortexkit/opencode-magic-context 0.49.17 → 0.49.19
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/dist/auto-search-worker.js +6 -2
- package/dist/chunk-embedding-write-worker.js +2 -2
- package/dist/{context-yhetp0kn.js → context-j31m2dv7.js} +27 -14
- package/dist/features/magic-context/dreamer/classify.d.ts +3 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts +11 -0
- package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +3 -0
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +3 -0
- package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
- package/dist/features/magic-context/storage-host-instruction-retention.d.ts +11 -0
- package/dist/features/magic-context/storage-host-instruction-retention.d.ts.map +1 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts +13 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/hooks/magic-context/auto-search-worker-evaluation.fixture.d.ts +2 -0
- package/dist/hooks/magic-context/auto-search-worker-evaluation.fixture.d.ts.map +1 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts +22 -1
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-trigger.d.ts +4 -2
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/file-token-coverage.d.ts +11 -0
- package/dist/hooks/magic-context/file-token-coverage.d.ts.map +1 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +9 -0
- package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/host-served-rows.d.ts +25 -0
- package/dist/hooks/magic-context/host-served-rows.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +7 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts +13 -0
- package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/pass-outcome.d.ts +2 -0
- package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts +2 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
- package/dist/hooks/magic-context/signed-thinking-hold.d.ts +59 -0
- package/dist/hooks/magic-context/signed-thinking-hold.d.ts.map +1 -0
- package/dist/hooks/magic-context/system-date-refresh.d.ts +15 -2
- package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +6 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-context-state.d.ts +19 -0
- package/dist/hooks/magic-context/transform-context-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +21 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/{index-aabzay3h.js → index-3esx8tkf.js} +256 -50
- package/dist/{index-v8awdgvd.js → index-3h4dp9wb.js} +1 -1
- package/dist/{index-5a1ex6x8.js → index-4911rn0b.js} +3 -3
- package/dist/{index-qn7xstt2.js → index-63k48rv9.js} +1 -2
- package/dist/{index-p7m7j0cp.js → index-9zcsy3hh.js} +53 -7
- package/dist/{index-nkpmm9q8.js → index-c1p88z1y.js} +3 -3
- package/dist/{index-hqhm3wke.js → index-cf74k5ak.js} +2 -2
- package/dist/{index-vrzjhftj.js → index-dxmq29t1.js} +4 -4
- package/dist/{index-kmdj7dsq.js → index-edmpdxcw.js} +1 -1
- package/dist/{index-4yt7dtf4.js → index-jhghdnsm.js} +113 -44
- package/dist/{index-fb2qxs2y.js → index-jrr131e0.js} +2 -2
- package/dist/{index-a7c4r5re.js → index-rpxdjkm0.js} +216 -40
- package/dist/{index-cwzfcbtq.js → index-se4m2g6w.js} +10 -1
- package/dist/index.js +21 -17
- package/dist/{message-index-bounded-1633vc7f.js → message-index-bounded-zyc8h32y.js} +3 -3
- package/dist/{message-index-dprebmtt.js → message-index-gtpqnj8w.js} +3 -3
- package/dist/migration-worker.js +2 -2
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/raw-ordinal-warmup-worker.js +4 -4
- package/dist/{read-session-chunk-4zp09ejb.js → read-session-chunk-jjb48bt9.js} +2 -2
- package/dist/{recomp-orchestrator-z3ecszx6.js → recomp-orchestrator-999jj4bc.js} +8 -8
- package/dist/{search-archive-znyy44ve.js → search-archive-6vm0e2f5.js} +3 -3
- package/dist/{search-6mee3vty.js → search-xsqk1bj4.js} +6 -6
- package/dist/shared/model-suggestion-retry.d.ts +30 -1
- package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
- package/dist/shared/prompt-async-transport.d.ts.map +1 -1
- package/dist/{storage-tags-8qns3012.js → storage-tags-r7k9tjcv.js} +1 -1
- package/dist/tools/ctx-expand/rust-mode-tag.test-support.d.ts +15 -0
- package/dist/tools/ctx-expand/rust-mode-tag.test-support.d.ts.map +1 -0
- package/dist/tools/ctx-expand/tools.d.ts +11 -0
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/{transform-postprocess-phase-3ydzxa62.js → transform-postprocess-phase-jj99r7z2.js} +8 -8
- package/dist/v2/auto-search-worker.js +6 -2
- package/dist/v2/chunk-embedding-write-worker.js +2 -2
- package/dist/v2/{context-jn3zpeqh.js → context-y5yamxak.js} +378 -92
- package/dist/v2/hooks/context.d.ts.map +1 -1
- package/dist/v2/hooks/payload.d.ts.map +1 -1
- package/dist/v2/hooks/tools.d.ts.map +1 -1
- package/dist/v2/{message-index-pxehjpzx.js → message-index-8vz2pq76.js} +3 -3
- package/dist/v2/{message-index-bounded-hpejqyd8.js → message-index-bounded-spd8r7pj.js} +3 -3
- package/dist/v2/migration-worker.js +2 -2
- package/dist/v2/raw-ordinal-warmup-worker.js +4 -4
- package/dist/v2/{read-session-chunk-zdppn4bj.js → read-session-chunk-cwkxrmns.js} +2 -2
- package/dist/v2/{recomp-orchestrator-cmqnfpp3.js → recomp-orchestrator-f6m9jr7p.js} +7 -7
- package/dist/v2/{search-archive-gfh11vs0.js → search-archive-wve3tq1k.js} +3 -3
- package/dist/v2/{search-66842dya.js → search-hwg7jfbj.js} +6 -6
- package/dist/v2/{server-zyzawhdw.js → server-27gq4mfz.js} +2 -2
- package/dist/v2/{server-3rhpvy84.js → server-2ar62dyx.js} +10 -1
- package/dist/v2/{server-ec4ca92p.js → server-45q69hkq.js} +1 -1
- package/dist/v2/{server-fw7wrk01.js → server-6vdzn1td.js} +216 -40
- package/dist/v2/{server-kh7dpa1a.js → server-9hx7g5jp.js} +53 -7
- package/dist/v2/{server-gtfhttbr.js → server-bmymkx73.js} +2 -2
- package/dist/v2/{server-vsw8tmm6.js → server-gsxjv2q2.js} +4 -4
- package/dist/v2/{server-my7k4eds.js → server-hm38q7r0.js} +1 -1
- package/dist/v2/{server-gpvay0xz.js → server-xq7dfjke.js} +1 -2
- package/dist/v2/server.js +2 -2
- package/dist/v2/{storage-tags-1h8k6ra9.js → storage-tags-3wdb8zph.js} +1 -1
- package/dist/v2/store-reader.d.ts +6 -0
- package/dist/v2/store-reader.d.ts.map +1 -1
- package/dist/v2/{wrapup-orchestrator-xs8redz9.js → wrapup-orchestrator-embdz8gj.js} +7 -7
- package/dist/{wrapup-orchestrator-j7d7ry5b.js → wrapup-orchestrator-whhbjmaz.js} +9 -9
- package/package.json +1 -1
- package/src/shared/model-suggestion-retry.test.ts +166 -0
- package/src/shared/model-suggestion-retry.ts +165 -14
- package/src/shared/prompt-async-transport.test.ts +107 -0
- package/src/shared/prompt-async-transport.ts +39 -0
|
@@ -89,11 +89,26 @@ export interface PromptRetryOptions {
|
|
|
89
89
|
* still self-heal at the primary AND at each fallback).
|
|
90
90
|
* - Iteration stops immediately on abort/timeout/context-overflow errors —
|
|
91
91
|
* fallbacks won't help and the caller's emergency-recovery path needs
|
|
92
|
-
* to handle these.
|
|
92
|
+
* to handle these. The exception is an attempt cut by
|
|
93
|
+
* `firstOutputTimeoutMs` (see there), which moves on to the next model.
|
|
93
94
|
* - On all-failed, the LAST error is thrown (matches legacy behavior when
|
|
94
95
|
* `fallbackModels` is empty).
|
|
95
96
|
*/
|
|
96
97
|
fallbackModels?: readonly ModelInput[];
|
|
98
|
+
/**
|
|
99
|
+
* How long an attempt may go without the model producing anything (no
|
|
100
|
+
* streamed text, reasoning, tool call, or token usage) before the model is
|
|
101
|
+
* treated as hung. The attempt is then stopped and the next configured model
|
|
102
|
+
* runs with whatever is left of `timeoutMs`, so a dead model costs this
|
|
103
|
+
* bound instead of the whole timeout. An attempt that has produced output
|
|
104
|
+
* keeps its full timeout and, if that expires, still ends the chain.
|
|
105
|
+
*
|
|
106
|
+
* Applies only to an attempt that has a later model to move to, is capped at
|
|
107
|
+
* half that attempt's timeout so the next model keeps a usable share, and
|
|
108
|
+
* only works with transports that watch the child for output (OpenCode's
|
|
109
|
+
* `prompt_async` wait). Undefined = no first-output deadline.
|
|
110
|
+
*/
|
|
111
|
+
firstOutputTimeoutMs?: number;
|
|
97
112
|
/**
|
|
98
113
|
* Identifier for structured logging (e.g. "dreamer:consolidate",
|
|
99
114
|
* "historian", "compressor", "dreamer"). Helps correlate fallback
|
|
@@ -168,9 +183,53 @@ export function isHostTimeoutError(error: unknown): boolean {
|
|
|
168
183
|
);
|
|
169
184
|
}
|
|
170
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The model chain cut this attempt short because the model had produced nothing
|
|
188
|
+
* (no streamed text, reasoning, tool call, or token usage) by the first-output
|
|
189
|
+
* deadline. A model that has not answered at all is treated like a provider
|
|
190
|
+
* failure, so the next configured model gets the rest of the time.
|
|
191
|
+
*/
|
|
192
|
+
export class ModelProducedNoOutputError extends Error {
|
|
193
|
+
constructor(readonly waitedMs: number) {
|
|
194
|
+
// Keeps "timed out after Nms" so ledgers record the attempt as timed out.
|
|
195
|
+
super(`prompt timed out after ${waitedMs}ms with no model output`);
|
|
196
|
+
this.name = "ModelProducedNoOutputError";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* What a transport has seen of the model during one attempt. `monitored` means
|
|
202
|
+
* the transport watches the child's messages and will set `received` as soon as
|
|
203
|
+
* the model streams anything. Transports that cannot see progress never set
|
|
204
|
+
* `monitored`, and the first-output deadline then never fires for them.
|
|
205
|
+
*/
|
|
206
|
+
interface ModelActivity {
|
|
207
|
+
monitored: boolean;
|
|
208
|
+
received: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Keyed by the per-attempt abort signal that `promptWithTimeout` hands the transport. */
|
|
212
|
+
const modelActivityBySignal = new WeakMap<AbortSignal, ModelActivity>();
|
|
213
|
+
|
|
214
|
+
/** A transport calls this once it is watching the child for model output. */
|
|
215
|
+
export function noteModelActivityMonitored(signal: AbortSignal | undefined): void {
|
|
216
|
+
const activity = signal ? modelActivityBySignal.get(signal) : undefined;
|
|
217
|
+
if (activity) activity.monitored = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A transport calls this when it sees the model produce anything for this attempt. */
|
|
221
|
+
export function noteModelOutputReceived(signal: AbortSignal | undefined): void {
|
|
222
|
+
const activity = signal ? modelActivityBySignal.get(signal) : undefined;
|
|
223
|
+
if (activity) {
|
|
224
|
+
activity.monitored = true;
|
|
225
|
+
activity.received = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
171
229
|
/** Our own slice expiry (see `promptWithTimeout`) or a host request timer. */
|
|
172
230
|
export function isPromptTimeoutError(error: unknown): boolean {
|
|
173
231
|
if (isHostTimeoutError(error)) return true;
|
|
232
|
+
if (error instanceof ModelProducedNoOutputError) return true;
|
|
174
233
|
return error instanceof Error && /^prompt timed out after \d+ms$/.test(error.message);
|
|
175
234
|
}
|
|
176
235
|
|
|
@@ -275,6 +334,7 @@ async function promptWithTimeout(
|
|
|
275
334
|
timeoutMs: number,
|
|
276
335
|
signal?: AbortSignal,
|
|
277
336
|
transport?: PromptTransport,
|
|
337
|
+
firstOutputTimeoutMs?: number,
|
|
278
338
|
): Promise<void> {
|
|
279
339
|
// Bail immediately if the caller's signal is already aborted (e.g.
|
|
280
340
|
// lease loss before this attempt was scheduled). Per spec
|
|
@@ -287,6 +347,23 @@ async function promptWithTimeout(
|
|
|
287
347
|
}
|
|
288
348
|
const controller = new AbortController();
|
|
289
349
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
350
|
+
const activity: ModelActivity = { monitored: false, received: false };
|
|
351
|
+
modelActivityBySignal.set(controller.signal, activity);
|
|
352
|
+
// Set when the first-output deadline, not the attempt timeout, ended the attempt.
|
|
353
|
+
let silent = false;
|
|
354
|
+
const firstOutputTimer =
|
|
355
|
+
firstOutputTimeoutMs !== undefined && firstOutputTimeoutMs < timeoutMs
|
|
356
|
+
? setTimeout(() => {
|
|
357
|
+
// Only a transport that watches the child can say the model is silent.
|
|
358
|
+
if (!activity.monitored || activity.received) return;
|
|
359
|
+
silent = true;
|
|
360
|
+
controller.abort();
|
|
361
|
+
}, firstOutputTimeoutMs)
|
|
362
|
+
: undefined;
|
|
363
|
+
const timeoutError = () =>
|
|
364
|
+
silent && firstOutputTimeoutMs !== undefined
|
|
365
|
+
? new ModelProducedNoOutputError(firstOutputTimeoutMs)
|
|
366
|
+
: new Error(`prompt timed out after ${timeoutMs}ms`);
|
|
290
367
|
|
|
291
368
|
// Link external signal to internal controller so external abort cancels the fetch
|
|
292
369
|
const onExternalAbort = () => controller.abort();
|
|
@@ -319,7 +396,7 @@ async function promptWithTimeout(
|
|
|
319
396
|
if (!transport || transport.childSessionId) {
|
|
320
397
|
await abortChildRun(client, transport?.childSessionId ?? args.path.id);
|
|
321
398
|
}
|
|
322
|
-
throw
|
|
399
|
+
throw timeoutError();
|
|
323
400
|
}
|
|
324
401
|
if (isHostTimeoutError(error)) {
|
|
325
402
|
// The host client's own request timer fired. Only our side of the
|
|
@@ -333,6 +410,7 @@ async function promptWithTimeout(
|
|
|
333
410
|
throw error;
|
|
334
411
|
} finally {
|
|
335
412
|
clearTimeout(timeout);
|
|
413
|
+
if (firstOutputTimer) clearTimeout(firstOutputTimer);
|
|
336
414
|
signal?.removeEventListener("abort", onExternalAbort);
|
|
337
415
|
}
|
|
338
416
|
// Some transports resolve with an error result when their fetch is aborted.
|
|
@@ -341,11 +419,7 @@ async function promptWithTimeout(
|
|
|
341
419
|
if (!transport || transport.childSessionId) {
|
|
342
420
|
await abortChildRun(client, transport?.childSessionId ?? args.path.id);
|
|
343
421
|
}
|
|
344
|
-
throw new Error(
|
|
345
|
-
signal?.aborted
|
|
346
|
-
? externalAbortMessage(signal)
|
|
347
|
-
: `prompt timed out after ${timeoutMs}ms`,
|
|
348
|
-
);
|
|
422
|
+
throw signal?.aborted ? new Error(externalAbortMessage(signal)) : timeoutError();
|
|
349
423
|
}
|
|
350
424
|
}
|
|
351
425
|
|
|
@@ -382,7 +456,12 @@ async function abortChildRun(client: Client, sessionId: string): Promise<void> {
|
|
|
382
456
|
* model. Caller has its own emergency-recovery path for this.
|
|
383
457
|
* - Timeout — same wall-clock budget on the same prompt is unlikely to
|
|
384
458
|
* succeed on another model. Caller decides whether to retry at a higher
|
|
385
|
-
* level (e.g. historian's MAX_HISTORIAN_RETRIES loop).
|
|
459
|
+
* level (e.g. historian's MAX_HISTORIAN_RETRIES loop). This covers an
|
|
460
|
+
* attempt that ran out its whole time, whether or not the model answered.
|
|
461
|
+
*
|
|
462
|
+
* A `ModelProducedNoOutputError` is the exception: no output was observed
|
|
463
|
+
* before the first-output deadline, which points at the model rather than the
|
|
464
|
+
* prompt, so the chain gives the next model the time that is left.
|
|
386
465
|
*
|
|
387
466
|
* Everything else (auth errors, ProviderModelNotFoundError without suggestion,
|
|
388
467
|
* rate limits, transient network failures, etc.) is considered retryable on a
|
|
@@ -397,6 +476,8 @@ function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
|
|
|
397
476
|
)
|
|
398
477
|
return true;
|
|
399
478
|
|
|
479
|
+
if (error instanceof ModelProducedNoOutputError) return false;
|
|
480
|
+
|
|
400
481
|
if (error instanceof Error) {
|
|
401
482
|
if (error.name === "AbortError") return true;
|
|
402
483
|
// promptWithTimeout wraps both abort cases in plain `Error` with a
|
|
@@ -442,7 +523,11 @@ function classifyPromptFailure(
|
|
|
442
523
|
if (externalSignal?.aborted || message === "prompt aborted by external signal") {
|
|
443
524
|
return "child_aborted";
|
|
444
525
|
}
|
|
445
|
-
if (
|
|
526
|
+
if (
|
|
527
|
+
error instanceof ModelProducedNoOutputError ||
|
|
528
|
+
/^prompt timed out after \d+ms$/.test(message) ||
|
|
529
|
+
isHostTimeoutError(error)
|
|
530
|
+
) {
|
|
446
531
|
return "provider_timeout";
|
|
447
532
|
}
|
|
448
533
|
if (phase === "validation") {
|
|
@@ -513,6 +598,7 @@ async function attemptOnce(
|
|
|
513
598
|
label: string,
|
|
514
599
|
transport?: PromptTransport,
|
|
515
600
|
allowModelSuggestions = true,
|
|
601
|
+
firstOutputTimeoutMs?: number,
|
|
516
602
|
): Promise<void> {
|
|
517
603
|
// Keep this snapshot separate from the object passed to the client. A
|
|
518
604
|
// failed prompt facade may rewrite its request body before rejecting; the
|
|
@@ -520,7 +606,14 @@ async function attemptOnce(
|
|
|
520
606
|
const originalBody = { ...args.body };
|
|
521
607
|
const attemptArgs = copyPromptArgs(args, originalBody);
|
|
522
608
|
try {
|
|
523
|
-
await promptWithTimeout(
|
|
609
|
+
await promptWithTimeout(
|
|
610
|
+
client,
|
|
611
|
+
attemptArgs,
|
|
612
|
+
timeoutMs,
|
|
613
|
+
signal,
|
|
614
|
+
transport,
|
|
615
|
+
firstOutputTimeoutMs,
|
|
616
|
+
);
|
|
524
617
|
return;
|
|
525
618
|
} catch (error) {
|
|
526
619
|
// Non-retryable failures and callers with model suggestions disabled
|
|
@@ -551,10 +644,48 @@ async function attemptOnce(
|
|
|
551
644
|
timeoutMs,
|
|
552
645
|
signal,
|
|
553
646
|
transport,
|
|
647
|
+
firstOutputTimeoutMs,
|
|
554
648
|
);
|
|
555
649
|
}
|
|
556
650
|
}
|
|
557
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Per-attempt timing across one model chain. An attempt cut by the first-output
|
|
654
|
+
* deadline has the time it used taken out of every later attempt's timeout, so
|
|
655
|
+
* a silent model never stretches the caller's overall wait past `timeoutMs`.
|
|
656
|
+
* Attempts that fail for other reasons keep the existing full per-attempt budget.
|
|
657
|
+
*/
|
|
658
|
+
function createChainClock(options: PromptRetryOptions, timeoutMs: number) {
|
|
659
|
+
const fallbacks = options.fallbackModels ?? [];
|
|
660
|
+
const hasValidFallbackFrom = (index: number) =>
|
|
661
|
+
fallbacks.slice(index).some((fallback) => {
|
|
662
|
+
const entry = toModelEntry(fallback);
|
|
663
|
+
return entry ? parseProviderModel(entry.model) !== null : false;
|
|
664
|
+
});
|
|
665
|
+
let silentSpentMs = 0;
|
|
666
|
+
return {
|
|
667
|
+
/** Budget for an attempt whose next candidate is `fallbacks[nextFallbackIndex]`. */
|
|
668
|
+
budget(nextFallbackIndex: number): { timeoutMs: number; firstOutputTimeoutMs?: number } {
|
|
669
|
+
const attemptTimeoutMs = Math.max(1, timeoutMs - silentSpentMs);
|
|
670
|
+
// The last model in the chain has nothing to move to, so it keeps the
|
|
671
|
+
// plain timeout rather than being cut short for no benefit.
|
|
672
|
+
if (
|
|
673
|
+
options.firstOutputTimeoutMs === undefined ||
|
|
674
|
+
!hasValidFallbackFrom(nextFallbackIndex)
|
|
675
|
+
)
|
|
676
|
+
return { timeoutMs: attemptTimeoutMs };
|
|
677
|
+
const bound = Math.min(options.firstOutputTimeoutMs, Math.floor(attemptTimeoutMs / 2));
|
|
678
|
+
return bound > 0
|
|
679
|
+
? { timeoutMs: attemptTimeoutMs, firstOutputTimeoutMs: bound }
|
|
680
|
+
: { timeoutMs: attemptTimeoutMs };
|
|
681
|
+
},
|
|
682
|
+
settle(error: unknown, startedAt: number): void {
|
|
683
|
+
if (error instanceof ModelProducedNoOutputError)
|
|
684
|
+
silentSpentMs += Date.now() - startedAt;
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
558
689
|
/**
|
|
559
690
|
* Run an OpenCode subagent prompt with model fallback support.
|
|
560
691
|
*
|
|
@@ -591,21 +722,26 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
591
722
|
: "primary";
|
|
592
723
|
|
|
593
724
|
let lastError: unknown = null;
|
|
725
|
+
const clock = createChainClock(options, timeoutMs);
|
|
594
726
|
|
|
727
|
+
const primaryStartedAt = Date.now();
|
|
595
728
|
try {
|
|
729
|
+
const budget = clock.budget(0);
|
|
596
730
|
await attemptOnce(
|
|
597
731
|
client,
|
|
598
732
|
baseArgs,
|
|
599
|
-
timeoutMs,
|
|
733
|
+
budget.timeoutMs,
|
|
600
734
|
options.signal,
|
|
601
735
|
callContext,
|
|
602
736
|
explicitPrimaryLabel,
|
|
603
737
|
options.transport,
|
|
604
738
|
options.allowModelSuggestions,
|
|
739
|
+
budget.firstOutputTimeoutMs,
|
|
605
740
|
);
|
|
606
741
|
return;
|
|
607
742
|
} catch (error) {
|
|
608
743
|
lastError = error;
|
|
744
|
+
clock.settle(error, primaryStartedAt);
|
|
609
745
|
if (isNonRetryable(error, options.signal)) throw error;
|
|
610
746
|
|
|
611
747
|
if (fallbacks.length === 0) {
|
|
@@ -637,16 +773,19 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
637
773
|
...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
|
|
638
774
|
});
|
|
639
775
|
|
|
776
|
+
const attemptStartedAt = Date.now();
|
|
640
777
|
try {
|
|
778
|
+
const budget = clock.budget(i + 1);
|
|
641
779
|
await attemptOnce(
|
|
642
780
|
client,
|
|
643
781
|
attemptArgs,
|
|
644
|
-
timeoutMs,
|
|
782
|
+
budget.timeoutMs,
|
|
645
783
|
options.signal,
|
|
646
784
|
callContext,
|
|
647
785
|
label,
|
|
648
786
|
options.transport,
|
|
649
787
|
options.allowModelSuggestions,
|
|
788
|
+
budget.firstOutputTimeoutMs,
|
|
650
789
|
);
|
|
651
790
|
log(
|
|
652
791
|
`[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
|
|
@@ -654,6 +793,7 @@ export async function promptSyncWithModelSuggestionRetry(
|
|
|
654
793
|
return;
|
|
655
794
|
} catch (error) {
|
|
656
795
|
lastError = error;
|
|
796
|
+
clock.settle(error, attemptStartedAt);
|
|
657
797
|
if (isNonRetryable(error, options.signal)) throw error;
|
|
658
798
|
|
|
659
799
|
const remaining = fallbacks.length - i - 1;
|
|
@@ -682,6 +822,7 @@ async function attemptAndValidate<TOutput, TValidated>(
|
|
|
682
822
|
callContext: string,
|
|
683
823
|
attempt: PromptAttemptInfo,
|
|
684
824
|
options: ValidatedPromptRetryOptions<TOutput, TValidated>,
|
|
825
|
+
firstOutputTimeoutMs?: number,
|
|
685
826
|
): Promise<ValidatedPromptRetryResult<TOutput, TValidated>> {
|
|
686
827
|
try {
|
|
687
828
|
await attemptOnce(
|
|
@@ -693,6 +834,7 @@ async function attemptAndValidate<TOutput, TValidated>(
|
|
|
693
834
|
attempt.label,
|
|
694
835
|
options.transport,
|
|
695
836
|
options.allowModelSuggestions,
|
|
837
|
+
firstOutputTimeoutMs,
|
|
696
838
|
);
|
|
697
839
|
} catch (error) {
|
|
698
840
|
throw {
|
|
@@ -779,7 +921,9 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
779
921
|
let firstError: unknown = null;
|
|
780
922
|
let lastError: unknown = null;
|
|
781
923
|
const cooledPrimary = primaryQuotaFailure(explicitPrimaryLabel);
|
|
924
|
+
const clock = createChainClock(options, timeoutMs);
|
|
782
925
|
|
|
926
|
+
const primaryStartedAt = Date.now();
|
|
783
927
|
try {
|
|
784
928
|
if (cooledPrimary)
|
|
785
929
|
throw {
|
|
@@ -794,10 +938,11 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
794
938
|
skipped: true,
|
|
795
939
|
},
|
|
796
940
|
} satisfies FailedAttempt;
|
|
941
|
+
const budget = clock.budget(0);
|
|
797
942
|
return await attemptAndValidate(
|
|
798
943
|
client,
|
|
799
944
|
baseArgs,
|
|
800
|
-
timeoutMs,
|
|
945
|
+
budget.timeoutMs,
|
|
801
946
|
options.signal,
|
|
802
947
|
callContext,
|
|
803
948
|
{
|
|
@@ -808,10 +953,12 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
808
953
|
model: baseBody.model,
|
|
809
954
|
},
|
|
810
955
|
options,
|
|
956
|
+
budget.firstOutputTimeoutMs,
|
|
811
957
|
);
|
|
812
958
|
} catch (caught) {
|
|
813
959
|
const failure = caught as FailedAttempt;
|
|
814
960
|
failedAttempts.push(failure);
|
|
961
|
+
clock.settle(failure.error, primaryStartedAt);
|
|
815
962
|
firstError = failure.error;
|
|
816
963
|
lastError = failure.error;
|
|
817
964
|
if (
|
|
@@ -876,15 +1023,18 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
876
1023
|
model: parsed,
|
|
877
1024
|
};
|
|
878
1025
|
|
|
1026
|
+
const attemptStartedAt = Date.now();
|
|
879
1027
|
try {
|
|
1028
|
+
const budget = clock.budget(i + 1);
|
|
880
1029
|
const result = await attemptAndValidate(
|
|
881
1030
|
client,
|
|
882
1031
|
attemptArgs,
|
|
883
|
-
timeoutMs,
|
|
1032
|
+
budget.timeoutMs,
|
|
884
1033
|
options.signal,
|
|
885
1034
|
callContext,
|
|
886
1035
|
attempt,
|
|
887
1036
|
options,
|
|
1037
|
+
budget.firstOutputTimeoutMs,
|
|
888
1038
|
);
|
|
889
1039
|
log(
|
|
890
1040
|
`[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
|
|
@@ -893,6 +1043,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
|
|
|
893
1043
|
} catch (caught) {
|
|
894
1044
|
const failure = caught as FailedAttempt;
|
|
895
1045
|
failedAttempts.push(failure);
|
|
1046
|
+
clock.settle(failure.error, attemptStartedAt);
|
|
896
1047
|
lastError = failure.error;
|
|
897
1048
|
if (isNonRetryable(failure.error, options.signal)) {
|
|
898
1049
|
throwWithPromptFailure(
|
|
@@ -645,3 +645,110 @@ test("session errors belonging to another child do not fail the run", async () =
|
|
|
645
645
|
await promptAsyncAndWaitForIdle(host.client, request(), { pollIntervalMs: 1 });
|
|
646
646
|
expect(host.polls()).toBe(1);
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
describe("prompt_async transport and the first-output deadline", () => {
|
|
650
|
+
/**
|
|
651
|
+
* A child session where each model answers its own way. "dead" leaves the
|
|
652
|
+
* empty assistant row the host writes before the provider responds and stays
|
|
653
|
+
* busy forever; "partial" streams a text part and then stays busy; "answer"
|
|
654
|
+
* settles with a final assistant message on the next poll.
|
|
655
|
+
*/
|
|
656
|
+
function modelHost(behaviours: Record<string, "dead" | "partial" | "answer">) {
|
|
657
|
+
const messages: Message[] = [];
|
|
658
|
+
let current: "dead" | "partial" | "answer" | null = null;
|
|
659
|
+
const sent: string[] = [];
|
|
660
|
+
const promptAsync = mock(
|
|
661
|
+
async (request: { body: { model?: { providerID: string; modelID: string } } }) => {
|
|
662
|
+
const model = request.body.model
|
|
663
|
+
? `${request.body.model.providerID}/${request.body.model.modelID}`
|
|
664
|
+
: "primary";
|
|
665
|
+
sent.push(model);
|
|
666
|
+
current = behaviours[model] ?? "answer";
|
|
667
|
+
messages.push({
|
|
668
|
+
info: { id: `msg_user_${messages.length}`, role: "user", time: { created: 1 } },
|
|
669
|
+
parts: [{ type: "text", text: "go" }],
|
|
670
|
+
});
|
|
671
|
+
messages.push({
|
|
672
|
+
info: {
|
|
673
|
+
id: `msg_assistant_${messages.length}`,
|
|
674
|
+
role: "assistant",
|
|
675
|
+
time: { created: 2 },
|
|
676
|
+
},
|
|
677
|
+
parts: current === "partial" ? [{ type: "text", text: "<classify>" }] : [],
|
|
678
|
+
});
|
|
679
|
+
return { data: undefined };
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
const abort = mock(async () => {
|
|
683
|
+
current = null;
|
|
684
|
+
return { data: true };
|
|
685
|
+
});
|
|
686
|
+
const status = mock(async () => {
|
|
687
|
+
if (current === "dead" || current === "partial")
|
|
688
|
+
return { data: { "ses-child": { type: "busy" } } };
|
|
689
|
+
if (current === "answer") {
|
|
690
|
+
const last = messages.at(-1);
|
|
691
|
+
if (last && !last.info.time.completed) {
|
|
692
|
+
last.info.time.completed = 3;
|
|
693
|
+
last.info.finish = "stop";
|
|
694
|
+
last.parts = [{ type: "text", text: "<classify></classify>" }];
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return { data: {} };
|
|
698
|
+
});
|
|
699
|
+
const list = mock(async () => ({ data: messages.map((message) => ({ ...message })) }));
|
|
700
|
+
return {
|
|
701
|
+
client: { session: { promptAsync, abort, status, messages: list } } as never,
|
|
702
|
+
sent,
|
|
703
|
+
abort,
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function runChain(
|
|
708
|
+
host: ReturnType<typeof modelHost>,
|
|
709
|
+
timeoutMs: number,
|
|
710
|
+
firstOutputTimeoutMs: number,
|
|
711
|
+
) {
|
|
712
|
+
const transport = createPromptAsyncTransport(host.client, "ses-child", {
|
|
713
|
+
pollIntervalMs: 5,
|
|
714
|
+
});
|
|
715
|
+
return promptSyncWithValidatedOutputRetry(
|
|
716
|
+
host.client,
|
|
717
|
+
{
|
|
718
|
+
...request(),
|
|
719
|
+
body: {
|
|
720
|
+
model: { providerID: "opencode", modelID: "free-model" },
|
|
721
|
+
parts: [{ type: "text", text: "go" }],
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
transport,
|
|
726
|
+
timeoutMs,
|
|
727
|
+
firstOutputTimeoutMs,
|
|
728
|
+
fallbackModels: ["anthropic/claude-sonnet-5-5"],
|
|
729
|
+
fetchOutput: async () => "<classify></classify>",
|
|
730
|
+
validateOutput: (output: string) => output,
|
|
731
|
+
},
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
test("a model that never answers is cut at the deadline and the fallback completes", async () => {
|
|
736
|
+
const host = modelHost({ "opencode/free-model": "dead" });
|
|
737
|
+
const startedAt = Date.now();
|
|
738
|
+
|
|
739
|
+
const run = await runChain(host, 5_000, 60);
|
|
740
|
+
|
|
741
|
+
expect(run.attempt.label).toBe("anthropic/claude-sonnet-5-5");
|
|
742
|
+
expect(host.sent).toEqual(["opencode/free-model", "anthropic/claude-sonnet-5-5"]);
|
|
743
|
+
// The dead model's run is stopped on the host before the fallback is sent.
|
|
744
|
+
expect(host.abort).toHaveBeenCalledWith({ path: { id: "ses-child" } });
|
|
745
|
+
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("a model that streamed part of an answer keeps its timeout and does not fall back", async () => {
|
|
749
|
+
const host = modelHost({ "opencode/free-model": "partial" });
|
|
750
|
+
|
|
751
|
+
await expect(runChain(host, 200, 40)).rejects.toThrow("prompt timed out after 200ms");
|
|
752
|
+
expect(host.sent).toEqual(["opencode/free-model"]);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
@@ -11,6 +11,7 @@ import { sumTokensFromChildMessages } from "../features/magic-context/subagent-t
|
|
|
11
11
|
import { describeAssistantError } from "./assistant-message-extractor";
|
|
12
12
|
import { sessionLog } from "./logger";
|
|
13
13
|
import type { PromptArgs, PromptTransport } from "./model-suggestion-retry";
|
|
14
|
+
import { noteModelActivityMonitored, noteModelOutputReceived } from "./model-suggestion-retry";
|
|
14
15
|
|
|
15
16
|
type Client = ReturnType<typeof createOpencodeClient> | undefined;
|
|
16
17
|
|
|
@@ -202,6 +203,29 @@ function isTerminalAssistant(message: unknown): boolean {
|
|
|
202
203
|
);
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* True when an assistant row shows the model has answered: any token usage, or a
|
|
208
|
+
* non-empty text or reasoning part, or a tool call. A row the host creates before
|
|
209
|
+
* the provider responds carries none of these.
|
|
210
|
+
*/
|
|
211
|
+
function showsModelOutput(message: unknown): boolean {
|
|
212
|
+
if (infoOf(message).role !== "assistant") return false;
|
|
213
|
+
const tokens = sumTokensFromChildMessages([message]);
|
|
214
|
+
if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) return true;
|
|
215
|
+
const parts = (message as { parts?: unknown } | null)?.parts;
|
|
216
|
+
if (!Array.isArray(parts)) return false;
|
|
217
|
+
return parts.some((part) => {
|
|
218
|
+
if (!part || typeof part !== "object") return false;
|
|
219
|
+
const { type, text } = part as { type?: unknown; text?: unknown };
|
|
220
|
+
if (type === "tool") return true;
|
|
221
|
+
return (
|
|
222
|
+
(type === "text" || type === "reasoning") &&
|
|
223
|
+
typeof text === "string" &&
|
|
224
|
+
text.trim().length > 0
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
205
229
|
function unwrapData(response: unknown): unknown {
|
|
206
230
|
if (response && typeof response === "object" && "data" in response) {
|
|
207
231
|
return (response as { data?: unknown }).data;
|
|
@@ -324,6 +348,10 @@ export async function promptAsyncAndWaitForIdle(
|
|
|
324
348
|
}
|
|
325
349
|
let sentAt = Date.now();
|
|
326
350
|
let sawBusy = false;
|
|
351
|
+
// From here on this wait reports whether the model has produced anything,
|
|
352
|
+
// which lets the model chain tell a silent model from a slow answer.
|
|
353
|
+
noteModelActivityMonitored(signal);
|
|
354
|
+
let sawOutput = false;
|
|
327
355
|
|
|
328
356
|
for (;;) {
|
|
329
357
|
await sleep(pollIntervalMs, signal);
|
|
@@ -341,6 +369,17 @@ export async function promptAsyncAndWaitForIdle(
|
|
|
341
369
|
}
|
|
342
370
|
const status = await readStatus(session, sessionId, dir, signal);
|
|
343
371
|
const messages = await readMessages(session, sessionId, dir, signal);
|
|
372
|
+
if (
|
|
373
|
+
!sawOutput &&
|
|
374
|
+
messages.some((message) => {
|
|
375
|
+
const id = messageId(message);
|
|
376
|
+
// Rows from before this send belong to an earlier attempt.
|
|
377
|
+
return id !== null && !initialBaseline.has(id) && showsModelOutput(message);
|
|
378
|
+
})
|
|
379
|
+
) {
|
|
380
|
+
sawOutput = true;
|
|
381
|
+
noteModelOutputReceived(signal);
|
|
382
|
+
}
|
|
344
383
|
if (budget) {
|
|
345
384
|
const latest = messages.at(-1);
|
|
346
385
|
const latestId = messageId(latest);
|