muonroi-cli 1.6.3 → 1.6.4
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/src/generated/version.d.ts +1 -1
- package/dist/src/generated/version.js +1 -1
- package/dist/src/orchestrator/message-processor.js +104 -3
- package/dist/src/orchestrator/stall-watchdog.d.ts +31 -0
- package/dist/src/orchestrator/stall-watchdog.js +24 -0
- package/dist/src/orchestrator/stall-watchdog.test.js +46 -1
- package/dist/src/utils/settings.d.ts +13 -0
- package/dist/src/utils/settings.js +21 -0
- package/dist/src/utils/settings.test.js +31 -0
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "1.6.
|
|
1
|
+
export declare const PACKAGE_VERSION = "1.6.4";
|
|
2
2
|
export declare const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/sync-version.cjs. DO NOT EDIT BY HAND.
|
|
2
2
|
// Sourced from package.json at build time so it survives bun --compile bundling.
|
|
3
|
-
export const PACKAGE_VERSION = "1.6.
|
|
3
|
+
export const PACKAGE_VERSION = "1.6.4";
|
|
4
4
|
export const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
|
|
5
5
|
//# sourceMappingURL=version.js.map
|
|
@@ -89,7 +89,7 @@ import { statusBarStore } from "../ui/status-bar/store.js";
|
|
|
89
89
|
import { appendDecisionLog } from "../usage/decision-log.js";
|
|
90
90
|
import { openUrl } from "../utils/open-url.js";
|
|
91
91
|
import { appendAudit, toolNeedsApproval } from "../utils/permission-mode.js";
|
|
92
|
-
import { getAutoCouncilConfidence, getAutoCouncilMinRoles, getProviderStallTimeoutMs, getRoleModels, getTopLevelCompactKeepLast, getTopLevelCompactThresholdChars, getTopLevelToolBudgetChars, isAutoCouncilEnabled, isProviderDisabled, loadMcpServers, loadValidSubAgents, } from "../utils/settings.js";
|
|
92
|
+
import { getAutoCouncilConfidence, getAutoCouncilMinRoles, getProviderStallRetries, getProviderStallTimeoutMs, getRoleModels, getTopLevelCompactKeepLast, getTopLevelCompactThresholdChars, getTopLevelToolBudgetChars, isAutoCouncilEnabled, isProviderDisabled, loadMcpServers, loadValidSubAgents, } from "../utils/settings.js";
|
|
93
93
|
import { resolveShell } from "../utils/shell.js";
|
|
94
94
|
import { relaxCompactionSettings } from "./compaction.js";
|
|
95
95
|
import { wrapToolSetWithDedup } from "./cross-turn-dedup.js";
|
|
@@ -108,7 +108,7 @@ import { forcedFinalize, getSessionLastTask, incSessionStep, parseBudgetOverride
|
|
|
108
108
|
import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
|
|
109
109
|
import { formatElisionManifest, getSessionExperienceCounts, recordCompaction, recordElision, } from "./session-experience.js";
|
|
110
110
|
import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
|
|
111
|
-
import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
|
|
111
|
+
import { createStallWatchdog, STALL_ERROR_MESSAGE, shouldRepromptStall, stallRepromptBackoffMs, } from "./stall-watchdog.js";
|
|
112
112
|
import { wrapToolSetWithCap } from "./sub-agent-cap.js";
|
|
113
113
|
import { compactSubAgentMessages, cumulativeMessageChars } from "./subagent-compactor.js";
|
|
114
114
|
import { detectTextEmittedToolCall, parseDsmlToolCalls } from "./text-tool-call-detector.js";
|
|
@@ -813,6 +813,16 @@ export class MessageProcessor {
|
|
|
813
813
|
// clear toast and SKIP the transient-retry (a stalled provider just stalls
|
|
814
814
|
// again, wasting another full timeout of silence).
|
|
815
815
|
let stallTriggered = false;
|
|
816
|
+
// Time-to-first-byte stall RE-PROMPT: some providers (observed:
|
|
817
|
+
// xai/grok-build-0.1) accept the request then never send the first byte —
|
|
818
|
+
// a single wedged socket, not a down backend, so a fresh request usually
|
|
819
|
+
// goes through. When the watchdog fires with ZERO chunks received this
|
|
820
|
+
// attempt, we re-issue the SAME request up to `maxStallRetries` times
|
|
821
|
+
// (loop-persistent counter). Gated on zero-chunks so it can NEVER restart a
|
|
822
|
+
// turn that already ran tools or emitted text — those go to the partial-
|
|
823
|
+
// answer rescue path instead. maxStallRetries = 0 restores legacy behaviour.
|
|
824
|
+
let stallRetryCount = 0;
|
|
825
|
+
const maxStallRetries = getProviderStallRetries();
|
|
816
826
|
// Auto-council: route to multi-model debate when EITHER
|
|
817
827
|
// (a) PIL classified taskType=plan|analyze with high confidence AND the
|
|
818
828
|
// prompt is complex enough to justify the debate cost, OR
|
|
@@ -928,7 +938,7 @@ export class MessageProcessor {
|
|
|
928
938
|
return;
|
|
929
939
|
}
|
|
930
940
|
try {
|
|
931
|
-
while (true) {
|
|
941
|
+
streamAttempt: while (true) {
|
|
932
942
|
// SAMR Phase 2: switch to fast model for tool-execution steps
|
|
933
943
|
if (stepRouterPhase === "phase2" && phase2Runtime) {
|
|
934
944
|
runtime = phase2Runtime;
|
|
@@ -936,6 +946,65 @@ export class MessageProcessor {
|
|
|
936
946
|
}
|
|
937
947
|
deps.setCompactedThisTurn(false);
|
|
938
948
|
let assistantText = "";
|
|
949
|
+
// Count of stream parts received in THIS attempt. Stays 0 only when the
|
|
950
|
+
// provider never sent a first byte → the safe-to-re-prompt stall case.
|
|
951
|
+
let chunksThisAttempt = 0;
|
|
952
|
+
// Decide whether a fired stall watchdog should re-prompt (re-issue the
|
|
953
|
+
// same request) instead of falling through to rescue/error. Returns the
|
|
954
|
+
// backoff ms to wait before re-issuing, or null to NOT re-prompt. Reads
|
|
955
|
+
// the live per-attempt locals; safe to call only when stallTriggered.
|
|
956
|
+
const planStallReprompt = () => {
|
|
957
|
+
if (!shouldRepromptStall({
|
|
958
|
+
stallTriggered,
|
|
959
|
+
stallRetryCount,
|
|
960
|
+
maxStallRetries,
|
|
961
|
+
chunksThisAttempt,
|
|
962
|
+
assistantTextEmpty: assistantText.trim() === "",
|
|
963
|
+
aborted: signal.aborted,
|
|
964
|
+
})) {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
stallRetryCount++;
|
|
968
|
+
const backoffMs = stallRepromptBackoffMs(stallRetryCount);
|
|
969
|
+
try {
|
|
970
|
+
const _ar = globalThis.__muonroiAgentRuntime;
|
|
971
|
+
_ar?.emitEvent({
|
|
972
|
+
t: "event",
|
|
973
|
+
kind: "stream-retry",
|
|
974
|
+
attempt: stallRetryCount,
|
|
975
|
+
maxAttempts: maxStallRetries + 1,
|
|
976
|
+
errorName: "TimeoutError",
|
|
977
|
+
errorMessage: "provider-stall (no first byte) — re-prompting",
|
|
978
|
+
nextDelayMs: backoffMs,
|
|
979
|
+
});
|
|
980
|
+
_ar?.emitEvent({
|
|
981
|
+
t: "event",
|
|
982
|
+
kind: "toast",
|
|
983
|
+
level: "warning",
|
|
984
|
+
text: `Model stalled — re-prompting (attempt ${stallRetryCount}/${maxStallRetries})…`,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
catch (emitErr) {
|
|
988
|
+
console.error(`[message-processor] stall-reprompt telemetry failed: ${emitErr?.message}`);
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
if (deps.session) {
|
|
992
|
+
logInteraction(deps.session.id, "stream_retry", {
|
|
993
|
+
data: {
|
|
994
|
+
attempt: stallRetryCount,
|
|
995
|
+
maxAttempts: maxStallRetries + 1,
|
|
996
|
+
errorName: "provider-stall",
|
|
997
|
+
errorMessage: "no first byte within stall timeout — re-prompted",
|
|
998
|
+
nextDelayMs: backoffMs,
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
catch (logErr) {
|
|
1004
|
+
console.error(`[message-processor] stall-reprompt log failed: ${logErr?.message}`);
|
|
1005
|
+
}
|
|
1006
|
+
return backoffMs;
|
|
1007
|
+
};
|
|
939
1008
|
// Tracks where `assistantText` was at the previous step boundary so
|
|
940
1009
|
// `onStepFinish` can compute the text emitted within the just-finished
|
|
941
1010
|
// step (input to the self-repetition detector).
|
|
@@ -1730,6 +1799,11 @@ export class MessageProcessor {
|
|
|
1730
1799
|
const _wireProviderIdTop = runtime.modelInfo?.provider ?? "unknown";
|
|
1731
1800
|
for await (const part of result.fullStream) {
|
|
1732
1801
|
stall.pet(); // chunk arrived — reset the stall watchdog
|
|
1802
|
+
// Count only real content parts. The watchdog abort itself surfaces
|
|
1803
|
+
// as an "abort" part — counting it would defeat the TTFB-stall gate
|
|
1804
|
+
// (a frozen-before-first-byte stall yields ONLY the abort part).
|
|
1805
|
+
if (part.type !== "abort")
|
|
1806
|
+
chunksThisAttempt++;
|
|
1733
1807
|
if (signal.aborted) {
|
|
1734
1808
|
yield { type: "content", content: "\n\n[Cancelled]" };
|
|
1735
1809
|
break;
|
|
@@ -2283,6 +2357,19 @@ export class MessageProcessor {
|
|
|
2283
2357
|
// instead of a benign "[Cancelled]" so a hung provider no longer
|
|
2284
2358
|
// looks like a silent freeze.
|
|
2285
2359
|
if (stallTriggered) {
|
|
2360
|
+
// Time-to-first-byte stall (no real chunk this attempt): the
|
|
2361
|
+
// socket wedged before any output — re-issue the SAME request
|
|
2362
|
+
// rather than giving up. Bounded by maxStallRetries; never
|
|
2363
|
+
// fires once tools ran or text flowed (planStallReprompt gate).
|
|
2364
|
+
const _stallBackoff = planStallReprompt();
|
|
2365
|
+
if (_stallBackoff != null) {
|
|
2366
|
+
stall.dispose();
|
|
2367
|
+
await new Promise((r) => setTimeout(r, _stallBackoff));
|
|
2368
|
+
if (!signal.aborted) {
|
|
2369
|
+
stallTriggered = false;
|
|
2370
|
+
continue streamAttempt;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2286
2373
|
stall.dispose();
|
|
2287
2374
|
// A response tool already produced the terminal structured
|
|
2288
2375
|
// answer (buffered from its call args) before the provider
|
|
@@ -2913,6 +3000,20 @@ export class MessageProcessor {
|
|
|
2913
3000
|
attemptedOverflowRecovery = true;
|
|
2914
3001
|
continue;
|
|
2915
3002
|
}
|
|
3003
|
+
// Stall surfaced as a throw (rather than an "abort" stream part):
|
|
3004
|
+
// apply the SAME time-to-first-byte re-prompt as the abort-part path.
|
|
3005
|
+
// The watchdog already fired (stallTriggered) so its timer is spent —
|
|
3006
|
+
// no dispose needed; the next attempt arms a fresh watchdog.
|
|
3007
|
+
if (stallTriggered) {
|
|
3008
|
+
const _stallBackoff = planStallReprompt();
|
|
3009
|
+
if (_stallBackoff != null) {
|
|
3010
|
+
await new Promise((r) => setTimeout(r, _stallBackoff));
|
|
3011
|
+
if (!signal.aborted) {
|
|
3012
|
+
stallTriggered = false;
|
|
3013
|
+
continue;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
2916
3017
|
// Transient network/server error retry — up to MAX_STREAM_RETRIES extra attempts.
|
|
2917
3018
|
// Only retry when no content has flowed yet (assistantText empty) to avoid
|
|
2918
3019
|
// partial-output corruption. Honour the abort signal between retries.
|
|
@@ -31,4 +31,35 @@ export interface StallWatchdog {
|
|
|
31
31
|
export declare const STALL_ABORT_REASON = "provider-stall";
|
|
32
32
|
/** User-facing message surfaced when the stall watchdog fires. */
|
|
33
33
|
export declare const STALL_ERROR_MESSAGE: string;
|
|
34
|
+
/** Inputs to the stall re-prompt decision — see {@link shouldRepromptStall}. */
|
|
35
|
+
export interface StallRepromptState {
|
|
36
|
+
/** The watchdog fired for this attempt. */
|
|
37
|
+
stallTriggered: boolean;
|
|
38
|
+
/** How many stall re-prompts have already happened this turn. */
|
|
39
|
+
stallRetryCount: number;
|
|
40
|
+
/** Configured cap (getProviderStallRetries); 0 disables re-prompt. */
|
|
41
|
+
maxStallRetries: number;
|
|
42
|
+
/** Real content parts received this attempt (the abort part is NOT counted). */
|
|
43
|
+
chunksThisAttempt: number;
|
|
44
|
+
/** True when no assistant text has flowed this attempt. */
|
|
45
|
+
assistantTextEmpty: boolean;
|
|
46
|
+
/** True on genuine user cancel (never re-prompt over a cancel). */
|
|
47
|
+
aborted: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Decide whether a fired stall watchdog should trigger a re-prompt (re-issue
|
|
51
|
+
* the same request) instead of surfacing the stall.
|
|
52
|
+
*
|
|
53
|
+
* ONLY a time-to-first-byte stall qualifies: zero real chunks AND no assistant
|
|
54
|
+
* text this attempt, under the retry cap, and not a user cancel. Re-issuing
|
|
55
|
+
* after tools ran or text flowed would corrupt/duplicate output — those cases
|
|
56
|
+
* fall through to the partial-answer rescue path instead. Pure (no side
|
|
57
|
+
* effects) so it is unit-testable in isolation from the orchestrator loop.
|
|
58
|
+
*/
|
|
59
|
+
export declare function shouldRepromptStall(s: StallRepromptState): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Exponential backoff (ms, capped at 4s) before the Nth stall re-prompt
|
|
62
|
+
* (1-based): 500 → 1000 → 2000 → 4000 → 4000.
|
|
63
|
+
*/
|
|
64
|
+
export declare function stallRepromptBackoffMs(attempt: number): number;
|
|
34
65
|
export declare function createStallWatchdog(timeoutMs: number, onFire?: () => void): StallWatchdog;
|
|
@@ -23,6 +23,30 @@ export const STALL_ABORT_REASON = "provider-stall";
|
|
|
23
23
|
export const STALL_ERROR_MESSAGE = "Model not responding — no output received within the stall timeout. " +
|
|
24
24
|
"The provider may be out of balance, rate-limited, or unreachable. " +
|
|
25
25
|
"Tune MUONROI_PROVIDER_STALL_TIMEOUT_MS (0 disables) or switch model/provider.";
|
|
26
|
+
/**
|
|
27
|
+
* Decide whether a fired stall watchdog should trigger a re-prompt (re-issue
|
|
28
|
+
* the same request) instead of surfacing the stall.
|
|
29
|
+
*
|
|
30
|
+
* ONLY a time-to-first-byte stall qualifies: zero real chunks AND no assistant
|
|
31
|
+
* text this attempt, under the retry cap, and not a user cancel. Re-issuing
|
|
32
|
+
* after tools ran or text flowed would corrupt/duplicate output — those cases
|
|
33
|
+
* fall through to the partial-answer rescue path instead. Pure (no side
|
|
34
|
+
* effects) so it is unit-testable in isolation from the orchestrator loop.
|
|
35
|
+
*/
|
|
36
|
+
export function shouldRepromptStall(s) {
|
|
37
|
+
return (s.stallTriggered &&
|
|
38
|
+
s.stallRetryCount < s.maxStallRetries &&
|
|
39
|
+
s.chunksThisAttempt === 0 &&
|
|
40
|
+
s.assistantTextEmpty &&
|
|
41
|
+
!s.aborted);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Exponential backoff (ms, capped at 4s) before the Nth stall re-prompt
|
|
45
|
+
* (1-based): 500 → 1000 → 2000 → 4000 → 4000.
|
|
46
|
+
*/
|
|
47
|
+
export function stallRepromptBackoffMs(attempt) {
|
|
48
|
+
return Math.min(500 * 2 ** (Math.max(1, attempt) - 1), 4_000);
|
|
49
|
+
}
|
|
26
50
|
export function createStallWatchdog(timeoutMs, onFire) {
|
|
27
51
|
const controller = new AbortController();
|
|
28
52
|
let firedFlag = false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createStallWatchdog, STALL_ABORT_REASON } from "./stall-watchdog.js";
|
|
2
|
+
import { createStallWatchdog, STALL_ABORT_REASON, shouldRepromptStall, stallRepromptBackoffMs, } from "./stall-watchdog.js";
|
|
3
3
|
describe("createStallWatchdog", () => {
|
|
4
4
|
beforeEach(() => vi.useFakeTimers());
|
|
5
5
|
afterEach(() => vi.useRealTimers());
|
|
@@ -70,4 +70,49 @@ describe("createStallWatchdog", () => {
|
|
|
70
70
|
expect(wd.signal.aborted).toBe(true);
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
|
+
describe("shouldRepromptStall", () => {
|
|
74
|
+
// A clean time-to-first-byte stall: watchdog fired, zero chunks, no text,
|
|
75
|
+
// under the cap, not cancelled — the ONLY case that re-prompts.
|
|
76
|
+
const ttfb = (over = {}) => ({
|
|
77
|
+
stallTriggered: true,
|
|
78
|
+
stallRetryCount: 0,
|
|
79
|
+
maxStallRetries: 1,
|
|
80
|
+
chunksThisAttempt: 0,
|
|
81
|
+
assistantTextEmpty: true,
|
|
82
|
+
aborted: false,
|
|
83
|
+
...over,
|
|
84
|
+
});
|
|
85
|
+
it("re-prompts a time-to-first-byte stall under the cap", () => {
|
|
86
|
+
expect(shouldRepromptStall(ttfb())).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it("does NOT re-prompt when the watchdog never fired", () => {
|
|
89
|
+
expect(shouldRepromptStall(ttfb({ stallTriggered: false }))).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
it("does NOT re-prompt once the retry cap is reached", () => {
|
|
92
|
+
expect(shouldRepromptStall(ttfb({ stallRetryCount: 1, maxStallRetries: 1 }))).toBe(false);
|
|
93
|
+
// maxStallRetries=0 means the feature is disabled — never re-prompt.
|
|
94
|
+
expect(shouldRepromptStall(ttfb({ stallRetryCount: 0, maxStallRetries: 0 }))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
it("does NOT re-prompt once a real chunk has arrived (mid-stream stall → rescue)", () => {
|
|
97
|
+
expect(shouldRepromptStall(ttfb({ chunksThisAttempt: 1 }))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
it("does NOT re-prompt once assistant text has flowed (output would corrupt)", () => {
|
|
100
|
+
expect(shouldRepromptStall(ttfb({ assistantTextEmpty: false }))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it("does NOT re-prompt over a genuine user cancel", () => {
|
|
103
|
+
expect(shouldRepromptStall(ttfb({ aborted: true }))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe("stallRepromptBackoffMs", () => {
|
|
107
|
+
it("grows exponentially and caps at 4s", () => {
|
|
108
|
+
expect(stallRepromptBackoffMs(1)).toBe(500);
|
|
109
|
+
expect(stallRepromptBackoffMs(2)).toBe(1000);
|
|
110
|
+
expect(stallRepromptBackoffMs(3)).toBe(2000);
|
|
111
|
+
expect(stallRepromptBackoffMs(4)).toBe(4000);
|
|
112
|
+
expect(stallRepromptBackoffMs(5)).toBe(4000);
|
|
113
|
+
});
|
|
114
|
+
it("treats attempt < 1 as the first attempt", () => {
|
|
115
|
+
expect(stallRepromptBackoffMs(0)).toBe(500);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
73
118
|
//# sourceMappingURL=stall-watchdog.test.js.map
|
|
@@ -317,6 +317,19 @@ export declare function getSubAgentBudgetChars(): number;
|
|
|
317
317
|
* Default 120_000 (2 min). Env override: MUONROI_PROVIDER_STALL_TIMEOUT_MS.
|
|
318
318
|
*/
|
|
319
319
|
export declare function getProviderStallTimeoutMs(): number;
|
|
320
|
+
/**
|
|
321
|
+
* Number of times to AUTOMATICALLY re-issue a streaming model call after the
|
|
322
|
+
* stall watchdog fires WITHOUT any chunk having arrived (a time-to-first-byte
|
|
323
|
+
* "frozen" stall). Some providers (observed: xai/grok-build-0.1) accept a
|
|
324
|
+
* request then never send the first byte, yet a fresh request goes through —
|
|
325
|
+
* a single dead socket, not a down backend. Re-prompting is gated on
|
|
326
|
+
* zero-chunks-this-attempt so it can NEVER restart a turn that already ran
|
|
327
|
+
* tools or emitted text (that would corrupt/duplicate output — the partial-
|
|
328
|
+
* answer rescue path handles those). Each re-prompt waits a short backoff.
|
|
329
|
+
* Range 0–5; 0 restores the legacy "surface the stall, never retry" behaviour.
|
|
330
|
+
* Default 1. Env override: MUONROI_PROVIDER_STALL_RETRIES.
|
|
331
|
+
*/
|
|
332
|
+
export declare function getProviderStallRetries(): number;
|
|
320
333
|
/**
|
|
321
334
|
* Phase B3 — threshold (in chars of cumulative message content) above which
|
|
322
335
|
* the sub-agent `prepareStep` compactor rewrites older tool_result parts
|
|
@@ -693,6 +693,27 @@ export function getProviderStallTimeoutMs() {
|
|
|
693
693
|
}
|
|
694
694
|
return 120_000;
|
|
695
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Number of times to AUTOMATICALLY re-issue a streaming model call after the
|
|
698
|
+
* stall watchdog fires WITHOUT any chunk having arrived (a time-to-first-byte
|
|
699
|
+
* "frozen" stall). Some providers (observed: xai/grok-build-0.1) accept a
|
|
700
|
+
* request then never send the first byte, yet a fresh request goes through —
|
|
701
|
+
* a single dead socket, not a down backend. Re-prompting is gated on
|
|
702
|
+
* zero-chunks-this-attempt so it can NEVER restart a turn that already ran
|
|
703
|
+
* tools or emitted text (that would corrupt/duplicate output — the partial-
|
|
704
|
+
* answer rescue path handles those). Each re-prompt waits a short backoff.
|
|
705
|
+
* Range 0–5; 0 restores the legacy "surface the stall, never retry" behaviour.
|
|
706
|
+
* Default 1. Env override: MUONROI_PROVIDER_STALL_RETRIES.
|
|
707
|
+
*/
|
|
708
|
+
export function getProviderStallRetries() {
|
|
709
|
+
const envRaw = process.env.MUONROI_PROVIDER_STALL_RETRIES;
|
|
710
|
+
if (envRaw !== undefined && envRaw !== "") {
|
|
711
|
+
const n = Number(envRaw);
|
|
712
|
+
if (Number.isFinite(n) && n >= 0 && n <= 5)
|
|
713
|
+
return Math.floor(n);
|
|
714
|
+
}
|
|
715
|
+
return 1;
|
|
716
|
+
}
|
|
696
717
|
/**
|
|
697
718
|
* Phase B3 — threshold (in chars of cumulative message content) above which
|
|
698
719
|
* the sub-agent `prepareStep` compactor rewrites older tool_result parts
|
|
@@ -157,4 +157,35 @@ describe("resolveTelegramAudioInputSettings", () => {
|
|
|
157
157
|
expect(result.language).toBe("vi");
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
describe("getProviderStallRetries", () => {
|
|
161
|
+
it("defaults to 1 when the env var is unset or blank", async () => {
|
|
162
|
+
vi.unstubAllEnvs();
|
|
163
|
+
const { getProviderStallRetries } = await import("./settings.js");
|
|
164
|
+
expect(getProviderStallRetries()).toBe(1);
|
|
165
|
+
vi.stubEnv("MUONROI_PROVIDER_STALL_RETRIES", "");
|
|
166
|
+
expect(getProviderStallRetries()).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
it("honours an in-range override (0 disables, up to 5)", async () => {
|
|
169
|
+
const { getProviderStallRetries } = await import("./settings.js");
|
|
170
|
+
vi.stubEnv("MUONROI_PROVIDER_STALL_RETRIES", "0");
|
|
171
|
+
expect(getProviderStallRetries()).toBe(0);
|
|
172
|
+
vi.stubEnv("MUONROI_PROVIDER_STALL_RETRIES", "3");
|
|
173
|
+
expect(getProviderStallRetries()).toBe(3);
|
|
174
|
+
vi.stubEnv("MUONROI_PROVIDER_STALL_RETRIES", "5");
|
|
175
|
+
expect(getProviderStallRetries()).toBe(5);
|
|
176
|
+
});
|
|
177
|
+
it("falls back to the default for out-of-range or non-numeric values", async () => {
|
|
178
|
+
const { getProviderStallRetries } = await import("./settings.js");
|
|
179
|
+
for (const bad of ["6", "-1", "abc", "2.5"]) {
|
|
180
|
+
vi.stubEnv("MUONROI_PROVIDER_STALL_RETRIES", bad);
|
|
181
|
+
// "2.5" floors to 2 (in range) — only the others fall back.
|
|
182
|
+
if (bad === "2.5") {
|
|
183
|
+
expect(getProviderStallRetries()).toBe(2);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
expect(getProviderStallRetries()).toBe(1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
160
191
|
//# sourceMappingURL=settings.test.js.map
|