Skip to content
Merged
15 changes: 10 additions & 5 deletions packages/core/src/skills/bundled/loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,24 @@ Examples:
Use this path only when the user supplied a prompt and no interval.

1. Do not call CronCreate for this path.
2. Run the parsed prompt immediately now.
2. If this tick opens with a `<task-notification>` block (a monitor or background event re-invoked you, not a bare `/loop` wakeup prompt), handle that event before re-running the prompt.
- If the notification says the watched condition was met, finish the loop.
- If a monitor auto-stopped on idle or max-events, restart it once if the watch is still useful, re-arm the fallback, and report the restart count to the user. If it auto-stops again on the next tick, end the loop and report the repeated auto-stop to the user.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The "restart it once" guidance assumes the model can (a) track that it already restarted across ticks 1200–1800s apart, and (b) reconstruct the original monitor command. Both are fragile:

  • State persistence: the restart count lives only in conversation memory. Over 1200–1800s ticks, context compaction can erase it, causing the model to either restart indefinitely or end the loop prematurely. Consider encoding the count in the LoopWakeup reason field or the continuation prompt so it survives compaction.
  • Command availability: the terminal <task-notification> XML emitted by monitorRegistry.ts (emitTerminalNotification) includes <task-id>, <status>, <event-count>, and <summary> — but not the original command. The <summary> contains only the truncated description (≤80 chars), not a runnable command. After context compaction, the model may not be able to reconstruct the exact command to restart.

Consider adding the original command to the terminal notification XML so the model has it available for restart without relying on conversation history.

— qwen3.7-max via Qwen Code /review

- If the signal is ambiguous, re-arm a shorter follow-up and investigate on the next tick.
3. Run the parsed prompt immediately now.
- If it is a slash command, invoke it via the Skill tool.
- Otherwise, act on it directly.
3. Before ending the turn, decide whether another check is useful.
4. Before ending the turn, decide whether another check is useful.
- Call LoopWakeup only if continued follow-up is useful.
- Do not call LoopWakeup if the task is complete.
- Do not call LoopWakeup if the task is blocked on user input or external state that cannot be checked later.
- Do not call LoopWakeup just to keep polling when no useful next check exists.
4. When scheduling a continuation, call LoopWakeup with:
- `delaySeconds`: the next useful delay in seconds. The runtime clamps to 60–3600 (1–60 min); follow the tool's own guidance on picking a value (it accounts for the prompt-cache window).
- If you started a background agent or a Monitor, it wakes you via a terminal `<task-notification>` on exit, failure, cancellation, or monitor auto-stop — so set LoopWakeup as a long fallback rather than a short poll. Do not omit it just because something is watching: the work may hang, or a Monitor may auto-stop on idle or max-events (and one owned by another agent routes its notification only to that agent). Omit LoopWakeup only on the terminal conditions above (complete, blocked, or repeated monitor auto-stop).
5. When scheduling a continuation, call LoopWakeup with:
- `delaySeconds`: the next useful delay in seconds. The runtime clamps to 60–3600 (1–60 min); follow the tool's own guidance on picking a value — it accounts for the prompt-cache window and for the fallback-heartbeat case when a background task will wake you.
- `prompt`: `/loop ${original prompt}`
- `reason`: a short reason for the chosen delay.
5. Briefly tell the user what was done now. If a wakeup was scheduled, include when the next check is expected. If no wakeup was scheduled, say the loop is complete or not continuing.
6. Briefly tell the user what was done now. If a wakeup was scheduled, include when the next check is expected. If no wakeup was scheduled, say the loop is complete or not continuing.

## Fixed-interval recurring path

Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/skills/bundled/loop/SKILL.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ describe('bundled loop skill', () => {
expect(body).not.toContain('delayMinutes');
});

