@cortexkit/opencode-magic-context 0.49.18 → 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-m897gfp9.js → context-j31m2dv7.js} +16 -12
- 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-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/hook.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/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/transform-postprocess-phase.d.ts +2 -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-936py2wm.js → index-3esx8tkf.js} +60 -26
- package/dist/{index-v8awdgvd.js → index-3h4dp9wb.js} +1 -1
- package/dist/{index-24jbw52d.js → index-4911rn0b.js} +3 -3
- package/dist/{index-qn7xstt2.js → index-63k48rv9.js} +1 -2
- package/dist/{index-rmwykbbb.js → index-9zcsy3hh.js} +16 -1
- package/dist/{index-r4nkrs05.js → index-c1p88z1y.js} +3 -3
- package/dist/{index-s7j8tvhv.js → index-dxmq29t1.js} +3 -3
- package/dist/{index-6s5941tm.js → index-edmpdxcw.js} +1 -1
- package/dist/{index-e4xv8890.js → index-jhghdnsm.js} +26 -12
- package/dist/{index-fgncb0ng.js → index-jrr131e0.js} +2 -2
- package/dist/{index-s1ex9y6p.js → index-rpxdjkm0.js} +165 -27
- package/dist/{index-x4njr8df.js → index-se4m2g6w.js} +1 -1
- package/dist/index.js +20 -16
- package/dist/{message-index-bounded-n1frdn85.js → message-index-bounded-zyc8h32y.js} +2 -2
- package/dist/{message-index-66jv6fh4.js → message-index-gtpqnj8w.js} +2 -2
- 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 +3 -3
- package/dist/{read-session-chunk-v06g3vka.js → read-session-chunk-jjb48bt9.js} +1 -1
- package/dist/{recomp-orchestrator-ghpk2dtq.js → recomp-orchestrator-999jj4bc.js} +7 -7
- package/dist/{search-archive-4d53y23e.js → search-archive-6vm0e2f5.js} +2 -2
- package/dist/{search-nkf271d4.js → search-xsqk1bj4.js} +5 -5
- 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/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-zpgav0h7.js → transform-postprocess-phase-jj99r7z2.js} +7 -7
- package/dist/v2/auto-search-worker.js +6 -2
- package/dist/v2/chunk-embedding-write-worker.js +2 -2
- package/dist/v2/{context-f2xh7ba4.js → context-y5yamxak.js} +85 -33
- package/dist/v2/hooks/tools.d.ts.map +1 -1
- package/dist/v2/{message-index-48f7hw7a.js → message-index-8vz2pq76.js} +2 -2
- package/dist/v2/{message-index-bounded-w3hfm29z.js → message-index-bounded-spd8r7pj.js} +2 -2
- package/dist/v2/migration-worker.js +2 -2
- package/dist/v2/raw-ordinal-warmup-worker.js +3 -3
- package/dist/v2/{read-session-chunk-zpgmxwte.js → read-session-chunk-cwkxrmns.js} +1 -1
- package/dist/v2/{recomp-orchestrator-4vfxej9t.js → recomp-orchestrator-f6m9jr7p.js} +6 -6
- package/dist/v2/{search-archive-vv48s2aa.js → search-archive-wve3tq1k.js} +2 -2
- package/dist/v2/{search-kza414f0.js → search-hwg7jfbj.js} +5 -5
- package/dist/v2/{server-2ehj0bw7.js → server-2ar62dyx.js} +1 -1
- package/dist/v2/{server-qv7f0hhe.js → server-45q69hkq.js} +1 -1
- package/dist/v2/{server-bfpda1tb.js → server-6vdzn1td.js} +165 -27
- package/dist/v2/{server-xhab8jx8.js → server-9hx7g5jp.js} +16 -1
- package/dist/v2/{server-a2eqhkxr.js → server-bmymkx73.js} +2 -2
- package/dist/v2/{server-1nyybqan.js → server-gsxjv2q2.js} +3 -3
- 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/{wrapup-orchestrator-psqz7yh0.js → wrapup-orchestrator-embdz8gj.js} +6 -6
- package/dist/{wrapup-orchestrator-s9zc5b00.js → wrapup-orchestrator-whhbjmaz.js} +8 -8
- 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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
runManagedWrapup2
|
|
3
|
-
} from "./index-
|
|
4
|
-
import"./index-
|
|
5
|
-
import"./index-
|
|
3
|
+
} from "./index-4911rn0b.js";
|
|
4
|
+
import"./index-c1p88z1y.js";
|
|
5
|
+
import"./index-rpxdjkm0.js";
|
|
6
6
|
import"./index-4s3t0nd0.js";
|
|
7
|
-
import"./index-
|
|
8
|
-
import"./index-
|
|
7
|
+
import"./index-3h4dp9wb.js";
|
|
8
|
+
import"./index-se4m2g6w.js";
|
|
9
9
|
import"./index-sd3egckz.js";
|
|
10
10
|
import"./index-24fezq97.js";
|
|
11
11
|
import"./index-ze7mvk54.js";
|
|
@@ -14,7 +14,7 @@ import"./index-4dhv7mqz.js";
|
|
|
14
14
|
import"./index-v10f1rjv.js";
|
|
15
15
|
import"./index-s74zqahq.js";
|
|
16
16
|
import"./index-qt1qqasc.js";
|
|
17
|
-
import"./index-
|
|
17
|
+
import"./index-9zcsy3hh.js";
|
|
18
18
|
import"./index-cf74k5ak.js";
|
|
19
19
|
import"./index-1zx87sdm.js";
|
|
20
20
|
import"./index-kq82mtg0.js";
|
|
@@ -25,9 +25,9 @@ import"./index-wgypt3cs.js";
|
|
|
25
25
|
import"./index-rr2bb45z.js";
|
|
26
26
|
import"./index-b5vqckbw.js";
|
|
27
27
|
import"./index-6x57ar45.js";
|
|
28
|
-
import"./index-
|
|
28
|
+
import"./index-jrr131e0.js";
|
|
29
29
|
import"./index-fb3zxn06.js";
|
|
30
|
-
import"./index-
|
|
30
|
+
import"./index-63k48rv9.js";
|
|
31
31
|
import"./index-2vxtcb95.js";
|
|
32
32
|
import"./index-a6mc0v4d.js";
|
|
33
33
|
import"./index-fr0wbhdn.js";
|
package/package.json
CHANGED
|
@@ -5,6 +5,8 @@ import { recordedGeminiQuotaMessages } from "../features/magic-context/dreamer/p
|
|
|
5
5
|
import { HiddenAgentStepLimit } from "../v2/hooks/hidden-child";
|
|
6
6
|
import {
|
|
7
7
|
getPromptFailureDetail,
|
|
8
|
+
noteModelActivityMonitored,
|
|
9
|
+
noteModelOutputReceived,
|
|
8
10
|
promptSyncWithModelSuggestionRetry,
|
|
9
11
|
promptSyncWithValidatedOutputRetry,
|
|
10
12
|
} from "./model-suggestion-retry";
|
|
@@ -833,3 +835,167 @@ test("timeout rejects an uncooperative carrier and aborts its child", async () =
|
|
|
833
835
|
clearTimeout(timer);
|
|
834
836
|
}
|
|
835
837
|
});
|
|
838
|
+
|
|
839
|
+
describe("first-output deadline", () => {
|
|
840
|
+
const primary = { providerID: "opencode", modelID: "free-model" };
|
|
841
|
+
type Behaviour = "silent" | "partial" | "answer" | "unmonitored";
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* A transport double that behaves per model. "silent" watches the child but the
|
|
845
|
+
* model never answers; "partial" streams something and then hangs; "unmonitored"
|
|
846
|
+
* hangs without ever reporting whether the model answered (the Pi and OpenCode 2
|
|
847
|
+
* carriers); "answer" completes at once.
|
|
848
|
+
*/
|
|
849
|
+
function modelTransport(behaviours: Record<string, Behaviour>) {
|
|
850
|
+
const calls: Array<{ model: string; startedAt: number }> = [];
|
|
851
|
+
const transport = Object.assign(
|
|
852
|
+
async (request: PromptCall & { signal?: AbortSignal }) => {
|
|
853
|
+
const model = request.body.model
|
|
854
|
+
? `${request.body.model.providerID}/${request.body.model.modelID}`
|
|
855
|
+
: "primary";
|
|
856
|
+
calls.push({ model, startedAt: Date.now() });
|
|
857
|
+
const behaviour = behaviours[model] ?? "answer";
|
|
858
|
+
if (behaviour === "answer") return;
|
|
859
|
+
if (behaviour === "silent") noteModelActivityMonitored(request.signal);
|
|
860
|
+
if (behaviour === "partial") noteModelOutputReceived(request.signal);
|
|
861
|
+
await new Promise<void>((_resolve, reject) =>
|
|
862
|
+
request.signal?.addEventListener("abort", () => reject(new Error("aborted"))),
|
|
863
|
+
);
|
|
864
|
+
},
|
|
865
|
+
{ childSessionId: "child-first-output" },
|
|
866
|
+
);
|
|
867
|
+
return { transport, calls };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function run(
|
|
871
|
+
behaviours: Record<string, Behaviour>,
|
|
872
|
+
options: { timeoutMs: number; firstOutputTimeoutMs?: number; fallbackModels?: string[] },
|
|
873
|
+
) {
|
|
874
|
+
const { transport, calls } = modelTransport(behaviours);
|
|
875
|
+
const startedAt = Date.now();
|
|
876
|
+
let caught: unknown;
|
|
877
|
+
let result: Awaited<ReturnType<typeof promptSyncWithValidatedOutputRetry>> | undefined;
|
|
878
|
+
try {
|
|
879
|
+
result = await promptSyncWithValidatedOutputRetry(
|
|
880
|
+
createClient(mock(async () => ({}))),
|
|
881
|
+
createArgs(primary),
|
|
882
|
+
{
|
|
883
|
+
transport: transport as never,
|
|
884
|
+
timeoutMs: options.timeoutMs,
|
|
885
|
+
...(options.firstOutputTimeoutMs !== undefined
|
|
886
|
+
? { firstOutputTimeoutMs: options.firstOutputTimeoutMs }
|
|
887
|
+
: {}),
|
|
888
|
+
fallbackModels: options.fallbackModels ?? ["anthropic/claude-sonnet-5-5"],
|
|
889
|
+
fetchOutput: async () => "<classify></classify>",
|
|
890
|
+
validateOutput: (output: string) => output,
|
|
891
|
+
},
|
|
892
|
+
);
|
|
893
|
+
} catch (error) {
|
|
894
|
+
caught = error;
|
|
895
|
+
}
|
|
896
|
+
return { result, caught, calls, elapsedMs: Date.now() - startedAt };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
test("a primary that streams nothing by the deadline moves on to the fallback", async () => {
|
|
900
|
+
const outcome = await run(
|
|
901
|
+
{ "opencode/free-model": "silent" },
|
|
902
|
+
{ timeoutMs: 2_000, firstOutputTimeoutMs: 40 },
|
|
903
|
+
);
|
|
904
|
+
expect(outcome.caught).toBeUndefined();
|
|
905
|
+
expect(outcome.result?.attempt.label).toBe("anthropic/claude-sonnet-5-5");
|
|
906
|
+
expect(outcome.calls.map((call) => call.model)).toEqual([
|
|
907
|
+
"opencode/free-model",
|
|
908
|
+
"anthropic/claude-sonnet-5-5",
|
|
909
|
+
]);
|
|
910
|
+
// The silent primary was cut at its first-output deadline, not its timeout.
|
|
911
|
+
expect(outcome.elapsedMs).toBeLessThan(1_000);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("the unvalidated chain also moves on from a silent primary", async () => {
|
|
915
|
+
const { transport, calls } = modelTransport({ "opencode/free-model": "silent" });
|
|
916
|
+
await promptSyncWithModelSuggestionRetry(
|
|
917
|
+
createClient(mock(async () => ({}))),
|
|
918
|
+
createArgs(primary),
|
|
919
|
+
{
|
|
920
|
+
transport: transport as never,
|
|
921
|
+
timeoutMs: 2_000,
|
|
922
|
+
firstOutputTimeoutMs: 40,
|
|
923
|
+
fallbackModels: ["anthropic/claude-sonnet-5-5"],
|
|
924
|
+
},
|
|
925
|
+
);
|
|
926
|
+
expect(calls.map((call) => call.model)).toEqual([
|
|
927
|
+
"opencode/free-model",
|
|
928
|
+
"anthropic/claude-sonnet-5-5",
|
|
929
|
+
]);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test("a primary that produced output before timing out does not fall back", async () => {
|
|
933
|
+
const outcome = await run(
|
|
934
|
+
{ "opencode/free-model": "partial" },
|
|
935
|
+
{ timeoutMs: 120, firstOutputTimeoutMs: 20 },
|
|
936
|
+
);
|
|
937
|
+
expect((outcome.caught as Error).message).toBe("prompt timed out after 120ms");
|
|
938
|
+
expect(outcome.calls).toHaveLength(1);
|
|
939
|
+
expect(getPromptFailureDetail(outcome.caught)?.failureClass).toBe("provider_timeout");
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("a silent primary's time comes out of the fallback's timeout", async () => {
|
|
943
|
+
// The fallback is the last model, so it gets no first-output cut of its own;
|
|
944
|
+
// its timeout is what the silent primary left of the overall 300 ms.
|
|
945
|
+
const outcome = await run(
|
|
946
|
+
{ "opencode/free-model": "silent", "anthropic/claude-sonnet-5-5": "silent" },
|
|
947
|
+
{ timeoutMs: 300, firstOutputTimeoutMs: 100 },
|
|
948
|
+
);
|
|
949
|
+
const message = (outcome.caught as Error).message;
|
|
950
|
+
const fallbackTimeoutMs = Number(/prompt timed out after (\d+)ms$/.exec(message)?.[1]);
|
|
951
|
+
expect(outcome.calls).toHaveLength(2);
|
|
952
|
+
expect(fallbackTimeoutMs).toBeLessThanOrEqual(200);
|
|
953
|
+
expect(fallbackTimeoutMs).toBeGreaterThan(100);
|
|
954
|
+
expect(outcome.elapsedMs).toBeLessThan(300 + 150);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("the last model in the chain is never cut for silence", async () => {
|
|
958
|
+
const outcome = await run(
|
|
959
|
+
{ "opencode/free-model": "silent" },
|
|
960
|
+
{ timeoutMs: 120, firstOutputTimeoutMs: 20, fallbackModels: [] },
|
|
961
|
+
);
|
|
962
|
+
expect((outcome.caught as Error).message).toBe("prompt timed out after 120ms");
|
|
963
|
+
expect(outcome.calls).toHaveLength(1);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("a transport that cannot see model output keeps the plain timeout", async () => {
|
|
967
|
+
const outcome = await run(
|
|
968
|
+
{ "opencode/free-model": "unmonitored" },
|
|
969
|
+
{ timeoutMs: 120, firstOutputTimeoutMs: 20 },
|
|
970
|
+
);
|
|
971
|
+
expect((outcome.caught as Error).message).toBe("prompt timed out after 120ms");
|
|
972
|
+
expect(outcome.calls).toHaveLength(1);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("a caller without a first-output deadline keeps timeouts final", async () => {
|
|
976
|
+
// The historian passes no first-output deadline: a silent model still ends
|
|
977
|
+
// its chain at the timeout, exactly as before.
|
|
978
|
+
const outcome = await run({ "opencode/free-model": "silent" }, { timeoutMs: 120 });
|
|
979
|
+
expect((outcome.caught as Error).message).toBe("prompt timed out after 120ms");
|
|
980
|
+
expect(outcome.calls).toHaveLength(1);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("a caller without a first-output deadline still falls back on a provider failure", async () => {
|
|
984
|
+
const prompt = mock(async (args: PromptCall) => {
|
|
985
|
+
if (args.body.model?.providerID === "opencode")
|
|
986
|
+
throw new Error("429 Too Many Requests: quota exceeded");
|
|
987
|
+
return {};
|
|
988
|
+
});
|
|
989
|
+
const result = await promptSyncWithValidatedOutputRetry(
|
|
990
|
+
createClient(prompt),
|
|
991
|
+
createArgs(primary),
|
|
992
|
+
{
|
|
993
|
+
fallbackModels: ["anthropic/claude-sonnet-5-5"],
|
|
994
|
+
fetchOutput: async () => "summary",
|
|
995
|
+
validateOutput: (output: string) => output,
|
|
996
|
+
},
|
|
997
|
+
);
|
|
998
|
+
expect(result.attempt.label).toBe("anthropic/claude-sonnet-5-5");
|
|
999
|
+
expect(prompt).toHaveBeenCalledTimes(2);
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
@@ -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(
|