@moxxy/sdk 0.27.0 → 0.28.1

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.
Files changed (44) hide show
  1. package/dist/compactor-helpers.d.ts.map +1 -1
  2. package/dist/compactor-helpers.js +26 -1
  3. package/dist/compactor-helpers.js.map +1 -1
  4. package/dist/events.d.ts +8 -2
  5. package/dist/events.d.ts.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -2
  9. package/dist/index.js.map +1 -1
  10. package/dist/mode/checkpoint.d.ts +107 -0
  11. package/dist/mode/checkpoint.d.ts.map +1 -0
  12. package/dist/mode/checkpoint.js +2 -0
  13. package/dist/mode/checkpoint.js.map +1 -0
  14. package/dist/mode/react-loop.d.ts +129 -0
  15. package/dist/mode/react-loop.d.ts.map +1 -0
  16. package/dist/mode/react-loop.js +480 -0
  17. package/dist/mode/react-loop.js.map +1 -0
  18. package/dist/mode/stuck-loop.d.ts +7 -0
  19. package/dist/mode/stuck-loop.d.ts.map +1 -1
  20. package/dist/mode/stuck-loop.js +4 -0
  21. package/dist/mode/stuck-loop.js.map +1 -1
  22. package/dist/mode-helpers.d.ts +4 -0
  23. package/dist/mode-helpers.d.ts.map +1 -1
  24. package/dist/mode-helpers.js +3 -0
  25. package/dist/mode-helpers.js.map +1 -1
  26. package/dist/mode.d.ts +36 -2
  27. package/dist/mode.d.ts.map +1 -1
  28. package/dist/mode.js.map +1 -1
  29. package/dist/tool-dispatch.d.ts +35 -1
  30. package/dist/tool-dispatch.d.ts.map +1 -1
  31. package/dist/tool-dispatch.js +51 -0
  32. package/dist/tool-dispatch.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/compactor-helpers.test.ts +43 -0
  35. package/src/compactor-helpers.ts +34 -1
  36. package/src/events.ts +8 -2
  37. package/src/index.ts +13 -0
  38. package/src/mode/checkpoint.ts +105 -0
  39. package/src/mode/react-loop.ts +694 -0
  40. package/src/mode/stuck-loop.ts +11 -0
  41. package/src/mode-helpers.ts +16 -0
  42. package/src/mode.ts +36 -2
  43. package/src/tool-dispatch.test.ts +77 -0
  44. 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
+ }