@moxxy/mode-default 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moxxy (moxxy.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @moxxy/mode-default
2
+
3
+ The **default loop strategy** (mode) for [moxxy](https://moxxy.ai) — a Claude
4
+ Code-style ReAct loop: the agent thinks, optionally calls tools, sees the
5
+ results, and continues until it produces a final answer. It also handles
6
+ retryable provider errors (e.g. 429s) with backoff.
7
+
8
+ A "mode" is the turn-by-turn brain of a moxxy [`Session`](https://www.npmjs.com/package/@moxxy/core);
9
+ register this one to get sensible default agent behaviour, or author your own
10
+ against `@moxxy/sdk`'s `defineMode`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm i @moxxy/core @moxxy/sdk @moxxy/mode-default
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ts
21
+ import { Session, collectTurn, autoAllowResolver } from '@moxxy/core';
22
+ import defaultModePlugin from '@moxxy/mode-default';
23
+ import openaiPlugin from '@moxxy/plugin-provider-openai';
24
+
25
+ const session = new Session({ cwd: process.cwd(), permissionResolver: autoAllowResolver });
26
+
27
+ session.pluginHost.registerStatic(defaultModePlugin); // ← the loop strategy
28
+ session.pluginHost.registerStatic(openaiPlugin);
29
+ session.providers.setActive('openai', { apiKey: process.env.OPENAI_API_KEY });
30
+
31
+ const events = await collectTurn(session, 'What is 2+2? Use a tool if you have one.');
32
+ ```
33
+
34
+ The mode is selected automatically once registered (it registers as the default).
35
+ To run a different strategy, register another mode package and switch with
36
+ `session.modes.setActive(name)`.
37
+
38
+ ## Exports
39
+
40
+ - `default` / `defaultModePlugin` — the moxxy plugin to register.
41
+ - `defaultMode` — the underlying `Mode` (if you compose registries yourself).
42
+ - `DEFAULT_MODE_NAME` — its registered name.
43
+
44
+ ## License
45
+
46
+ MIT
@@ -0,0 +1,8 @@
1
+ import { type CollectedToolUse } from '@moxxy/sdk';
2
+ import { DEFAULT_MODE_NAME } from './turn-iterator.js';
3
+ export { DEFAULT_MODE_NAME };
4
+ export type { CollectedToolUse };
5
+ export declare const defaultMode: import("@moxxy/sdk").ModeDef;
6
+ export declare const defaultModePlugin: import("@moxxy/sdk").Plugin;
7
+ export default defaultModePlugin;
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAkB,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvE,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,CAAC;AAEjC,eAAO,MAAM,WAAW,8BAItB,CAAC;AAEH,eAAO,MAAM,iBAAiB,6BAI5B,CAAC;AAEH,eAAe,iBAAiB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ import { defineMode, definePlugin, } from '@moxxy/sdk';
2
+ import { runDefaultMode, DEFAULT_MODE_NAME } from './turn-iterator.js';
3
+ export { DEFAULT_MODE_NAME };
4
+ export const defaultMode = defineMode({
5
+ name: DEFAULT_MODE_NAME,
6
+ description: 'Default ReAct-style loop: model thinks, calls tools, observes results, repeats',
7
+ run: runDefaultMode,
8
+ });
9
+ export const defaultModePlugin = definePlugin({
10
+ name: '@moxxy/mode-default',
11
+ version: '0.0.0',
12
+ modes: [defaultMode],
13
+ });
14
+ export default defaultModePlugin;
15
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,YAAY,GAEb,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvE,OAAO,EAAE,iBAAiB,EAAE,CAAC;AAG7B,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;IACpC,IAAI,EAAE,iBAAiB;IACvB,WAAW,EAAE,gFAAgF;IAC7F,GAAG,EAAE,cAAc;CACpB,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,iBAAiB,GAAG,YAAY,CAAC;IAC5C,IAAI,EAAE,qBAAqB;IAC3B,OAAO,EAAE,OAAO;IAChB,KAAK,EAAE,CAAC,WAAW,CAAC;CACrB,CAAC,CAAC;AAEH,eAAe,iBAAiB,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type ModeContext, type MoxxyEvent } from '@moxxy/sdk';
2
+ export declare const DEFAULT_MODE_NAME = "default";
3
+ /**
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.)
14
+ */
15
+ export declare const MAX_CONSECUTIVE_RETRIES = 6;
16
+ /** Override the retry back-off sleep (test seam). Returns a restore fn. */
17
+ export declare function __setRetrySleepForTests(fn: (ms: number, signal: AbortSignal) => Promise<void>): () => void;
18
+ export declare function runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
19
+ //# sourceMappingURL=turn-iterator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-iterator.d.ts","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,KAAK,WAAW,EAChB,KAAK,UAAU,EAEhB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,iBAAiB,YAAY,CAAC;AAE3C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AA0BzC,2EAA2E;AAC3E,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,CAqNjF"}
@@ -0,0 +1,250 @@
1
+ import { buildSystemPromptWithSkills, collectProviderStream, createStuckLoopDetector, emitRequestsAndDetectStuck, executeToolUses, isContextOverflowError, projectMessages, runCompactionIfNeeded, runElisionIfNeeded, usageEventFields, } from '@moxxy/sdk';
2
+ export const DEFAULT_MODE_NAME = 'default';
3
+ /**
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.)
14
+ */
15
+ export const MAX_CONSECUTIVE_RETRIES = 6;
16
+ /** Exponential schedule, capped — base*2^(attempt-1), attempt is 1-based. */
17
+ function retryBackoffMs(attempt) {
18
+ const base = 500;
19
+ const cap = 30_000;
20
+ return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
21
+ }
22
+ // Abort-aware sleep, injectable for tests so the back-off path runs instantly
23
+ // and deterministically. Production uses a real timer that clears on abort so a
24
+ // pending back-off never outlives a cancelled turn.
25
+ let sleepImpl = (ms, signal) => new Promise((resolve) => {
26
+ if (signal.aborted)
27
+ return resolve();
28
+ const timer = setTimeout(() => {
29
+ signal.removeEventListener('abort', onAbort);
30
+ resolve();
31
+ }, ms);
32
+ const onAbort = () => {
33
+ clearTimeout(timer);
34
+ resolve();
35
+ };
36
+ signal.addEventListener('abort', onAbort, { once: true });
37
+ });
38
+ /** Override the retry back-off sleep (test seam). Returns a restore fn. */
39
+ export function __setRetrySleepForTests(fn) {
40
+ const prev = sleepImpl;
41
+ sleepImpl = fn;
42
+ return () => {
43
+ sleepImpl = prev;
44
+ };
45
+ }
46
+ export async function* runDefaultMode(ctx) {
47
+ // High soft cap as a safety net against truly runaway modes (network
48
+ // glitch causing an infinite retry, bad prompt, etc.) — primary
49
+ // termination signal is the stuck-loop detector, which catches the
50
+ // common "model keeps calling the same tool" case ~10 iterations in.
51
+ const maxIterations = ctx.maxIterations ?? 500;
52
+ const detector = createStuckLoopDetector();
53
+ // Reactive-compaction budget per overflow episode. If the provider keeps
54
+ // rejecting for context size even after compacting this many times, give up
55
+ // (the overflow is in the recent, un-compactable tail). Reset on any clean
56
+ // provider call so a long turn can recover from multiple overflow episodes.
57
+ const MAX_REACTIVE_COMPACTIONS = 2;
58
+ let reactiveCompactions = 0;
59
+ // Consecutive retryable-error count; reset on any clean provider call. Caps
60
+ // the busy-loop a sustained retryable condition would otherwise create.
61
+ let consecutiveRetries = 0;
62
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
63
+ if (ctx.signal.aborted) {
64
+ yield await ctx.emit({
65
+ type: 'abort',
66
+ sessionId: ctx.sessionId,
67
+ turnId: ctx.turnId,
68
+ source: 'system',
69
+ reason: 'signal aborted',
70
+ });
71
+ return;
72
+ }
73
+ yield await ctx.emit({
74
+ type: 'mode_iteration',
75
+ sessionId: ctx.sessionId,
76
+ turnId: ctx.turnId,
77
+ source: 'system',
78
+ strategy: DEFAULT_MODE_NAME,
79
+ iteration,
80
+ });
81
+ // Auto-compact before composing the next provider request. If the
82
+ // active compactor's `shouldCompact` returns true, this appends a
83
+ // compaction event onto the log — projectMessagesFromLog (called
84
+ // by buildMessages) honors it, so the model sees a summarized
85
+ // prefix instead of overflowing the window mid-loop.
86
+ await runCompactionIfNeeded(ctx);
87
+ // Turn-boundary elision (context-on-demand): stub old bulky tool output and
88
+ // (when enabled) old text turns, recall-able on demand. Composes with
89
+ // compaction over the same projection.
90
+ await runElisionIfNeeded(ctx);
91
+ const { messages, stablePrefixIndex } = buildMessages(ctx);
92
+ yield await ctx.emit({
93
+ type: 'provider_request',
94
+ sessionId: ctx.sessionId,
95
+ turnId: ctx.turnId,
96
+ source: 'system',
97
+ provider: ctx.provider.name,
98
+ model: ctx.model,
99
+ });
100
+ const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(ctx, messages, {
101
+ iteration,
102
+ stablePrefixIndex,
103
+ });
104
+ yield await ctx.emit({
105
+ type: 'provider_response',
106
+ sessionId: ctx.sessionId,
107
+ turnId: ctx.turnId,
108
+ source: 'system',
109
+ provider: ctx.provider.name,
110
+ model: ctx.model,
111
+ ...usageEventFields(usage),
112
+ });
113
+ if (error) {
114
+ // The request was too big for the model's window: our token estimate
115
+ // lagged the provider's real tokenizer, so the proactive compactor
116
+ // didn't fire. Force a compaction and retry rather than dying — this is
117
+ // the auto-compact-on-overflow path.
118
+ if (isContextOverflowError(error.message) &&
119
+ reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
120
+ reactiveCompactions += 1;
121
+ const compacted = await runCompactionIfNeeded(ctx, { force: true });
122
+ if (compacted) {
123
+ yield await ctx.emit({
124
+ type: 'error',
125
+ sessionId: ctx.sessionId,
126
+ turnId: ctx.turnId,
127
+ source: 'system',
128
+ kind: 'retryable',
129
+ message: 'context window exceeded — compacted older turns, retrying',
130
+ });
131
+ continue;
132
+ }
133
+ }
134
+ if (!error.retryable) {
135
+ yield await ctx.emit({
136
+ type: 'error',
137
+ sessionId: ctx.sessionId,
138
+ turnId: ctx.turnId,
139
+ source: 'system',
140
+ kind: 'fatal',
141
+ message: error.message,
142
+ });
143
+ return;
144
+ }
145
+ // Retryable: surface it, then back off before retrying. A persistent
146
+ // retryable condition (sustained 429 / outage) must NOT busy-loop the
147
+ // provider — give up with a fatal error after the bounded retry count.
148
+ consecutiveRetries += 1;
149
+ yield await ctx.emit({
150
+ type: 'error',
151
+ sessionId: ctx.sessionId,
152
+ turnId: ctx.turnId,
153
+ source: 'system',
154
+ kind: 'retryable',
155
+ message: error.message,
156
+ });
157
+ if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
158
+ yield await ctx.emit({
159
+ type: 'error',
160
+ sessionId: ctx.sessionId,
161
+ turnId: ctx.turnId,
162
+ source: 'system',
163
+ kind: 'fatal',
164
+ message: `provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
165
+ `(last: ${error.message}); giving up rather than hammering the provider.`,
166
+ });
167
+ return;
168
+ }
169
+ await sleepImpl(retryBackoffMs(consecutiveRetries), ctx.signal);
170
+ if (ctx.signal.aborted) {
171
+ yield await ctx.emit({
172
+ type: 'abort',
173
+ sessionId: ctx.sessionId,
174
+ turnId: ctx.turnId,
175
+ source: 'system',
176
+ reason: 'signal aborted during retry back-off',
177
+ });
178
+ return;
179
+ }
180
+ continue;
181
+ }
182
+ // Clean provider call — reset the overflow-recovery + retry budgets.
183
+ reactiveCompactions = 0;
184
+ consecutiveRetries = 0;
185
+ // Finalize the reasoning summary for THIS call BEFORE the tool/assistant
186
+ // emits, so the log order is reasoning → tool_use → text (projection
187
+ // attaches the signed thinking block as content[0] of the same assistant
188
+ // turn). collectProviderStream already guards on non-empty text / encrypted.
189
+ if (reasoning) {
190
+ yield await ctx.emit({
191
+ type: 'reasoning_message',
192
+ sessionId: ctx.sessionId,
193
+ turnId: ctx.turnId,
194
+ source: 'model',
195
+ content: reasoning.text,
196
+ ...(reasoning.signature ? { signature: reasoning.signature } : {}),
197
+ ...(reasoning.redacted ? { redacted: true } : {}),
198
+ ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
199
+ });
200
+ }
201
+ const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, {
202
+ abortedResultMessage: 'default mode loop aborted (stuck pattern) before this call ran',
203
+ nearHint: 'against the same target (only volatile args like maxBytes varied)',
204
+ fatalMessage: ({ toolName, count, how }) => `default mode loop aborted — detected stuck pattern: tool "${toolName}" called ` +
205
+ `${count} times ${how}. The model is likely looping on the same call; ` +
206
+ `reset or rephrase.`,
207
+ });
208
+ if (stuck)
209
+ return;
210
+ if (text || stopReason === 'end_turn' || toolUses.length === 0) {
211
+ yield await ctx.emit({
212
+ type: 'assistant_message',
213
+ sessionId: ctx.sessionId,
214
+ turnId: ctx.turnId,
215
+ source: 'model',
216
+ content: text,
217
+ stopReason,
218
+ });
219
+ }
220
+ // Execute whenever the model requested tools, regardless of stopReason.
221
+ // Providers vary in how reliably they report `stopReason: 'tool_use'`
222
+ // (Codex's Responses API doesn't carry one on `response.completed`, so
223
+ // the provider has to infer it from emitted events). Trusting only
224
+ // stopReason here meant a single provider mis-mapping silently dropped
225
+ // tool calls — `tool_call_requested` would be emitted with no matching
226
+ // `tool_result`, leaving an orphan pending dot and a stuck-looking UI.
227
+ if (toolUses.length === 0)
228
+ return;
229
+ const exited = yield* executeToolUses(ctx, toolUses, iteration);
230
+ if (exited)
231
+ return;
232
+ }
233
+ yield await ctx.emit({
234
+ type: 'error',
235
+ sessionId: ctx.sessionId,
236
+ turnId: ctx.turnId,
237
+ source: 'system',
238
+ kind: 'fatal',
239
+ message: `default mode loop exceeded maxIterations (${maxIterations})`,
240
+ });
241
+ }
242
+ function buildMessages(ctx) {
243
+ // Compose the system prompt with the skill catalog so the model knows
244
+ // which playbooks exist; without this skills are invisible to the
245
+ // model and it falls back to ad-hoc tool calls (the classic
246
+ // `web_fetch instead of media-digest skill` symptom).
247
+ const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list());
248
+ return projectMessages(ctx, { ...(systemPrompt ? { systemPrompt } : {}) });
249
+ }
250
+ //# sourceMappingURL=turn-iterator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-iterator.js","sourceRoot":"","sources":["../src/turn-iterator.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@moxxy/mode-default",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "mode",
9
+ "loop",
10
+ "react",
11
+ "tool-use"
12
+ ],
13
+ "homepage": "https://moxxy.ai",
14
+ "bugs": {
15
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
20
+ "directory": "packages/mode-default"
21
+ },
22
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "moxxy": {
41
+ "plugin": {
42
+ "entry": "./dist/index.js",
43
+ "kind": "mode"
44
+ }
45
+ },
46
+ "dependencies": {
47
+ "@moxxy/sdk": "0.15.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^22.10.0",
51
+ "typescript": "^5.7.3",
52
+ "vitest": "^2.1.8",
53
+ "zod": "^3.24.0",
54
+ "@moxxy/core": "0.3.0",
55
+ "@moxxy/testing": "0.0.25",
56
+ "@moxxy/tsconfig": "0.0.0",
57
+ "@moxxy/vitest-preset": "0.0.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.json",
61
+ "typecheck": "tsc -p tsconfig.json --noEmit",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "clean": "rm -rf dist .turbo"
65
+ }
66
+ }
@@ -0,0 +1,391 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { definePlugin, defineTool, type ProviderEvent } from '@moxxy/sdk';
4
+ import { Session, autoAllowResolver, collectTurn, silentLogger } from '@moxxy/core';
5
+ import { FakeProvider, textReply, toolUseReply, createFakeSession } from '@moxxy/testing';
6
+ import { defaultModePlugin } from './index.js';
7
+ import { MAX_CONSECUTIVE_RETRIES, __setRetrySleepForTests } from './turn-iterator.js';
8
+
9
+ /** A scripted provider reply that surfaces a retryable error (e.g. a 429). */
10
+ const retryableErrorReply = (message = 'rate limited (429)'): ReadonlyArray<ProviderEvent> => [
11
+ { type: 'message_start', model: 'fake' },
12
+ { type: 'error', message, retryable: true },
13
+ { type: 'message_end', stopReason: 'end_turn' },
14
+ ];
15
+
16
+ const sessionWith = (provider: FakeProvider): Session => {
17
+ const session = createFakeSession({ provider });
18
+ session.pluginHost.registerStatic(defaultModePlugin);
19
+ return session;
20
+ };
21
+
22
+ describe('defaultMode end-to-end', () => {
23
+ it('runs a plain text turn and emits the expected event sequence', async () => {
24
+ const provider = new FakeProvider({ script: [textReply('hello there')] });
25
+ const session = sessionWith(provider);
26
+
27
+ const events = await collectTurn(session, 'hi');
28
+ const types = events.map((e) => e.type);
29
+
30
+ expect(types).toEqual([
31
+ 'user_prompt',
32
+ 'mode_iteration',
33
+ 'provider_request',
34
+ 'assistant_chunk',
35
+ 'provider_response',
36
+ 'assistant_message',
37
+ ]);
38
+ const last = events[events.length - 1];
39
+ if (last.type !== 'assistant_message') throw new Error('expected assistant_message last');
40
+ expect(last.content).toBe('hello there');
41
+ expect(last.stopReason).toBe('end_turn');
42
+ });
43
+
44
+ it('runs tool_use then continues loop with the result', async () => {
45
+ const provider = new FakeProvider({
46
+ script: [toolUseReply('echo', { msg: 'world' }, 'c1'), textReply('done: world')],
47
+ });
48
+ const session = sessionWith(provider);
49
+ session.tools.register(
50
+ defineTool({
51
+ name: 'echo',
52
+ description: 'returns msg',
53
+ inputSchema: z.object({ msg: z.string() }),
54
+ handler: (i) => i.msg,
55
+ }),
56
+ );
57
+
58
+ const events = await collectTurn(session, 'go');
59
+ const toolResult = events.find((e) => e.type === 'tool_result');
60
+ if (toolResult?.type !== 'tool_result') throw new Error('expected tool_result');
61
+ expect(toolResult.ok).toBe(true);
62
+ expect(toolResult.output).toBe('world');
63
+
64
+ const last = events[events.length - 1];
65
+ if (last.type !== 'assistant_message') throw new Error('expected assistant_message last');
66
+ expect(last.content).toBe('done: world');
67
+ });
68
+
69
+ it('records denial when permission resolver says no', async () => {
70
+ const provider = new FakeProvider({
71
+ script: [toolUseReply('Bash', { command: 'rm -rf /' }, 'c1'), textReply('aborted')],
72
+ });
73
+ const session = new Session({
74
+ cwd: '/tmp',
75
+ logger: silentLogger,
76
+ permissionResolver: { name: 'deny', async check() { return { mode: 'deny', reason: 'no shells' }; } },
77
+ });
78
+ session.pluginHost.registerStatic({
79
+ __moxxy: 'plugin' as const,
80
+ name: 'shim',
81
+ version: '0.0.0',
82
+ providers: [
83
+ {
84
+ name: provider.name,
85
+ models: [...provider.models],
86
+ createClient: () => provider,
87
+ },
88
+ ],
89
+ });
90
+ session.providers.setActive(provider.name);
91
+ session.pluginHost.registerStatic(defaultModePlugin);
92
+ session.tools.register(
93
+ defineTool({
94
+ name: 'Bash',
95
+ description: '',
96
+ inputSchema: z.object({ command: z.string() }),
97
+ handler: () => 'should not run',
98
+ }),
99
+ );
100
+
101
+ const events = await collectTurn(session, 'do it');
102
+ const denied = events.find((e) => e.type === 'tool_call_denied');
103
+ expect(denied).toBeDefined();
104
+ const result = events.find((e) => e.type === 'tool_result');
105
+ if (result?.type !== 'tool_result') throw new Error('expected tool_result');
106
+ expect(result.ok).toBe(false);
107
+ expect(result.error?.kind).toBe('denied');
108
+ });
109
+
110
+ it('handles tool handler throws as failure result', async () => {
111
+ const provider = new FakeProvider({
112
+ script: [toolUseReply('boom', {}, 'c1'), textReply('recovered')],
113
+ });
114
+ const session = sessionWith(provider);
115
+ session.tools.register(
116
+ defineTool({
117
+ name: 'boom',
118
+ description: '',
119
+ inputSchema: z.object({}),
120
+ handler: () => {
121
+ throw new Error('explode');
122
+ },
123
+ }),
124
+ );
125
+
126
+ const events = await collectTurn(session, 'go');
127
+ const result = events.find((e) => e.type === 'tool_result');
128
+ if (result?.type !== 'tool_result') throw new Error('expected tool_result');
129
+ expect(result.ok).toBe(false);
130
+ expect(result.error?.kind).toBe('threw');
131
+ expect(result.error?.message).toContain('explode');
132
+ });
133
+
134
+ it('aborts via stuck-loop detector when the model hammers the same call', async () => {
135
+ // The detector fires when the same (name, input) pair appears
136
+ // REPEAT_THRESHOLD times in the last WINDOW calls. A scripted
137
+ // provider that returns the same toolUse over and over is the
138
+ // canonical stuck-loop pattern — bail before burning the soft
139
+ // maxIterations cap.
140
+ const provider = new FakeProvider({
141
+ script: Array(20).fill(toolUseReply('loop', {}, 'cN')),
142
+ });
143
+ const session = sessionWith(provider);
144
+ session.tools.register(
145
+ defineTool({
146
+ name: 'loop',
147
+ description: '',
148
+ inputSchema: z.object({}),
149
+ handler: () => 'ok',
150
+ }),
151
+ );
152
+ void autoAllowResolver;
153
+
154
+ const events = await collectTurn(session, 'spin');
155
+ const errors = events.filter((e) => e.type === 'error');
156
+ expect(errors).toHaveLength(1);
157
+ if (errors[0]?.type !== 'error') throw new Error();
158
+ expect(errors[0].message).toMatch(/stuck pattern/);
159
+ // And the safety-net cap is still wired — exercise its message
160
+ // path by counting outgoing tool calls. The detector should fire
161
+ // around iteration 3, well below the 500-iteration cap.
162
+ const toolCalls = events.filter((e) => e.type === 'tool_call_requested');
163
+ expect(toolCalls.length).toBeLessThan(10);
164
+ // Regression: a stuck trip must NOT leave an orphan tool_call_requested.
165
+ // The detector fires AFTER the final request is emitted but the turn ends
166
+ // before executeToolUses runs it — without synthesizing a result, that
167
+ // request renders as a tool stuck "running" forever (and the provider
168
+ // rejects it next turn). Every emitted request must have a paired result.
169
+ const results = events.filter((e) => e.type === 'tool_result');
170
+ expect(results.length).toBe(toolCalls.length);
171
+ });
172
+
173
+ it('emits a paired result for every request even when the stuck loop trips', async () => {
174
+ // Distinct callIds per turn so the assertion is per-call, mirroring how the
175
+ // desktop fold (pair-events) matches tool_result back to tool_call_requested
176
+ // by callId. A leftover orphan here is the exact "tool spins forever, flips
177
+ // to error on the next message" symptom.
178
+ const provider = new FakeProvider({
179
+ script: Array.from({ length: 20 }, (_, i) => toolUseReply('loop', {}, `c${i}`)),
180
+ });
181
+ const session = sessionWith(provider);
182
+ session.tools.register(
183
+ defineTool({
184
+ name: 'loop',
185
+ description: '',
186
+ inputSchema: z.object({}),
187
+ handler: () => 'ok',
188
+ }),
189
+ );
190
+
191
+ const events = await collectTurn(session, 'spin');
192
+ const requestedIds = new Set(
193
+ events.filter((e) => e.type === 'tool_call_requested').map((e) => e.callId),
194
+ );
195
+ const resolvedIds = new Set(
196
+ events.filter((e) => e.type === 'tool_result').map((e) => e.callId),
197
+ );
198
+ // No requested call may be left without a result.
199
+ const orphans = [...requestedIds].filter((id) => !resolvedIds.has(id));
200
+ expect(orphans).toEqual([]);
201
+ });
202
+
203
+ it('respects an explicit maxIterations cap when no stuck loop fires', async () => {
204
+ // To hit the cap without tripping the detector, vary the input
205
+ // each iteration so the recent-calls window never sees a repeat.
206
+ const script = Array.from({ length: 60 }, (_, i) =>
207
+ toolUseReply('vary', { i }, `c${i}`),
208
+ );
209
+ const provider = new FakeProvider({ script });
210
+ const session = sessionWith(provider);
211
+ session.tools.register(
212
+ defineTool({
213
+ name: 'vary',
214
+ description: '',
215
+ inputSchema: z.object({ i: z.number() }),
216
+ handler: () => 'ok',
217
+ }),
218
+ );
219
+ void autoAllowResolver;
220
+
221
+ const events = await collectTurn(session, 'spin', { maxIterations: 3 });
222
+ const errors = events.filter((e) => e.type === 'error');
223
+ expect(errors).toHaveLength(1);
224
+ if (errors[0]?.type !== 'error') throw new Error();
225
+ expect(errors[0].message).toMatch(/maxIterations/);
226
+ });
227
+
228
+ it('executes tools even when provider reports stopReason: end_turn', async () => {
229
+ // Regression for the codex provider bug where Responses-API turns with
230
+ // tool calls were reported as `stop_reason: end_turn`. The loop must
231
+ // execute tools whenever they're requested, regardless of stopReason —
232
+ // otherwise a single provider mis-mapping leaves orphan
233
+ // tool_call_requested events and a stuck-looking pending dot.
234
+ const provider = new FakeProvider({
235
+ script: [
236
+ [
237
+ { type: 'message_start', model: 'fake' },
238
+ { type: 'tool_use_start', id: 'c1', name: 'echo' },
239
+ { type: 'tool_use_end', id: 'c1', input: { msg: 'hi' } },
240
+ // Note: end_turn, NOT tool_use — the bug scenario.
241
+ { type: 'message_end', stopReason: 'end_turn' },
242
+ ],
243
+ textReply('done'),
244
+ ],
245
+ });
246
+ const session = sessionWith(provider);
247
+ session.tools.register(
248
+ defineTool({
249
+ name: 'echo',
250
+ description: 'returns msg',
251
+ inputSchema: z.object({ msg: z.string() }),
252
+ handler: (i) => i.msg,
253
+ }),
254
+ );
255
+
256
+ const events = await collectTurn(session, 'go');
257
+ const requested = events.find((e) => e.type === 'tool_call_requested');
258
+ const result = events.find((e) => e.type === 'tool_result');
259
+ expect(requested).toBeDefined();
260
+ expect(result).toBeDefined();
261
+ if (result?.type !== 'tool_result') throw new Error('expected tool_result');
262
+ expect(result.ok).toBe(true);
263
+ expect(result.output).toBe('hi');
264
+ });
265
+
266
+ it('delivers hook-injected req.system to the provider without duplicating the system prompt', async () => {
267
+ const provider = new FakeProvider({ script: [textReply('ok')] });
268
+ const session = sessionWith(provider);
269
+ // The plugin-memory consolidation-nudge pattern: an onBeforeProviderCall
270
+ // hook appends to req.system. This must reach the provider request —
271
+ // previously the loop prefilled req.system with the system prompt and
272
+ // every provider dropped the field, so hook injections vanished.
273
+ session.pluginHost.registerStatic(
274
+ definePlugin({
275
+ name: '@moxxy/test-nudge',
276
+ version: '0.0.0',
277
+ hooks: {
278
+ onBeforeProviderCall: (req) => ({
279
+ ...req,
280
+ system: (req.system ?? '') + '[memory note] consider consolidating',
281
+ }),
282
+ },
283
+ }),
284
+ );
285
+
286
+ await collectTurn(session, 'hi', { systemPrompt: 'BASE PROMPT' });
287
+
288
+ expect(provider.received).toHaveLength(1);
289
+ const req = provider.received[0]!;
290
+ // The hook saw an UNSET req.system (no duplicated prompt) and its
291
+ // injection arrived verbatim.
292
+ expect(req.system).toBe('[memory note] consider consolidating');
293
+ // The composed system prompt still rides as the leading system message.
294
+ const first = req.messages[0]!;
295
+ expect(first.role).toBe('system');
296
+ expect(first.content[0]).toMatchObject({ type: 'text', text: 'BASE PROMPT' });
297
+ });
298
+
299
+ it('leaves req.system unset when no hook injects system text', async () => {
300
+ const provider = new FakeProvider({ script: [textReply('ok')] });
301
+ const session = sessionWith(provider);
302
+ await collectTurn(session, 'hi', { systemPrompt: 'BASE PROMPT' });
303
+ expect(provider.received[0]!.system).toBeUndefined();
304
+ });
305
+
306
+ it('emits abort event when session is aborted mid-stream', async () => {
307
+ const provider = new FakeProvider({
308
+ script: [toolUseReply('slow', {}, 'c1'), textReply('after')],
309
+ });
310
+ const session = sessionWith(provider);
311
+ session.tools.register(
312
+ defineTool({
313
+ name: 'slow',
314
+ description: '',
315
+ inputSchema: z.object({}),
316
+ handler: async () => {
317
+ await new Promise((r) => setTimeout(r, 1000));
318
+ return 'done';
319
+ },
320
+ }),
321
+ );
322
+
323
+ setTimeout(() => session.abort('test abort'), 20);
324
+ const events = await collectTurn(session, 'go');
325
+ const aborted = events.find((e) => e.type === 'abort');
326
+ // Either the abort fires before tool execution completes, or the tool_result has kind 'aborted'
327
+ const result = events.find((e) => e.type === 'tool_result');
328
+ if (result?.type === 'tool_result' && !result.ok) {
329
+ expect(result.error?.kind === 'aborted' || result.error?.kind === 'threw').toBe(true);
330
+ } else {
331
+ expect(aborted).toBeDefined();
332
+ }
333
+ });
334
+
335
+ it('backs off then recovers when a retryable provider error precedes a clean call', async () => {
336
+ const sleep = vi.fn(async () => {});
337
+ const restore = __setRetrySleepForTests(sleep);
338
+ try {
339
+ const provider = new FakeProvider({
340
+ script: [retryableErrorReply(), textReply('recovered')],
341
+ });
342
+ const session = sessionWith(provider);
343
+
344
+ const events = await collectTurn(session, 'hi');
345
+ // The retryable error was surfaced...
346
+ const retryable = events.filter((e) => e.type === 'error' && e.kind === 'retryable');
347
+ expect(retryable).toHaveLength(1);
348
+ // ...the loop waited before retrying (back-off applied, not a busy-loop)...
349
+ expect(sleep).toHaveBeenCalledTimes(1);
350
+ expect(sleep.mock.calls[0]![0]).toBeGreaterThan(0);
351
+ // ...and the next clean call succeeded.
352
+ const last = events[events.length - 1];
353
+ if (last.type !== 'assistant_message') throw new Error('expected assistant_message last');
354
+ expect(last.content).toBe('recovered');
355
+ } finally {
356
+ restore();
357
+ }
358
+ });
359
+
360
+ it('gives up with a fatal error after the bounded retry count, not maxIterations', async () => {
361
+ const delays: number[] = [];
362
+ const restore = __setRetrySleepForTests(async (ms) => {
363
+ delays.push(ms);
364
+ });
365
+ try {
366
+ // The provider returns a retryable error forever — without a bound this
367
+ // would busy-loop up to maxIterations (500) times.
368
+ const provider = new FakeProvider({
369
+ script: Array.from({ length: 50 }, () => retryableErrorReply()),
370
+ });
371
+ const session = sessionWith(provider);
372
+
373
+ const events = await collectTurn(session, 'hi');
374
+ const fatal = events.filter((e) => e.type === 'error' && e.kind === 'fatal');
375
+ expect(fatal).toHaveLength(1);
376
+ if (fatal[0]?.type !== 'error') throw new Error();
377
+ expect(fatal[0].message).toMatch(/giving up/i);
378
+ // It hit the provider exactly MAX_CONSECUTIVE_RETRIES times (one provider
379
+ // call per retry), nowhere near the 500-iteration cap.
380
+ expect(provider.received).toHaveLength(MAX_CONSECUTIVE_RETRIES);
381
+ // Back-off delays increased (exponential), proving real back-off ran on
382
+ // each attempt before the final give-up.
383
+ expect(delays).toHaveLength(MAX_CONSECUTIVE_RETRIES - 1);
384
+ for (let i = 1; i < delays.length; i++) {
385
+ expect(delays[i]!).toBeGreaterThan(delays[i - 1]!);
386
+ }
387
+ } finally {
388
+ restore();
389
+ }
390
+ });
391
+ });
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ defineMode,
3
+ definePlugin,
4
+ type CollectedToolUse,
5
+ } from '@moxxy/sdk';
6
+
7
+ import { runDefaultMode, DEFAULT_MODE_NAME } from './turn-iterator.js';
8
+
9
+ export { DEFAULT_MODE_NAME };
10
+ export type { CollectedToolUse };
11
+
12
+ export const defaultMode = defineMode({
13
+ name: DEFAULT_MODE_NAME,
14
+ description: 'Default ReAct-style loop: model thinks, calls tools, observes results, repeats',
15
+ run: runDefaultMode,
16
+ });
17
+
18
+ export const defaultModePlugin = definePlugin({
19
+ name: '@moxxy/mode-default',
20
+ version: '0.0.0',
21
+ modes: [defaultMode],
22
+ });
23
+
24
+ export default defaultModePlugin;
@@ -0,0 +1,290 @@
1
+ import {
2
+ buildSystemPromptWithSkills,
3
+ collectProviderStream,
4
+ createStuckLoopDetector,
5
+ emitRequestsAndDetectStuck,
6
+ executeToolUses,
7
+ isContextOverflowError,
8
+ projectMessages,
9
+ runCompactionIfNeeded,
10
+ runElisionIfNeeded,
11
+ usageEventFields,
12
+ type ModeContext,
13
+ type MoxxyEvent,
14
+ type ProjectedMessages,
15
+ } from '@moxxy/sdk';
16
+
17
+ export const DEFAULT_MODE_NAME = 'default';
18
+
19
+ /**
20
+ * Bounded back-off for a *retryable* provider error (rate-limit/429,
21
+ * overloaded, transient 5xx, ECONNRESET/ETIMEDOUT). Without it, a sustained
22
+ * retryable condition becomes a tight busy-loop: the loop rebuilds the message
23
+ * set and re-hits the provider with zero delay up to `maxIterations` times,
24
+ * burning rate-limit budget and worsening the throttle. We back off
25
+ * exponentially (abort-aware) and give up with a fatal error after
26
+ * {@link MAX_CONSECUTIVE_RETRIES} consecutive failures — the counter resets on
27
+ * any clean provider call, so a long turn can still recover from transient
28
+ * blips. (The context-overflow path keeps its own MAX_REACTIVE_COMPACTIONS
29
+ * budget and is handled before this.)
30
+ */
31
+ export const MAX_CONSECUTIVE_RETRIES = 6;
32
+
33
+ /** Exponential schedule, capped — base*2^(attempt-1), attempt is 1-based. */
34
+ function retryBackoffMs(attempt: number): number {
35
+ const base = 500;
36
+ const cap = 30_000;
37
+ return Math.min(cap, base * 2 ** Math.max(0, attempt - 1));
38
+ }
39
+
40
+ // Abort-aware sleep, injectable for tests so the back-off path runs instantly
41
+ // and deterministically. Production uses a real timer that clears on abort so a
42
+ // pending back-off never outlives a cancelled turn.
43
+ let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> =>
44
+ new Promise<void>((resolve) => {
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
+ });
56
+
57
+ /** Override the retry back-off sleep (test seam). Returns a restore fn. */
58
+ export function __setRetrySleepForTests(
59
+ fn: (ms: number, signal: AbortSignal) => Promise<void>,
60
+ ): () => void {
61
+ const prev = sleepImpl;
62
+ sleepImpl = fn;
63
+ return () => {
64
+ sleepImpl = prev;
65
+ };
66
+ }
67
+
68
+ export async function* runDefaultMode(ctx: ModeContext): AsyncIterable<MoxxyEvent> {
69
+ // High soft cap as a safety net against truly runaway modes (network
70
+ // glitch causing an infinite retry, bad prompt, etc.) — primary
71
+ // termination signal is the stuck-loop detector, which catches the
72
+ // common "model keeps calling the same tool" case ~10 iterations in.
73
+ const maxIterations = ctx.maxIterations ?? 500;
74
+ const detector = createStuckLoopDetector();
75
+ // Reactive-compaction budget per overflow episode. If the provider keeps
76
+ // rejecting for context size even after compacting this many times, give up
77
+ // (the overflow is in the recent, un-compactable tail). Reset on any clean
78
+ // provider call so a long turn can recover from multiple overflow episodes.
79
+ const MAX_REACTIVE_COMPACTIONS = 2;
80
+ let reactiveCompactions = 0;
81
+ // Consecutive retryable-error count; reset on any clean provider call. Caps
82
+ // the busy-loop a sustained retryable condition would otherwise create.
83
+ let consecutiveRetries = 0;
84
+
85
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
86
+ if (ctx.signal.aborted) {
87
+ yield await ctx.emit({
88
+ type: 'abort',
89
+ sessionId: ctx.sessionId,
90
+ turnId: ctx.turnId,
91
+ source: 'system',
92
+ reason: 'signal aborted',
93
+ });
94
+ return;
95
+ }
96
+
97
+ yield await ctx.emit({
98
+ type: 'mode_iteration',
99
+ sessionId: ctx.sessionId,
100
+ turnId: ctx.turnId,
101
+ source: 'system',
102
+ strategy: DEFAULT_MODE_NAME,
103
+ iteration,
104
+ });
105
+
106
+ // Auto-compact before composing the next provider request. If the
107
+ // active compactor's `shouldCompact` returns true, this appends a
108
+ // compaction event onto the log — projectMessagesFromLog (called
109
+ // by buildMessages) honors it, so the model sees a summarized
110
+ // prefix instead of overflowing the window mid-loop.
111
+ await runCompactionIfNeeded(ctx);
112
+ // Turn-boundary elision (context-on-demand): stub old bulky tool output and
113
+ // (when enabled) old text turns, recall-able on demand. Composes with
114
+ // compaction over the same projection.
115
+ await runElisionIfNeeded(ctx);
116
+
117
+ const { messages, stablePrefixIndex } = buildMessages(ctx);
118
+ yield await ctx.emit({
119
+ type: 'provider_request',
120
+ sessionId: ctx.sessionId,
121
+ turnId: ctx.turnId,
122
+ source: 'system',
123
+ provider: ctx.provider.name,
124
+ model: ctx.model,
125
+ });
126
+
127
+ const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(
128
+ ctx,
129
+ messages,
130
+ {
131
+ iteration,
132
+ stablePrefixIndex,
133
+ },
134
+ );
135
+
136
+ yield await ctx.emit({
137
+ type: 'provider_response',
138
+ sessionId: ctx.sessionId,
139
+ turnId: ctx.turnId,
140
+ source: 'system',
141
+ provider: ctx.provider.name,
142
+ model: ctx.model,
143
+ ...usageEventFields(usage),
144
+ });
145
+
146
+ if (error) {
147
+ // The request was too big for the model's window: our token estimate
148
+ // lagged the provider's real tokenizer, so the proactive compactor
149
+ // didn't fire. Force a compaction and retry rather than dying — this is
150
+ // the auto-compact-on-overflow path.
151
+ if (
152
+ isContextOverflowError(error.message) &&
153
+ reactiveCompactions < MAX_REACTIVE_COMPACTIONS
154
+ ) {
155
+ reactiveCompactions += 1;
156
+ const compacted = await runCompactionIfNeeded(ctx, { force: true });
157
+ if (compacted) {
158
+ yield await ctx.emit({
159
+ type: 'error',
160
+ sessionId: ctx.sessionId,
161
+ turnId: ctx.turnId,
162
+ source: 'system',
163
+ kind: 'retryable',
164
+ message: 'context window exceeded — compacted older turns, retrying',
165
+ });
166
+ continue;
167
+ }
168
+ }
169
+ if (!error.retryable) {
170
+ yield await ctx.emit({
171
+ type: 'error',
172
+ sessionId: ctx.sessionId,
173
+ turnId: ctx.turnId,
174
+ source: 'system',
175
+ kind: 'fatal',
176
+ message: error.message,
177
+ });
178
+ return;
179
+ }
180
+ // Retryable: surface it, then back off before retrying. A persistent
181
+ // retryable condition (sustained 429 / outage) must NOT busy-loop the
182
+ // provider — give up with a fatal error after the bounded retry count.
183
+ consecutiveRetries += 1;
184
+ yield await ctx.emit({
185
+ type: 'error',
186
+ sessionId: ctx.sessionId,
187
+ turnId: ctx.turnId,
188
+ source: 'system',
189
+ kind: 'retryable',
190
+ message: error.message,
191
+ });
192
+ if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
193
+ yield await ctx.emit({
194
+ type: 'error',
195
+ sessionId: ctx.sessionId,
196
+ turnId: ctx.turnId,
197
+ source: 'system',
198
+ kind: 'fatal',
199
+ message:
200
+ `provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
201
+ `(last: ${error.message}); giving up rather than hammering the provider.`,
202
+ });
203
+ return;
204
+ }
205
+ await sleepImpl(retryBackoffMs(consecutiveRetries), ctx.signal);
206
+ if (ctx.signal.aborted) {
207
+ yield await ctx.emit({
208
+ type: 'abort',
209
+ sessionId: ctx.sessionId,
210
+ turnId: ctx.turnId,
211
+ source: 'system',
212
+ reason: 'signal aborted during retry back-off',
213
+ });
214
+ return;
215
+ }
216
+ continue;
217
+ }
218
+ // Clean provider call — reset the overflow-recovery + retry budgets.
219
+ reactiveCompactions = 0;
220
+ consecutiveRetries = 0;
221
+
222
+ // Finalize the reasoning summary for THIS call BEFORE the tool/assistant
223
+ // emits, so the log order is reasoning → tool_use → text (projection
224
+ // attaches the signed thinking block as content[0] of the same assistant
225
+ // turn). collectProviderStream already guards on non-empty text / encrypted.
226
+ if (reasoning) {
227
+ yield await ctx.emit({
228
+ type: 'reasoning_message',
229
+ sessionId: ctx.sessionId,
230
+ turnId: ctx.turnId,
231
+ source: 'model',
232
+ content: reasoning.text,
233
+ ...(reasoning.signature ? { signature: reasoning.signature } : {}),
234
+ ...(reasoning.redacted ? { redacted: true } : {}),
235
+ ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
236
+ });
237
+ }
238
+
239
+ const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, {
240
+ abortedResultMessage: 'default mode loop aborted (stuck pattern) before this call ran',
241
+ nearHint: 'against the same target (only volatile args like maxBytes varied)',
242
+ fatalMessage: ({ toolName, count, how }) =>
243
+ `default mode loop aborted — detected stuck pattern: tool "${toolName}" called ` +
244
+ `${count} times ${how}. The model is likely looping on the same call; ` +
245
+ `reset or rephrase.`,
246
+ });
247
+ if (stuck) return;
248
+
249
+ if (text || stopReason === 'end_turn' || toolUses.length === 0) {
250
+ yield await ctx.emit({
251
+ type: 'assistant_message',
252
+ sessionId: ctx.sessionId,
253
+ turnId: ctx.turnId,
254
+ source: 'model',
255
+ content: text,
256
+ stopReason,
257
+ });
258
+ }
259
+
260
+ // Execute whenever the model requested tools, regardless of stopReason.
261
+ // Providers vary in how reliably they report `stopReason: 'tool_use'`
262
+ // (Codex's Responses API doesn't carry one on `response.completed`, so
263
+ // the provider has to infer it from emitted events). Trusting only
264
+ // stopReason here meant a single provider mis-mapping silently dropped
265
+ // tool calls — `tool_call_requested` would be emitted with no matching
266
+ // `tool_result`, leaving an orphan pending dot and a stuck-looking UI.
267
+ if (toolUses.length === 0) return;
268
+
269
+ const exited = yield* executeToolUses(ctx, toolUses, iteration);
270
+ if (exited) return;
271
+ }
272
+
273
+ yield await ctx.emit({
274
+ type: 'error',
275
+ sessionId: ctx.sessionId,
276
+ turnId: ctx.turnId,
277
+ source: 'system',
278
+ kind: 'fatal',
279
+ message: `default mode loop exceeded maxIterations (${maxIterations})`,
280
+ });
281
+ }
282
+
283
+ function buildMessages(ctx: ModeContext): ProjectedMessages {
284
+ // Compose the system prompt with the skill catalog so the model knows
285
+ // which playbooks exist; without this skills are invisible to the
286
+ // model and it falls back to ad-hoc tool calls (the classic
287
+ // `web_fetch instead of media-digest skill` symptom).
288
+ const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list());
289
+ return projectMessages(ctx, { ...(systemPrompt ? { systemPrompt } : {}) });
290
+ }