diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 057754cd9ef8..42ed0b945003 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -8,6 +8,7 @@ import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" import PROMPT_INITIALIZE from "./template/initialize.txt" +import PROMPT_LOOP from "./template/loop.txt" import PROMPT_REVIEW from "./template/review.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" @@ -45,6 +46,8 @@ export function hints(template: string) { export const Default = { INIT: "init", + LOOP: "loop", + PROACTIVE: "proactive", REVIEW: "review", } as const @@ -86,6 +89,24 @@ const layer = Layer.effect( subtask: true, hints: hints(PROMPT_REVIEW), } + commands[Default.LOOP] = { + name: Default.LOOP, + description: "run a prompt repeatedly until it finishes or you stop it", + source: "command", + get template() { + return PROMPT_LOOP + }, + hints: ["$ARGUMENTS"], + } + commands[Default.PROACTIVE] = { + name: Default.PROACTIVE, + description: "alias for /loop", + source: "command", + get template() { + return PROMPT_LOOP + }, + hints: ["$ARGUMENTS"], + } for (const [name, command] of Object.entries(cfg.command ?? {})) { commands[name] = { diff --git a/packages/opencode/src/command/template/loop.txt b/packages/opencode/src/command/template/loop.txt new file mode 100644 index 000000000000..69d8ea053b6a --- /dev/null +++ b/packages/opencode/src/command/template/loop.txt @@ -0,0 +1,8 @@ +Run a prompt repeatedly while the session stays open. + +Usage: +- `/loop ` starts a self-paced loop. +- `/loop 5m ` starts a scheduled loop. +- `/loop stop` stops the active loop for the session. + +If no prompt is provided, use `.claude/loop.md` from the project when present. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..0a4ac6d551bb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -81,6 +81,22 @@ IMPORTANT: const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` +const LOOP_CONTINUE_MARKER = "" +const LOOP_STOP_MARKER = "" +const LOOP_SELF_PACED_DELAY_MS = 1000 + +type LoopCommandParsed = + | { action: "stop" } + | { + action: "start" + intervalMs?: number + prompt: string + } + +type LoopState = { + stop: () => void +} + function mcpResourceBase64Size(value: string) { const trimmed = value.replace(/\s/g, "") const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0 @@ -151,9 +167,180 @@ const layer = Layer.effect( const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { yield* Effect.logInfo("cancel", { "session.id": sessionID }) + stopLoop(sessionID) yield* state.cancel(sessionID) }) + const activeLoops = new Map() + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const loop of activeLoops.values()) loop.stop() + activeLoops.clear() + }), + ) + + const createSyntheticReply = Effect.fn("SessionPrompt.createSyntheticReply")(function* (input: { + sessionID: SessionID + userText: string + assistantText: string + agent: string + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + variant?: string + }) { + const ctx = yield* InstanceState.context + const userMsg: SessionV1.User = { + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: input.agent, + model: { + providerID: input.model.providerID, + modelID: input.model.modelID, + ...(input.variant ? { variant: input.variant } : {}), + }, + } + yield* sessions.updateMessage(userMsg) + yield* sessions.updatePart({ + id: PartID.ascending(), + type: "text", + messageID: userMsg.id, + sessionID: input.sessionID, + text: input.userText, + }) + + const assistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: input.sessionID, + parentID: userMsg.id, + mode: input.agent, + agent: input.agent, + cost: 0, + path: { cwd: ctx.directory, root: ctx.worktree }, + time: { created: Date.now(), completed: Date.now() }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: input.model.modelID, + providerID: input.model.providerID, + ...(input.variant ? { variant: input.variant } : {}), + finish: "stop", + } + yield* sessions.updateMessage(assistant) + const part: SessionV1.TextPart = { + id: PartID.ascending(), + type: "text", + messageID: assistant.id, + sessionID: input.sessionID, + text: input.assistantText, + } + yield* sessions.updatePart(part) + return { info: assistant, parts: [part] } + }) + + const stopLoop = (sessionID: SessionID) => { + const loop = activeLoops.get(sessionID) + if (!loop) return false + loop.stop() + activeLoops.delete(sessionID) + return true + } + + const readLoopPrompt = Effect.fn("SessionPrompt.readLoopPrompt")(function* () { + const ctx = yield* InstanceState.context + const filepath = path.join(ctx.worktree, ".claude", "loop.md") + const exists = yield* fsys.exists(filepath).pipe(Effect.orDie) + if (!exists) return undefined + const content = (yield* fsys.readFileString(filepath).pipe(Effect.orDie)).trim() + return content || undefined + }) + + const normalizeLoopResult = Effect.fn("SessionPrompt.normalizeLoopResult")(function* (result: SessionV1.WithParts) { + const textPart = result.parts.findLast((part): part is SessionV1.TextPart => part.type === "text") + if (!textPart) return { continueLoop: false, result } + const parsed = extractLoopControl(textPart.text) + if (parsed.text === textPart.text) return { continueLoop: false, result } + textPart.text = parsed.text + yield* sessions.updatePart(textPart) + return { continueLoop: parsed.action === "continue", result } + }) + + const loopStep = Effect.fn("SessionPrompt.loopStep")(function* (input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + variant?: string + promptText: string + parts?: CommandInput["parts"] + }) { + const result = yield* prompt({ + sessionID: input.sessionID, + messageID: MessageID.ascending(), + agent: input.agent, + model: input.model, + variant: input.variant, + parts: [{ type: "text", text: input.promptText }, ...(input.parts ?? [])], + }) + return yield* normalizeLoopResult(result) + }) + + const startLoop = Effect.fn("SessionPrompt.startLoop")(function* (input: { + sessionID: SessionID + agent: string + model: { providerID: ProviderV2.ID; modelID: ModelV2.ID } + variant?: string + promptText: string + intervalMs?: number + }) { + stopLoop(input.sessionID) + let stopped = false + + const delay = input.intervalMs ?? LOOP_SELF_PACED_DELAY_MS + + const fiber = yield* Effect.gen(function* () { + while (!stopped) { + const exit = yield* loopStep({ + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + variant: input.variant, + promptText: input.promptText, + }).pipe(Effect.exit) + + if (stopped) return + + if (Exit.isSuccess(exit)) { + if (!exit.value.continueLoop) { + stopLoop(input.sessionID) + return + } + } else { + const error = Cause.squash(exit.cause) + if (error instanceof Session.BusyError) { + // continue waiting + } else { + stopLoop(input.sessionID) + yield* events.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ + message: error instanceof Error ? error.message : String(error), + }).toObject(), + }) + return + } + } + + yield* Effect.sleep(delay) + } + }).pipe(Effect.forkIn(scope)) + + activeLoops.set(input.sessionID, { + stop: () => { + stopped = true + }, + }) + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: Types.DeepMutable = [{ type: "text", text: template }] @@ -1367,7 +1554,60 @@ const layer = Layer.effect( yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const agentName = cmd.agent ?? input.agent + const agentName = cmd.agent ?? input.agent ?? (yield* agents.defaultInfo()).name + + if (input.command === Command.Default.LOOP || input.command === Command.Default.PROACTIVE) { + const taskModel = input.model ? Provider.parseModel(input.model) : yield* currentModel(input.sessionID) + yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) + const parsed = parseLoopCommandArguments(input.arguments) + if (parsed.action === "stop") { + const stopped = stopLoop(input.sessionID) + const result = yield* createSyntheticReply({ + sessionID: input.sessionID, + userText: `/${input.command} stop`, + assistantText: stopped ? "Stopped the active loop." : "No active loop is running for this session.", + agent: agentName, + model: taskModel, + variant: input.variant, + }).pipe(Effect.orDie) + yield* events.publish(Command.Event.Executed, { + name: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: result.info.id, + }) + return result + } + + const loopPrompt = parsed.prompt || (yield* readLoopPrompt().pipe(Effect.orDie)) || DEFAULT_LOOP_PROMPT + const first = yield* loopStep({ + sessionID: input.sessionID, + agent: agentName, + model: taskModel, + variant: input.variant, + promptText: loopPrompt, + parts: input.parts, + }).pipe(Effect.orDie) + if (first.continueLoop) { + yield* startLoop({ + sessionID: input.sessionID, + agent: agentName, + model: taskModel, + variant: input.variant, + promptText: loopPrompt, + intervalMs: parsed.intervalMs, + }).pipe(Effect.orDie) + } else { + stopLoop(input.sessionID) + } + yield* events.publish(Command.Event.Executed, { + name: input.command, + sessionID: input.sessionID, + arguments: input.arguments, + messageID: first.result.info.id, + }) + return first.result + } const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) @@ -1561,6 +1801,70 @@ export const CommandInput = Schema.Struct({ }) export type CommandInput = Schema.Schema.Type +const durationRegex = /^([1-9]\d*)(s|m|h|d)$/i +export const DEFAULT_LOOP_PROMPT = [ + "You are running in loop mode.", + "", + "Perform proactive maintenance for the current session and project.", + "If there is nothing useful to do right now, say what you checked and stop.", + "", + `End your final text response with exactly one control marker on its own line: ${LOOP_CONTINUE_MARKER} if another loop iteration should run, or ${LOOP_STOP_MARKER} if the work is complete or blocked until the user or an external system changes state.`, +].join("\n") + +export function parseLoopCommandArguments(input: string): LoopCommandParsed { + const trimmed = input.trim() + if (!trimmed) return { action: "start", prompt: "" } + if (/^(stop|off)$/i.test(trimmed)) return { action: "stop" } + const [first, ...rest] = trimmed.split(/\s+/) + const match = first.match(durationRegex) + if (!match) return { action: "start", prompt: buildLoopPrompt(trimmed) } + const value = Number(match[1]) + const unit = match[2].toLowerCase() + const intervalMs = + unit === "s" + ? value * 1000 + : unit === "m" + ? value * 60 * 1000 + : unit === "h" + ? value * 60 * 60 * 1000 + : value * 24 * 60 * 60 * 1000 + return { + action: "start", + intervalMs, + prompt: rest.length > 0 ? buildLoopPrompt(rest.join(" ")) : "", + } +} + +export function buildLoopPrompt(goal: string) { + const trimmed = goal.trim() + return [ + "You are running in loop mode.", + "", + trimmed ? `Goal:\n${trimmed}` : "Perform proactive maintenance for the current session and project.", + "", + "Work autonomously until the goal is complete or you are blocked by the user, permissions, or an external dependency.", + "Re-check relevant state before stopping so you do not miss new changes.", + `End your final text response with exactly one control marker on its own line: ${LOOP_CONTINUE_MARKER} if another loop iteration should run, or ${LOOP_STOP_MARKER} if the work is complete or blocked until the user or an external system changes state.`, + ].join("\n") +} + +export function extractLoopControl(text: string): { action: "continue" | "stop"; text: string } { + const trimmedEnd = text.replace(/\s+$/, "") + if (trimmedEnd.endsWith(LOOP_CONTINUE_MARKER)) { + return { + action: "continue", + text: trimmedEnd.slice(0, -LOOP_CONTINUE_MARKER.length).replace(/\s+$/, ""), + } + } + if (trimmedEnd.endsWith(LOOP_STOP_MARKER)) { + return { + action: "stop", + text: trimmedEnd.slice(0, -LOOP_STOP_MARKER.length).replace(/\s+$/, ""), + } + } + return { action: "stop", text } +} + /** @internal Exported for testing */ export function createStructuredOutputTool(input: { schema: Record diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..a12aede8a15c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" -import { expect } from "bun:test" +import { expect, test } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" import { fileURLToPath } from "url" @@ -877,6 +877,74 @@ it.instance("loop continues when finish is stop but assistant has tool parts", ( }), ) +it.instance("loop command self-paces until assistant signals stop", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.text("first iteration\n") + yield* llm.text("second iteration\n") + + const result = yield* prompt.command({ + sessionID: session.id, + command: "loop", + arguments: "check whether the deploy finished", + }) + + expect(result.parts.some((part) => part.type === "text" && part.text === "first iteration")).toBe(true) + let texts: string[] = [] + for (let i = 0; i < 20; i++) { + yield* Effect.sleep(75) + const messages = yield* sessions.messages({ sessionID: session.id }) + texts = messages.flatMap((message) => + message.info.role === "assistant" + ? message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])) + : [], + ) + if (texts.includes("second iteration")) break + } + + expect(texts).toContain("first iteration") + expect(texts).toContain("second iteration") + expect(texts.join("\n")).not.toContain(" + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* llm.text("first iteration\n") + + yield* prompt.command({ + sessionID: session.id, + command: "loop", + arguments: "watch the deploy", + }) + const stop = yield* prompt.command({ + sessionID: session.id, + command: "loop", + arguments: "stop", + }) + + expect(stop.parts.some((part) => part.type === "text" && part.text === "Stopped the active loop.")).toBe(true) + yield* Effect.sleep(1200) + expect(yield* llm.calls).toBe(1) + }), +) + it.instance("failed subtask preserves metadata on error tool state", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({ @@ -2400,3 +2468,31 @@ noLLMServer.instance( }), 30_000, ) + +test("parseLoopCommandArguments handles stop, interval, and prompt text", () => { + expect(SessionPrompt.parseLoopCommandArguments("stop")).toEqual({ action: "stop" }) + expect(SessionPrompt.parseLoopCommandArguments("5m check deploy")).toEqual({ + action: "start", + intervalMs: 5 * 60 * 1000, + prompt: SessionPrompt.buildLoopPrompt("check deploy"), + }) + expect(SessionPrompt.parseLoopCommandArguments("check deploy")).toEqual({ + action: "start", + prompt: SessionPrompt.buildLoopPrompt("check deploy"), + }) +}) + +test("extractLoopControl strips loop markers from assistant text", () => { + expect(SessionPrompt.extractLoopControl("still working\n")).toEqual({ + action: "continue", + text: "still working", + }) + expect(SessionPrompt.extractLoopControl("all done\n")).toEqual({ + action: "stop", + text: "all done", + }) + expect(SessionPrompt.extractLoopControl("no marker")).toEqual({ + action: "stop", + text: "no marker", + }) +})