@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 +21 -0
- package/README.md +46 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/turn-iterator.d.ts +19 -0
- package/dist/turn-iterator.d.ts.map +1 -0
- package/dist/turn-iterator.js +250 -0
- package/dist/turn-iterator.js.map +1 -0
- package/package.json +66 -0
- package/src/index.test.ts +391 -0
- package/src/index.ts +24 -0
- package/src/turn-iterator.ts +290 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|