@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
@@ -32,6 +32,13 @@ export interface StuckLoopDetector {
32
32
  readonly repeatThreshold: number;
33
33
  /** Record the call and report whether the loop guard should trip. */
34
34
  record(toolName: string, input: unknown): StuckSignal;
35
+ /**
36
+ * Clear both sliding windows. Used by the nudge (steer-don't-stop) stuck
37
+ * policy: after a trip is surfaced to the model, the detector starts a fresh
38
+ * episode — otherwise every subsequent call inside the still-full window
39
+ * would re-trip and re-nudge on each iteration.
40
+ */
41
+ reset(): void;
35
42
  }
36
43
 
37
44
  /**
@@ -102,5 +109,9 @@ export function createStuckLoopDetector(opts: LoopGuardSettings = {}): StuckLoop
102
109
  }
103
110
  return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
104
111
  },
112
+ reset(): void {
113
+ recent.length = 0;
114
+ recentNear.length = 0;
115
+ },
105
116
  };
106
117
  }
@@ -9,6 +9,8 @@
9
9
  * - `./mode/single-shot.ts` — single-shot (no-tools) provider turn
10
10
  * - `./mode/stuck-loop.ts` — sliding-window stuck-tool-call detector
11
11
  * - `./mode/stable-hash.ts` — key-order-canonical input hash util
12
+ * - `./mode/react-loop.ts` — the shared ReAct loop core (+ checkpoint gate)
13
+ * - `./mode/checkpoint.ts` — turn-end checkpoint contract
12
14
  */
13
15
 
14
16
  export {
@@ -33,3 +35,17 @@ export {
33
35
  type LoopGuardSettings,
34
36
  } from './mode/stuck-loop.js';
35
37
  export { stableHash } from './mode/stable-hash.js';
38
+ export {
39
+ runReactLoop,
40
+ MAX_CONSECUTIVE_RETRIES,
41
+ __setRetrySleepForTests,
42
+ type ReactLoopOptions,
43
+ type ProviderSuccessInfo,
44
+ type ToolBatchInfo,
45
+ type StopDirective,
46
+ } from './mode/react-loop.js';
47
+ export type {
48
+ CheckpointContext,
49
+ CheckpointResult,
50
+ TurnCheckpoint,
51
+ } from './mode/checkpoint.js';
package/src/mode.ts CHANGED
@@ -131,14 +131,33 @@ export interface ModeContext {
131
131
  * Absent in synthetic test contexts that don't model a full Session.
132
132
  */
133
133
  readonly subagents?: SubagentSpawner;
134
+ /**
135
+ * True when this context belongs to a spawned child agent (set by the
136
+ * subagent runtime). The shared ReAct loop disarms turn-end checkpoints in
137
+ * subagent sessions — the recursion backstop that keeps a checkpoint which
138
+ * (mistakenly) spawns a child in a checkpoint-bearing mode from gating the
139
+ * child's turn-end and recursing forever.
140
+ */
141
+ readonly isSubagent?: boolean;
134
142
  /**
135
143
  * Request the session switch its active mode AFTER this turn fully drains.
136
144
  * Used by terminal workflow modes (BMAD finishing its last phase) to hand
137
145
  * control back to a normal mode so the next message isn't trapped in the
138
- * workflow. The switch is applied post-turn by the runner; an unknown /
139
- * unregistered mode name is ignored. Absent in synthetic test contexts.
146
+ * workflow, and by {@link ModeDef.transient} modes (goal) to disarm
147
+ * themselves once their objective concludes. The switch is applied
148
+ * post-turn by the runner; an unknown / unregistered mode name is ignored.
149
+ * Absent in synthetic test contexts.
140
150
  */
141
151
  readonly requestModeSwitch?: (modeName: string) => void;
152
+ /**
153
+ * Name of the mode that was active before the current one (per the mode
154
+ * registry), when known. Lets a {@link ModeDef.transient} mode hand control
155
+ * back to whatever the user was in before arming it (`/goal` from research
156
+ * mode reverts to research, not blindly to default). Absent when the
157
+ * registry has no history (first mode of the session) or in synthetic test
158
+ * contexts — callers should fall back to `'default'`.
159
+ */
160
+ readonly previousModeName?: string;
142
161
  emit(event: EmittedEvent): Promise<MoxxyEvent>;
143
162
  }
144
163
 
@@ -225,6 +244,21 @@ export interface ModeDef {
225
244
  * more later) opt in the same way. See {@link isSelectableMode}.
226
245
  */
227
246
  readonly special?: ModeSpecial;
247
+ /**
248
+ * Marks a mode that arms for a SINGLE objective rather than becoming the
249
+ * session's standing behavior (goal mode). Uniformly across surfaces:
250
+ *
251
+ * - it is never persisted as the category default (`plugins.mode.default`)
252
+ * — `/goal` once must not make every future session boot autonomous;
253
+ * - a persisted/config default naming it is refused at boot (the
254
+ * protected default stays active instead);
255
+ * - the mode itself is expected to hand control back (via
256
+ * `ctx.requestModeSwitch`, typically to `ctx.previousModeName`) when
257
+ * its objective concludes.
258
+ *
259
+ * Switching INTO it (picker, `/goal`, `setMode` RPC) still works normally.
260
+ */
261
+ readonly transient?: boolean;
228
262
  run(ctx: ModeContext): AsyncIterable<MoxxyEvent>;
229
263
  }
