@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-loop.ts CHANGED
@@ -1,19 +1,9 @@
1
1
  import {
2
- buildSystemPromptWithSkills,
3
- collectProviderStream,
4
- createStuckLoopDetector,
5
- emitRequestsAndDetectStuck,
6
- executeToolUses,
7
- isContextOverflowError,
8
- nextBackoffMs,
9
- projectMessages,
10
- runCompactionIfNeeded,
11
- runElisionIfNeeded,
12
- sleepWithAbort,
13
- usageEventFields,
2
+ runReactLoop,
14
3
  type ModeContext,
15
4
  type MoxxyEvent,
16
5
  type PermissionResolver,
6
+ type TurnCheckpoint,
17
7
  } from '@moxxy/sdk';
18
8
 
19
9
  import { detectGoalTerminal } from './completion.js';
@@ -21,56 +11,48 @@ import {
21
11
  CONTINUE_NUDGE,
22
12
  GOAL_ABANDON_TOOL,
23
13
  GOAL_COMPLETE_TOOL,
24
- GOAL_MAX_ITERATIONS,
25
14
  GOAL_MAX_NOOP_ITERATIONS,
26
15
  GOAL_MODE_NAME,
27
16
  GOAL_PLUGIN_ID,
28
17
  GOAL_SYSTEM_PROMPT,
29
- GOAL_TOKEN_BUDGET,
30
- MAX_CONSECUTIVE_RETRIES,
31
18
  STALL_NUDGE,
19
+ STUCK_NUDGE_SUFFIX,
32
20
  } from './constants.js';
33
21
 
34
- /** Exponential back-off base/cap for the retry schedule (attempt is 1-based). */
35
- const RETRY_BACKOFF_BASE_MS = 500;
36
- const RETRY_BACKOFF_CAP_MS = 30_000;
37
-
38
- // Abort-aware sleep, injectable for tests so the back-off path runs instantly
39
- // and deterministically. Production delegates to the SDK's sleepWithAbort: a
40
- // real timer that clears (and drops its abort listener) when the signal fires,
41
- // so a pending back-off never outlives a cancelled goal run.
42
- let sleepImpl = (ms: number, signal: AbortSignal): Promise<void> => sleepWithAbort(ms, signal);
43
-
44
- /**
45
- * Override the retry back-off sleep (test seam). Returns a restore fn that
46
- * callers MUST invoke (in a `finally`) — `sleepImpl` is a module-scoped
47
- * singleton shared process-wide, so a leaked override bleeds the fake sleep
48
- * into every other turn/test running in the same worker. Test-only.
49
- */
50
- export function __setRetrySleepForTests(
51
- fn: (ms: number, signal: AbortSignal) => Promise<void>,
52
- ): () => void {
53
- const prev = sleepImpl;
54
- sleepImpl = fn;
55
- return () => {
56
- sleepImpl = prev;
57
- };
58
- }
22
+ // The retry back-off (and its test seam) lives in the SDK's shared ReAct
23
+ // core now — re-export so existing importers/tests keep working.
24
+ export { __setRetrySleepForTests } from '@moxxy/sdk';
59
25
 
