diff --git a/packages/core/src/services/monitorRegistry.test.ts b/packages/core/src/services/monitorRegistry.test.ts index 42009218f1d..4526e2b4d7b 100644 --- a/packages/core/src/services/monitorRegistry.test.ts +++ b/packages/core/src/services/monitorRegistry.test.ts @@ -279,7 +279,7 @@ describe('MonitorRegistry', () => { it('completes a monitor and emits terminal notification', () => { const callback = vi.fn(); registry.setNotificationCallback(callback); - registry.register(createEntry()); + registry.register(createEntry({ command: 'grep "a&b" < /dev/null' })); registry.complete('mon-1', 0); @@ -290,6 +290,9 @@ describe('MonitorRegistry', () => { const [displayText, modelText] = callback.mock.calls[0] as [string, string]; expect(displayText).toContain('completed'); expect(modelText).toContain('completed'); + expect(modelText).toContain( + 'grep "a&b" < /dev/null', + ); expect(modelText).toContain('Exited with code 0'); }); diff --git a/packages/core/src/services/monitorRegistry.ts b/packages/core/src/services/monitorRegistry.ts index 77262747580..01e4fdcd49b 100644 --- a/packages/core/src/services/monitorRegistry.ts +++ b/packages/core/src/services/monitorRegistry.ts @@ -562,6 +562,7 @@ export class MonitorRegistry { `${escapeXml(entry.status)}`, `${entry.eventCount}`, `Monitor "${escapeXml(desc)}" ${statusText}. Total events: ${entry.eventCount}.${entry.droppedLines > 0 ? ` ${entry.droppedLines} lines dropped due to throttling.` : ''}`, + `${escapeXml(stripDisplayControlChars(entry.command))}`, ); if (detail) { xmlParts.push( diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md index 761ffa09685..26a04be7fca 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.md +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -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 `` 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, cancel any pending fallback LoopWakeup with CronDelete if you still have its ID, then 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, report the restart count to the user, and include that count in the LoopWakeup prompt or reason (for example, `monitor restarted 1/1 time`) so it survives context compaction. If it auto-stops again on the next tick, end the loop and report the repeated auto-stop to the user. + - If the signal is ambiguous, re-arm a shorter follow-up and investigate on the next tick. If the signal remains ambiguous for three consecutive ticks, end the loop and report that the watch could not reach a clear conclusion. +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). - - `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. + - If you started a background agent or a Monitor, it wakes you via a terminal `` 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}` plus any state the next tick must preserve, such as `monitor restarted 1/1 time`. + - `reason`: a short reason for the chosen delay. Include the monitor restart count here when re-arming after an auto-stop. +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 because a notification ended the loop, mention whether the stale fallback was cancelled; if the wakeup ID was lost, ignore or answer the stale wakeup briefly when it fires. ## Fixed-interval recurring path diff --git a/packages/core/src/skills/bundled/loop/SKILL.test.ts b/packages/core/src/skills/bundled/loop/SKILL.test.ts index cabaefaa2b8..feafafc0539 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.test.ts +++ b/packages/core/src/skills/bundled/loop/SKILL.test.ts @@ -47,6 +47,38 @@ 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(''); + 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 ``'); + expect(body).not.toContain('per stdout line'); + expect(body).toContain( + 'If the notification says the watched condition was met', + ); + expect(body).toContain('cancel any pending fallback LoopWakeup'); + expect(body).toContain('CronDelete'); + expect(body).toContain('If a monitor auto-stopped'); + expect(body).toContain('restart it once'); + expect(body).toContain('monitor restarted 1/1 time'); + expect(body).toContain('so it survives context compaction'); + 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('three consecutive ticks'); + 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'); + expect(body).toContain('stale fallback was cancelled'); + }); + it('keeps fixed-interval inputs on the recurring cron path', () => { const { body } = loadLoopSkill(); diff --git a/packages/core/src/tools/loop-wakeup.test.ts b/packages/core/src/tools/loop-wakeup.test.ts index 525be3a8f47..e792d957ea1 100644 --- a/packages/core/src/tools/loop-wakeup.test.ts +++ b/packages/core/src/tools/loop-wakeup.test.ts @@ -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(''); + expect(tool.description).toContain('terminal ``'); + 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, diff --git a/packages/core/src/tools/loop-wakeup.ts b/packages/core/src/tools/loop-wakeup.ts index d870ff488ce..73fb0f728df 100644 --- a/packages/core/src/tools/loop-wakeup.ts +++ b/packages/core/src/tools/loop-wakeup.ts @@ -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 `` 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 \`\` 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',