@moxxy/mode-default 0.1.0 → 0.1.2
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/turn-iterator.d.ts +7 -1
- package/dist/turn-iterator.d.ts.map +1 -1
- package/dist/turn-iterator.js +58 -26
- package/dist/turn-iterator.js.map +1 -1
- package/package.json +4 -4
- package/src/index.test.ts +83 -0
- package/src/turn-iterator.ts +64 -25
package/dist/turn-iterator.d.ts
CHANGED
|
@@ -13,7 +13,13 @@ export declare const DEFAULT_MODE_NAME = "default";
|
|
|
13
13
|
* budget and is handled before this.)
|
|
14
14
|
*/
|
|
15
15
|
export declare const MAX_CONSECUTIVE_RETRIES = 6;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Override the retry back-off sleep (test seam). Returns a restore fn that
|
|
18
|
+
* callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
|
|
19
|
+
* singleton shared process-wide, so a leaked override bleeds the fake sleep
|
|
20
|
+
* into every other turn/test running in the same worker (parallel subagent
|
|
21
|
+
* fan-out, multiple Sessions in one host). Test-only; never call from prod.
|
|
22
|
+
*/
|
|
17
23
|
export declare function __setRetrySleepForTests(fn: (ms: number, signal: AbortSignal) => Promise<void>): () => void;
|
|
18
24
|
export declare function runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
|
|
19
25
|
//# sourceMappingURL=turn-iterator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EAaL,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAYzC;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,EAAE,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,GACrD,MAAM,IAAI,CAMZ;AAED,wBAAuB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAkQjF"}
|
package/dist/turn-iterator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, usageEventFields, } from '@moxxy/sdk';
|
|
1
|
+
import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, nextBackoffMs, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, sleepWithAbort, usageEventFields, } from '@moxxy/sdk';
|
|
2
2
|
export const DEFAULT_MODE_NAME = 'default';
|
|
3
3
|
/**
|
|
4
4
|
* Bounded back-off for a *retryable* provider error (rate-limit/429,
|
|
@@ -13,29 +13,21 @@ export const DEFAULT_MODE_NAME = 'default';
|
|
|
13
13
|
* budget and is handled before this.)
|
|
14
14
|
*/
|
|
15
15
|
export const MAX_CONSECUTIVE_RETRIES = 6;
|
|
16
|
-
/** Exponential
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const cap = 30_000;
|
|
20
|
-
return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
|
|
21
|
-
}
|
|
16
|
+
/** Exponential back-off base/cap for the retry schedule (attempt is 1-based). */
|
|
17
|
+
const RETRY_BACKOFF_BASE_MS = 500;
|
|
18
|
+
const RETRY_BACKOFF_CAP_MS = 30_000;
|
|
22
19
|
// Abort-aware sleep, injectable for tests so the back-off path runs instantly
|
|
23
|
-
// and deterministically. Production
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
resolve();
|
|
35
|
-
};
|
|
36
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
37
|
-
});
|
|
38
|
-
/** Override the retry back-off sleep (test seam). Returns a restore fn. */
|
|
20
|
+
// and deterministically. Production delegates to the SDK's sleepWithAbort: a
|
|
21
|
+
// real timer that clears (and drops its abort listener) when the signal fires,
|
|
22
|
+
// so a pending back-off never outlives a cancelled turn.
|
|
23
|
+
let sleepImpl = (ms, signal) => sleepWithAbort(ms, signal);
|
|
24
|
+
/**
|
|
25
|
+
* Override the retry back-off sleep (test seam). Returns a restore fn that
|
|
26
|
+
* callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
|
|
27
|
+
* singleton shared process-wide, so a leaked override bleeds the fake sleep
|
|
28
|
+
* into every other turn/test running in the same worker (parallel subagent
|
|
29
|
+
* fan-out, multiple Sessions in one host). Test-only; never call from prod.
|
|
30
|
+
*/
|
|
39
31
|
export function __setRetrySleepForTests(fn) {
|
|
40
32
|
const prev = sleepImpl;
|
|
41
33
|
sleepImpl = fn;
|
|
@@ -48,7 +40,14 @@ export async function* runDefaultMode(ctx) {
|
|
|
48
40
|
// glitch causing an infinite retry, bad prompt, etc.) — primary
|
|
49
41
|
// termination signal is the stuck-loop detector, which catches the
|
|
50
42
|
// common "model keeps calling the same tool" case ~10 iterations in.
|
|
51
|
-
|
|
43
|
+
// Coerce a caller/config-supplied bound to a positive integer; a degenerate
|
|
44
|
+
// value (0, negative, NaN, fractional) would otherwise make the loop never
|
|
45
|
+
// run and emit a misleading "exceeded maxIterations" fatal. Treat anything
|
|
46
|
+
// un-coercible (NaN) as the default rather than failing the turn.
|
|
47
|
+
const requestedMaxIterations = ctx.maxIterations;
|
|
48
|
+
const maxIterations = typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
|
|
49
|
+
? Math.max(1, Math.floor(requestedMaxIterations))
|
|
50
|
+
: 500;
|
|
52
51
|
const detector = createStuckLoopDetector();
|
|
53
52
|
// Reactive-compaction budget per overflow episode. If the provider keeps
|
|
54
53
|
// rejecting for context size even after compacting this many times, give up
|
|
@@ -101,6 +100,21 @@ export async function* runDefaultMode(ctx) {
|
|
|
101
100
|
iteration,
|
|
102
101
|
stablePrefixIndex,
|
|
103
102
|
});
|
|
103
|
+
// A user cancellation WHILE the provider stream was being consumed surfaces
|
|
104
|
+
// as a non-retryable provider `error` ("The operation was aborted") rather
|
|
105
|
+
// than a clean abort — collectProviderStream catches the fetch AbortError
|
|
106
|
+
// and classifies it as fatal. Treat it as the cancellation it is so
|
|
107
|
+
// downstream channels render a 'stopped' turn, not a failed/error turn.
|
|
108
|
+
if (ctx.signal.aborted) {
|
|
109
|
+
yield await ctx.emit({
|
|
110
|
+
type: 'abort',
|
|
111
|
+
sessionId: ctx.sessionId,
|
|
112
|
+
turnId: ctx.turnId,
|
|
113
|
+
source: 'system',
|
|
114
|
+
reason: 'signal aborted during provider stream',
|
|
115
|
+
});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
104
118
|
yield await ctx.emit({
|
|
105
119
|
type: 'provider_response',
|
|
106
120
|
sessionId: ctx.sessionId,
|
|
@@ -117,9 +131,12 @@ export async function* runDefaultMode(ctx) {
|
|
|
117
131
|
// the auto-compact-on-overflow path.
|
|
118
132
|
if (isContextOverflowError(error.message) &&
|
|
119
133
|
reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
|
|
120
|
-
reactiveCompactions += 1;
|
|
121
134
|
const compacted = await runCompactionIfNeeded(ctx, { force: true });
|
|
122
135
|
if (compacted) {
|
|
136
|
+
// Only count an attempt that actually compacted against the budget —
|
|
137
|
+
// a no-op (overflow lives in the un-compactable recent tail) must not
|
|
138
|
+
// deny a later, genuinely compactable overflow its retry.
|
|
139
|
+
reactiveCompactions += 1;
|
|
123
140
|
yield await ctx.emit({
|
|
124
141
|
type: 'error',
|
|
125
142
|
sessionId: ctx.sessionId,
|
|
@@ -166,7 +183,7 @@ export async function* runDefaultMode(ctx) {
|
|
|
166
183
|
});
|
|
167
184
|
return;
|
|
168
185
|
}
|
|
169
|
-
await sleepImpl(
|
|
186
|
+
await sleepImpl(nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS), ctx.signal);
|
|
170
187
|
if (ctx.signal.aborted) {
|
|
171
188
|
yield await ctx.emit({
|
|
172
189
|
type: 'abort',
|
|
@@ -208,6 +225,21 @@ export async function* runDefaultMode(ctx) {
|
|
|
208
225
|
if (stuck)
|
|
209
226
|
return;
|
|
210
227
|
if (text || stopReason === 'end_turn' || toolUses.length === 0) {
|
|
228
|
+
// A completion with no text, no tool uses, and a non-natural stop (e.g.
|
|
229
|
+
// 'max_tokens' truncated to nothing) yields a blank assistant bubble that
|
|
230
|
+
// silently swallows the truncation signal. Surface a retryable note so the
|
|
231
|
+
// user sees why the turn produced nothing, alongside the (preserved)
|
|
232
|
+
// empty assistant_message.
|
|
233
|
+
if (!text && toolUses.length === 0 && stopReason !== 'end_turn') {
|
|
234
|
+
yield await ctx.emit({
|
|
235
|
+
type: 'error',
|
|
236
|
+
sessionId: ctx.sessionId,
|
|
237
|
+
turnId: ctx.turnId,
|
|
238
|
+
source: 'system',
|
|
239
|
+
kind: 'retryable',
|
|
240
|
+
message: `provider returned an empty completion (stopReason: ${stopReason ?? 'unknown'})`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
211
243
|
yield await ctx.emit({
|
|
212
244
|
type: 'assistant_message',
|
|
213
245
|
sessionId: ctx.sessionId,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,2BAA2B,EAC3B,qBAAqB,EACrB,uBAAuB,EACvB,0BAA0B,EAC1B,eAAe,EACf,sBAAsB,EACtB,eAAe,EACf,qBAAqB,EACrB,kBAAkB,EAClB,gBAAgB,GAIjB,MAAM,YAAY,CAAC;AAEpB,MAAM,CAAC,MAAM,iBAAiB,GAAG,SAAS,CAAC;AAE3C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC;AAEzC,
|
|
1
|
+
{"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moxxy/mode-default",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Default loop strategy (mode) for moxxy — a Claude Code-style ReAct tool-use loop. Register it into an @moxxy/core Session to give your agent its turn-by-turn reasoning + tool-calling behaviour.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"moxxy",
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@moxxy/sdk": "0.15.
|
|
47
|
+
"@moxxy/sdk": "0.15.2"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^22.10.0",
|
|
51
51
|
"typescript": "^5.7.3",
|
|
52
52
|
"vitest": "^2.1.8",
|
|
53
53
|
"zod": "^3.24.0",
|
|
54
|
-
"@moxxy/core": "0.
|
|
55
|
-
"@moxxy/testing": "0.0.
|
|
54
|
+
"@moxxy/core": "0.5.2",
|
|
55
|
+
"@moxxy/testing": "0.0.29",
|
|
56
56
|
"@moxxy/tsconfig": "0.0.0",
|
|
57
57
|
"@moxxy/vitest-preset": "0.0.0"
|
|
58
58
|
},
|
package/src/index.test.ts
CHANGED
|
@@ -13,6 +13,23 @@ const retryableErrorReply = (message = 'rate limited (429)'): ReadonlyArray<Prov
|
|
|
13
13
|
{ type: 'message_end', stopReason: 'end_turn' },
|
|
14
14
|
];
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A scripted reply mimicking the real provider stack when the user aborts WHILE
|
|
18
|
+
* the stream is being consumed: the fetch AbortError is caught and classified
|
|
19
|
+
* non-retryable with the canonical "operation was aborted" message.
|
|
20
|
+
*/
|
|
21
|
+
const abortDuringStreamReply = (): ReadonlyArray<ProviderEvent> => [
|
|
22
|
+
{ type: 'message_start', model: 'fake' },
|
|
23
|
+
{ type: 'error', message: 'The operation was aborted', retryable: false },
|
|
24
|
+
{ type: 'message_end', stopReason: 'end_turn' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** A reply with no text, no tools, and a non-natural stop (truncated to empty). */
|
|
28
|
+
const emptyMaxTokensReply = (): ReadonlyArray<ProviderEvent> => [
|
|
29
|
+
{ type: 'message_start', model: 'fake' },
|
|
30
|
+
{ type: 'message_end', stopReason: 'max_tokens' },
|
|
31
|
+
];
|
|
32
|
+
|
|
16
33
|
const sessionWith = (provider: FakeProvider): Session => {
|
|
17
34
|
const session = createFakeSession({ provider });
|
|
18
35
|
session.pluginHost.registerStatic(defaultModePlugin);
|
|
@@ -357,6 +374,72 @@ describe('defaultMode end-to-end', () => {
|
|
|
357
374
|
}
|
|
358
375
|
});
|
|
359
376
|
|
|
377
|
+
it('emits a clean abort (not a fatal error) when aborted during the provider stream', async () => {
|
|
378
|
+
// Regression: a cancellation WHILE collectProviderStream is consuming the
|
|
379
|
+
// stream surfaces as a non-retryable provider error ("operation was
|
|
380
|
+
// aborted"). The loop must recognize the set abort signal and emit a clean
|
|
381
|
+
// `abort` event, NOT a `kind: 'fatal'` error that renders as a failed turn.
|
|
382
|
+
// Abort synchronously at the start of the provider call so the signal is set
|
|
383
|
+
// by the time collectProviderStream returns the abort-classified error.
|
|
384
|
+
let abort: () => void = () => {};
|
|
385
|
+
const provider = new FakeProvider({
|
|
386
|
+
script: [abortDuringStreamReply()],
|
|
387
|
+
onRequest: () => abort(),
|
|
388
|
+
});
|
|
389
|
+
const session = sessionWith(provider);
|
|
390
|
+
abort = () => session.abort('test abort mid-stream');
|
|
391
|
+
|
|
392
|
+
const events = await collectTurn(session, 'hi');
|
|
393
|
+
const aborted = events.find((e) => e.type === 'abort');
|
|
394
|
+
expect(aborted).toBeDefined();
|
|
395
|
+
if (aborted?.type !== 'abort') throw new Error('expected abort');
|
|
396
|
+
expect(aborted.reason).toMatch(/provider stream/);
|
|
397
|
+
// No fatal error event leaked the raw abort message.
|
|
398
|
+
const fatal = events.filter((e) => e.type === 'error' && e.kind === 'fatal');
|
|
399
|
+
expect(fatal).toHaveLength(0);
|
|
400
|
+
// And the raw "operation was aborted" message was never surfaced as an error.
|
|
401
|
+
const rawAbortError = events.filter(
|
|
402
|
+
(e) => e.type === 'error' && /operation was aborted/i.test(e.message),
|
|
403
|
+
);
|
|
404
|
+
expect(rawAbortError).toHaveLength(0);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('coerces a degenerate maxIterations (0) to at least one iteration instead of an instant fatal', async () => {
|
|
408
|
+
// Regression: an unvalidated maxIterations of 0 made the loop body never run
|
|
409
|
+
// and emit a misleading "exceeded maxIterations" fatal. A coerced bound runs
|
|
410
|
+
// the turn normally.
|
|
411
|
+
const provider = new FakeProvider({ script: [textReply('hello')] });
|
|
412
|
+
const session = sessionWith(provider);
|
|
413
|
+
|
|
414
|
+
const events = await collectTurn(session, 'hi', { maxIterations: 0 });
|
|
415
|
+
// The single iteration ran and produced a normal assistant message.
|
|
416
|
+
const last = events[events.length - 1];
|
|
417
|
+
if (last.type !== 'assistant_message') throw new Error('expected assistant_message last');
|
|
418
|
+
expect(last.content).toBe('hello');
|
|
419
|
+
// No fatal "exceeded maxIterations" fired on the very first turn.
|
|
420
|
+
const fatal = events.filter((e) => e.type === 'error' && e.kind === 'fatal');
|
|
421
|
+
expect(fatal).toHaveLength(0);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('surfaces an empty-completion note when a turn truncates to no text and no tools', async () => {
|
|
425
|
+
// Regression: a max_tokens completion truncated to nothing emitted only a
|
|
426
|
+
// blank assistant bubble. Now a retryable note explains why the turn was
|
|
427
|
+
// empty, alongside the (preserved) empty assistant_message.
|
|
428
|
+
const provider = new FakeProvider({ script: [emptyMaxTokensReply()] });
|
|
429
|
+
const session = sessionWith(provider);
|
|
430
|
+
|
|
431
|
+
const events = await collectTurn(session, 'hi');
|
|
432
|
+
const note = events.filter(
|
|
433
|
+
(e) => e.type === 'error' && e.kind === 'retryable' && /empty completion/i.test(e.message),
|
|
434
|
+
);
|
|
435
|
+
expect(note).toHaveLength(1);
|
|
436
|
+
// The empty assistant_message is still emitted (existing behavior preserved).
|
|
437
|
+
const assistant = events.filter((e) => e.type === 'assistant_message');
|
|
438
|
+
expect(assistant).toHaveLength(1);
|
|
439
|
+
if (assistant[0]?.type !== 'assistant_message') throw new Error('expected assistant_message');
|
|
440
|
+
expect(assistant[0].content).toBe('');
|
|
441
|
+
});
|
|
442
|
+
|
|
360
443
|
it('gives up with a fatal error after the bounded retry count, not maxIterations', async () => {
|
|
361
444
|
const delays: number[] = [];
|
|
362
445
|
const restore = __setRetrySleepForTests(async (ms) => {
|
package/src/turn-iterator.ts
CHANGED
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
emitRequestsAndDetectStuck,
|
|
6
6
|
executeToolUses,
|
|
7
7
|
isContextOverflowError,
|
|
8
|
+
nextBackoffMs,
|
|
8
9
|
projectMessages,
|
|
9
10
|
runCompactionIfNeeded,
|
|
10
11
|
runElisionIfNeeded,
|
|
12
|
+
sleepWithAbort,
|
|
11
13
|
usageEventFields,
|
|
12
14
|
type ModeContext,
|
|
13
15
|
type MoxxyEvent,
|
|
@@ -30,31 +32,23 @@ export const DEFAULT_MODE_NAME = 'default';
|
|
|
30
32
|
*/
|
|
31
33
|
export const MAX_CONSECUTIVE_RETRIES = 6;
|
|
32
34
|
|
|
33
|
-
/** Exponential
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const cap = 30_000;
|
|
37
|
-
return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
|
|
38
|
-
}
|
|
35
|
+
/** Exponential back-off base/cap for the retry schedule (attempt is 1-based). */
|
|
36
|
+
const RETRY_BACKOFF_BASE_MS = 500;
|
|
37
|
+
const RETRY_BACKOFF_CAP_MS = 30_000;
|
|
39
38
|
|
|
40
39
|
// Abort-aware sleep, injectable for tests so the back-off path runs instantly
|
|
41
|
-
// and deterministically. Production
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (signal.aborted) return resolve();
|
|
46
|
-
const timer = setTimeout(() => {
|
|
47
|
-
signal.removeEventListener('abort', onAbort);
|
|
48
|
-
resolve();
|
|
49
|
-
}, ms);
|
|
50
|
-
const onAbort = (): void => {
|
|
51
|
-
clearTimeout(timer);
|
|
52
|
-
resolve();
|
|
53
|
-
};
|
|
54
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
55
|
-
});
|
|
40
|
+
// and deterministically. Production delegates to the SDK's sleepWithAbort: a
|
|
41
|
+
// real timer that clears (and drops its abort listener) when the signal fires,
|
|
42
|
+
// so a pending back-off never outlives a cancelled turn.
|
|
43
|
+
let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> => sleepWithAbort(ms, signal);
|
|
56
44
|
|
|
57
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Override the retry back-off sleep (test seam). Returns a restore fn that
|
|
47
|
+
* callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
|
|
48
|
+
* singleton shared process-wide, so a leaked override bleeds the fake sleep
|
|
49
|
+
* into every other turn/test running in the same worker (parallel subagent
|
|
50
|
+
* fan-out, multiple Sessions in one host). Test-only; never call from prod.
|
|
51
|
+
*/
|
|
58
52
|
export function __setRetrySleepForTests(
|
|
59
53
|
fn: (ms: number, signal: AbortSignal) => Promise<void>,
|
|
60
54
|
): () => void {
|
|
@@ -70,7 +64,15 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
|
|
|
70
64
|
// glitch causing an infinite retry, bad prompt, etc.) — primary
|
|
71
65
|
// termination signal is the stuck-loop detector, which catches the
|
|
72
66
|
// common "model keeps calling the same tool" case ~10 iterations in.
|
|
73
|
-
|
|
67
|
+
// Coerce a caller/config-supplied bound to a positive integer; a degenerate
|
|
68
|
+
// value (0, negative, NaN, fractional) would otherwise make the loop never
|
|
69
|
+
// run and emit a misleading "exceeded maxIterations" fatal. Treat anything
|
|
70
|
+
// un-coercible (NaN) as the default rather than failing the turn.
|
|
71
|
+
const requestedMaxIterations = ctx.maxIterations;
|
|
72
|
+
const maxIterations =
|
|
73
|
+
typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
|
|
74
|
+
? Math.max(1, Math.floor(requestedMaxIterations))
|
|
75
|
+
: 500;
|
|
74
76
|
const detector = createStuckLoopDetector();
|
|
75
77
|
// Reactive-compaction budget per overflow episode. If the provider keeps
|
|
76
78
|
// rejecting for context size even after compacting this many times, give up
|
|
@@ -133,6 +135,22 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
|
|
|
133
135
|
},
|
|
134
136
|
);
|
|
135
137
|
|
|
138
|
+
// A user cancellation WHILE the provider stream was being consumed surfaces
|
|
139
|
+
// as a non-retryable provider `error` ("The operation was aborted") rather
|
|
140
|
+
// than a clean abort — collectProviderStream catches the fetch AbortError
|
|
141
|
+
// and classifies it as fatal. Treat it as the cancellation it is so
|
|
142
|
+
// downstream channels render a 'stopped' turn, not a failed/error turn.
|
|
143
|
+
if (ctx.signal.aborted) {
|
|
144
|
+
yield await ctx.emit({
|
|
145
|
+
type: 'abort',
|
|
146
|
+
sessionId: ctx.sessionId,
|
|
147
|
+
turnId: ctx.turnId,
|
|
148
|
+
source: 'system',
|
|
149
|
+
reason: 'signal aborted during provider stream',
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
136
154
|
yield await ctx.emit({
|
|
137
155
|
type: 'provider_response',
|
|
138
156
|
sessionId: ctx.sessionId,
|
|
@@ -152,9 +170,12 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
|
|
|
152
170
|
isContextOverflowError(error.message) &&
|
|
153
171
|
reactiveCompactions < MAX_REACTIVE_COMPACTIONS
|
|
154
172
|
) {
|
|
155
|
-
reactiveCompactions += 1;
|
|
156
173
|
const compacted = await runCompactionIfNeeded(ctx, { force: true });
|
|
157
174
|
if (compacted) {
|
|
175
|
+
// Only count an attempt that actually compacted against the budget —
|
|
176
|
+
// a no-op (overflow lives in the un-compactable recent tail) must not
|
|
177
|
+
// deny a later, genuinely compactable overflow its retry.
|
|
178
|
+
reactiveCompactions += 1;
|
|
158
179
|
yield await ctx.emit({
|
|
159
180
|
type: 'error',
|
|
160
181
|
sessionId: ctx.sessionId,
|
|
@@ -202,7 +223,10 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
|
|
|
202
223
|
});
|
|
203
224
|
return;
|
|
204
225
|
}
|
|
205
|
-
await sleepImpl(
|
|
226
|
+
await sleepImpl(
|
|
227
|
+
nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS),
|
|
228
|
+
ctx.signal,
|
|
229
|
+
);
|
|
206
230
|
if (ctx.signal.aborted) {
|
|
207
231
|
yield await ctx.emit({
|
|
208
232
|
type: 'abort',
|
|
@@ -247,6 +271,21 @@ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEven
|
|
|
247
271
|
if (stuck) return;
|
|
248
272
|
|
|
249
273
|
if (text || stopReason === 'end_turn' || toolUses.length === 0) {
|
|
274
|
+
// A completion with no text, no tool uses, and a non-natural stop (e.g.
|
|
275
|
+
// 'max_tokens' truncated to nothing) yields a blank assistant bubble that
|
|
276
|
+
// silently swallows the truncation signal. Surface a retryable note so the
|
|
277
|
+
// user sees why the turn produced nothing, alongside the (preserved)
|
|
278
|
+
// empty assistant_message.
|
|
279
|
+
if (!text && toolUses.length === 0 && stopReason !== 'end_turn') {
|
|
280
|
+
yield await ctx.emit({
|
|
281
|
+
type: 'error',
|
|
282
|
+
sessionId: ctx.sessionId,
|
|
283
|
+
turnId: ctx.turnId,
|
|
284
|
+
source: 'system',
|
|
285
|
+
kind: 'retryable',
|
|
286
|
+
message: `provider returned an empty completion (stopReason: ${stopReason ?? 'unknown'})`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
250
289
|
yield await ctx.emit({
|
|
251
290
|
type: 'assistant_message',
|
|
252
291
|
sessionId: ctx.sessionId,
|