60
26
  /**
61
27
  * Goal mode driver.
62
28
  *
63
- * Unlike tool-use (which returns the instant the model stops emitting tools),
64
- * goal mode treats "stopped emitting tools" as a cue to re-prompt: it keeps
65
- * the model working autonomously across iterations until the model explicitly
66
- * calls `goal_complete` (success) or `goal_abandon` (blocked). Every iteration
67
- * is guarded so the loop always terminates:
29
+ * Unlike the default mode (which returns the instant the model stops emitting
30
+ * tools), goal mode treats "stopped emitting tools" as a cue to re-prompt: it
31
+ * keeps the model working autonomously across iterations until the model
32
+ * explicitly calls `goal_complete` (success) or `goal_abandon` (blocked).
33
+ *
34
+ * Goal mode is deliberately GUARDRAIL-FREE: the user asked for an outcome and
35
+ * opted into full autonomy, so nothing heuristic may kill the run mid-delivery.
36
+ * There is no iteration cap (unless the embedder set an explicit
37
+ * `ctx.maxIterations`), no token budget, and a stuck-loop trip steers the model
38
+ * instead of aborting. The ONLY ways a run ends:
68
39
  *
69
- * - hard iteration cap (`maxIterations`)
70
- * - cumulative token budget
71
- * - stuck-loop detector (same identical-call detection as tool-use)
72
- * - no-progress detection (repeated idle iterations stall stop)
73
- * - `ctx.signal.aborted` checked every iteration and mid tool batch
40
+ * - the model calls `goal_complete` (verified success) or `goal_abandon`
41
+ * (blocked, needs the user),
42
+ * - the model goes idle {@link GOAL_MAX_NOOP_ITERATIONS} rounds in a row
43
+ * despite nudges it has decided it's done without saying so, so the run
44
+ * ends cleanly as a soft completion,
45
+ * - the user aborts (Esc / stop), or
46
+ * - a genuinely fatal condition (un-compactable context overflow, provider
47
+ * giving up after bounded retries).
48
+ *
49
+ * Goal mode is also ONE-SHOT (`transient: true` on the ModeDef): it arms for a
50
+ * single objective. When the run concludes as DONE — `goal_complete` or the
51
+ * idle soft-completion — the session hands back to the mode that was active
52
+ * before goal mode (via `ctx.requestModeSwitch`), so the user's next message is
53
+ * normal chat again. While the goal is UNFINISHED — `goal_abandon` (the model
54
+ * needs an answer to continue), a fatal error, or a user abort — the mode stays
55
+ * armed so the user's reply resumes the autonomous run.
74
56
  *
75
57
  * Tool calls are auto-approved for the whole run (the user opted into full
76
58
  * autonomy) by swapping in a resolver that replaces only the PROMPT path:
@@ -96,10 +78,9 @@ export async function* runGoalMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>
96
78
  // Auto-approve for the duration of the run — the user chose to let goal
97
79
  // mode run unattended, so nothing may ever block on an interactive prompt.
98
80
  // But ONLY the prompt is skipped: the session resolver's prompt-free
99
- // `policyCheck` (deny/allow rules from ~/.moxxy/permissions.json, then the
100
- // tool's own declared rule) is consulted first, so a user deny rule still
101
- // denies in goal mode. Anything the policy doesn't decide is allowed.
102
- // Scoped to goalCtx so it never leaks past this loop.
81
+ // `policyCheck` is consulted first, so a user deny rule still denies in
82
+ // goal mode. Anything the policy doesn't decide is allowed. Scoped to
83
+ // goalCtx so it never leaks past this loop.
103
84
  const sessionResolver = ctx.permissions;
104
85
  const autoApprove: PermissionResolver = {
105
86
  name: 'goal-auto-approve',
@@ -115,6 +96,17 @@ export async function* runGoalMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>
115
96
  permissions: autoApprove,
116
97
  };
117
98
 
99
+ // Hand the session back to whatever the user was in before arming goal mode
100
+ // — applied by the runner AFTER the turn drains, and only on clean
101
+ // completion. Called on the DONE terminals only (complete / idle
102
+ // soft-completion): an unfinished goal (abandon / fatal / abort) keeps the
103
+ // mode armed so the user's reply resumes the run.
104
+ const disarm = (): void => {
105
+ const previous = ctx.previousModeName;
106
+ const target = previous && previous !== GOAL_MODE_NAME ? previous : 'default';
107
+ ctx.requestModeSwitch?.(target);
108
+ };
109
+
118
110
  yield await ctx.emit({
119
111
  type: 'plugin_event',
120
112
  sessionId: ctx.sessionId,
@@ -122,263 +114,74 @@ export async function* runGoalMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>
122
114
  source: 'plugin',
123
115
  pluginId: GOAL_PLUGIN_ID,
124
116
  subtype: 'goal_started',
125
- payload: { autoApprove: true, maxIterations: ctx.maxIterations ?? GOAL_MAX_ITERATIONS },
117
+ payload: { autoApprove: true, maxIterations: ctx.maxIterations ?? null },
126
118
  });
127
119
 
128
- const detector = createStuckLoopDetector(ctx.loopGuard);
129
- // Coerce a caller/config-supplied bound to a positive integer. A degenerate
130
- // value (0, negative, NaN, fractional) would otherwise make the for-loop
131
- // never run and fall straight to the "iteration cap reached" fatal work was
132
- // never done, yet the message blames the model. The config schema validates
133
- // this as a positive int, but programmatic callers (subagents/workflows)
134
- // bypass that schema. Un-coercible (NaN) falls back to the default.
135
- const requestedMaxIterations = ctx.maxIterations;
136
- const maxIterations =
137
- typeof requestedMaxIterations === 'number' && Number.isFinite(requestedMaxIterations)
138
- ? Math.max(1, Math.floor(requestedMaxIterations))
139
- : GOAL_MAX_ITERATIONS;
140
- let noop = 0; // consecutive idle (no-tool) iterations
141
- let totalTokens = 0;
142
- let reactiveCompactions = 0;
143
- const MAX_REACTIVE_COMPACTIONS = 2;
144
- // Consecutive retryable-error count; reset on any clean provider call. Caps
145
- // the busy-loop a sustained retryable condition (429 / overloaded / transient
146
- // 5xx / ECONNRESET) would otherwise create — goal mode runs unattended with
147
- // auto-approval, so a tight retry loop hammers the provider with nobody
148
- // watching. The token budget can't backstop this: a request that errors
149
- // before message_end reports no usage, so totalTokens never advances.
150
- let consecutiveRetries = 0;
151
-
152
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
153
- if (ctx.signal.aborted) {
154
- yield await ctx.emit({
155
- type: 'abort',
156
- sessionId: ctx.sessionId,
157
- turnId: ctx.turnId,
158
- source: 'system',
159
- reason: 'signal aborted',
160
- });
161
- return;
162
- }
163
-
164
- yield await ctx.emit({
165
- type: 'mode_iteration',
166
- sessionId: ctx.sessionId,
167
- turnId: ctx.turnId,
168
- source: 'system',
169
- strategy: GOAL_MODE_NAME,
170
- iteration,
171
- });
172
-
173
- await runCompactionIfNeeded(goalCtx);
174
- await runElisionIfNeeded(goalCtx);
175
-
176
- // Nudge only when the model went idle last iteration (no tool calls and no
177
- // completion). After a productive iteration the tool results carry the
178
- // model forward on their own — no nudge needed.
179
- const nudge =
180
- noop === 0 ? undefined : noop >= GOAL_MAX_NOOP_ITERATIONS - 1 ? STALL_NUDGE : CONTINUE_NUDGE;
181
-
182
- const baseSystem = buildSystemPromptWithSkills(goalCtx.systemPrompt, goalCtx.skills.list()) ?? '';
183
- const { messages, stablePrefixIndex } = projectMessages(goalCtx, {
184
- ...(baseSystem ? { systemPrompt: baseSystem } : {}),
185
- ...(nudge ? { trailingUserText: nudge } : {}),
186
- });
187
-
188
- yield await ctx.emit({
189
- type: 'provider_request',
190
- sessionId: ctx.sessionId,
191
- turnId: ctx.turnId,
192
- source: 'system',
193
- provider: goalCtx.provider.name,
194
- model: goalCtx.model,
195
- });
196
-
197
- const { text, toolUses, stopReason, error, usage, reasoning } = await collectProviderStream(
198
- goalCtx,
199
- messages,
200
- {
201
- iteration,
202
- stablePrefixIndex,
203
- // The nudge is volatile — injected for this call only, never appended
204
- // to the log — so the cache strategy must keep its rolling tail
205
- // breakpoint BEFORE it. Otherwise every idle iteration caches a
206
- // prefix ending in a message that won't exist at that position next
207
- // call: a guaranteed-wasted cache write.
208
- ...(nudge ? { volatileTailCount: 1 } : {}),
209
- },
210
- );
211
-
212
- yield await ctx.emit({
213
- type: 'provider_response',
214
- sessionId: ctx.sessionId,
215
- turnId: ctx.turnId,
216
- source: 'system',
217
- provider: goalCtx.provider.name,
218
- model: goalCtx.model,
219
- ...usageEventFields(usage),
220
- });
221
-
222
- // Tally the FULL prompt of each call. Anthropic reports the cached portion
223
- // separately (`inputTokens` is only the non-cached prefix), so on a long
224
- // goal run — where the rolling cache breakpoint serves most of the prompt
225
- // as cacheRead — counting input+output alone undercounts by a large factor
226
- // and the runaway-budget backstop could be exceeded many times over before
227
- // it trips. Include both cache fields so the budget reflects real usage.
228
- if (usage)
229
- totalTokens +=
230
- (usage.inputTokens ?? 0) +
231
- (usage.cacheReadTokens ?? 0) +
232
- (usage.cacheCreationTokens ?? 0) +
233
- (usage.outputTokens ?? 0);
234
-
235
- if (error) {
236
- const overflow = isContextOverflowError(error.message);
237
- if (overflow && reactiveCompactions < MAX_REACTIVE_COMPACTIONS) {
238
- const compacted = await runCompactionIfNeeded(goalCtx, { force: true });
239
- if (compacted) {
240
- // Only count an attempt that actually compacted — a no-op (overflow
241
- // lives in the un-compactable recent tail) must not deny a later,
242
- // genuinely compactable overflow its retry.
243
- reactiveCompactions += 1;
244
- yield await ctx.emit({
245
- type: 'error',
246
- sessionId: ctx.sessionId,
247
- turnId: ctx.turnId,
248
- source: 'system',
249
- kind: 'retryable',
250
- message: 'context window exceeded — compacted older turns, retrying',
251
- });
252
- continue;
253
- }
254
- }
255
- // A context overflow that can't be compacted further is fatal regardless
256
- // of the provider's `retryable` flag: some providers mark "reduce the
257
- // length" errors retryable, but the prompt cannot shrink, so a retry just
258
- // re-sends the identical over-budget request and overflows again — a tight
259
- // loop bounded only by maxIterations (the request errors before usage is
260
- // reported, so the token budget never advances to stop it).
261
- const fatal = !error.retryable || overflow;
262
- if (fatal) {
263
- yield await ctx.emit({
264
- type: 'error',
265
- sessionId: ctx.sessionId,
266
- turnId: ctx.turnId,
267
- source: 'system',
268
- kind: 'fatal',
269
- message: `goal: ${error.message}`,
270
- });
271
- return;
272
- }
273
- // Retryable: surface it, then back off before retrying. A persistent
274
- // retryable condition (sustained 429 / outage) must NOT busy-loop the
275
- // provider — give up with a fatal error after the bounded retry count.
276
- // The counter resets on any clean provider call, so a long run can still
277
- // recover from transient blips.
278
- consecutiveRetries += 1;
279
- yield await ctx.emit({
280
- type: 'error',
281
- sessionId: ctx.sessionId,
282
- turnId: ctx.turnId,
283
- source: 'system',
284
- kind: 'retryable',
285
- message: `goal: ${error.message}`,
286
- });
287
- if (consecutiveRetries >= MAX_CONSECUTIVE_RETRIES) {
288
- yield await ctx.emit({
289
- type: 'error',
290
- sessionId: ctx.sessionId,
291
- turnId: ctx.turnId,
292
- source: 'system',
293
- kind: 'fatal',
294
- message:
295
- `goal: provider kept returning a retryable error ${consecutiveRetries} times in a row ` +
296
- `(last: ${error.message}); giving up rather than hammering the provider.`,
297
- });
298
- return;
299
- }
300
- await sleepImpl(
301
- nextBackoffMs(consecutiveRetries, RETRY_BACKOFF_BASE_MS, RETRY_BACKOFF_CAP_MS),
302
- ctx.signal,
303
- );
304
- if (ctx.signal.aborted) {
305
- yield await ctx.emit({
306
- type: 'abort',
120
+ // The model idled without calling goal_complete: nudge it back to work with
121
+ // a volatile trailing prompt (this call only never appended to the log).
122
+ // After GOAL_MAX_NOOP_ITERATIONS consecutive idle rounds the model has
123
+ // clearly decided it's done without declaring it end the run cleanly as a
124
+ // soft completion (and disarm) rather than spin forever.
125
+ const idleNudge: TurnCheckpoint = {
126
+ name: 'goal-idle',
127
+ gateOn: 'idle',
128
+ run: async (check) => {
129
+ if (check.consecutiveIdle >= GOAL_MAX_NOOP_ITERATIONS) {
130
+ await goalCtx.emit({
131
+ type: 'plugin_event',
307
132
  sessionId: ctx.sessionId,
308
133
  turnId: ctx.turnId,
309
- source: 'system',
310
- reason: 'signal aborted during retry back-off',
134
+ source: 'plugin',
135
+ pluginId: GOAL_PLUGIN_ID,
136
+ subtype: 'goal_stalled',
137
+ payload: { idleIterations: check.consecutiveIdle, iteration: check.iteration },
311
138
  });
312
- return;
313
- }
314
- continue;
315
- }
316
- // Clean provider call — reset the overflow-recovery + retry budgets.
317
- reactiveCompactions = 0;
318
- consecutiveRetries = 0;
319
-
320
- // Finalize the reasoning summary for THIS call before any exit decision or
321
- // tool/assistant emit, so the log order is reasoning → tool_use → text
322
- // (projection attaches the signed thinking block as content[0] of the same
323
- // turn). Emitting it ahead of the budget backstop keeps every exit path
324
- // consistent — the budget-exhausting call's reasoning is logged just like
325
- // any other call's, rather than being silently dropped at this exit.
326
- if (reasoning) {
327
- yield await ctx.emit({
328
- type: 'reasoning_message',
329
- sessionId: ctx.sessionId,
330
- turnId: ctx.turnId,
331
- source: 'model',
332
- content: reasoning.text,
333
- ...(reasoning.signature ? { signature: reasoning.signature } : {}),
334
- ...(reasoning.redacted ? { redacted: true } : {}),
335
- ...(reasoning.encrypted ? { encrypted: reasoning.encrypted } : {}),
336
- });
337
- }
338
-
339
- // Token budget backstop (alongside the iteration cap).
340
- if (totalTokens > GOAL_TOKEN_BUDGET) {
341
- // Persist the budget-exhausting call's assistant text before exiting,
342
- // just like every productive iteration does (the assistant emit below
343
- // this block is skipped by the `return`). Otherwise the model's last
344
- // words vanish from the log, so a resume ("continue from here") loses
345
- // that context. We do NOT execute its tool calls — the run is stopping.
346
- if (text || stopReason === 'end_turn' || toolUses.length === 0) {
347
- yield await ctx.emit({
139
+ await goalCtx.emit({
348
140
  type: 'assistant_message',
349
141
  sessionId: ctx.sessionId,
350
142
  turnId: ctx.turnId,
351
- source: 'model',
352
- content: text,
353
- stopReason,
143
+ source: 'system',
144
+ content:
145
+ 'Goal run ended: the model stopped working without calling `goal_complete` — ' +
146
+ 'it likely considers the goal done. Review the work above; if something is ' +
147
+ 'missing, describe it in your next message.',
148
+ stopReason: 'end_turn',
354
149
  });
150
+ disarm();
151
+ return { action: 'stop' };
355
152
  }
356
- yield await ctx.emit({
357
- type: 'plugin_event',
358
- sessionId: ctx.sessionId,
359
- turnId: ctx.turnId,
360
- source: 'plugin',
361
- pluginId: GOAL_PLUGIN_ID,
362
- subtype: 'goal_budget_exhausted',
363
- payload: { totalTokens, budget: GOAL_TOKEN_BUDGET, iteration },
364
- });
365
- yield await ctx.emit({
366
- type: 'assistant_message',
367
- sessionId: ctx.sessionId,
368
- turnId: ctx.turnId,
369
- source: 'system',
370
- content:
371
- `Goal mode stopped: token budget exhausted (${totalTokens.toLocaleString()} > ` +
372
- `${GOAL_TOKEN_BUDGET.toLocaleString()}) before the goal was completed. ` +
373
- `Send another message to continue from here.`,
374
- stopReason: 'end_turn',
375
- });
376
- return;
377
- }
153
+ return {
154
+ action: 'inject',
155
+ volatile: true,
156
+ text: check.consecutiveIdle >= GOAL_MAX_NOOP_ITERATIONS - 1 ? STALL_NUDGE : CONTINUE_NUDGE,
157
+ };
158
+ },
159
+ };
378
160
 
379
- const stuck = yield* emitRequestsAndDetectStuck(ctx, toolUses, detector, {
380
- abortedResultMessage: 'goal mode aborted (stuck pattern) before this call ran',
161
+ yield* runReactLoop(goalCtx, {
162
+ strategyName: GOAL_MODE_NAME,
163
+ // No iteration cap: a goal run ends via its terminals, never because a
164
+ // counter ran out mid-delivery. An explicit ctx.maxIterations (set by a
165
+ // programmatic embedder) still takes precedence inside runReactLoop.
166
+ defaultMaxIterations: Number.POSITIVE_INFINITY,
167
+ errorPrefix: 'goal: ',
168
+ checkpoints: [idleNudge],
169
+ // The idle checkpoint stops itself at GOAL_MAX_NOOP_ITERATIONS consecutive
170
+ // idles, before this backstop can trip — it exists so a future checkpoint
171
+ // bug degrades loudly instead of looping. (The core resets the budget
172
+ // whenever the model does tool work, so spread-out idle rounds across a
173
+ // long run never exhaust it.)
174
+ maxInjections: GOAL_MAX_NOOP_ITERATIONS,
175
+ stuck: {
176
+ // Never abort an unattended run on a repetition heuristic — the repeats
177
+ // are often legitimate (re-running a failing build between edits).
178
+ // Steer instead: visible warning + a volatile nudge on the next call.
179
+ action: 'nudge',
381
180
  nearHint: 'against the same target (only volatile args varied)',
181
+ nudgeText: ({ toolName, count, how }) =>
182
+ `You have called the tool \`${toolName}\` ${count} times ${how}. Repeating the same ` +
183
+ `call will not produce a different result. Step back, reassess, and take a DIFFERENT ` +
184
+ `next action toward the goal. ${STUCK_NUDGE_SUFFIX}`,
382
185
  extraOnStuck: ({ toolName, count, kind }) => [
383
186
  {
384
187
  type: 'plugin_event',
@@ -390,130 +193,100 @@ export async function* runGoalMode(ctx: ModeContext): AsyncIterable<MoxxyEvent>
390
193
  payload: { tool: toolName, count, kind },
391
194
  },
392
195
  ],
393
- fatalMessage: ({ toolName, count, how }) =>
394
- `goal mode aborted stuck pattern: tool "${toolName}" called ${count} times ${how}. ` +
395
- `The model is looping on the same call; send another message to redirect it.`,
396
- });
397
- if (stuck) return;
398
-
399
- if (text || stopReason === 'end_turn' || toolUses.length === 0) {
400
- yield await ctx.emit({
401
- type: 'assistant_message',
402
- sessionId: ctx.sessionId,
403
- turnId: ctx.turnId,
404
- source: 'model',
405
- content: text,
406
- stopReason,
407
- });
408
- }
409
-
410
- if (toolUses.length === 0) {
411
- // The model idled without calling goal_complete. Count it; nudge next
412
- // iteration. After enough idle rounds, stop rather than spin forever.
413
- noop += 1;
414
- if (noop >= GOAL_MAX_NOOP_ITERATIONS) {
415
- yield await ctx.emit({
196
+ },
197
+ onToolBatchEnd: async (loopCtx, { toolUses, iteration }) => {
198
+ // Did this batch end the run? (goal_complete / goal_abandon, confirmed
199
+ // via a successful tool_result in the log.) Only materialise the log
200
+ // (an O(n) copy of the ever-growing append-only log) when the batch
201
+ // actually used a goal tool — otherwise this ran on every productive
202
+ // iteration, O(n²) per run.
203
+ const hasGoalTool = toolUses.some(
204
+ (t) => t.name === GOAL_COMPLETE_TOOL || t.name === GOAL_ABANDON_TOOL,
205
+ );
206
+ const terminal = hasGoalTool ? detectGoalTerminal(loopCtx.log.slice(), toolUses) : null;
207
+ if (terminal?.kind === 'complete') {
208
+ await loopCtx.emit({
416
209
  type: 'plugin_event',
417
210
  sessionId: ctx.sessionId,
418
211
  turnId: ctx.turnId,
419
212
  source: 'plugin',
420
213
  pluginId: GOAL_PLUGIN_ID,
421
- subtype: 'goal_stalled',
422
- payload: { idleIterations: noop, iteration },
214
+ subtype: 'goal_completed',
215
+ payload: {
216
+ summary: terminal.summary,
217
+ evidenceCount: terminal.evidence.length,
218
+ iterations: iteration,
219
+ },
220
+ });
221
+ const evidenceBlock =
222
+ terminal.evidence.length > 0
223
+ ? `\n\n${terminal.evidence.map((e) => `- ${e}`).join('\n')}`
224
+ : '';
225
+ await loopCtx.emit({
226
+ type: 'assistant_message',
227
+ sessionId: ctx.sessionId,
228
+ turnId: ctx.turnId,
229
+ source: 'system',
230
+ content: `✓ Goal complete — ${terminal.summary}${evidenceBlock}`,
231
+ stopReason: 'end_turn',
232
+ });
233
+ disarm();
234
+ return { action: 'stop' };
235
+ }
236
+ if (terminal?.kind === 'abandon') {
237
+ await loopCtx.emit({
238
+ type: 'plugin_event',
239
+ sessionId: ctx.sessionId,
240
+ turnId: ctx.turnId,
241
+ source: 'plugin',
242
+ pluginId: GOAL_PLUGIN_ID,
243
+ subtype: 'goal_abandoned',
244
+ payload: {
245
+ reason: terminal.reason,
246
+ ...(terminal.needsFromUser ? { needsFromUser: terminal.needsFromUser } : {}),
247
+ iterations: iteration,
248
+ },
423
249
  });
424
- yield await ctx.emit({
250
+ const needs = terminal.needsFromUser ? `\n\nNeeds from you: ${terminal.needsFromUser}` : '';
251
+ await loopCtx.emit({
425
252
  type: 'assistant_message',
426
253
  sessionId: ctx.sessionId,
427
254
  turnId: ctx.turnId,
428
255
  source: 'system',
429
256
  content:
430
- 'Goal mode stopped: the model went idle without calling `goal_complete`. ' +
431
- 'It may believe the goal is done — review the work above, and send another message to continue if not.',
257
+ `Goal abandoned ${terminal.reason}${needs}\n\n` +
258
+ `Goal mode stays armed: your reply resumes the autonomous run.`,
432
259
  stopReason: 'end_turn',
433
260
  });
434
- return;
261
+ // Deliberately NOT disarming: the model needs something from the user
262
+ // and their reply should resume the autonomous run.
263
+ return { action: 'stop' };
435
264
  }
436
- continue;
437
- }
438
- noop = 0;
439
-
440
- const exited = yield* executeToolUses(goalCtx, toolUses, iteration);
441
- if (exited) return;
442
-
443
- // Did this batch end the run? (goal_complete / goal_abandon, confirmed via
444
- // a successful tool_result in the log.) Only materialise the log (an O(n)
445
- // copy of the ever-growing append-only log) when the batch actually used a
446
- // goal tool — otherwise this ran on every productive iteration, O(n²) per
447
- // run. detectGoalTerminal itself early-returns null on an empty batch, so a
448
- // batch with no goal tools needs no log at all.
449
- const hasGoalTool = toolUses.some(
450
- (t) => t.name === GOAL_COMPLETE_TOOL || t.name === GOAL_ABANDON_TOOL,
451
- );
452
- const terminal = hasGoalTool ? detectGoalTerminal(ctx.log.slice(), toolUses) : null;
453
- if (terminal?.kind === 'complete') {
454
- yield await ctx.emit({
455
- type: 'plugin_event',
456
- sessionId: ctx.sessionId,
457
- turnId: ctx.turnId,
458
- source: 'plugin',
459
- pluginId: GOAL_PLUGIN_ID,
460
- subtype: 'goal_completed',
461
- payload: { summary: terminal.summary, evidenceCount: terminal.evidence.length, iterations: iteration },
462
- });
463
- const evidenceBlock =
464
- terminal.evidence.length > 0 ? `\n\n${terminal.evidence.map((e) => `- ${e}`).join('\n')}` : '';
465
- yield await ctx.emit({
466
- type: 'assistant_message',
467
- sessionId: ctx.sessionId,
468
- turnId: ctx.turnId,
469
- source: 'system',
470
- content: `✓ Goal complete — ${terminal.summary}${evidenceBlock}`,
471
- stopReason: 'end_turn',
472
- });
473
- return;
474
- }
475
- if (terminal?.kind === 'abandon') {
476
- yield await ctx.emit({
265
+ return undefined;
266
+ },
267
+ onMaxIterations: async (loopCtx, maxIterations) => {
268
+ // Only reachable when an embedder set an explicit ctx.maxIterations —
269
+ // goal mode itself is uncapped.
270
+ await loopCtx.emit({
477
271
  type: 'plugin_event',
478
272
  sessionId: ctx.sessionId,
479
273
  turnId: ctx.turnId,
480
274
  source: 'plugin',
481
275
  pluginId: GOAL_PLUGIN_ID,
482
- subtype: 'goal_abandoned',
483
- payload: { reason: terminal.reason, ...(terminal.needsFromUser ? { needsFromUser: terminal.needsFromUser } : {}), iterations: iteration },
276
+ subtype: 'goal_max_iterations',
277
+ payload: { maxIterations },
484
278
  });
485
- const needs = terminal.needsFromUser ? `\n\nNeeds from you: ${terminal.needsFromUser}` : '';
486
- yield await ctx.emit({
487
- type: 'assistant_message',
279
+ await loopCtx.emit({
280
+ type: 'error',
488
281
  sessionId: ctx.sessionId,
489
282
  turnId: ctx.turnId,
490
283
  source: 'system',
491
- content: `Goal abandoned — ${terminal.reason}${needs}`,
492
- stopReason: 'end_turn',
284
+ kind: 'fatal',
285
+ message:
286
+ `goal mode reached the configured iteration cap (${maxIterations}) without calling ` +
287
+ `goal_complete. Send another message to continue.`,
493
288
  });
494
- return;
495
- }
496
- }
497
-
498
- // Iteration cap hit without the model declaring done.
499
- yield await ctx.emit({
500
- type: 'plugin_event',
501
- sessionId: ctx.sessionId,
502
- turnId: ctx.turnId,
503
- source: 'plugin',
504
- pluginId: GOAL_PLUGIN_ID,
505
- subtype: 'goal_max_iterations',
506
- payload: { maxIterations },
507
- });
508
- yield await ctx.emit({
509
- type: 'error',
510
- sessionId: ctx.sessionId,
511
- turnId: ctx.turnId,
512
- source: 'system',
513
- kind: 'fatal',
514
- message:
515
- `goal mode reached the iteration cap (${maxIterations}) without calling goal_complete. ` +
516
- `Stopping to avoid an unbounded run; send another message to continue.`,
289
+ },
517
290
  });
518
291
  }
519
292