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