@moxxy/mode-goal 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/src/goal-tools.ts CHANGED
@@ -28,6 +28,8 @@ export const goalCompleteTool = defineTool({
28
28
  .describe('Concrete proof the goal is met: commands run and their results, files changed, tests passed.'),
29
29
  }),
30
30
  permission: { action: 'allow' },
31
+ // Side-effect-free loop signal (see the module comment) — no capabilities.
32
+ isolation: { capabilities: { net: { mode: 'none' }, timeMs: 10_000 } },
31
33
  handler: (input) => {
32
34
  const { summary, evidence } = input as { summary: string; evidence?: string[] };
33
35
  return {
@@ -52,6 +54,8 @@ export const goalAbandonTool = defineTool({
52
54
  .describe('What the user must provide or decide for the goal to continue.'),
53
55
  }),
54
56
  permission: { action: 'allow' },
57
+ // Side-effect-free loop signal (see the module comment) — no capabilities.
58
+ isolation: { capabilities: { net: { mode: 'none' }, timeMs: 10_000 } },
55
59
  handler: (input) => {
56
60
  const { reason, needsFromUser } = input as { reason: string; needsFromUser?: string };
57
61
  return { acknowledged: true, reason, ...(needsFromUser ? { needsFromUser } : {}) };
package/src/index.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
2
  import { z } from 'zod';
3
- import { defineTool, type ProviderEvent } from '@moxxy/sdk';
3
+ import { defineMode, definePlugin, defineTool, type ProviderEvent } from '@moxxy/sdk';
4
4
  import { collectTurn } from '@moxxy/core';
5
5
  import { FakeProvider, createFakeSession, textReply, toolUseReply } from '@moxxy/testing';
6
6
 
@@ -157,13 +157,17 @@ describe('goalMode end-to-end', () => {
157
157
  expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(false);
158
158
  });
159
159
 
160
- it('emits a paired result for every request even when the stuck loop trips', async () => {
161
- // The model hammers the same (name, input) until the detector trips. The
162
- // stuck trip ends the turn before executeToolUses runs the final request —
163
- // without synthesizing a result that request is orphaned (renders as a tool
164
- // stuck "running" forever, flips to error only on the next user_prompt).
160
+ it('a stuck-loop trip steers the run instead of killing it (no guardrail abort)', async () => {
161
+ // The model hammers the same (name, input) well past the detector's
162
+ // threshold, then recovers and completes. Goal mode must surface the
163
+ // repetition (goal_stuck + a visible NON-fatal warning + a volatile nudge
164
+ // on the next call) but NEVER abort the trip used to end the run with a
165
+ // fatal error mid-delivery.
165
166
  const provider = new FakeProvider({
166
- script: Array.from({ length: 20 }, (_, i) => toolUseReply('loop', {}, `c${i}`)),
167
+ script: [
168
+ ...Array.from({ length: 12 }, (_, i) => toolUseReply('loop', {}, `c${i}`)),
169
+ toolUseReply('goal_complete', { summary: 'recovered and finished' }, 'gc-stuck'),
170
+ ],
167
171
  });
168
172
  const session = createFakeSession({ provider });
169
173
  session.pluginHost.registerStatic(goalModePlugin);
@@ -178,92 +182,34 @@ describe('goalMode end-to-end', () => {
178
182
  );
179
183
 
180
184
  const events = await collectTurn(session, 'spin');
181
- expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stuck')).toBe(true);
182
185
 
186
+ // The repetition was noticed and surfaced…
187
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stuck')).toBe(true);
188
+ const warn = events.find(
189
+ (e) => e.type === 'error' && e.kind === 'retryable' && e.message.includes('repetitive'),
190
+ );
191
+ expect(warn).toBeDefined();
192
+ // …but nothing fatal happened and the run completed.
193
+ expect(events.some((e) => e.type === 'error' && e.kind === 'fatal')).toBe(false);
194
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
195
+ true,
196
+ );
197
+ // Every request got a real result (the batch executed — nothing synthesized
198
+ // as aborted, no orphans).
183
199
  const requestedIds = new Set(
184
200
  events.filter((e) => e.type === 'tool_call_requested').map((e) => e.callId),
185
201
  );
186
202
  const resolvedIds = new Set(
187
203
  events.filter((e) => e.type === 'tool_result').map((e) => e.callId),
188
204
  );
189
- const orphans = [...requestedIds].filter((id) => !resolvedIds.has(id));
190
- expect(orphans).toEqual([]);
191
- });
192
-
193
- // u67-2: the cumulative token-budget backstop must stop the run (the exact
194
- // unbounded-run failure the guard exists to prevent).
195
- it('stops with goal_budget_exhausted when cumulative usage exceeds the budget', () => {
196
- // A single reply whose usage blows past GOAL_TOKEN_BUDGET (4M). The budget
197
- // check runs right after the provider call, before any tool work, so this
198
- // trips on iteration 1.
199
- const hugeUsage: ProviderEvent[] = [
200
- { type: 'message_start', model: 'fake' },
201
- { type: 'text_delta', delta: 'working...' },
202
- {
203
- type: 'message_end',
204
- stopReason: 'end_turn',
205
- usage: { inputTokens: 5_000_000, outputTokens: 0 },
206
- },
207
- ];
208
- const provider = new FakeProvider({ script: [hugeUsage] });
209
- const session = createFakeSession({ provider });
210
- session.pluginHost.registerStatic(goalModePlugin);
211
- session.modes.setActive(GOAL_MODE_NAME);
212
-
213
- return collectTurn(session, 'do an expensive thing').then((events) => {
214
- const exhausted = events.find(
215
- (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
216
- );
217
- expect(exhausted).toBeDefined();
218
- if (exhausted?.type !== 'plugin_event') throw new Error('expected goal_budget_exhausted');
219
- expect((exhausted.payload as { budget: number }).budget).toBe(4_000_000);
220
- // It did NOT falsely report completion, and a final system message tells
221
- // the user how to continue.
222
- expect(
223
- events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
224
- ).toBe(false);
225
- const finalMsg = events
226
- .filter((e) => e.type === 'assistant_message' && e.source === 'system')
227
- .pop();
228
- if (finalMsg?.type !== 'assistant_message') throw new Error('expected final system message');
229
- expect(finalMsg.content).toContain('token budget exhausted');
230
- });
231
- });
232
-
233
- // u67-3: the budget-exhausting call's reasoning must still be persisted to the
234
- // log before the budget exit, matching every other exit path (otherwise the
235
- // final call's reasoning is silently dropped).
236
- it('persists the budget-exhausting call reasoning before goal_budget_exhausted', () => {
237
- const hugeUsageWithReasoning: ProviderEvent[] = [
238
- { type: 'message_start', model: 'fake' },
239
- { type: 'reasoning_delta', delta: 'I should think hard about this expensive task.' },
240
- { type: 'reasoning_signature', signature: 'sig-1' },
241
- { type: 'text_delta', delta: 'working...' },
242
- {
243
- type: 'message_end',
244
- stopReason: 'end_turn',
245
- usage: { inputTokens: 5_000_000, outputTokens: 0 },
246
- },
247
- ];
248
- const provider = new FakeProvider({ script: [hugeUsageWithReasoning] });
249
- const session = createFakeSession({ provider });
250
- session.pluginHost.registerStatic(goalModePlugin);
251
- session.modes.setActive(GOAL_MODE_NAME);
252
-
253
- return collectTurn(session, 'do an expensive thing').then((events) => {
254
- const reasoningIdx = events.findIndex((e) => e.type === 'reasoning_message');
255
- const exhaustedIdx = events.findIndex(
256
- (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
257
- );
258
- expect(reasoningIdx).toBeGreaterThanOrEqual(0);
259
- expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
260
- // The reasoning_message lands BEFORE the budget exit (the bug dropped it).
261
- expect(reasoningIdx).toBeLessThan(exhaustedIdx);
262
- const reasoning = events[reasoningIdx];
263
- if (reasoning?.type !== 'reasoning_message') throw new Error('expected reasoning_message');
264
- expect(reasoning.content).toContain('think hard');
265
- expect(reasoning.signature).toBe('sig-1');
266
- });
205
+ expect([...requestedIds].filter((id) => !resolvedIds.has(id))).toEqual([]);
206
+ // The nudge rode the next provider call as a volatile trailing message.
207
+ const nudged = provider.received.some((req) =>
208
+ req.messages
209
+ .flatMap((m) => m.content)
210
+ .some((c) => 'text' in c && c.text.includes('Repeating the same call will not produce')),
211
+ );
212
+ expect(nudged).toBe(true);
267
213
  });
268
214
 
269
215
  // u67-2: the hard iteration cap must end the run with goal_max_iterations + a
@@ -386,8 +332,10 @@ describe('goalMode end-to-end', () => {
386
332
  sleeps += 1;
387
333
  });
388
334
  // The provider is stuck returning retryable errors forever. Without the
389
- // cap this would re-hit the provider up to maxIterations (150) times with
335
+ // bounded retry budget this would re-hit the provider endlessly with
390
336
  // zero spacing — exactly the unattended busy-loop the guard prevents.
337
+ // (This is a PROVIDER-protection bound, not a goal guardrail: it fires
338
+ // only on consecutive provider failures, never on productive work.)
391
339
  const provider = new FakeProvider({
392
340
  script: Array.from({ length: 50 }, () => retryableErrorReply('rate limited')),
393
341
  });
@@ -506,72 +454,165 @@ describe('goalMode end-to-end', () => {
506
454
  });
507
455
  });
508
456
 
509
- it('persists the budget-exhausting call assistant text before stopping', () => {
510
- // The model produces real text on the call that blows the budget. That text
511
- // must land in the log (source: 'model') so a resume keeps the context
512
- // it was silently dropped before the fix.
513
- const hugeUsageWithText: ProviderEvent[] = [
514
- { type: 'message_start', model: 'fake' },
515
- { type: 'text_delta', delta: 'Here is my final analysis before stopping.' },
516
- {
517
- type: 'message_end',
518
- stopReason: 'end_turn',
519
- usage: { inputTokens: 5_000_000, outputTokens: 0 },
520
- },
521
- ];
522
- const provider = new FakeProvider({ script: [hugeUsageWithText] });
523
- const session = createFakeSession({ provider });
524
- session.pluginHost.registerStatic(goalModePlugin);
525
- session.modes.setActive(GOAL_MODE_NAME);
457
+ describe('no guardrails: nothing heuristic kills a goal run', () => {
458
+ it('runs past the old 150-iteration cap and still completes (uncapped by default)', async () => {
459
+ // 155 productive iterations (varied inputs dodge the stuck detector),
460
+ // then done. Under the old GOAL_MAX_ITERATIONS=150 cap this died with a
461
+ // fatal "iteration cap" error five rounds short of delivering.
462
+ const rounds = 155;
463
+ const provider = new FakeProvider({
464
+ script: [
465
+ ...Array.from({ length: rounds }, (_, i) => toolUseReply('work', { step: i }, `w${i}`)),
466
+ toolUseReply('goal_complete', { summary: 'went the distance' }, 'gc-long'),
467
+ ],
468
+ });
469
+ const session = createFakeSession({ provider });
470
+ session.pluginHost.registerStatic(goalModePlugin);
471
+ session.modes.setActive(GOAL_MODE_NAME);
472
+ session.tools.register(
473
+ defineTool({
474
+ name: 'work',
475
+ description: '',
476
+ inputSchema: z.object({ step: z.number() }),
477
+ handler: () => 'ok',
478
+ }),
479
+ );
480
+
481
+ const events = await collectTurn(session, 'a very long goal');
482
+
483
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
484
+ true,
485
+ );
486
+ expect(
487
+ events.some((e) => e.type === 'error' && e.kind === 'fatal'),
488
+ ).toBe(false);
489
+ expect(provider.received.length).toBe(rounds + 1);
490
+ });
491
+
492
+ it('spread-out idle rounds never exhaust the checkpoint budget mid-run', async () => {
493
+ // Idle → nudged back to work → idle → nudged → … four idle EPISODES
494
+ // (more than maxInjections=3), each recovered by tool work, then done.
495
+ // Before the per-episode budget reset this run died on the 4th idle with
496
+ // "checkpoint budget exhausted".
497
+ const script: Array<ReadonlyArray<ProviderEvent>> = [];
498
+ for (let i = 0; i < 4; i++) {
499
+ script.push(textReply(`progress note ${i}`));
500
+ script.push(toolUseReply('work', { step: i }, `iw${i}`));
501
+ }
502
+ script.push(toolUseReply('goal_complete', { summary: 'finished after pauses' }, 'gc-idle'));
503
+ const provider = new FakeProvider({ script });
504
+ const session = createFakeSession({ provider });
505
+ session.pluginHost.registerStatic(goalModePlugin);
506
+ session.modes.setActive(GOAL_MODE_NAME);
507
+ session.tools.register(
508
+ defineTool({
509
+ name: 'work',
510
+ description: '',
511
+ inputSchema: z.object({ step: z.number() }),
512
+ handler: () => 'ok',
513
+ }),
514
+ );
515
+
516
+ const events = await collectTurn(session, 'goal with thinking pauses');
526
517
 
527
- return collectTurn(session, 'expensive').then((events) => {
528
- // The model's own text was persisted…
529
- const modelMsg = events.find(
530
- (e) => e.type === 'assistant_message' && e.source === 'model',
518
+ expect(
519
+ events.some((e) => e.type === 'error' && e.message.includes('checkpoint budget exhausted')),
520
+ ).toBe(false);
521
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stalled')).toBe(
522
+ false,
531
523
  );
532
- if (modelMsg?.type !== 'assistant_message') throw new Error('expected a model assistant_message');
533
- expect(modelMsg.content).toContain('final analysis');
534
- // …before the budget plugin_event (matching the reasoning ordering rule).
535
- const modelIdx = events.indexOf(modelMsg);
536
- const exhaustedIdx = events.findIndex(
537
- (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
524
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
525
+ true,
538
526
  );
539
- expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
540
- expect(modelIdx).toBeLessThan(exhaustedIdx);
541
527
  });
542
528
  });
543
529
 
544
- it('counts cached prompt tokens toward the budget so the runaway guard trips', () => {
545
- // Most of the prompt is served from cache (cacheRead) with a tiny live
546
- // input. Counting input+output alone would leave totalTokens far under the
547
- // 4M budget; including the cache fields trips it on iteration 1.
548
- const cachedHeavyUsage: ProviderEvent[] = [
549
- { type: 'message_start', model: 'fake' },
550
- { type: 'text_delta', delta: 'working...' },
551
- {
552
- type: 'message_end',
553
- stopReason: 'end_turn',
554
- usage: {
555
- inputTokens: 100,
556
- outputTokens: 100,
557
- cacheReadTokens: 5_000_000,
558
- cacheCreationTokens: 0,
559
- },
560
- },
561
- ];
562
- const provider = new FakeProvider({ script: [cachedHeavyUsage] });
563
- const session = createFakeSession({ provider });
564
- session.pluginHost.registerStatic(goalModePlugin);
565
- session.modes.setActive(GOAL_MODE_NAME);
530
+ describe('one-shot semantics: goal mode disarms itself when the goal concludes', () => {
531
+ /** A no-op mode to stand in for whatever the user was in before /goal. */
532
+ const priorModePlugin = definePlugin({
533
+ name: '@moxxy/testing/prior-mode',
534
+ modes: [
535
+ defineMode({
536
+ name: 'prior',
537
+ run: async function* () {
538
+ /* never driven in these tests */
539
+ },
540
+ }),
541
+ ],
542
+ });
543
+
544
+ function armedSession(provider: FakeProvider) {
545
+ const session = createFakeSession({ provider });
546
+ // Register the prior mode FIRST (auto-activates), then arm goal — the
547
+ // registry records 'prior' as the previous mode, like a real /goal.
548
+ session.pluginHost.registerStatic(priorModePlugin);
549
+ session.pluginHost.registerStatic(goalModePlugin);
550
+ session.modes.setActive(GOAL_MODE_NAME);
551
+ return session;
552
+ }
553
+
554
+ it('reverts to the previous mode after goal_complete', async () => {
555
+ const provider = new FakeProvider({
556
+ script: [toolUseReply('goal_complete', { summary: 'done' }, 'gc-rev')],
557
+ });
558
+ const session = armedSession(provider);
559
+
560
+ await collectTurn(session, 'do it');
561
+
562
+ expect(session.modes.getActiveName()).toBe('prior');
563
+ });
566
564
 
567
- return collectTurn(session, 'cached and expensive').then((events) => {
568
- const exhausted = events.find(
569
- (e) => e.type === 'plugin_event' && e.subtype === 'goal_budget_exhausted',
565
+ it('reverts to the previous mode after an idle stall (soft completion)', async () => {
566
+ const provider = new FakeProvider({
567
+ script: [textReply('a'), textReply('b'), textReply('c')],
568
+ });
569
+ const session = armedSession(provider);
570
+
571
+ const events = await collectTurn(session, 'vague thing');
572
+
573
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stalled')).toBe(
574
+ true,
575
+ );
576
+ expect(session.modes.getActiveName()).toBe('prior');
577
+ });
578
+
579
+ it('stays armed after goal_abandon so the user reply resumes the run', async () => {
580
+ const provider = new FakeProvider({
581
+ script: [
582
+ toolUseReply('goal_abandon', { reason: 'need the API key', needsFromUser: 'the key' }, 'ga-1'),
583
+ ],
584
+ });
585
+ const session = armedSession(provider);
586
+
587
+ const events = await collectTurn(session, 'deploy it');
588
+
589
+ expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_abandoned')).toBe(
590
+ true,
570
591
  );
571
- expect(exhausted).toBeDefined();
572
- if (exhausted?.type !== 'plugin_event') throw new Error('expected goal_budget_exhausted');
573
- // totalTokens reflects the FULL prompt (input + cacheRead + output).
574
- expect((exhausted.payload as { totalTokens: number }).totalTokens).toBe(5_000_200);
592
+ expect(session.modes.getActiveName()).toBe(GOAL_MODE_NAME);
593
+ });
594
+
595
+ it('stays armed after a user abort (the goal is unfinished)', async () => {
596
+ const ctrl = new AbortController();
597
+ const provider = new FakeProvider({
598
+ script: [toolUseReply('work', { step: 1 }, 'wa-1')],
599
+ });
600
+ const session = armedSession(provider);
601
+ session.tools.register(
602
+ defineTool({
603
+ name: 'work',
604
+ description: '',
605
+ inputSchema: z.object({ step: z.number() }),
606
+ handler: () => {
607
+ ctrl.abort();
608
+ return 'ok';
609
+ },
610
+ }),
611
+ );
612
+
613
+ await collectTurn(session, 'interrupt me', { signal: ctrl.signal });
614
+
615
+ expect(session.modes.getActiveName()).toBe(GOAL_MODE_NAME);
575
616
  });
576
617
  });
577
618
  });
package/src/index.ts CHANGED
@@ -8,11 +8,16 @@ export { GOAL_MODE_NAME } from './constants.js';
8
8
 
9
9
  export const goalMode = defineMode({
10
10
  name: GOAL_MODE_NAME,
11
- description: 'Autonomous goal loop: works across many turns until it calls goal_complete (tools auto-approved)',
11
+ description:
12
+ 'Autonomous goal run: works until it calls goal_complete (tools auto-approved), then hands back to your previous mode',
12
13
  // Goal mode auto-approves tools and keeps working unattended, so channels
13
14
  // surface a persistent accent badge while it's active — the user must always
14
15
  // know the agent is driving itself.
15
16
  badge: { label: 'GOAL', tone: 'attention' },
17
+ // One-shot: armed per objective, disarms itself when the goal concludes,
18
+ // and is never persisted as the boot/category default — `/goal` once must
19
+ // not make every future session autonomous.
20
+ transient: true,
16
21
  run: runGoalMode,
17
22
  });
18
23