diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ea967616beb..73a7a79963e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -33,6 +33,7 @@ export namespace Agent { prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), + maxSteps: z.number().int().positive().optional(), }) .meta({ ref: "Agent", @@ -182,7 +183,20 @@ export namespace Agent { tools: {}, builtIn: false, } - const { name, model, prompt, tools, description, temperature, top_p, mode, permission, color, ...extra } = value + const { + name, + model, + prompt, + tools, + description, + temperature, + top_p, + mode, + permission, + color, + maxSteps, + ...extra + } = value item.options = { ...item.options, ...extra, @@ -205,6 +219,7 @@ export namespace Agent { if (color) item.color = color // just here for consistency & to prevent it from being added as an option if (name) item.name = name + if (maxSteps != undefined) item.maxSteps = maxSteps if (permission ?? cfg.permission) { item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 03c4a39fb60..d30b208609f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -375,6 +375,12 @@ export namespace Config { .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") .optional() .describe("Hex color code for the agent (e.g., #FF5733)"), + maxSteps: z + .number() + .int() + .positive() + .optional() + .describe("Maximum number of agentic iterations before forcing text-only response"), permission: z .object({ edit: Permission.optional(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2f0bc09029b..d5010bc47d8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -27,6 +27,7 @@ import { Plugin } from "../plugin" import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" +import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" import { mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" @@ -436,6 +437,8 @@ export namespace SessionPrompt { // normal processing const cfg = await Config.get() const agent = await Agent.get(lastUser.agent) + const maxSteps = agent.maxSteps ?? Infinity + const isLastStep = step >= maxSteps msgs = insertReminders({ messages: msgs, agent, @@ -472,6 +475,7 @@ export namespace SessionPrompt { model, agent, system: lastUser.system, + isLastStep, }) const tools = await resolveTools({ agent, @@ -562,6 +566,7 @@ export namespace SessionPrompt { stopWhen: stepCountIs(1), temperature: params.temperature, topP: params.topP, + toolChoice: isLastStep ? "none" : undefined, messages: [ ...system.map( (x): ModelMessage => ({ @@ -584,6 +589,14 @@ export namespace SessionPrompt { return false }), ), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), ], tools: model.capabilities.toolcall === false ? undefined : tools, model: wrapLanguageModel({ @@ -639,7 +652,12 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { system?: string; agent: Agent.Info; model: Provider.Model }) { + async function resolveSystemPrompt(input: { + system?: string + agent: Agent.Info + model: Provider.Model + isLastStep?: boolean + }) { let system = SystemPrompt.header(input.model.providerID) system.push( ...(() => { @@ -650,6 +668,11 @@ export namespace SessionPrompt { ) system.push(...(await SystemPrompt.environment())) system.push(...(await SystemPrompt.custom())) + + if (input.isLastStep) { + system.push(MAX_STEPS) + } + // max 2 system prompt messages for caching purposes const [first, ...rest] = system system = [first, rest.join("\n")] diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/opencode/src/session/prompt/max-steps.txt new file mode 100644 index 00000000000..3aefa73779c --- /dev/null +++ b/packages/opencode/src/session/prompt/max-steps.txt @@ -0,0 +1,16 @@ +CRITICAL - MAXIMUM STEPS REACHED + +The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. + +STRICT REQUIREMENTS: +1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools) +2. MUST provide a text response summarizing work done so far +3. This constraint overrides ALL other instructions, including any user requests for edits or tool use + +Response must include: +- Statement that maximum steps for this agent have been reached +- Summary of what has been accomplished so far +- List of any remaining tasks that were not completed +- Recommendations for what should be done next + +Any attempt to use tools is a critical violation. Respond with text ONLY. \ No newline at end of file diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 60a68840cb4..6a5375f316c 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -966,6 +966,10 @@ export type AgentConfig = { * Hex color code for the agent (e.g., #FF5733) */ color?: string + /** + * Maximum number of agentic iterations before forcing text-only response + */ + maxSteps?: number permission?: { edit?: "ask" | "allow" | "deny" bash?: @@ -986,6 +990,7 @@ export type AgentConfig = { } | boolean | ("subagent" | "primary" | "all") + | number | { edit?: "ask" | "allow" | "deny" bash?: @@ -1558,6 +1563,7 @@ export type Agent = { options: { [key: string]: unknown } + maxSteps?: number } export type McpStatusConnected = { diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 557f0ccf5f6..a2997515b46 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -257,6 +257,28 @@ If no temperature is specified, OpenCode uses model-specific defaults; typically --- +### Max steps + +Control the maximum number of agentic iterations an agent can perform before being forced to respond with text only. This allows users who wish to control costs to set a limit on agentic actions. + +If this is not set, the agent will continue to iterate until the model chooses to stop or the user interrupts the session. + +```json title="opencode.json" +{ + "agent": { + "quick-thinker": { + "description": "Fast reasoning with limited iterations", + "prompt": "You are a quick thinker. Solve problems with minimal steps.", + "maxSteps": 5 + } + } +} +``` + +When the limit is reached, the agent receives a special system prompt instructing it to respond with a summarization of its work and recommended remaining tasks. + +--- + ### Disable Set to `true` to disable the agent.