230
264
 
@@ -9,7 +9,10 @@ import {
9
9
  dispatchToolCall,
10
10
  executeToolUses,
11
11
  emitRequestsAndDetectStuck,
12
+ emitRequestsAndNudgeOnStuck,
12
13
  type StuckLoopReport,
14
+ type StuckNudgeReport,
15
+ type StuckTripInfo,
13
16
  } from './tool-dispatch.js';
14
17
 
15
18
  /**
@@ -213,3 +216,77 @@ describe('emitRequestsAndDetectStuck — stuck trip', () => {
213
216
  expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
214
217
  });
215
218
  });
219
+
220
+ describe('emitRequestsAndNudgeOnStuck — steer, never stop', () => {
221
+ const nudgeReport: StuckNudgeReport = {
222
+ nearHint: 'with nearly identical input',
223
+ warnMessage: ({ toolName, count }) => `repetitive: ${toolName} x${count}`,
224
+ extraOnStuck: ({ toolName }) => [
225
+ {
226
+ type: 'plugin_event',
227
+ sessionId: asSessionId('s1'),
228
+ turnId: asTurnId('t1'),
229
+ source: 'plugin',
230
+ pluginId: 'test' as never,
231
+ subtype: 'test_stuck',
232
+ payload: { toolName },
233
+ },
234
+ ],
235
+ };
236
+
237
+ function nudgeDetector(trip: StuckSignal): StuckLoopDetector & { resets: number } {
238
+ const d = {
239
+ resets: 0,
240
+ record: () => trip,
241
+ reset: () => {
242
+ d.resets += 1;
243
+ },
244
+ };
245
+ return d as unknown as StuckLoopDetector & { resets: number };
246
+ }
247
+
248
+ it('on a trip: warns (retryable, not fatal), emits extra events, resets the detector, and does NOT synthesize results', async () => {
249
+ const { ctx, events } = makeCtx();
250
+ const det = nudgeDetector({ stuck: true, kind: 'exact', count: 8 });
251
+ const trip = (await drain(
252
+ emitRequestsAndNudgeOnStuck(ctx, [tool], det, nudgeReport),
253
+ )) as StuckTripInfo | null;
254
+
255
+ // Trip info is returned for the loop's volatile nudge…
256
+ expect(trip).toEqual({ toolName: 'Read', count: 8, kind: 'exact', how: 'with identical input' });
257
+ // …the detector started a fresh episode…
258
+ expect(det.resets).toBe(1);
259
+ // …the request stands (the batch will execute — no synthesized results)…
260
+ expect(events.some((e) => e.type === 'tool_call_requested')).toBe(true);
261
+ expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
262
+ // …the warning is visible but NON-fatal, with the extra event before it.
263
+ const err = events.find((e) => e.type === 'error') as
264
+ | { kind: string; message: string }
265
+ | undefined;
266
+ expect(err?.kind).toBe('retryable');
267
+ expect(err?.message).toBe('repetitive: Read x8');
268
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'test_stuck')).toBe(true);
269
+ });
270
+
271
+ it('a near trip uses the report nearHint wording', async () => {
272
+ const { ctx } = makeCtx();
273
+ const trip = (await drain(
274
+ emitRequestsAndNudgeOnStuck(
275
+ ctx,
276
+ [tool],
277
+ nudgeDetector({ stuck: true, kind: 'near', count: 5 }),
278
+ nudgeReport,
279
+ ),
280
+ )) as StuckTripInfo | null;
281
+ expect(trip?.how).toBe('with nearly identical input');
282
+ });
283
+
284
+ it('returns null and stays quiet when the detector does not trip', async () => {
285
+ const { ctx, events } = makeCtx();
286
+ const det = nudgeDetector({ stuck: false, kind: 'exact', count: 0 });
287
+ const trip = await drain(emitRequestsAndNudgeOnStuck(ctx, [tool], det, nudgeReport));
288
+ expect(trip).toBeNull();
289
+ expect(det.resets).toBe(0);
290
+ expect(events.filter((e) => e.type !== 'tool_call_requested')).toHaveLength(0);
291
+ });
292
+ });
@@ -2,7 +2,11 @@ import { asToolCallId } from './ids.js';
2
2
  import type { EmittedEvent, MoxxyEvent } from './events.js';
3
3
  import type { ModeContext } from './mode.js';
4
4
  import type { ToolCallVerdict } from './hooks.js';
5
- import type { CollectedToolUse, StuckLoopDetector, StuckSignal } from './mode-helpers.js';
5
+ // Import the concrete modules, not the './mode-helpers.js' barrel: the barrel
6
+ // re-exports the ReAct loop core, which imports THIS file — going through the
7
+ // barrel here closes that loop into a real import cycle.
8
+ import type { CollectedToolUse } from './mode/collect-stream.js';
9
+ import type { StuckLoopDetector, StuckSignal } from './mode/stuck-loop.js';
6
10
 
7
11
  /**
8
12
  * Execute a single tool-use end-to-end: dispatch `dispatchToolCall` hooks, run
@@ -282,3 +286,80 @@ export async function* emitRequestsAndDetectStuck(
282
286
  }
283
287
  return false;
284
288
  }
289
+
290
+ /** A stuck-detector trip, as surfaced to the nudge policy. */
291
+ export interface StuckTripInfo {
292
+ readonly toolName: string;
293
+ readonly count: number;
294
+ readonly kind: StuckSignal['kind'];
295
+ /** Human phrasing of HOW it repeated ("with identical input" / the near hint). */
296
+ readonly how: string;
297
+ }
298
+
299
+ /** Wording + extra events for {@link emitRequestsAndNudgeOnStuck}. */
300
+ export interface StuckNudgeReport {
301
+ /** Explanation for a `near` match (an `exact` match always reads
302
+ * "with identical input"). */
303
+ readonly nearHint: string;
304
+ /** Build the visible (non-fatal) warning surfaced to the user on a trip. */
305
+ readonly warnMessage: (info: StuckTripInfo) => string;
306
+ /** Extra events to emit right before the warning — goal mode surfaces a
307
+ * `goal_stuck` plugin_event here. */
308
+ readonly extraOnStuck?: StuckLoopReport['extraOnStuck'];
309
+ }
310
+
311
+ /**
312
+ * The steer-don't-stop counterpart of {@link emitRequestsAndDetectStuck}: emit
313
+ * a `tool_call_requested` per call and feed the detector, but a trip NEVER
314
+ * aborts the turn — the batch still executes (the repeated calls are usually
315
+ * legitimate work, e.g. re-running a failing build between edits). Instead the
316
+ * trip is surfaced as a visible warning + the report's extra events, the
317
+ * detector is reset (fresh episode — no re-trip on every subsequent call while
318
+ * the window is still full), and the trip info is returned so the loop can
319
+ * steer the model with a volatile nudge on its next provider call.
320
+ *
321
+ * Used by modes that must never kill an unattended run on a repetition
322
+ * heuristic (goal mode). Returns the first trip of the batch, or null.
323
+ */
324
+ export async function* emitRequestsAndNudgeOnStuck(
325
+ ctx: ModeContext,
326
+ toolUses: ReadonlyArray<CollectedToolUse>,
327
+ detector: StuckLoopDetector,
328
+ report: StuckNudgeReport,
329
+ ): AsyncGenerator<MoxxyEvent, StuckTripInfo | null, unknown> {
330
+ let trip: StuckTripInfo | null = null;
331
+ for (const t of toolUses) {
332
+ yield await ctx.emit({
333
+ type: 'tool_call_requested',
334
+ sessionId: ctx.sessionId,
335
+ turnId: ctx.turnId,
336
+ source: 'model',
337
+ callId: asToolCallId(t.id),
338
+ name: t.name,
339
+ input: t.input,
340
+ });
341
+ const sig = detector.record(t.name, t.input);
342
+ if (!sig.stuck || trip) continue;
343
+ trip = {
344
+ toolName: t.name,
345
+ count: sig.count,
346
+ kind: sig.kind,
347
+ how: sig.kind === 'near' ? report.nearHint : 'with identical input',
348
+ };
349
+ detector.reset();
350
+ if (report.extraOnStuck) {
351
+ for (const e of report.extraOnStuck({ toolName: t.name, count: sig.count, kind: sig.kind })) {
352
+ yield await ctx.emit(e);
353
+ }
354
+ }
355
+ yield await ctx.emit({
356
+ type: 'error',
357
+ sessionId: ctx.sessionId,
358
+ turnId: ctx.turnId,
359
+ source: 'system',
360
+ kind: 'retryable',
361
+ message: report.warnMessage(trip),
362
+ });
363
+ }
364
+ return trip;
365
+ }