@moxxy/mode-goal 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/constants.d.ts +8 -26
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +9 -26
- package/dist/constants.js.map +1 -1
- package/dist/goal-loop.d.ts +27 -17
- package/dist/goal-loop.d.ts.map +1 -1
- package/dist/goal-loop.js +164 -351
- package/dist/goal-loop.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/constants.ts +11 -29
- package/src/goal-loop.ts +172 -399
- package/src/index.test.ts +186 -145
- package/src/index.ts +6 -1
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('
|
|
161
|
-
// The model hammers the same (name, input)
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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:
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
568
|
-
const
|
|
569
|
-
(
|
|
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(
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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:
|
|
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
|
|