/** * `agi_sleep` (§9.9). * * **A plain timed/source wait.** No predicate, no condition, no polling subsystem — * this was over-engineered once and deleted (settled decision D9 / R-TOOL-21c). * Waiting on an external event is: sleep, wake, one cheap check with the tools it * already has, sleep again. Checking is an ordinary turn. */ import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { StringEnum } from "@earendil-works/pi-ai"; import { type ExtensionAPI, type ExtensionContext, defineTool } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { humanDuration } from "../scheduler/notify.ts"; import { WORKER_WAIT_MS, type SleepRequest } from "../scheduler/index.ts"; import type { WorkerConfig } from "../worker/config.ts"; const SleepUntil = StringEnum(["worker", "tick", "user"]); const MAX_NOTE_CHARS = 200; export interface SleepDetails { agiSleep: { ms: number; requestedMs: number | undefined; clamped: "capped" | "floored" | undefined; until: SleepRequest["source"]; }; } export interface SleepDeps { config: (ctx: ExtensionContext) => WorkerConfig; enabled: () => boolean; /** True when the most recent `agi_note` this turn was `level: "blocked"`. */ blocked: () => boolean; /** Preserves the selected wake source until the Scheduler arms at `agent_settled`. */ requestSleep: (request: SleepRequest) => void; activeRuns: (ctx: ExtensionContext) => number; /** The wait reason already shown by the current series, if any. */ currentNote: () => string | undefined; /** True when an explicit progress note already fired in this agent run. */ noteEmitted: () => boolean; /** `agi_note`-equivalent emission for the `note` shorthand. */ emitNote: (text: string) => void; } export interface ClampResult { ms: number; clamped: "capped" | "floored" | undefined; } /** * R-TOOL-21a. Clamped to `[minSleepMs, maxSleepMs]`, and the *result states the actual * duration*. Silent clamping is the actual bug this rule prevents: an orchestrator that * asked for two hours and was given ten minutes, without being told, believes it waited * two hours and reasons from that (E64a). */ export function clampSleep(requestedMs: number, config: { minSleepMs: number; maxSleepMs: number }): ClampResult { const requested = Math.floor(requestedMs); if (requested > config.maxSleepMs) return { ms: config.maxSleepMs, clamped: "capped" }; if (requested < config.minSleepMs) return { ms: config.minSleepMs, clamped: "floored" }; return { ms: requested, clamped: undefined }; } /** `"Sleeping 10m (requested 2h, capped)."` — the exact shape R-TOOL-21a mandates. */ export function formatSleepResult(actualMs: number, requestedMs: number | undefined, clamped: ClampResult["clamped"]): string { if (clamped === undefined || requestedMs === undefined) return `Sleeping ${humanDuration(actualMs)}.`; return `Sleeping ${humanDuration(actualMs)} (requested ${humanDuration(requestedMs)}, ${clamped}).`; } export function defaultWakeSource(activeRuns: number): "worker" | "tick" { return activeRuns > 0 ? "worker" : "tick"; } export function registerSleepTool(pi: ExtensionAPI, deps: SleepDeps): void { pi.registerTool( defineTool({ name: "agi_sleep", label: "AGI Sleep", description: "End your turn and hand control back, optionally after a fixed delay or until a wake source. Use this when you are " + "waiting for a worker, a review, CI, or a deploy. It is a plain wait: there are no conditions " + "and no predicates. To wait on something external, sleep, wake, run one cheap check, and sleep " + "again. Use this tool for timed waits.", promptSnippet: "agi_sleep: end the turn, waiting for a wake source or timer", parameters: Type.Object({ ms: Type.Optional( Type.Integer({ minimum: 0, description: "Sleep this long in milliseconds, then wake. Clamped to the configured range." }), ), until: Type.Optional(SleepUntil), note: Type.Optional( Type.String({ maxLength: MAX_NOTE_CHARS, description: "What you are waiting for. Required on the first sleep of a wait." }), ), }), execute: async (_id, params, _signal, _onUpdate, ctx): Promise> => { if (!deps.enabled()) throw new Error("agi_sleep: AGI mode is not enabled."); if (deps.blocked()) { // R-TOOL-21 / E62. A blocked orchestrator that sleeps wakes still blocked // and burns a turn. Forever. The user has to answer. throw new Error( "agi_sleep: you reported blocked. State the decision you need and end your turn so the user can answer.", ); } const config = deps.config(ctx); const note = params.note?.trim(); const noteFields = note === undefined || note.length === 0 ? {} : { note }; if (note !== undefined && note.length > 0 && note !== deps.currentNote() && !deps.noteEmitted()) deps.emitNote(note); const explicitUntil = params.until === "worker" || params.until === "tick" || params.until === "user" ? params.until : undefined; // An explicit source is authoritative even when a model redundantly supplies // `ms`. Source waits are event subscriptions, not short timer fallbacks. if (explicitUntil !== undefined || params.ms === undefined) { const until = explicitUntil ?? defaultWakeSource(deps.activeRuns(ctx)); if (until === "worker" && deps.activeRuns(ctx) === 0) { throw new Error( "agi_sleep: there is no running worker to wake this wait. Resume the paused worker, stop it, or use a timer for an external condition.", ); } deps.requestSleep({ source: until, ...noteFields }); const text = until === "user" ? "Waiting for the user. Your turn is over; nothing is scheduled to wake you." : until === "worker" ? "Waiting for a worker completion or the five-minute trajectory review." : "Waiting for the next scheduled tick."; return { content: [{ type: "text", text }], details: { agiSleep: { ms: until === "worker" ? WORKER_WAIT_MS : 0, requestedMs: undefined, clamped: undefined, until } }, terminate: true, }; } const requested = params.ms; const clamp = clampSleep(requested, config); deps.requestSleep({ source: "timer", durationMs: clamp.ms, ...noteFields }); const lines = [formatSleepResult(clamp.ms, requested, clamp.clamped)]; if (note !== undefined && note.length > 0) lines.push(`Waiting for: ${note}`); return { content: [{ type: "text", text: lines.join("\n") }], details: { agiSleep: { ms: clamp.ms, requestedMs: requested, clamped: clamp.clamped, until: "timer" } }, // R-TOOL-19 / F9. Without terminate the orchestrator gets one more turn // after deciding to sleep, which it will fill with something — usually a // redundant status check. It is also what makes an in-turn // sleep→check→sleep loop structurally impossible (R-TOOL-21b, E64i). terminate: true, }; }, }), ); }