@moxxy/sdk 0.27.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/events.d.ts +8 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/mode/checkpoint.d.ts +107 -0
- package/dist/mode/checkpoint.d.ts.map +1 -0
- package/dist/mode/checkpoint.js +2 -0
- package/dist/mode/checkpoint.js.map +1 -0
- package/dist/mode/react-loop.d.ts +129 -0
- package/dist/mode/react-loop.d.ts.map +1 -0
- package/dist/mode/react-loop.js +480 -0
- package/dist/mode/react-loop.js.map +1 -0
- package/dist/mode/stuck-loop.d.ts +7 -0
- package/dist/mode/stuck-loop.d.ts.map +1 -1
- package/dist/mode/stuck-loop.js +4 -0
- package/dist/mode/stuck-loop.js.map +1 -1
- package/dist/mode-helpers.d.ts +4 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +3 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +36 -2
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/tool-dispatch.d.ts +35 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +51 -0
- package/dist/tool-dispatch.js.map +1 -1
- package/package.json +1 -1
- package/src/events.ts +8 -2
- package/src/index.ts +13 -0
- package/src/mode/checkpoint.ts +105 -0
- package/src/mode/react-loop.ts +694 -0
- package/src/mode/stuck-loop.ts +11 -0
- package/src/mode-helpers.ts +16 -0
- package/src/mode.ts +36 -2
- package/src/tool-dispatch.test.ts +77 -0
- package/src/tool-dispatch.ts +82 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { isContextOverflowError, runCompactionIfNeeded } from '../compactor-helpers.js';
|
|
2
|
+
import { runElisionIfNeeded } from '../elision-helpers.js';
|
|
3
|
+
import type { MoxxyEvent } from '../events.js';
|
|
4
|
+
import { asPluginId } from '../ids.js';
|
|
5
|
+
import type { ModeContext } from '../mode.js';
|
|
6
|
+
import type { TokenUsage } from '../provider.js';
|
|
7
|
+
import type { StopReason } from '../provider-utils.js';
|
|
8
|
+
import { usageEventFields } from '../token-accounting.js';
|
|
9
|
+
import {
|
|
10
|
+
emitRequestsAndDetectStuck,
|
|
11
|
+
emitRequestsAndNudgeOnStuck,
|
|
12
|
+
executeToolUses,
|
|
13
|
+
type StuckLoopReport,
|
|
14
|
+
type StuckTripInfo,
|
|
15
|
+
} from '../tool-dispatch.js';
|
|
16
|
+
import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
|
|
17
|
+
import type { CheckpointResult, TurnCheckpoint } from './checkpoint.js';
|
|
18
|
+
import { collectProviderStream, type CollectedToolUse } from './collect-stream.js';
|
|
19
|
+
import { buildSystemPromptWithSkills, projectMessages } from './project-messages.js';
|
|
20
|
+
import { createStuckLoopDetector } from './stuck-loop.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The shared ReAct loop core every tool-calling mode runs on. One hardened
|
|
24
|
+
* copy of the plumbing that used to be duplicated per mode (default / goal /
|
|
25
|
+
* collab-agent): provider retry with bounded exponential back-off, reactive
|
|
26
|
+
* compaction on context overflow, turn-boundary elision, stuck-loop
|
|
27
|
+
* detection, abort handling on every await — plus the turn-end checkpoint
|
|
28
|
+
* gate ({@link TurnCheckpoint}) that lets a mode verify a completion claim
|
|
29
|
+
* (run lints, spawn a reviewer agent) before the turn is allowed to end.
|
|
30
|
+
*
|
|
31
|
+
* Modes express their POLICY through {@link ReactLoopOptions}: hooks fire at
|
|
32
|
+
* fixed points (iteration start, provider success, tool batch end, iteration
|
|
33
|
+
* cap) and checkpoints fire at the turn-end candidate. Hooks emit their own
|
|
34
|
+
* events via `ctx.emit` — the live event channel is the log subscription
|
|
35
|
+
* (`runTurn` discards the yielded stream), so emit order is what channels
|
|
36
|
+
* and tests observe.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Bounded back-off for retryable provider errors — see the field docs. */
|
|
40
|
+
export const MAX_CONSECUTIVE_RETRIES = 6;
|
|
41
|
+
const RETRY_BACKOFF_BASE_MS = 500;
|
|
42
|
+
const RETRY_BACKOFF_CAP_MS = 30_000;
|
|
43
|
+
const DEFAULT_MAX_ITERATIONS = 500;
|
|
44
|
+
const MAX_REACTIVE_COMPACTIONS = 2;
|
|
45
|
+
const DEFAULT_MAX_INJECTIONS = 3;
|
|
46
|
+
const DEFAULT_MAX_INJECT_CHARS = 16_384;
|
|
47
|
+
const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
|
|
48
|
+
const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
|
|
49
|
+
|
|
50
|
+
// Abort-aware sleep, injectable for tests so back-off paths run instantly and
|
|
51
|
+
// deterministically. Production delegates to sleepWithAbort: a real timer that
|
|
52
|
+
// clears (and drops its abort listener) when the signal fires, so a pending
|
|
53
|
+
// back-off never outlives a cancelled turn.
|
|
54
|
+
let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> => sleepWithAbort(ms, signal);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Override the retry back-off sleep (test seam). Returns a restore fn that
|
|
58
|
+
* callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
|
|
59
|
+
* singleton shared process-wide, so a leaked override bleeds the fake sleep
|
|
60
|
+
* into every other turn/test running in the same worker (parallel subagent
|
|
61
|
+
* fan-out, multiple Sessions in one host). Test-only; never call from prod.
|
|
62
|
+
*/
|
|
63
|
+
export function __setRetrySleepForTests(
|
|
64
|
+
fn: (ms: number, signal: AbortSignal) => Promise<void>,
|
|
65
|
+
): () => void {
|
|
66
|
+
const prev = sleepImpl;
|
|
67
|
+
sleepImpl = fn;
|
|
68
|
+
return () => {
|
|
69
|
+
sleepImpl = prev;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
|
|
74
|
+
export interface ProviderSuccessInfo {
|
|
75
|
+
readonly text: string;
|
|
76
|
+
readonly stopReason: StopReason;
|
|
77
|
+
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
78
|
+
readonly usage?: TokenUsage;
|
|
79
|
+
readonly iteration: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
|
|
83
|
+
export interface ToolBatchInfo {
|
|
84
|
+
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
85
|
+
readonly iteration: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
|
|
89
|
+
export interface StopDirective {
|
|
90
|
+
readonly action: 'stop';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ReactLoopOptions {
|
|
94
|
+
/** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
|
|
95
|
+
readonly strategyName: string;
|
|
96
|
+
/**
|
|
97
|
+
* Iteration cap when the context doesn't supply one. Default 500. May be
|
|
98
|
+
* `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
|
|
99
|
+
* ends only via a terminal signal (checkpoint/hook stop, abort, fatal
|
|
100
|
+
* error) — an explicit `ctx.maxIterations` still takes precedence.
|
|
101
|
+
*/
|
|
102
|
+
readonly defaultMaxIterations?: number;
|
|
103
|
+
/** Prefix for provider-error messages (goal mode uses `'goal: '`). */
|
|
104
|
+
readonly errorPrefix?: string;
|
|
105
|
+
/**
|
|
106
|
+
* When set, an already-aborted signal is reported (with this reason) BEFORE
|
|
107
|
+
* the first iteration — goal/collab preflight. When omitted the first
|
|
108
|
+
* iteration's own abort check covers it with the generic reason.
|
|
109
|
+
*/
|
|
110
|
+
readonly preflightAbortReason?: string;
|
|
111
|
+
/** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
|
|
112
|
+
readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
|
|
113
|
+
/**
|
|
114
|
+
* Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
|
|
115
|
+
* consecutive gate rounds with no tool work between them; the count resets
|
|
116
|
+
* whenever a tool batch executes (default 3). When exhausted the turn ends
|
|
117
|
+
* with the model's answer as-is plus a visible warning — a
|
|
118
|
+
* permanently-failing gate degrades loudly instead of looping forever.
|
|
119
|
+
*/
|
|
120
|
+
readonly maxInjections?: number;
|
|
121
|
+
/** Clamp on injected feedback length in characters (default 16_384). */
|
|
122
|
+
readonly maxInjectChars?: number;
|
|
123
|
+
/**
|
|
124
|
+
* Stuck-loop policy + wording overrides; sensible defaults from
|
|
125
|
+
* `strategyName`. `action` picks what a detector trip does:
|
|
126
|
+
*
|
|
127
|
+
* - `'abort'` (default): fail the batch and end the turn with a fatal
|
|
128
|
+
* error — the historical behavior, right for attended modes where the
|
|
129
|
+
* user is present to redirect.
|
|
130
|
+
* - `'nudge'`: never stop. The batch still executes (repeated calls are
|
|
131
|
+
* usually legitimate work — re-running a failing build between edits),
|
|
132
|
+
* a visible warning + `extraOnStuck` events are emitted, the detector
|
|
133
|
+
* resets, and `nudgeText` (or a default) rides the next provider call
|
|
134
|
+
* as a volatile steer. For unattended modes (goal) where a heuristic
|
|
135
|
+
* must never kill the run.
|
|
136
|
+
*/
|
|
137
|
+
readonly stuck?: Partial<StuckLoopReport> & {
|
|
138
|
+
readonly action?: 'abort' | 'nudge';
|
|
139
|
+
/** Volatile steer for `'nudge'` trips; default wording when omitted. */
|
|
140
|
+
readonly nudgeText?: (info: StuckTripInfo) => string;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Runs after compaction/elision, before the provider call. May return a
|
|
144
|
+
* volatile user message to ride ONLY the next call (collab's inbox/pause
|
|
145
|
+
* awareness) — never appended to the log; the cache strategy is told via
|
|
146
|
+
* `volatileTailCount` so its tail breakpoint stays ahead of it.
|
|
147
|
+
*/
|
|
148
|
+
readonly onIterationStart?: (
|
|
149
|
+
ctx: ModeContext,
|
|
150
|
+
iteration: number,
|
|
151
|
+
) => Promise<{ readonly volatileUserText?: string } | undefined>;
|
|
152
|
+
/**
|
|
153
|
+
* Runs after every clean provider call (reasoning already logged, tools not
|
|
154
|
+
* yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
|
|
155
|
+
* its own wrap-up events first (goal mode's token-budget backstop).
|
|
156
|
+
*/
|
|
157
|
+
readonly onProviderSuccess?: (
|
|
158
|
+
ctx: ModeContext,
|
|
159
|
+
info: ProviderSuccessInfo,
|
|
160
|
+
) => Promise<StopDirective | undefined>;
|
|
161
|
+
/**
|
|
162
|
+
* Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
|
|
163
|
+
* end the turn (goal/collab terminal-tool detection: `goal_complete`,
|
|
164
|
+
* `collab_done`); the hook emits its own completion events first.
|
|
165
|
+
*/
|
|
166
|
+
readonly onToolBatchEnd?: (
|
|
167
|
+
ctx: ModeContext,
|
|
168
|
+
info: ToolBatchInfo,
|
|
169
|
+
) => Promise<StopDirective | undefined>;
|
|
170
|
+
/**
|
|
171
|
+
* Replaces the default "exceeded maxIterations" fatal error — the hook
|
|
172
|
+
* emits its own cap-reached events (goal mode adds a plugin event and its
|
|
173
|
+
* own wording). The loop returns right after.
|
|
174
|
+
*/
|
|
175
|
+
readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function* runReactLoop(
|
|
179
|
+
ctx: ModeContext,
|
|
180
|
+
opts: ReactLoopOptions,
|
|
181
|
+
): AsyncIterable<MoxxyEvent> {
|
|
182
|
+
if (opts.preflightAbortReason && ctx.signal.aborted) {
|
|
183
|
+
yield await emitAbort(ctx, opts.preflightAbortReason);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Coerce a caller/config-supplied bound to a positive integer; a degenerate
|
|
188
|
+
// value (0, negative, NaN, fractional) would otherwise make the loop never
|
|
189
|
+
// run and emit a misleading "exceeded maxIterations" fatal. The config
|
|
190
|
+
// schema validates this, but programmatic callers (subagents/workflows)
|
|
191
|
+
// bypass that schema.
|
|
192
|
+
const requestedMaxIterations = ctx.maxIterations;
|
|
193
|
+
const maxIterations =
|
|
194
|
+
typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
|
|
195
|
+
? Math.max(1, Math.floor(requestedMaxIterations))
|
|
196
|
+
: (opts.defaultMaxIterations ?? DEFAULT_MAX_ITERATIONS);
|
|
197
|
+
|
|
198
|
+
const detector = createStuckLoopDetector(ctx.loopGuard);
|
|
199
|
+
const stuckReport = buildStuckReport(opts);
|
|
200
|
+
const prefix = opts.errorPrefix ?? '';
|
|
201
|
+
|
|
202
|
+
// Recursion backstop: a checkpoint that spawns a child running a
|
|
203
|
+
// checkpoint-bearing mode would gate the child's turn-end, spawn a
|
|
204
|
+
// grandchild, and so on forever. Checkpoint authors should spawn children
|
|
205
|
+
// as `mode: 'default'`; this disarm means a mistake degrades to an ungated
|
|
206
|
+
// child instead of unbounded recursion.
|
|
207
|
+
const checkpoints = ctx.isSubagent ? [] : (opts.checkpoints ?? []);
|
|
208
|
+
const injectionBudget = Math.max(0, Math.floor(opts.maxInjections ?? DEFAULT_MAX_INJECTIONS));
|
|
209
|
+
const maxInjectChars = Math.max(1024, Math.floor(opts.maxInjectChars ?? DEFAULT_MAX_INJECT_CHARS));
|
|
210
|
+
let injectionsUsed = 0;
|
|
211
|
+
let consecutiveIdle = 0;
|
|
212
|
+
// A volatile injection from the previous round's checkpoint (goal's nudge),
|
|
213
|
+
// consumed by the next provider call only.
|
|
214
|
+
let pendingVolatileText: string | undefined;
|
|
215
|
+
|
|
216
|
+
// Reactive-compaction budget per overflow episode and consecutive
|
|
217
|
+
// retryable-error count; both reset on any clean provider call so a long
|
|
218
|
+
// turn can recover from multiple transient episodes.
|
|
219
|
+
let reactiveCompactions = 0;
|
|
220
|
+
let consecutiveRetries = 0;
|
|
221
|
+
|
|
222
|
+
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
|
223
|
+
if (ctx.signal.aborted) {
|
|
224
|
+
yield await emitAbort(ctx, 'signal aborted');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
yield await ctx.emit({
|
|
229
|
+
type: 'mode_iteration',
|
|
230
|
+
sessionId: ctx.sessionId,
|
|
231
|
+
turnId: ctx.turnId,
|
|
232
|
+
source: 'system',
|
|
233
|
+
strategy: opts.strategyName,
|
|
234
|
+
iteration,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Auto-compact before composing the next provider request, then apply
|
|
238
|
+
// turn-boundary elision (context-on-demand). Both mutate the log's
|
|
239
|
+
// projection, not the loop's state.
|
|
240
|
+
await runCompactionIfNeeded(ctx);
|
|
241
|
+
await runElisionIfNeeded(ctx);
|
|
242
|
+
|
|
243
|
+
const hookStart = opts.onIterationStart
|
|
244
|
+
? await opts.onIterationStart(ctx, iteration)
|
|
245
|
+
: undefined;
|
|
246
|
+
// At most ONE volatile trailing user message per call: a pending
|
|
247
|
+
// checkpoint nudge and an iteration-start note merge into it.
|
|
248
|
+
const volatileParts = [pendingVolatileText, hookStart?.volatileUserText].filter(
|
|
249
|
+
(s): s is string => typeof s === 'string' && s.length > 0,
|
|
250
|
+
);
|
|
251
|
+
pendingVolatileText = undefined;
|
|
252
|
+
const volatileText = volatileParts.length > 0 ? volatileParts.join('\n\n') : undefined;
|
|
253
|
+
|
|
254
|
+
// onIterationStart may block for a long time (collab's cooperative-pause
|
|
255
|
+
// poll idles here until the human resumes) — re-check the signal so an
|
|
256
|
+
// abort during the hook ends the turn cleanly instead of burning a
|
|
257
|
+
// provider call that is already cancelled.
|
|
258
|
+
if (ctx.signal.aborted) {
|
|
259
|
+
yield await emitAbort(ctx, 'signal aborted');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const systemPrompt = buildSystemPromptWithSkills(ctx.systemPrompt, ctx.skills.list());
|
|
264
|
+
const { messages, stablePrefixIndex } = projectMessages(ctx, {
|
|
265
|
+
...(systemPrompt ? { systemPrompt } : {}),
|
|
266
|
+
...(volatileText ? { trailingUserText: volatileText } : {}),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
yield await ctx.emit({
|
|
270
|
+
type: 'provider_request',
|
|
271
|
+
sessionId: ctx.sessionId,
|
|
272
|
+
turnId: ctx.turnId,
|
|
273
|
+
source: 'system',
|
|
274
|
+
provider: ctx.provider.name,
|
|
275
|
+
model: ctx.model,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(
|
|
279
|
+
ctx,
|
|
280
|
+
messages,
|
|
281
|
+
{
|
|
282
|
+
iteration,
|
|
283
|
+
stablePrefixIndex,
|
|
284
|
+
// Volatile text is injected for this call only, never appended to the
|
|
285
|
+
// log — the cache strategy must keep its rolling tail breakpoint
|
|
286
|
+
// BEFORE it, or every such call caches a prefix ending in a message
|
|
287
|
+
// that won't exist at that position next call: a guaranteed-wasted
|
|
288
|
+
// cache write.
|
|
289
|
+
...(volatileText ? { volatileTailCount: 1 } : {}),
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// A user cancellation WHILE the provider stream was being consumed
|
|
294
|
+
// surfaces as a non-retryable provider `error` ("The operation was
|
|
295
|
+
// aborted") rather than a clean abort — collectProviderStream catches the
|
|
296
|
+
// fetch AbortError and classifies it as fatal. Treat it as the
|
|
297
|
+
// cancellation it is so channels render a 'stopped' turn, not a failed one.
|
|
298
|
+
if (ctx.signal.aborted) {
|
|
299
|
+
yield await emitAbort(ctx, 'signal aborted during provider stream');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
yield await ctx.emit({
|
|
304
|
+
type: 'provider_response',
|
|
305
|
+
sessionId: ctx.sessionId,
|
|
306
|
+
turnId: ctx.turnId,
|
|
307
|
+
source: 'system',
|
|
308
|
+
provider: ctx.provider.name,
|
|
309
|
+
model: ctx.model,
|
|
310
|
+
...usageEventFields(usage),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (error) {
|
|
314
|
+
const overflow = isContextOverflowError(error.message);
|
|
315
|
+
// The request was too big for the model's window: our token estimate
|
|
316
|
+
// lagged the provider's real tokenizer, so the proactive compactor
|
|
317
|
+
// didn't fire. Force a compaction and retry rather than dying.
|
|
318
|
+
if (overflow && reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
|
|
319
|
+
const compacted = await runCompactionIfNeeded(ctx, { force: true });
|
|
320
|
+
if (compacted) {
|
|
321
|
+
// Only count an attempt that actually compacted against the budget —
|
|
322
|
+
// a no-op (overflow lives in the un-compactable recent tail) must
|
|
323
|
+
// not deny a later, genuinely compactable overflow its retry.
|
|
324
|
+
reactiveCompactions += 1;
|
|
325
|
+
yield await emitError(ctx, 'retryable', 'context window exceeded — compacted older turns, retrying');
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// A context overflow that can't be compacted further is fatal regardless
|
|
330
|
+
// of the provider's `retryable` flag: some providers mark "reduce the
|
|
331
|
+
// length" errors retryable, but the prompt cannot shrink, so a retry
|
|
332
|
+
// just re-sends the identical over-budget request and overflows again.
|
|
333
|
+
if (!error.retryable || overflow) {
|
|
334
|
+
yield await emitError(ctx, 'fatal', `${prefix}${error.message}`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
// Retryable: surface it, then back off before retrying. A persistent
|
|
338
|
+
// retryable condition (sustained 429 / outage) must NOT busy-loop the
|
|
339
|
+
// provider — give up with a fatal error after the bounded retry count.
|
|
340
|
+
consecutiveRetries += 1;
|
|
341
|
+
yield await emitError(ctx, 'retryable', `${prefix}${error.message}`);
|
|
342
|
+
if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
|
|
343
|
+
yield await emitError(
|
|
344
|
+
ctx,
|
|
345
|
+
'fatal',
|
|
346
|
+
`${prefix}provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
|
|
347
|
+
`(last: ${error.message}); giving up rather than hammering the provider.`,
|
|
348
|
+
);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
await sleepImpl(
|
|
352
|
+
nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS),
|
|
353
|
+
ctx.signal,
|
|
354
|
+
);
|
|
355
|
+
if (ctx.signal.aborted) {
|
|
356
|
+
yield await emitAbort(ctx, 'signal aborted during retry back-off');
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
// Clean provider call — reset the overflow-recovery + retry budgets.
|
|
362
|
+
reactiveCompactions = 0;
|
|
363
|
+
consecutiveRetries = 0;
|
|
364
|
+
|
|
365
|
+
// Finalize the reasoning summary for THIS call BEFORE any exit decision or
|
|
366
|
+
// tool/assistant emits, so the log order is reasoning → tool_use → text
|
|
367
|
+
// (projection attaches the signed thinking block as content[0] of the
|
|
368
|
+
// same assistant turn) and every exit path logs it consistently.
|
|
369
|
+
if (reasoning) {
|
|
370
|
+
yield await ctx.emit({
|
|
371
|
+
type: 'reasoning_message',
|
|
372
|
+
sessionId: ctx.sessionId,
|
|
373
|
+
turnId: ctx.turnId,
|
|
374
|
+
source: 'model',
|
|
375
|
+
content: reasoning.text,
|
|
376
|
+
...(reasoning.signature ? { signature: reasoning.signature } : {}),
|
|
377
|
+
...(reasoning.redacted ? { redacted: true } : {}),
|
|
378
|
+
...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (opts.onProviderSuccess) {
|
|
383
|
+
const directive = await opts.onProviderSuccess(ctx, {
|
|
384
|
+
text,
|
|
385
|
+
stopReason,
|
|
386
|
+
toolUses,
|
|
387
|
+
...(usage ? { usage } : {}),
|
|
388
|
+
iteration,
|
|
389
|
+
});
|
|
390
|
+
if (directive?.action === 'stop') return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (opts.stuck?.action === 'nudge') {
|
|
394
|
+
const trip = yield* emitRequestsAndNudgeOnStuck(ctx, toolUses, detector, {
|
|
395
|
+
nearHint: stuckReport.nearHint,
|
|
396
|
+
warnMessage: (info) =>
|
|
397
|
+
`${opts.strategyName} loop noticed a repetitive pattern: tool "${info.toolName}" called ` +
|
|
398
|
+
`${info.count} times ${info.how} — steering the model to change approach (the run continues).`,
|
|
399
|
+
...(stuckReport.extraOnStuck ? { extraOnStuck: stuckReport.extraOnStuck } : {}),
|
|
400
|
+
});
|
|
401
|
+
if (trip) {
|
|
402
|
+
const nudge = opts.stuck.nudgeText?.(trip) ?? defaultStuckNudge(trip);
|
|
403
|
+
// Ride the NEXT provider call; merge with anything already pending.
|
|
404
|
+
pendingVolatileText = pendingVolatileText ? `${pendingVolatileText}\n\n${nudge}` : nudge;
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, stuckReport);
|
|
408
|
+
// A stuck trip kills the turn — it never reaches the checkpoint gate;
|
|
409
|
+
// gating a turn that is being aborted would just burn a checker run.
|
|
410
|
+
if (stuck) return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (text || stopReason === 'end_turn' || toolUses.length === 0) {
|
|
414
|
+
// A completion with no text, no tool uses, and a non-natural stop (e.g.
|
|
415
|
+
// 'max_tokens' truncated to nothing) yields a blank assistant bubble
|
|
416
|
+
// that silently swallows the truncation signal. Surface a retryable
|
|
417
|
+
// note so the user sees why, alongside the (preserved) empty
|
|
418
|
+
// assistant_message.
|
|
419
|
+
if (!text && toolUses.length === 0 && stopReason !== 'end_turn') {
|
|
420
|
+
yield await emitError(
|
|
421
|
+
ctx,
|
|
422
|
+
'retryable',
|
|
423
|
+
`provider returned an empty completion (stopReason: ${stopReason ?? 'unknown'})`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
yield await ctx.emit({
|
|
427
|
+
type: 'assistant_message',
|
|
428
|
+
sessionId: ctx.sessionId,
|
|
429
|
+
turnId: ctx.turnId,
|
|
430
|
+
source: 'model',
|
|
431
|
+
content: text,
|
|
432
|
+
stopReason,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (toolUses.length === 0) {
|
|
437
|
+
// ── The turn-end candidate: the model stopped calling tools. ──
|
|
438
|
+
consecutiveIdle += 1;
|
|
439
|
+
const verdict = yield* runCheckpointGate(ctx, checkpoints, {
|
|
440
|
+
candidateText: text,
|
|
441
|
+
stopReason,
|
|
442
|
+
iteration,
|
|
443
|
+
consecutiveIdle,
|
|
444
|
+
injectionsUsed,
|
|
445
|
+
injectionBudget,
|
|
446
|
+
maxInjectChars,
|
|
447
|
+
});
|
|
448
|
+
if (verdict.kind === 'end') return;
|
|
449
|
+
injectionsUsed += 1;
|
|
450
|
+
if (verdict.volatileText !== undefined) pendingVolatileText = verdict.volatileText;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
consecutiveIdle = 0;
|
|
454
|
+
// The injection budget is per idle-EPISODE, not per turn: it exists to
|
|
455
|
+
// stop a permanently-failing gate from looping a turn that makes no
|
|
456
|
+
// progress. Once the model does real tool work again the episode is over —
|
|
457
|
+
// without this reset, a long unattended run died on its Nth *spread-out*
|
|
458
|
+
// idle round ("checkpoint budget exhausted") even though every earlier
|
|
459
|
+
// nudge had successfully put the model back to work.
|
|
460
|
+
injectionsUsed = 0;
|
|
461
|
+
|
|
462
|
+
// Execute whenever the model requested tools, regardless of stopReason.
|
|
463
|
+
// Providers vary in how reliably they report `stopReason: 'tool_use'`;
|
|
464
|
+
// trusting only stopReason silently dropped tool calls on providers that
|
|
465
|
+
// mis-map it, leaving orphaned tool_call_requested events.
|
|
466
|
+
const exited = yield* executeToolUses(ctx, toolUses, iteration);
|
|
467
|
+
if (exited) return;
|
|
468
|
+
|
|
469
|
+
if (opts.onToolBatchEnd) {
|
|
470
|
+
const directive = await opts.onToolBatchEnd(ctx, { toolUses, iteration });
|
|
471
|
+
if (directive?.action === 'stop') return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (opts.onMaxIterations) {
|
|
476
|
+
await opts.onMaxIterations(ctx, maxIterations);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
yield await emitError(
|
|
480
|
+
ctx,
|
|
481
|
+
'fatal',
|
|
482
|
+
`${opts.strategyName} mode loop exceeded maxIterations (${maxIterations})`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
|
|
487
|
+
type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
|
|
488
|
+
|
|
489
|
+
interface GateRound {
|
|
490
|
+
readonly candidateText: string;
|
|
491
|
+
readonly stopReason: StopReason;
|
|
492
|
+
readonly iteration: number;
|
|
493
|
+
readonly consecutiveIdle: number;
|
|
494
|
+
readonly injectionsUsed: number;
|
|
495
|
+
readonly injectionBudget: number;
|
|
496
|
+
readonly maxInjectChars: number;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function* runCheckpointGate(
|
|
500
|
+
ctx: ModeContext,
|
|
501
|
+
checkpoints: ReadonlyArray<TurnCheckpoint>,
|
|
502
|
+
round: GateRound,
|
|
503
|
+
): AsyncGenerator<MoxxyEvent, GateVerdict> {
|
|
504
|
+
// Natural completions face every checkpoint; truncated/errored candidates
|
|
505
|
+
// only face the idle-tolerant ones — reviewing a half-sentence as if it
|
|
506
|
+
// were a completion claim wastes a checker run and confuses the model.
|
|
507
|
+
const eligible = checkpoints.filter(
|
|
508
|
+
(cp) => round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle',
|
|
509
|
+
);
|
|
510
|
+
if (eligible.length === 0) return { kind: 'end' };
|
|
511
|
+
|
|
512
|
+
// Budget exhausted → the answer ships as-is, but LOUDLY: silent
|
|
513
|
+
// degradation would let the user believe a gated answer passed its gates.
|
|
514
|
+
if (round.injectionsUsed >= round.injectionBudget) {
|
|
515
|
+
yield await emitError(
|
|
516
|
+
ctx,
|
|
517
|
+
'retryable',
|
|
518
|
+
`checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
|
|
519
|
+
`ending the turn with unresolved checkpoint feedback; verify the result manually.`,
|
|
520
|
+
);
|
|
521
|
+
return { kind: 'end' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const cp of eligible) {
|
|
525
|
+
// User cancelled mid-gate: end the turn WITHOUT retracting the
|
|
526
|
+
// already-logged answer — an abort must never un-say what was said.
|
|
527
|
+
if (ctx.signal.aborted) return { kind: 'end' };
|
|
528
|
+
|
|
529
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
|
|
530
|
+
name: cp.name,
|
|
531
|
+
iteration: round.iteration,
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
|
|
535
|
+
const timeoutCtl = new AbortController();
|
|
536
|
+
const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
|
|
537
|
+
timer.unref?.();
|
|
538
|
+
let result: CheckpointResult;
|
|
539
|
+
try {
|
|
540
|
+
result = await cp.run(
|
|
541
|
+
{
|
|
542
|
+
candidateText: round.candidateText,
|
|
543
|
+
stopReason: round.stopReason,
|
|
544
|
+
iteration: round.iteration,
|
|
545
|
+
consecutiveIdle: round.consecutiveIdle,
|
|
546
|
+
injectionsUsed: round.injectionsUsed,
|
|
547
|
+
injectionBudget: round.injectionBudget,
|
|
548
|
+
signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
|
|
549
|
+
},
|
|
550
|
+
ctx,
|
|
551
|
+
);
|
|
552
|
+
} catch (err) {
|
|
553
|
+
if (ctx.signal.aborted) return { kind: 'end' };
|
|
554
|
+
// Fail OPEN, visibly. A crashed or timed-out checker downgrades the
|
|
555
|
+
// gate to a warning — it must never wedge the turn (fail-closed is a
|
|
556
|
+
// turn that can never end) and never crash it (the answer is already
|
|
557
|
+
// logged).
|
|
558
|
+
const why = timeoutCtl.signal.aborted
|
|
559
|
+
? `timed out after ${timeoutMs}ms`
|
|
560
|
+
: `failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
561
|
+
yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
|
|
562
|
+
continue;
|
|
563
|
+
} finally {
|
|
564
|
+
clearTimeout(timer);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
switch (result.action) {
|
|
568
|
+
case 'pass': {
|
|
569
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
case 'stop': {
|
|
573
|
+
// The checkpoint already emitted its own wrap-up events.
|
|
574
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
|
|
575
|
+
return { kind: 'end' };
|
|
576
|
+
}
|
|
577
|
+
case 'retry': {
|
|
578
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
|
|
579
|
+
return { kind: 'loop' };
|
|
580
|
+
}
|
|
581
|
+
case 'inject': {
|
|
582
|
+
// Guard the guard: an inject with blank text would loop the turn
|
|
583
|
+
// with no new signal for the model — a checker bug; fail open.
|
|
584
|
+
const feedback = result.text?.trim();
|
|
585
|
+
if (!feedback) {
|
|
586
|
+
yield await emitError(
|
|
587
|
+
ctx,
|
|
588
|
+
'retryable',
|
|
589
|
+
`checkpoint "${cp.name}" injected empty feedback — ignored`,
|
|
590
|
+
);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const clamped = clampChars(feedback, round.maxInjectChars);
|
|
594
|
+
if (result.volatile) {
|
|
595
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
|
|
596
|
+
name: cp.name,
|
|
597
|
+
volatile: true,
|
|
598
|
+
});
|
|
599
|
+
return { kind: 'loop', volatileText: clamped };
|
|
600
|
+
}
|
|
601
|
+
// Persistent: a checkpoint-origin user prompt. Projected as a
|
|
602
|
+
// user-role message by the existing projection case, marked via the
|
|
603
|
+
// same `origin` machinery trigger prompts use, cache-safe because it
|
|
604
|
+
// is an ordinary append at the log tail.
|
|
605
|
+
yield await ctx.emit({
|
|
606
|
+
type: 'user_prompt',
|
|
607
|
+
sessionId: ctx.sessionId,
|
|
608
|
+
turnId: ctx.turnId,
|
|
609
|
+
source: 'system',
|
|
610
|
+
text: clamped,
|
|
611
|
+
origin: { kind: 'checkpoint', name: cp.name },
|
|
612
|
+
});
|
|
613
|
+
yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
|
|
614
|
+
name: cp.name,
|
|
615
|
+
volatile: false,
|
|
616
|
+
});
|
|
617
|
+
return { kind: 'loop' };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { kind: 'end' };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function defaultStuckNudge(trip: StuckTripInfo): string {
|
|
625
|
+
return (
|
|
626
|
+
`You have called the tool \`${trip.toolName}\` ${trip.count} times ${trip.how}. ` +
|
|
627
|
+
`Repeating the same call will not produce a different result. Step back, reassess what ` +
|
|
628
|
+
`you learned from the previous attempts, and take a DIFFERENT next action or approach.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function buildStuckReport(opts: ReactLoopOptions): StuckLoopReport {
|
|
633
|
+
const name = opts.strategyName;
|
|
634
|
+
return {
|
|
635
|
+
abortedResultMessage:
|
|
636
|
+
opts.stuck?.abortedResultMessage ??
|
|
637
|
+
`${name} mode loop aborted (stuck pattern) before this call ran`,
|
|
638
|
+
nearHint:
|
|
639
|
+
opts.stuck?.nearHint ?? 'against the same target (only volatile args like maxBytes varied)',
|
|
640
|
+
fatalMessage:
|
|
641
|
+
opts.stuck?.fatalMessage ??
|
|
642
|
+
(({ toolName, count, how }) =>
|
|
643
|
+
`${name} mode loop aborted — detected stuck pattern: tool "${toolName}" called ` +
|
|
644
|
+
`${count} times ${how}. The model is likely looping on the same call; ` +
|
|
645
|
+
`reset or rephrase.`),
|
|
646
|
+
...(opts.stuck?.extraOnStuck ? { extraOnStuck: opts.stuck.extraOnStuck } : {}),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function clampChars(text: string, max: number): string {
|
|
651
|
+
if (text.length <= max) return text;
|
|
652
|
+
return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
|
|
656
|
+
return ctx.emit({
|
|
657
|
+
type: 'abort',
|
|
658
|
+
sessionId: ctx.sessionId,
|
|
659
|
+
turnId: ctx.turnId,
|
|
660
|
+
source: 'system',
|
|
661
|
+
reason,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function emitError(
|
|
666
|
+
ctx: ModeContext,
|
|
667
|
+
kind: 'retryable' | 'fatal',
|
|
668
|
+
message: string,
|
|
669
|
+
): Promise<MoxxyEvent> {
|
|
670
|
+
return ctx.emit({
|
|
671
|
+
type: 'error',
|
|
672
|
+
sessionId: ctx.sessionId,
|
|
673
|
+
turnId: ctx.turnId,
|
|
674
|
+
source: 'system',
|
|
675
|
+
kind,
|
|
676
|
+
message,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function emitCheckpointEvent(
|
|
681
|
+
ctx: ModeContext,
|
|
682
|
+
subtype: string,
|
|
683
|
+
payload: unknown,
|
|
684
|
+
): Promise<MoxxyEvent> {
|
|
685
|
+
return ctx.emit({
|
|
686
|
+
type: 'plugin_event',
|
|
687
|
+
sessionId: ctx.sessionId,
|
|
688
|
+
turnId: ctx.turnId,
|
|
689
|
+
source: 'system',
|
|
690
|
+
pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
|
|
691
|
+
subtype,
|
|
692
|
+
payload,
|
|
693
|
+
});
|
|
694
|
+
}
|