@moxxy/mode-default 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,11 @@
1
1
  import { type ModeContext, type MoxxyEvent } from '@moxxy/sdk';
2
2
  export declare const DEFAULT_MODE_NAME = "default";
3
+ export { MAX_CONSECUTIVE_RETRIES, __setRetrySleepForTests } from '@moxxy/sdk';
3
4
  /**
4
- * Bounded back-off for a *retryable* provider error (rate-limit/429,
5
- * overloaded, transient 5xx, ECONNRESET/ETIMEDOUT). Without it, a sustained
6
- * retryable condition becomes a tight busy-loop: the loop rebuilds the message
7
- * set and re-hits the provider with zero delay up to `maxIterations` times,
8
- * burning rate-limit budget and worsening the throttle. We back off
9
- * exponentially (abort-aware) and give up with a fatal error after
10
- * {@link MAX_CONSECUTIVE_RETRIES} consecutive failures — the counter resets on
11
- * any clean provider call, so a long turn can still recover from transient
12
- * blips. (The context-overflow path keeps its own MAX_REACTIVE_COMPACTIONS
13
- * budget and is handled before this.)
5
+ * Default ReAct-style loop: model thinks, calls tools, observes results,
6
+ * repeats and returns the moment the model stops calling tools. Pure
7
+ * delegation to {@link runReactLoop} with no hooks and no checkpoints: the
8
+ * shared core IS the default behavior; other modes layer policy on top.
14
9
  */
15
- export declare const MAX_CONSECUTIVE_RETRIES = 6;
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
- */
23
- export declare function __setRetrySleepForTests(fn: (ms: number, signal: AbortSignal) => Promise<void>): () => void;
24
10
  export declare function runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
25
11
  //# 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,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"}
1
+ {"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAK3C,OAAO,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE9E;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAE1E"}
@@ -1,282 +1,16 @@
1
- import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, nextBackoffMs, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, sleepWithAbort, usageEventFields, } from '@moxxy/sdk';
1
+ import { runReactLoop, } from '@moxxy/sdk';
2
2
  export const DEFAULT_MODE_NAME = 'default';
3
+ // The loop plumbing (bounded retry back-off, reactive compaction, elision,
4
+ // stuck detection, abort handling) lives in the SDK's shared ReAct core —
5
+ // re-export its constants/test seam so existing importers keep working.
6
+ export { MAX_CONSECUTIVE_RETRIES, __setRetrySleepForTests } from '@moxxy/sdk';
3
7
  /**
4
- * Bounded back-off for a *retryable* provider error (rate-limit/429,
5
- * overloaded, transient 5xx, ECONNRESET/ETIMEDOUT). Without it, a sustained
6
- * retryable condition becomes a tight busy-loop: the loop rebuilds the message
7
- * set and re-hits the provider with zero delay up to `maxIterations` times,
8
- * burning rate-limit budget and worsening the throttle. We back off
9
- * exponentially (abort-aware) and give up with a fatal error after
10
- * {@link MAX_CONSECUTIVE_RETRIES} consecutive failures — the counter resets on
11
- * any clean provider call, so a long turn can still recover from transient
12
- * blips. (The context-overflow path keeps its own MAX_REACTIVE_COMPACTIONS
13
- * budget and is handled before this.)
8
+ * Default ReAct-style loop: model thinks, calls tools, observes results,
9
+ * repeats and returns the moment the model stops calling tools. Pure
10
+ * delegation to {@link runReactLoop} with no hooks and no checkpoints: the
11
+ * shared core IS the default behavior; other modes layer policy on top.
14
12
  */