it('teaches the self-paced loop to lean on monitor/background-task notifications', () => {
const { body } = loadLoopSkill();

expect(body).toContain('<task-notification>');
expect(body).toContain('set LoopWakeup as a long fallback');
expect(body).toContain('auto-stop on idle or max-events');
expect(body).toContain('handle that event before re-running the prompt');
expect(body).toContain('terminal `<task-notification>`');
expect(body).not.toContain('per stdout line');
expect(body).toContain(
'If the notification says the watched condition was met',
);
expect(body).toContain('If a monitor auto-stopped');
expect(body).toContain('restart it once');
expect(body).toContain('report the repeated auto-stop to the user');
expect(body).toContain('report the restart count');
expect(body).toContain('If the signal is ambiguous');
expect(body).toContain('Do not omit it just because something is watching');
expect(body).toContain('the work may hang');
expect(body).toContain(
'one owned by another agent routes its notification only to that agent',
);
expect(body).toContain('repeated monitor auto-stop');
expect(body).toContain('not a bare `/loop` wakeup prompt');
});

it('keeps fixed-interval inputs on the recurring cron path', () => {
const { body } = loadLoopSkill();

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/tools/loop-wakeup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ describe('LoopWakeupTool', () => {
expect(tool.name).toBe('loop_wakeup');
});

it('documents the fallback-heartbeat semantics for monitor/background work', () => {
expect(tool.description).toContain('fallback heartbeat');
expect(tool.description).toContain('<task-notification>');
expect(tool.description).toContain('terminal `<task-notification>`');
expect(tool.description).not.toContain('per stdout line');
const params = tool.schema.parametersJsonSchema as {
properties: { delaySeconds: { description: string } };
};
const delay = params.properties.delaySeconds.description;
// Both sides of the rule: long fallback when something else wakes you,
// short poll only when you are the sole watcher.
expect(delay).toContain('1200-1800s');
expect(delay).toContain('60-270s');
expect(delay).toContain('Monitor');
});

it('uses ask permission because it schedules future model input', async () => {
const invocation = tool.build({
delaySeconds: 300,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tools/loop-wakeup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ export class LoopWakeupTool extends BaseDeclarativeTool<
super(
LoopWakeupTool.Name,
ToolDisplayNames.LOOP_WAKEUP,
'Schedule when to resume work in a self-paced loop iteration (always pass the `prompt` arg). Call this before ending the turn to keep the loop alive; omit the call to end the loop. Session-only and one-shot — it does not persist or recur. A self-paced wakeup chain may run for at most 24h.',
'Schedule when to resume work in a self-paced loop iteration (always pass the `prompt` arg). Call this before ending the turn to keep the loop alive; omit the call to end the loop. Session-only and one-shot — it does not persist or recur. A self-paced wakeup chain may run for at most 24h. When a background task you started will wake you on its own — a backgrounded agent or a Monitor sends a terminal `<task-notification>` on exit, failure, cancellation, or monitor auto-stop — keep this wakeup as a long fallback heartbeat rather than a poll; see `delaySeconds`.',
Kind.Other,
{
type: 'object',
properties: {
delaySeconds: {
type: 'number',
description: `Seconds from now to wake up. Clamped to [${WAKEUP_MIN_SECONDS}, ${WAKEUP_MAX_SECONDS}]. Prefer 60-270s for fast-changing state, 1200s+ when there is no reason to check sooner.`,
description: `Seconds from now to wake up. Clamped to [${WAKEUP_MIN_SECONDS}, ${WAKEUP_MAX_SECONDS}]. Use 60-270s only when actively polling external state that nothing else reports (a CI run, a remote queue) — staying inside the ~5-min prompt-cache window. When a background task you started will wake you via a \`<task-notification>\` once it finishes, that is the real wake signal — use 1200-1800s here as a fallback for when it never arrives (the task hangs, a Monitor auto-stops on idle or max-events, or another agent owns it). With no specific signal to watch, default to 1200s+.`,
},
prompt: {
type: 'string',
Expand Down
Loading