@moxxy/mode-goal 0.26.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/LICENSE +21 -0
- package/dist/completion.d.ts +20 -0
- package/dist/completion.d.ts.map +1 -0
- package/dist/completion.js +48 -0
- package/dist/completion.js.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/goal-loop.d.ts +34 -0
- package/dist/goal-loop.d.ts.map +1 -0
- package/dist/goal-loop.js +454 -0
- package/dist/goal-loop.js.map +1 -0
- package/dist/goal-tools.d.ts +14 -0
- package/dist/goal-tools.d.ts.map +1 -0
- package/dist/goal-tools.js +55 -0
- package/dist/goal-tools.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/completion.test.ts +68 -0
- package/src/completion.ts +55 -0
- package/src/constants.ts +75 -0
- package/src/goal-loop.ts +523 -0
- package/src/goal-tools.ts +61 -0
- package/src/index.test.ts +577 -0
- package/src/index.ts +28 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool, type ProviderEvent } from '@moxxy/sdk';
|
|
4
|
+
import { collectTurn } from '@moxxy/core';
|
|
5
|
+
import { FakeProvider, createFakeSession, textReply, toolUseReply } from '@moxxy/testing';
|
|
6
|
+
|
|
7
|
+
import { goalModePlugin, GOAL_MODE_NAME } from './index.js';
|
|
8
|
+
import { __setRetrySleepForTests } from './goal-loop.js';
|
|
9
|
+
|
|
10
|
+
/** A scripted provider reply that surfaces a retryable error mid-stream. */
|
|
11
|
+
function retryableErrorReply(message = 'overloaded'): ProviderEvent[] {
|
|
12
|
+
return [
|
|
13
|
+
{ type: 'message_start', model: 'fake' },
|
|
14
|
+
{ type: 'error', message, retryable: true },
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('goalMode end-to-end', () => {
|
|
19
|
+
it('stops with goal_completed when the model calls goal_complete', async () => {
|
|
20
|
+
const provider = new FakeProvider({
|
|
21
|
+
script: [
|
|
22
|
+
toolUseReply('goal_complete', { summary: 'Refactored the parser', evidence: ['tests pass'] }, 'gc1'),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
const session = createFakeSession({ provider });
|
|
26
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
27
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
28
|
+
|
|
29
|
+
const events = await collectTurn(session, 'refactor the parser');
|
|
30
|
+
|
|
31
|
+
// The run announced it started, then completed (and nothing after).
|
|
32
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_started')).toBe(true);
|
|
33
|
+
const completed = events.find((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed');
|
|
34
|
+
expect(completed).toBeDefined();
|
|
35
|
+
if (completed?.type !== 'plugin_event') throw new Error('expected goal_completed');
|
|
36
|
+
expect((completed.payload as { summary: string }).summary).toBe('Refactored the parser');
|
|
37
|
+
|
|
38
|
+
// Final system message surfaces the summary to the user.
|
|
39
|
+
const finalMsg = events
|
|
40
|
+
.filter((e) => e.type === 'assistant_message' && e.source === 'system')
|
|
41
|
+
.pop();
|
|
42
|
+
if (finalMsg?.type !== 'assistant_message') throw new Error('expected final system message');
|
|
43
|
+
expect(finalMsg.content).toContain('Refactored the parser');
|
|
44
|
+
|
|
45
|
+
// The goal tool actually ran and was auto-approved (no permission prompt).
|
|
46
|
+
expect(
|
|
47
|
+
events.some((e) => e.type === 'tool_call_approved' && e.mode === 'allow'),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('auto-approves a normal tool call mid-run (full autonomy), then completes', async () => {
|
|
52
|
+
const provider = new FakeProvider({
|
|
53
|
+
script: [
|
|
54
|
+
// First the model does real work via a tool…
|
|
55
|
+
toolUseReply('list_dir', { path: '.' }, 'work1'),
|
|
56
|
+
// …then declares done.
|
|
57
|
+
toolUseReply('goal_complete', { summary: 'listed files' }, 'gc2'),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const session = createFakeSession({ provider });
|
|
61
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
62
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
63
|
+
|
|
64
|
+
const events = await collectTurn(session, 'list the files then finish');
|
|
65
|
+
// The work tool was auto-approved without any ask/permission round-trip.
|
|
66
|
+
const approvals = events.filter((e) => e.type === 'tool_call_approved');
|
|
67
|
+
expect(approvals.length).toBeGreaterThanOrEqual(2); // work tool + goal_complete
|
|
68
|
+
expect(approvals.every((e) => e.type === 'tool_call_approved' && e.mode === 'allow')).toBe(true);
|
|
69
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('honours a user deny rule (policy) while still auto-approving other tools', async () => {
|
|
73
|
+
let dangerousRan = false;
|
|
74
|
+
const provider = new FakeProvider({
|
|
75
|
+
script: [
|
|
76
|
+
// The model tries the denied tool first…
|
|
77
|
+
toolUseReply('dangerous', { target: 'prod' }, 'd1'),
|
|
78
|
+
// …then a permitted one, then declares done.
|
|
79
|
+
toolUseReply('safe', {}, 's1'),
|
|
80
|
+
toolUseReply('goal_complete', { summary: 'finished without the denied tool' }, 'gc3'),
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
const session = createFakeSession({ provider });
|
|
84
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
85
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
86
|
+
session.tools.register(
|
|
87
|
+
defineTool({
|
|
88
|
+
name: 'dangerous',
|
|
89
|
+
description: '',
|
|
90
|
+
inputSchema: z.object({ target: z.string() }),
|
|
91
|
+
handler: () => {
|
|
92
|
+
dangerousRan = true;
|
|
93
|
+
return 'boom';
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
session.tools.register(
|
|
98
|
+
defineTool({ name: 'safe', description: '', inputSchema: z.object({}), handler: () => 'ok' }),
|
|
99
|
+
);
|
|
100
|
+
// Same persistent policy engine that backs ~/.moxxy/permissions.json.
|
|
101
|
+
await session.permissions.addDeny({ name: 'dangerous', reason: 'user denied this tool' });
|
|
102
|
+
// Tripwire: goal mode must never fall through to the interactive prompt
|
|
103
|
+
// path. If it did, dispatchToolCall would surface a pre-execute failure.
|
|
104
|
+
session.setPermissionResolver({
|
|
105
|
+
name: 'tripwire-prompt',
|
|
106
|
+
check: async () => {
|
|
107
|
+
throw new Error('interactive prompt fired in goal mode');
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const events = await collectTurn(session, 'do the thing');
|
|
112
|
+
|
|
113
|
+
// The deny rule held, with the user's reason…
|
|
114
|
+
const denied = events.find((e) => e.type === 'tool_call_denied');
|
|
115
|
+
if (denied?.type !== 'tool_call_denied') throw new Error('expected a tool_call_denied event');
|
|
116
|
+
expect(denied.decidedBy).toBe('resolver');
|
|
117
|
+
expect(denied.reason).toContain('user denied this tool');
|
|
118
|
+
// …the denied call still produced a well-formed failed tool_result…
|
|
119
|
+
const deniedResult = events.find(
|
|
120
|
+
(e) => e.type === 'tool_result' && e.callId === denied.callId,
|
|
121
|
+
);
|
|
122
|
+
if (deniedResult?.type !== 'tool_result') throw new Error('expected a tool_result for the denial');
|
|
123
|
+
expect(deniedResult.ok).toBe(false);
|
|
124
|
+
// …and the handler never executed.
|
|
125
|
+
expect(dangerousRan).toBe(false);
|
|
126
|
+
|
|
127
|
+
// Everything else still auto-approves without prompting (the tripwire
|
|
128
|
+
// would have failed those calls) and the run completes.
|
|
129
|
+
const approvals = events.filter((e) => e.type === 'tool_call_approved');
|
|
130
|
+
expect(approvals.length).toBeGreaterThanOrEqual(2); // safe + goal_complete
|
|
131
|
+
expect(approvals.every((e) => e.type === 'tool_call_approved' && e.mode === 'allow')).toBe(true);
|
|
132
|
+
expect(
|
|
133
|
+
events.some(
|
|
134
|
+
(e) => e.type === 'tool_result' && !e.ok && e.error.message.includes('pre-execute failure'),
|
|
135
|
+
),
|
|
136
|
+
).toBe(false);
|
|
137
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('stalls (goal_stalled) when the model keeps idling without completing', async () => {
|
|
141
|
+
// GOAL_MAX_NOOP_ITERATIONS idle (no-tool) replies → the loop gives up.
|
|
142
|
+
const provider = new FakeProvider({
|
|
143
|
+
script: [
|
|
144
|
+
textReply('Thinking about it...'),
|
|
145
|
+
textReply('Still working through it...'),
|
|
146
|
+
textReply('I believe this is fine.'),
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
const session = createFakeSession({ provider });
|
|
150
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
151
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
152
|
+
|
|
153
|
+
const events = await collectTurn(session, 'do something vague');
|
|
154
|
+
|
|
155
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stalled')).toBe(true);
|
|
156
|
+
// It did NOT falsely report completion.
|
|
157
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(false);
|
|
158
|
+
});
|
|
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).
|
|
165
|
+
const provider = new FakeProvider({
|
|
166
|
+
script: Array.from({ length: 20 }, (_, i) => toolUseReply('loop', {}, `c${i}`)),
|
|
167
|
+
});
|
|
168
|
+
const session = createFakeSession({ provider });
|
|
169
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
170
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
171
|
+
session.tools.register(
|
|
172
|
+
defineTool({
|
|
173
|
+
name: 'loop',
|
|
174
|
+
description: '',
|
|
175
|
+
inputSchema: z.object({}),
|
|
176
|
+
handler: () => 'ok',
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const events = await collectTurn(session, 'spin');
|
|
181
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_stuck')).toBe(true);
|
|
182
|
+
|
|
183
|
+
const requestedIds = new Set(
|
|
184
|
+
events.filter((e) => e.type === 'tool_call_requested').map((e) => e.callId),
|
|
185
|
+
);
|
|
186
|
+
const resolvedIds = new Set(
|
|
187
|
+
events.filter((e) => e.type === 'tool_result').map((e) => e.callId),
|
|
188
|
+
);
|
|
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
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// u67-2: the hard iteration cap must end the run with goal_max_iterations + a
|
|
270
|
+
// fatal error when the model never calls goal_complete.
|
|
271
|
+
it('stops with goal_max_iterations when the cap is reached without completing', async () => {
|
|
272
|
+
// The model keeps doing distinct work (varied inputs dodge the stuck-loop
|
|
273
|
+
// detector) and never declares done. ctx.maxIterations=2 bounds the run.
|
|
274
|
+
const provider = new FakeProvider({
|
|
275
|
+
script: [
|
|
276
|
+
toolUseReply('work', { step: 1 }, 'w1'),
|
|
277
|
+
toolUseReply('work', { step: 2 }, 'w2'),
|
|
278
|
+
toolUseReply('work', { step: 3 }, 'w3'),
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
const session = createFakeSession({ provider });
|
|
282
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
283
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
284
|
+
session.tools.register(
|
|
285
|
+
defineTool({
|
|
286
|
+
name: 'work',
|
|
287
|
+
description: '',
|
|
288
|
+
inputSchema: z.object({ step: z.number() }),
|
|
289
|
+
handler: () => 'ok',
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const events = await collectTurn(session, 'keep working forever', { maxIterations: 2 });
|
|
294
|
+
|
|
295
|
+
const cap = events.find(
|
|
296
|
+
(e) => e.type === 'plugin_event' && e.subtype === 'goal_max_iterations',
|
|
297
|
+
);
|
|
298
|
+
expect(cap).toBeDefined();
|
|
299
|
+
if (cap?.type !== 'plugin_event') throw new Error('expected goal_max_iterations');
|
|
300
|
+
expect((cap.payload as { maxIterations: number }).maxIterations).toBe(2);
|
|
301
|
+
// A fatal error closes the run; it did not falsely complete.
|
|
302
|
+
expect(
|
|
303
|
+
events.some((e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('iteration cap')),
|
|
304
|
+
).toBe(true);
|
|
305
|
+
expect(
|
|
306
|
+
events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
|
|
307
|
+
).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// A degenerate caller-supplied bound (0 / negative / NaN / fractional) bypasses
|
|
311
|
+
// the config schema (programmatic callers: subagents/workflows forward it raw).
|
|
312
|
+
// Without the clamp the for-loop never runs and control falls straight to the
|
|
313
|
+
// misleading "iteration cap reached (0)" fatal — work was never done, yet the
|
|
314
|
+
// message blames the model. The clamp must run at least one real iteration.
|
|
315
|
+
describe('degenerate maxIterations is clamped, not fatal-with-zero-work', () => {
|
|
316
|
+
for (const bad of [0, -5, Number.NaN, 1.9]) {
|
|
317
|
+
it(`runs ≥1 iteration and completes when maxIterations=${String(bad)}`, async () => {
|
|
318
|
+
const provider = new FakeProvider({
|
|
319
|
+
script: [toolUseReply('goal_complete', { summary: 'did one step' }, 'gc-clamp')],
|
|
320
|
+
});
|
|
321
|
+
const session = createFakeSession({ provider });
|
|
322
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
323
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
324
|
+
|
|
325
|
+
const events = await collectTurn(session, 'do it', { maxIterations: bad });
|
|
326
|
+
|
|
327
|
+
// It actually drove the model (real work happened) and completed —
|
|
328
|
+
// it did NOT fall through to the zero-work iteration-cap fatal.
|
|
329
|
+
expect(
|
|
330
|
+
events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed'),
|
|
331
|
+
).toBe(true);
|
|
332
|
+
expect(
|
|
333
|
+
events.some(
|
|
334
|
+
(e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('iteration cap'),
|
|
335
|
+
),
|
|
336
|
+
).toBe(false);
|
|
337
|
+
// The provider was actually called (the loop body ran at least once).
|
|
338
|
+
expect(provider.received.length).toBeGreaterThanOrEqual(1);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
it('reports the clamped cap (not the raw degenerate value) when it later runs out', async () => {
|
|
343
|
+
// maxIterations=0 clamps to 1; the model never completes, so the run hits
|
|
344
|
+
// the cap after a single iteration and the surfaced cap is the clamped 1.
|
|
345
|
+
const provider = new FakeProvider({
|
|
346
|
+
script: Array.from({ length: 3 }, (_, i) => toolUseReply('work', { step: i }, `c${i}`)),
|
|
347
|
+
});
|
|
348
|
+
const session = createFakeSession({ provider });
|
|
349
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
350
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
351
|
+
session.tools.register(
|
|
352
|
+
defineTool({
|
|
353
|
+
name: 'work',
|
|
354
|
+
description: '',
|
|
355
|
+
inputSchema: z.object({ step: z.number() }),
|
|
356
|
+
handler: () => 'ok',
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const events = await collectTurn(session, 'never finish', { maxIterations: 0 });
|
|
361
|
+
|
|
362
|
+
const cap = events.find(
|
|
363
|
+
(e) => e.type === 'plugin_event' && e.subtype === 'goal_max_iterations',
|
|
364
|
+
);
|
|
365
|
+
if (cap?.type !== 'plugin_event') throw new Error('expected goal_max_iterations');
|
|
366
|
+
// The reported cap is the clamped 1, never the misleading 0.
|
|
367
|
+
expect((cap.payload as { maxIterations: number }).maxIterations).toBe(1);
|
|
368
|
+
// …and one real iteration's worth of work actually ran first.
|
|
369
|
+
expect(provider.received.length).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('retryable provider errors (busy-loop guard)', () => {
|
|
374
|
+
// Make the back-off instant + deterministic so the bounded-retry path runs
|
|
375
|
+
// without real timers. Restore after every test (the seam is a module
|
|
376
|
+
// singleton shared process-wide).
|
|
377
|
+
let restore: (() => void) | undefined;
|
|
378
|
+
afterEach(() => {
|
|
379
|
+
restore?.();
|
|
380
|
+
restore = undefined;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('bails with a fatal error after MAX_CONSECUTIVE_RETRIES instead of busy-looping the provider', async () => {
|
|
384
|
+
let sleeps = 0;
|
|
385
|
+
restore = __setRetrySleepForTests(async () => {
|
|
386
|
+
sleeps += 1;
|
|
387
|
+
});
|
|
388
|
+
// The provider is stuck returning retryable errors forever. Without the
|
|
389
|
+
// cap this would re-hit the provider up to maxIterations (150) times with
|
|
390
|
+
// zero spacing — exactly the unattended busy-loop the guard prevents.
|
|
391
|
+
const provider = new FakeProvider({
|
|
392
|
+
script: Array.from({ length: 50 }, () => retryableErrorReply('rate limited')),
|
|
393
|
+
});
|
|
394
|
+
const session = createFakeSession({ provider });
|
|
395
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
396
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
397
|
+
|
|
398
|
+
const events = await collectTurn(session, 'do the thing');
|
|
399
|
+
|
|
400
|
+
// It gave up with a fatal error mentioning the repeated retryable failure…
|
|
401
|
+
const fatal = events.find(
|
|
402
|
+
(e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('retryable error'),
|
|
403
|
+
);
|
|
404
|
+
expect(fatal).toBeDefined();
|
|
405
|
+
// …and it did NOT consume the whole 150-iteration cap — only the bounded
|
|
406
|
+
// retry budget of provider calls happened (6), so 6 errors were surfaced
|
|
407
|
+
// and the loop stopped well before maxIterations.
|
|
408
|
+
const retryableErrors = events.filter(
|
|
409
|
+
(e) => e.type === 'error' && e.kind === 'retryable',
|
|
410
|
+
);
|
|
411
|
+
expect(retryableErrors.length).toBe(6);
|
|
412
|
+
// Every retry but the last backed off (abort-aware sleep), so the provider
|
|
413
|
+
// was never hammered back-to-back.
|
|
414
|
+
expect(sleeps).toBe(5);
|
|
415
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
|
|
416
|
+
false,
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('resets the retry counter after a clean call, recovering from a transient blip', async () => {
|
|
421
|
+
let sleeps = 0;
|
|
422
|
+
restore = __setRetrySleepForTests(async () => {
|
|
423
|
+
sleeps += 1;
|
|
424
|
+
});
|
|
425
|
+
// A few retryable blips, then the provider recovers and the model finishes.
|
|
426
|
+
const provider = new FakeProvider({
|
|
427
|
+
script: [
|
|
428
|
+
retryableErrorReply(),
|
|
429
|
+
retryableErrorReply(),
|
|
430
|
+
toolUseReply('goal_complete', { summary: 'recovered after a blip' }, 'gc-r'),
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
const session = createFakeSession({ provider });
|
|
434
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
435
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
436
|
+
|
|
437
|
+
const events = await collectTurn(session, 'finish despite blips');
|
|
438
|
+
|
|
439
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
|
|
440
|
+
true,
|
|
441
|
+
);
|
|
442
|
+
// It backed off on each of the two blips (2 sleeps) and never bailed fatal.
|
|
443
|
+
expect(sleeps).toBe(2);
|
|
444
|
+
expect(
|
|
445
|
+
events.some(
|
|
446
|
+
(e) => e.type === 'error' && e.kind === 'fatal' && e.message.includes('retryable'),
|
|
447
|
+
),
|
|
448
|
+
).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('aborts cleanly mid back-off when the signal fires', async () => {
|
|
452
|
+
const ctrl = new AbortController();
|
|
453
|
+
// The fake sleep aborts the turn the moment the back-off begins, then
|
|
454
|
+
// resolves — mirroring a user cancellation while a retry was pending.
|
|
455
|
+
restore = __setRetrySleepForTests(async (_ms, signal) => {
|
|
456
|
+
ctrl.abort();
|
|
457
|
+
expect(signal.aborted).toBe(true);
|
|
458
|
+
});
|
|
459
|
+
const provider = new FakeProvider({
|
|
460
|
+
script: [retryableErrorReply(), toolUseReply('goal_complete', { summary: 'x' }, 'gc-a')],
|
|
461
|
+
});
|
|
462
|
+
const session = createFakeSession({ provider });
|
|
463
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
464
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
465
|
+
|
|
466
|
+
const events = await collectTurn(session, 'cancel me', { signal: ctrl.signal });
|
|
467
|
+
|
|
468
|
+
// The run stopped at an abort (not a fatal) and never completed.
|
|
469
|
+
expect(events.some((e) => e.type === 'abort' && e.reason.includes('back-off'))).toBe(true);
|
|
470
|
+
expect(events.some((e) => e.type === 'plugin_event' && e.subtype === 'goal_completed')).toBe(
|
|
471
|
+
false,
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('treats an un-compactable context overflow marked retryable as fatal (no re-send loop)', async () => {
|
|
476
|
+
let sleeps = 0;
|
|
477
|
+
restore = __setRetrySleepForTests(async () => {
|
|
478
|
+
sleeps += 1;
|
|
479
|
+
});
|
|
480
|
+
// A context-overflow error the provider marked retryable. There is nothing
|
|
481
|
+
// older to compact (the fresh session's tail is the overflow), so a retry
|
|
482
|
+
// would just re-send the identical over-budget prompt forever. The guard
|
|
483
|
+
// must bail fatal instead of looping.
|
|
484
|
+
const provider = new FakeProvider({
|
|
485
|
+
script: Array.from({ length: 10 }, () => [
|
|
486
|
+
{ type: 'message_start', model: 'fake' },
|
|
487
|
+
{
|
|
488
|
+
type: 'error',
|
|
489
|
+
message: 'prompt is too long: 250000 tokens > 200000 maximum context length',
|
|
490
|
+
retryable: true,
|
|
491
|
+
},
|
|
492
|
+
] as ProviderEvent[]),
|
|
493
|
+
});
|
|
494
|
+
const session = createFakeSession({ provider });
|
|
495
|
+
session.pluginHost.registerStatic(goalModePlugin);
|
|
496
|
+
session.modes.setActive(GOAL_MODE_NAME);
|
|
497
|
+
|
|
498
|
+
const events = await collectTurn(session, 'overflow');
|
|
499
|
+
|
|
500
|
+
// It ended fatal without ever entering the retry back-off path.
|
|
501
|
+
expect(events.some((e) => e.type === 'error' && e.kind === 'fatal')).toBe(true);
|
|
502
|
+
expect(sleeps).toBe(0);
|
|
503
|
+
// The provider was hit at most twice (initial + one reactive-compaction
|
|
504
|
+
// retry that found nothing to compact), never the 10-deep script.
|
|
505
|
+
expect(provider.received.length).toBeLessThanOrEqual(2);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
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);
|
|
526
|
+
|
|
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',
|
|
531
|
+
);
|
|
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',
|
|
538
|
+
);
|
|
539
|
+
expect(exhaustedIdx).toBeGreaterThanOrEqual(0);
|
|
540
|
+
expect(modelIdx).toBeLessThan(exhaustedIdx);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
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);
|
|
566
|
+
|
|
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',
|
|
570
|
+
);
|
|
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);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineMode, definePlugin } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
import { GOAL_MODE_NAME } from './constants.js';
|
|
4
|
+
import { goalTools } from './goal-tools.js';
|
|
5
|
+
import { runGoalMode } from './goal-loop.js';
|
|
6
|
+
|
|
7
|
+
export { GOAL_MODE_NAME } from './constants.js';
|
|
8
|
+
|
|
9
|
+
export const goalMode = defineMode({
|
|
10
|
+
name: GOAL_MODE_NAME,
|
|
11
|
+
description: 'Autonomous goal loop: works across many turns until it calls goal_complete (tools auto-approved)',
|
|
12
|
+
// Goal mode auto-approves tools and keeps working unattended, so channels
|
|
13
|
+
// surface a persistent accent badge while it's active — the user must always
|
|
14
|
+
// know the agent is driving itself.
|
|
15
|
+
badge: { label: 'GOAL', tone: 'attention' },
|
|
16
|
+
run: runGoalMode,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const goalModePlugin = definePlugin({
|
|
20
|
+
name: '@moxxy/mode-goal',
|
|
21
|
+
version: '0.0.0',
|
|
22
|
+
// The mode AND its control tools ship together: the loop watches for the
|
|
23
|
+
// tools' results to terminate, so they're useless apart.
|
|
24
|
+
modes: [goalMode],
|
|
25
|
+
tools: goalTools,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export default goalModePlugin;
|