15
- export const MAX_CONSECUTIVE_RETRIES = 6;
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;
19
- // Abort-aware sleep, injectable for tests so the back-off path runs instantly
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
- */
31
- export function __setRetrySleepForTests(fn) {
32
- const prev = sleepImpl;
33
- sleepImpl = fn;
34
- return () => {
35
- sleepImpl = prev;
36
- };
37
- }
38
- export async function* runDefaultMode(ctx) {
39
- // High soft cap as a safety net against truly runaway modes (network
40
- // glitch causing an infinite retry, bad prompt, etc.) — primary
41
- // termination signal is the stuck-loop detector, which catches the
42
- // common "model keeps calling the same tool" case ~10 iterations in.
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;
51
- const detector = createStuckLoopDetector(ctx.loopGuard);
52
- // Reactive-compaction budget per overflow episode. If the provider keeps
53
- // rejecting for context size even after compacting this many times, give up
54
- // (the overflow is in the recent, un-compactable tail). Reset on any clean
55
- // provider call so a long turn can recover from multiple overflow episodes.
56
- const MAX_REACTIVE_COMPACTIONS = 2;
57
- let reactiveCompactions = 0;
58
- // Consecutive retryable-error count; reset on any clean provider call. Caps
59
- // the busy-loop a sustained retryable condition would otherwise create.
60
- let consecutiveRetries = 0;
61
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
62
- if (ctx.signal.aborted) {
63
- yield await ctx.emit({
64
- type: 'abort',
65
- sessionId: ctx.sessionId,
66
- turnId: ctx.turnId,
67
- source: 'system',
68
- reason: 'signal aborted',
69
- });
70
- return;
71
- }
72
- yield await ctx.emit({
73
- type: 'mode_iteration',
74
- sessionId: ctx.sessionId,
75
- turnId: ctx.turnId,
76
- source: 'system',
77
- strategy: DEFAULT_MODE_NAME,
78
- iteration,
79
- });
80
- // Auto-compact before composing the next provider request. If the
81
- // active compactor's `shouldCompact` returns true, this appends a
82
- // compaction event onto the log — projectMessagesFromLog (called
83
- // by buildMessages) honors it, so the model sees a summarized
84
- // prefix instead of overflowing the window mid-loop.
85
- await runCompactionIfNeeded(ctx);
86
- // Turn-boundary elision (context-on-demand): stub old bulky tool output and
87
- // (when enabled) old text turns, recall-able on demand. Composes with
88
- // compaction over the same projection.
89
- await runElisionIfNeeded(ctx);
90
- const { messages, stablePrefixIndex } = buildMessages(ctx);
91
- yield await ctx.emit({
92
- type: 'provider_request',
93
- sessionId: ctx.sessionId,
94
- turnId: ctx.turnId,
95
- source: 'system',
96
- provider: ctx.provider.name,
97
- model: ctx.model,
98
- });
99
- const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(ctx, messages, {
100
- iteration,
101
- stablePrefixIndex,
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
- }
118
- yield await ctx.emit({
119
- type: 'provider_response',
120
- sessionId: ctx.sessionId,
121
- turnId: ctx.turnId,
122
- source: 'system',
123
- provider: ctx.provider.name,
124
- model: ctx.model,
125
- ...usageEventFields(usage),
126
- });
127
- if (error) {
128
- // The request was too big for the model's window: our token estimate
129
- // lagged the provider's real tokenizer, so the proactive compactor
130
- // didn't fire. Force a compaction and retry rather than dying — this is
131
- // the auto-compact-on-overflow path.
132
- if (isContextOverflowError(error.message) &&
133
- reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
134
- const compacted = await runCompactionIfNeeded(ctx, { force: true });
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;
140
- yield await ctx.emit({
141
- type: 'error',
142
- sessionId: ctx.sessionId,
143
- turnId: ctx.turnId,
144
- source: 'system',
145
- kind: 'retryable',
146
- message: 'context window exceeded — compacted older turns, retrying',
147
- });
148
- continue;
149
- }
150
- }
151
- if (!error.retryable) {
152
- yield await ctx.emit({
153
- type: 'error',
154
- sessionId: ctx.sessionId,
155
- turnId: ctx.turnId,
156
- source: 'system',
157
- kind: 'fatal',
158
- message: error.message,
159
- });
160
- return;
161
- }
162
- // Retryable: surface it, then back off before retrying. A persistent
163
- // retryable condition (sustained 429 / outage) must NOT busy-loop the
164
- // provider — give up with a fatal error after the bounded retry count.
165
- consecutiveRetries += 1;
166
- yield await ctx.emit({
167
- type: 'error',
168
- sessionId: ctx.sessionId,
169
- turnId: ctx.turnId,
170
- source: 'system',
171
- kind: 'retryable',
172
- message: error.message,
173
- });
174
- if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
175
- yield await ctx.emit({
176
- type: 'error',
177
- sessionId: ctx.sessionId,
178
- turnId: ctx.turnId,
179
- source: 'system',
180
- kind: 'fatal',
181
- message: `provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
182
- `(last: ${error.message}); giving up rather than hammering the provider.`,
183
- });
184
- return;
185
- }
186
- await sleepImpl(nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS), ctx.signal);
187
- if (ctx.signal.aborted) {
188
- yield await ctx.emit({
189
- type: 'abort',
190
- sessionId: ctx.sessionId,
191
- turnId: ctx.turnId,
192
- source: 'system',
193
- reason: 'signal aborted during retry back-off',
194
- });
195
- return;
196
- }
197
- continue;
198
- }
199
- // Clean provider call — reset the overflow-recovery + retry budgets.
200
- reactiveCompactions = 0;
201
- consecutiveRetries = 0;
202
- // Finalize the reasoning summary for THIS call BEFORE the tool/assistant
203
- // emits, so the log order is reasoning → tool_use → text (projection
204
- // attaches the signed thinking block as content[0] of the same assistant
205
- // turn). collectProviderStream already guards on non-empty text / encrypted.
206
- if (reasoning) {
207
- yield await ctx.emit({
208
- type: 'reasoning_message',
209
- sessionId: ctx.sessionId,
210
- turnId: ctx.turnId,
211
- source: 'model',
212
- content: reasoning.text,
213
- ...(reasoning.signature ? { signature: reasoning.signature } : {}),
214
- ...(reasoning.redacted ? { redacted: true } : {}),
215
- ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
216
- });
217
- }
218
- const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, {
219
- abortedResultMessage: 'default mode loop aborted (stuck pattern) before this call ran',
220
- nearHint: 'against the same target (only volatile args like maxBytes varied)',
221
- fatalMessage: ({ toolName, count, how }) => `default mode loop aborted — detected stuck pattern: tool "${toolName}" called ` +
222
- `${count} times ${how}. The model is likely looping on the same call; ` +
223
- `reset or rephrase.`,
224
- });
225
- if (stuck)
226
- return;
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
- }
243
- yield await ctx.emit({
244
- type: 'assistant_message',
245
- sessionId: ctx.sessionId,
246
- turnId: ctx.turnId,
247
- source: 'model',
248
- content: text,
249
- stopReason,
250
- });
251
- }
252
- // Execute whenever the model requested tools, regardless of stopReason.
253
- // Providers vary in how reliably they report `stopReason: 'tool_use'`
254
- // (Codex's Responses API doesn't carry one on `response.completed`, so
255
- // the provider has to infer it from emitted events). Trusting only
256
- // stopReason here meant a single provider mis-mapping silently dropped
257
- // tool calls — `tool_call_requested` would be emitted with no matching
258
- // `tool_result`, leaving an orphan pending dot and a stuck-looking UI.
259
- if (toolUses.length === 0)
260
- return;
261
- const exited = yield* executeToolUses(ctx, toolUses, iteration);
262
- if (exited)
263
- return;
264
- }
265
- yield await ctx.emit({
266
- type: 'error',
267
- sessionId: ctx.sessionId,
268
- turnId: ctx.turnId,
269
- source: 'system',
270
- kind: 'fatal',
271
- message: `default mode loop exceeded maxIterations (${maxIterations})`,
272
- });
273
- }
274
- function buildMessages(ctx) {
275
- // Compose the system prompt with the skill catalog so the model knows
276
- // which playbooks exist; without this skills are invisible to the
277
- // model and it falls back to ad-hoc tool calls (the classic
278
- // `web_fetch instead of media-digest skill` symptom).
279
- const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list());
280
- return projectMessages(ctx, { ...(systemPrompt ? { systemPrompt } : {}) });
13
+ export function runDefaultMode(ctx) {
14
+ return runReactLoop(ctx, { strategyName: DEFAULT_MODE_NAME });
281
15
  }
282
16
  //# sourceMappingURL=turn-iterator.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,GAGb,MAAM,YAAY,CAAC;AAEpB,MAAM,CAAC,MAAM,iBAAiB,GAAG,SAAS,CAAC;AAE3C,2EAA2E;AAC3E,0EAA0E;AAC1E,wEAAwE;AACxE,OAAO,EAAE,uBAAuB,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE9E;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,GAAgB;IAC7C,OAAO,YAAY,CAAC,GAAG,EAAE,EAAE,YAAY,EAAE,iBAAiB,EAAE,CAAC,CAAC;AAChE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/mode-default",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
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,17 +44,17 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "@moxxy/sdk": "0.26.0"
47
+ "@moxxy/sdk": "0.28.0"
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/testing": "0.0.44",
55
54
  "@moxxy/vitest-preset": "0.0.0",
56
- "@moxxy/core": "0.26.0",
57
- "@moxxy/tsconfig": "0.0.0"
55
+ "@moxxy/tsconfig": "0.0.0",
56
+ "@moxxy/testing": "0.0.46",
57
+ "@moxxy/core": "0.28.0"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,458 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import {
4
+ defineMode,
5
+ definePlugin,
6
+ defineTool,
7
+ runReactLoop,
8
+ type CheckpointContext,
9
+ type CheckpointResult,
10
+ type ModeContext,
11
+ type ProviderEvent,
12
+ type TurnCheckpoint,
13
+ } from '@moxxy/sdk';
14
+ import { collectTurn, type Session } from '@moxxy/core';
15
+ import { FakeProvider, createFakeSession, textReply, toolUseReply } from '@moxxy/testing';
16
+
17
+ /**
18
+ * End-to-end coverage for the shared ReAct loop's turn-end checkpoint gate,
19
+ * exercised through a real Session + FakeProvider (the same harness the mode
20
+ * suites use) so every assertion runs against the events channels actually
21
+ * observe. The plain no-checkpoint path is covered by the mode-default suite
22
+ * (`index.test.ts`) — this file covers the gate.
23
+ */
24
+
25
+ const GATED_MODE = 'gated-test';
26
+
27
+ /** A text completion truncated by max_tokens — NOT a natural end_turn. */
28
+ const truncatedTextReply = (text: string): ReadonlyArray<ProviderEvent> => [
29
+ { type: 'message_start', model: 'fake' },
30
+ { type: 'text_delta', delta: text },
31
+ { type: 'message_end', stopReason: 'max_tokens' },
32
+ ];
33
+
34
+ /** Build a session whose active mode is runReactLoop + the given checkpoints. */
35
+ function gatedSession(
36
+ provider: FakeProvider,
37
+ checkpoints: ReadonlyArray<TurnCheckpoint>,
38
+ opts: { maxInjections?: number; asSubagent?: boolean } = {},
39
+ ): Session {
40
+ const session = createFakeSession({ provider });
41
+ session.pluginHost.registerStatic(
42
+ definePlugin({
43
+ name: '@moxxy/mode-gated-test',
44
+ version: '0.0.0',
45
+ modes: [
46
+ defineMode({
47
+ name: GATED_MODE,
48
+ description: 'runReactLoop with test checkpoints',
49
+ run: (ctx: ModeContext) =>
50
+ runReactLoop(opts.asSubagent ? { ...ctx, isSubagent: true } : ctx, {
51
+ strategyName: GATED_MODE,
52
+ checkpoints,
53
+ ...(opts.maxInjections !== undefined ? { maxInjections: opts.maxInjections } : {}),
54
+ }),
55
+ }),
56
+ ],
57
+ }),
58
+ );
59
+ session.modes.setActive(GATED_MODE);
60
+ return session;
61
+ }
62
+
63
+ /** The turn's final assistant text (gate lifecycle events may follow it). */
64
+ const lastAssistantText = (events: ReadonlyArray<{ type: string }>): string | undefined => {
65
+ const msgs = events.filter(
66
+ (e): e is { type: 'assistant_message'; content: string } => e.type === 'assistant_message',
67
+ );
68
+ return msgs[msgs.length - 1]?.content;
69
+ };
70
+
71
+ const checkpointSubtypes = (events: ReadonlyArray<{ type: string }>): string[] =>
72
+ events
73
+ .filter(
74
+ (e): e is { type: 'plugin_event'; subtype: string } =>
75
+ e.type === 'plugin_event' && String((e as { pluginId?: unknown }).pluginId) === 'react-loop',
76
+ )
77
+ .map((e) => e.subtype);
78
+
79
+ describe('runReactLoop checkpoint gate', () => {
80
+ it('persistently injects feedback, loops, and passes on the fixed attempt', async () => {
81
+ const provider = new FakeProvider({ script: [textReply('draft answer'), textReply('fixed answer')] });
82
+ let calls = 0;
83
+ const lintGate: TurnCheckpoint = {
84
+ name: 'lint',
85
+ run: async (check) => {
86
+ calls += 1;
87
+ expect(check.stopReason).toBe('end_turn');
88
+ return calls === 1 ? { action: 'inject', text: 'lint failed: unused import' } : { action: 'pass' };
89
+ },
90
+ };
91
+ const session = gatedSession(provider, [lintGate]);
92
+
93
+ const events = await collectTurn(session, 'do the thing');
94
+
95
+ // The injected feedback is a real, persistent log event with checkpoint provenance…
96
+ const injected = events.filter(
97
+ (e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint',
98
+ );
99
+ expect(injected).toHaveLength(1);
100
+ if (injected[0]?.type !== 'user_prompt') throw new Error('expected user_prompt');
101
+ expect(injected[0].text).toBe('lint failed: unused import');
102
+ expect(injected[0].source).toBe('system');
103
+ expect(injected[0].origin?.name).toBe('lint');
104
+
105
+ // …the loop ran a second provider round and the model saw the feedback…
106
+ expect(provider.received).toHaveLength(2);
107
+ const secondCallText = provider.received[1]!.messages
108
+ .flatMap((m) => m.content)
109
+ .map((c) => ('text' in c ? c.text : ''))
110
+ .join('\n');
111
+ expect(secondCallText).toContain('lint failed: unused import');
112
+
113
+ // …and the gate's lifecycle is observable.
114
+ expect(checkpointSubtypes(events)).toEqual([
115
+ 'checkpoint_started',
116
+ 'checkpoint_injected',
117
+ 'checkpoint_started',
118
+ 'checkpoint_passed',
119
+ ]);
120
+ expect(lastAssistantText(events)).toBe('fixed answer');
121
+ });
122
+
123
+ it('volatile injection rides exactly one provider call and never lands in the log', async () => {
124
+ const provider = new FakeProvider({
125
+ script: [textReply('first'), textReply('second'), textReply('third')],
126
+ });
127
+ let calls = 0;
128
+ const nudge: TurnCheckpoint = {
129
+ name: 'nudge',
130
+ run: async () => {
131
+ calls += 1;
132
+ if (calls < 3) return { action: 'inject', text: `keep going (${calls})`, volatile: true };
133
+ return { action: 'pass' };
134
+ },
135
+ };
136
+ const session = gatedSession(provider, [nudge]);
137
+
138
+ const events = await collectTurn(session, 'go');
139
+
140
+ // Never persisted…
141
+ expect(events.some((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint')).toBe(false);
142
+ // …but each nudge rode exactly its own next call.
143
+ const callTexts = provider.received.map((req) =>
144
+ req.messages.flatMap((m) => m.content).map((c) => ('text' in c ? c.text : '')).join('\n'),
145
+ );
146
+ expect(callTexts[0]).not.toContain('keep going');
147
+ expect(callTexts[1]).toContain('keep going (1)');
148
+ expect(callTexts[1]).not.toContain('keep going (2)');
149
+ expect(callTexts[2]).toContain('keep going (2)');
150
+ expect(callTexts[2]).not.toContain('keep going (1)');
151
+ });
152
+
153
+ it('retry loops again without injecting anything', async () => {
154
+ const provider = new FakeProvider({ script: [textReply('one'), textReply('two')] });
155
+ let calls = 0;
156
+ const retryOnce: TurnCheckpoint = {
157
+ name: 'retry-once',
158
+ gateOn: 'idle',
159
+ run: async () => (++calls === 1 ? { action: 'retry' } : { action: 'pass' }),
160
+ };
161
+ const session = gatedSession(provider, [retryOnce]);
162
+
163
+ const events = await collectTurn(session, 'go');
164
+
165
+ expect(provider.received).toHaveLength(2);
166
+ expect(events.some((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint')).toBe(false);
167
+ expect(checkpointSubtypes(events)).toEqual([
168
+ 'checkpoint_started',
169
+ 'checkpoint_retry',
170
+ 'checkpoint_started',
171
+ 'checkpoint_passed',
172
+ ]);
173
+ });
174
+
175
+ it('stop ends the turn immediately and skips remaining checkpoints', async () => {
176
+ const provider = new FakeProvider({ script: [textReply('answer')] });
177
+ let laterRan = false;
178
+ const stopper: TurnCheckpoint = {
179
+ name: 'stopper',
180
+ run: async () => ({ action: 'stop' }),
181
+ };
182
+ const later: TurnCheckpoint = {
183
+ name: 'later',
184
+ run: async () => {
185
+ laterRan = true;
186
+ return { action: 'pass' };
187
+ },
188
+ };
189
+ const session = gatedSession(provider, [stopper, later]);
190
+
191
+ const events = await collectTurn(session, 'go');
192
+
193
+ expect(provider.received).toHaveLength(1);
194
+ expect(laterRan).toBe(false);
195
+ expect(checkpointSubtypes(events)).toEqual(['checkpoint_started', 'checkpoint_stopped']);
196
+ });
197
+
198
+ it('exhausts the injection budget LOUDLY and ships the answer as-is', async () => {
199
+ const provider = new FakeProvider({
200
+ script: [textReply('v1'), textReply('v2'), textReply('v3')],
201
+ });
202
+ const alwaysRed: TurnCheckpoint = {
203
+ name: 'always-red',
204
+ run: async () => ({ action: 'inject', text: 'still failing' }),
205
+ };
206
+ const session = gatedSession(provider, [alwaysRed], { maxInjections: 2 });
207
+
208
+ const events = await collectTurn(session, 'go');
209
+
210
+ // Two injections spent, then the third candidate ends the turn with a warning.
211
+ expect(provider.received).toHaveLength(3);
212
+ expect(
213
+ events.filter((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint'),
214
+ ).toHaveLength(2);
215
+ const warning = events.find(
216
+ (e) => e.type === 'error' && e.message.includes('checkpoint budget exhausted'),
217
+ );
218
+ expect(warning).toBeDefined();
219
+ expect(lastAssistantText(events)).toBe('v3');
220
+ });
221
+
222
+ it('the injection budget is per idle-EPISODE: tool work resets it', async () => {
223
+ // Idle → inject → work → idle → inject → work → idle → inject → done.
224
+ // Three injections against maxInjections=2 — but never more than one per
225
+ // episode, so the budget (which exists to stop a no-progress wedge) must
226
+ // NOT trip. Before the reset, a long run died on its 3rd spread-out idle
227
+ // with "checkpoint budget exhausted" even though every nudge had worked.
228
+ const provider = new FakeProvider({
229
+ script: [
230
+ textReply('thinking 1'),
231
+ toolUseReply('work', { step: 1 }, 'w1'),
232
+ textReply('thinking 2'),
233
+ toolUseReply('work', { step: 2 }, 'w2'),
234
+ textReply('thinking 3'),
235
+ textReply('done'),
236
+ ],
237
+ });
238
+ const nudgeUnlessDone: TurnCheckpoint = {
239
+ name: 'nudge-unless-done',
240
+ gateOn: 'idle',
241
+ run: async (check): Promise<CheckpointResult> =>
242
+ check.candidateText === 'done'
243
+ ? { action: 'pass' }
244
+ : { action: 'inject', text: 'keep going' },
245
+ };
246
+ const session = gatedSession(provider, [nudgeUnlessDone], { maxInjections: 2 });
247
+ session.tools.register(
248
+ defineTool({
249
+ name: 'work',
250
+ description: '',
251
+ inputSchema: z.object({ step: z.number() }),
252
+ handler: () => 'ok',
253
+ }),
254
+ );
255
+
256
+ const events = await collectTurn(session, 'go');
257
+
258
+ // All three injections landed (the tool batches reset the budget)…
259
+ expect(
260
+ events.filter((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint'),
261
+ ).toHaveLength(3);
262
+ // …the run never hit the budget warning and finished naturally.
263
+ expect(
264
+ events.some((e) => e.type === 'error' && e.message.includes('checkpoint budget exhausted')),
265
+ ).toBe(false);
266
+ expect(lastAssistantText(events)).toBe('done');
267
+ });
268
+
269
+ it('a crashing checkpoint fails OPEN with a visible warning', async () => {
270
+ const provider = new FakeProvider({ script: [textReply('answer')] });
271
+ const broken: TurnCheckpoint = {
272
+ name: 'broken',
273
+ run: async () => {
274
+ throw new Error('boom');
275
+ },
276
+ };
277
+ const session = gatedSession(provider, [broken]);
278
+
279
+ const events = await collectTurn(session, 'go');
280
+
281
+ const warning = events.find(
282
+ (e) =>
283
+ e.type === 'error' &&
284
+ e.kind === 'retryable' &&
285
+ e.message.includes('checkpoint "broken" failed: boom — proceeding unchecked'),
286
+ );
287
+ expect(warning).toBeDefined();
288
+ expect(lastAssistantText(events)).toBe('answer');
289
+ });
290
+
291
+ it('a hung checkpoint times out and fails OPEN', async () => {
292
+ const provider = new FakeProvider({ script: [textReply('answer')] });
293
+ const hung: TurnCheckpoint = {
294
+ name: 'hung',
295
+ timeoutMs: 1_000, // the enforced floor — keeps the test fast-ish
296
+ run: (check: CheckpointContext) =>
297
+ new Promise<CheckpointResult>((_resolve, reject) => {
298
+ check.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
299
+ }),
300
+ };
301
+ const session = gatedSession(provider, [hung]);
302
+
303
+ const events = await collectTurn(session, 'go');
304
+
305
+ const warning = events.find(
306
+ (e) =>
307
+ e.type === 'error' &&
308
+ e.message.includes('checkpoint "hung" timed out after 1000ms — proceeding unchecked'),
309
+ );
310
+ expect(warning).toBeDefined();
311
+ expect(lastAssistantText(events)).toBe('answer');
312
+ }, 15_000);
313
+
314
+ it('empty injected feedback is ignored as a checker bug (fail open)', async () => {
315
+ const provider = new FakeProvider({ script: [textReply('answer')] });
316
+ const blank: TurnCheckpoint = {
317
+ name: 'blank',
318
+ run: async () => ({ action: 'inject', text: ' ' }),
319
+ };
320
+ const session = gatedSession(provider, [blank]);
321
+
322
+ const events = await collectTurn(session, 'go');
323
+
324
+ expect(provider.received).toHaveLength(1);
325
+ expect(
326
+ events.some(
327
+ (e) => e.type === 'error' && e.message.includes('checkpoint "blank" injected empty feedback'),
328
+ ),
329
+ ).toBe(true);
330
+ expect(events.some((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint')).toBe(false);
331
+ });
332
+
333
+ it('default gateOn skips truncated candidates; gateOn idle sees them', async () => {
334
+ const provider = new FakeProvider({ script: [truncatedTextReply('partial…')] });
335
+ let endTurnRan = false;
336
+ let idleRan = false;
337
+ let idleStopReason: string | undefined;
338
+ const endTurnGate: TurnCheckpoint = {
339
+ name: 'end-turn-only',
340
+ run: async () => {
341
+ endTurnRan = true;
342
+ return { action: 'pass' };
343
+ },
344
+ };
345
+ const idleGate: TurnCheckpoint = {
346
+ name: 'idle-too',
347
+ gateOn: 'idle',
348
+ run: async (check) => {
349
+ idleRan = true;
350
+ idleStopReason = check.stopReason;
351
+ return { action: 'pass' };
352
+ },
353
+ };
354
+ const session = gatedSession(provider, [endTurnGate, idleGate]);
355
+
356
+ await collectTurn(session, 'go');
357
+
358
+ expect(endTurnRan).toBe(false);
359
+ expect(idleRan).toBe(true);
360
+ expect(idleStopReason).toBe('max_tokens');
361
+ });
362
+
363
+ it('consecutiveIdle counts idle rounds and resets after a tool batch', async () => {
364
+ const provider = new FakeProvider({
365
+ script: [
366
+ textReply('idle once'),
367
+ toolUseReply('echo', { msg: 'work' }, 'c1'),
368
+ textReply('idle again'),
369
+ ],
370
+ });
371
+ const seen: number[] = [];
372
+ const recorder: TurnCheckpoint = {
373
+ name: 'recorder',
374
+ gateOn: 'idle',
375
+ run: async (check) => {
376
+ seen.push(check.consecutiveIdle);
377
+ return seen.length === 1 ? { action: 'retry' } : { action: 'pass' };
378
+ },
379
+ };
380
+ const session = gatedSession(provider, [recorder]);
381
+ session.tools.register(
382
+ defineTool({
383
+ name: 'echo',
384
+ description: 'returns msg',
385
+ inputSchema: z.object({ msg: z.string() }),
386
+ handler: (i) => i.msg,
387
+ }),
388
+ );
389
+
390
+ await collectTurn(session, 'go');
391
+
392
+ // Round 1 idles (consecutiveIdle 1), the retry round does real tool work
393
+ // (counter resets), the final idle round starts over at 1.
394
+ expect(seen).toEqual([1, 1]);
395
+ });
396
+
397
+ it('checkpoints are disarmed inside subagent sessions (recursion backstop)', async () => {
398
+ const provider = new FakeProvider({ script: [textReply('child answer')] });
399
+ let ran = false;
400
+ const recorder: TurnCheckpoint = {
401
+ name: 'recorder',
402
+ run: async () => {
403
+ ran = true;
404
+ return { action: 'pass' };
405
+ },
406
+ };
407
+ const session = gatedSession(provider, [recorder], { asSubagent: true });
408
+
409
+ const events = await collectTurn(session, 'go');
410
+
411
+ expect(ran).toBe(false);
412
+ expect(checkpointSubtypes(events)).toEqual([]);
413
+ const last = events[events.length - 1];
414
+ if (last?.type !== 'assistant_message') throw new Error('expected assistant_message last');
415
+ expect(last.content).toBe('child answer');
416
+ });
417
+
418
+ it('oversized feedback is clamped with an explicit truncation marker', async () => {
419
+ const provider = new FakeProvider({ script: [textReply('v1'), textReply('v2')] });
420
+ let calls = 0;
421
+ const chatty: TurnCheckpoint = {
422
+ name: 'chatty',
423
+ run: async () => (++calls === 1 ? { action: 'inject', text: 'x'.repeat(40_000) } : { action: 'pass' }),
424
+ };
425
+ const session = gatedSession(provider, [chatty]);
426
+
427
+ const events = await collectTurn(session, 'go');
428
+
429
+ const injected = events.find((e) => e.type === 'user_prompt' && e.origin?.kind === 'checkpoint');
430
+ if (injected?.type !== 'user_prompt') throw new Error('expected injected user_prompt');
431
+ expect(injected.text.length).toBeLessThan(17_000);
432
+ expect(injected.text).toContain('[checkpoint feedback truncated');
433
+ });
434
+
435
+ it('user abort during a checkpoint ends the turn quietly, keeping the answer', async () => {
436
+ const provider = new FakeProvider({ script: [textReply('answer')] });
437
+ const controller = new AbortController();
438
+ const aborter: TurnCheckpoint = {
439
+ name: 'aborter',
440
+ run: async () => {
441
+ controller.abort();
442
+ throw new Error('interrupted by user cancel');
443
+ },
444
+ };
445
+ const session = gatedSession(provider, [aborter]);
446
+
447
+ const events = await collectTurn(session, 'go', { signal: controller.signal });
448
+
449
+ // No "proceeding unchecked" noise — the cancel is not a checker failure…
450
+ expect(events.some((e) => e.type === 'error' && e.message.includes('proceeding unchecked'))).toBe(
451
+ false,
452
+ );
453
+ // …and the already-produced answer is not retracted.
454
+ expect(
455
+ events.some((e) => e.type === 'assistant_message' && e.content === 'answer'),
456
+ ).toBe(true);
457
+ });
458
+ });
@@ -1,329 +1,22 @@
1
1
  import {
2
- buildSystemPromptWithSkills,
3
- collectProviderStream,
4
- createStuckLoopDetector,
5
- emitRequestsAndDetectStuck,
6
- executeToolUses,
7
- isContextOverflowError,
8
- nextBackoffMs,
9
- projectMessages,
10
- runCompactionIfNeeded,
11
- runElisionIfNeeded,
12
- sleepWithAbort,
13
- usageEventFields,
2
+ runReactLoop,
14
3
  type ModeContext,
15
4
  type MoxxyEvent,
16
- type ProjectedMessages,
17
5
  } from '@moxxy/sdk';
18
6
 
19
7
  export const DEFAULT_MODE_NAME = 'default';
20
8
 
21
- /**
22
- * Bounded back-off for a *retryable* provider error (rate-limit/429,
23
- * overloaded, transient 5xx, ECONNRESET/ETIMEDOUT). Without it, a sustained
24
- * retryable condition becomes a tight busy-loop: the loop rebuilds the message
25
- * set and re-hits the provider with zero delay up to `maxIterations` times,
26
- * burning rate-limit budget and worsening the throttle. We back off
27
- * exponentially (abort-aware) and give up with a fatal error after
28
- * {@link MAX_CONSECUTIVE_RETRIES} consecutive failures — the counter resets on
29
- * any clean provider call, so a long turn can still recover from transient
30
- * blips. (The context-overflow path keeps its own MAX_REACTIVE_COMPACTIONS
31
- * budget and is handled before this.)
32
- */
33
- export const MAX_CONSECUTIVE_RETRIES = 6;
34
-
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;
38
-
39
- // Abort-aware sleep, injectable for tests so the back-off path runs instantly
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);
9
+ // The loop plumbing (bounded retry back-off, reactive compaction, elision,
10
+ // stuck detection, abort handling) lives in the SDK's shared ReAct core —
11
+ // re-export its constants/test seam so existing importers keep working.
12
+ export { MAX_CONSECUTIVE_RETRIES, __setRetrySleepForTests } from '@moxxy/sdk';
44
13
 
45
14
  /**
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.
15
+ * Default ReAct-style loop: model thinks, calls tools, observes results,
16
+ * repeats and returns the moment the model stops calling tools. Pure
17
+ * delegation to {@link runReactLoop} with no hooks and no checkpoints: the
18
+ * shared core IS the default behavior; other modes layer policy on top.
51
19
  */
52
- export function __setRetrySleepForTests(
53
- fn: (ms: number, signal: AbortSignal) => Promise<void>,
54
- ): () => void {
55
- const prev = sleepImpl;
56
- sleepImpl = fn;
57
- return () => {
58
- sleepImpl = prev;
59
- };
60
- }
61
-
62
- export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent> {
63
- // High soft cap as a safety net against truly runaway modes (network
64
- // glitch causing an infinite retry, bad prompt, etc.) — primary
65
- // termination signal is the stuck-loop detector, which catches the
66
- // common "model keeps calling the same tool" case ~10 iterations in.
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;
76
- const detector = createStuckLoopDetector(ctx.loopGuard);
77
- // Reactive-compaction budget per overflow episode. If the provider keeps
78
- // rejecting for context size even after compacting this many times, give up
79
- // (the overflow is in the recent, un-compactable tail). Reset on any clean
80
- // provider call so a long turn can recover from multiple overflow episodes.
81
- const MAX_REACTIVE_COMPACTIONS = 2;
82
- let reactiveCompactions = 0;
83
- // Consecutive retryable-error count; reset on any clean provider call. Caps
84
- // the busy-loop a sustained retryable condition would otherwise create.
85
- let consecutiveRetries = 0;
86
-
87
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
88
- if (ctx.signal.aborted) {
89
- yield await ctx.emit({
90
- type: 'abort',
91
- sessionId: ctx.sessionId,
92
- turnId: ctx.turnId,
93
- source: 'system',
94
- reason: 'signal aborted',
95
- });
96
- return;
97
- }
98
-
99
- yield await ctx.emit({
100
- type: 'mode_iteration',
101
- sessionId: ctx.sessionId,
102
- turnId: ctx.turnId,
103
- source: 'system',
104
- strategy: DEFAULT_MODE_NAME,
105
- iteration,
106
- });
107
-
108
- // Auto-compact before composing the next provider request. If the
109
- // active compactor's `shouldCompact` returns true, this appends a
110
- // compaction event onto the log — projectMessagesFromLog (called
111
- // by buildMessages) honors it, so the model sees a summarized
112
- // prefix instead of overflowing the window mid-loop.
113
- await runCompactionIfNeeded(ctx);
114
- // Turn-boundary elision (context-on-demand): stub old bulky tool output and
115
- // (when enabled) old text turns, recall-able on demand. Composes with
116
- // compaction over the same projection.
117
- await runElisionIfNeeded(ctx);
118
-
119
- const { messages, stablePrefixIndex } = buildMessages(ctx);
120
- yield await ctx.emit({
121
- type: 'provider_request',
122
- sessionId: ctx.sessionId,
123
- turnId: ctx.turnId,
124
- source: 'system',
125
- provider: ctx.provider.name,
126
- model: ctx.model,
127
- });
128
-
129
- const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(
130
- ctx,
131
- messages,
132
- {
133
- iteration,
134
- stablePrefixIndex,
135
- },
136
- );
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
-
154
- yield await ctx.emit({
155
- type: 'provider_response',
156
- sessionId: ctx.sessionId,
157
- turnId: ctx.turnId,
158
- source: 'system',
159
- provider: ctx.provider.name,
160
- model: ctx.model,
161
- ...usageEventFields(usage),
162
- });
163
-
164
- if (error) {
165
- // The request was too big for the model's window: our token estimate
166
- // lagged the provider's real tokenizer, so the proactive compactor
167
- // didn't fire. Force a compaction and retry rather than dying — this is
168
- // the auto-compact-on-overflow path.
169
- if (
170
- isContextOverflowError(error.message) &&
171
- reactiveCompactions < MAX_REACTIVE_COMPACTIONS
172
- ) {
173
- const compacted = await runCompactionIfNeeded(ctx, { force: true });
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;
179
- yield await ctx.emit({
180
- type: 'error',
181
- sessionId: ctx.sessionId,
182
- turnId: ctx.turnId,
183
- source: 'system',
184
- kind: 'retryable',
185
- message: 'context window exceeded — compacted older turns, retrying',
186
- });
187
- continue;
188
- }
189
- }
190
- if (!error.retryable) {
191
- yield await ctx.emit({
192
- type: 'error',
193
- sessionId: ctx.sessionId,
194
- turnId: ctx.turnId,
195
- source: 'system',
196
- kind: 'fatal',
197
- message: error.message,
198
- });
199
- return;
200
- }
201
- // Retryable: surface it, then back off before retrying. A persistent
202
- // retryable condition (sustained 429 / outage) must NOT busy-loop the
203
- // provider — give up with a fatal error after the bounded retry count.
204
- consecutiveRetries += 1;
205
- yield await ctx.emit({
206
- type: 'error',
207
- sessionId: ctx.sessionId,
208
- turnId: ctx.turnId,
209
- source: 'system',
210
- kind: 'retryable',
211
- message: error.message,
212
- });
213
- if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
214
- yield await ctx.emit({
215
- type: 'error',
216
- sessionId: ctx.sessionId,
217
- turnId: ctx.turnId,
218
- source: 'system',
219
- kind: 'fatal',
220
- message:
221
- `provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
222
- `(last: ${error.message}); giving up rather than hammering the provider.`,
223
- });
224
- return;
225
- }
226
- await sleepImpl(
227
- nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS),
228
- ctx.signal,
229
- );
230
- if (ctx.signal.aborted) {
231
- yield await ctx.emit({
232
- type: 'abort',
233
- sessionId: ctx.sessionId,
234
- turnId: ctx.turnId,
235
- source: 'system',
236
- reason: 'signal aborted during retry back-off',
237
- });
238
- return;
239
- }
240
- continue;
241
- }
242
- // Clean provider call — reset the overflow-recovery + retry budgets.
243
- reactiveCompactions = 0;
244
- consecutiveRetries = 0;
245
-
246
- // Finalize the reasoning summary for THIS call BEFORE the tool/assistant
247
- // emits, so the log order is reasoning → tool_use → text (projection
248
- // attaches the signed thinking block as content[0] of the same assistant
249
- // turn). collectProviderStream already guards on non-empty text / encrypted.
250
- if (reasoning) {
251
- yield await ctx.emit({
252
- type: 'reasoning_message',
253
- sessionId: ctx.sessionId,
254
- turnId: ctx.turnId,
255
- source: 'model',
256
- content: reasoning.text,
257
- ...(reasoning.signature ? { signature: reasoning.signature } : {}),
258
- ...(reasoning.redacted ? { redacted: true } : {}),
259
- ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
260
- });
261
- }
262
-
263
- const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, {
264
- abortedResultMessage: 'default mode loop aborted (stuck pattern) before this call ran',
265
- nearHint: 'against the same target (only volatile args like maxBytes varied)',
266
- fatalMessage: ({ toolName, count, how }) =>
267
- `default mode loop aborted — detected stuck pattern: tool "${toolName}" called ` +
268
- `${count} times ${how}. The model is likely looping on the same call; ` +
269
- `reset or rephrase.`,
270
- });
271
- if (stuck) return;
272
-
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
- }
289
- yield await ctx.emit({
290
- type: 'assistant_message',
291
- sessionId: ctx.sessionId,
292
- turnId: ctx.turnId,
293
- source: 'model',
294
- content: text,
295
- stopReason,
296
- });
297
- }
298
-
299
- // Execute whenever the model requested tools, regardless of stopReason.
300
- // Providers vary in how reliably they report `stopReason: 'tool_use'`
301
- // (Codex's Responses API doesn't carry one on `response.completed`, so
302
- // the provider has to infer it from emitted events). Trusting only
303
- // stopReason here meant a single provider mis-mapping silently dropped
304
- // tool calls — `tool_call_requested` would be emitted with no matching
305
- // `tool_result`, leaving an orphan pending dot and a stuck-looking UI.
306
- if (toolUses.length === 0) return;
307
-
308
- const exited = yield* executeToolUses(ctx, toolUses, iteration);
309
- if (exited) return;
310
- }
311
-
312
- yield await ctx.emit({
313
- type: 'error',
314
- sessionId: ctx.sessionId,
315
- turnId: ctx.turnId,
316
- source: 'system',
317
- kind: 'fatal',
318
- message: `default mode loop exceeded maxIterations (${maxIterations})`,
319
- });
320
- }
321
-
322
- function buildMessages(ctx: ModeContext): ProjectedMessages {
323
- // Compose the system prompt with the skill catalog so the model knows
324
- // which playbooks exist; without this skills are invisible to the
325
- // model and it falls back to ad-hoc tool calls (the classic
326
- // `web_fetch instead of media-digest skill` symptom).
327
- const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list());
328
- return projectMessages(ctx, { ...(systemPrompt ? { systemPrompt } : {}) });
20
+ export function runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent> {
21
+ return runReactLoop(ctx, { strategyName: DEFAULT_MODE_NAME });
329
22
  }