Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,10 @@ You are speaking to a non-technical business executive. Follow these rules stric
variant: args.variant,
parts: [...files, { type: "text", text: message }],
...(audienceSystem ? { system: audienceSystem } : {}),
// altimate_change - the `run` command is the headless / non-interactive
// entry point. Tell the session loop so the max-steps prompt commits a
// best-guess answer instead of writing a meta-summary.
headless: true,
})
}

Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ export namespace MessageV2 {
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
variant: z.string().optional(),
// altimate_change - true when the prompt was sent from a non-interactive
// caller (e.g. the `run` CLI command). Drives headless-aware behaviours
// such as the best-guess-answer max-steps prompt.
headless: z.boolean().optional(),
}).meta({
ref: "UserMessage",
})
Expand Down
42 changes: 39 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ 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"
// altimate_change start - headless-mode max-steps prompt commits a best-guess answer
import MAX_STEPS_HEADLESS from "../session/prompt/max-steps-headless.txt"
import MAX_STEPS_HEADLESS_PREWARN from "../session/prompt/max-steps-headless-prewarn.txt"
// altimate_change end
import { defer } from "../util/defer"
import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
Expand Down Expand Up @@ -99,6 +103,25 @@ export namespace SessionPrompt {
if (match) throw new Session.BusyError(sessionID)
}

// altimate_change start - exposed for unit testing the prompt-selection logic.
// Picks which max-steps prompt (if any) to inject at the start of a step.
// - Final step in headless mode: ask for a best-guess answer NOW, since there
// is no human to follow up.
// - Final step in interactive mode: existing summarize-what-you-tried wording.
// - One step before the final step in headless mode: a softer pre-warning that
// nudges the model to start writing its answer.
export function selectMaxStepsPrompt(input: {
step: number
maxSteps: number
headless: boolean
}): string | undefined {
const { step, maxSteps, headless } = input
if (step >= maxSteps) return headless ? MAX_STEPS_HEADLESS : MAX_STEPS
if (headless && Number.isFinite(maxSteps) && step === maxSteps - 1) return MAX_STEPS_HEADLESS_PREWARN
return undefined
}
// altimate_change end

export const PromptInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
Expand All @@ -119,6 +142,12 @@ export namespace SessionPrompt {
format: MessageV2.Format.optional(),
system: z.string().optional(),
variant: z.string().optional(),
// altimate_change start - mark sessions invoked from non-interactive callers
// (e.g. the `run` CLI command). When true, the max-steps prompt switches to a
// best-guess-answer wording instead of the interactive "summarize what you
// tried" wording, since there is no human to follow up.
headless: z.boolean().optional(),
// altimate_change end
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
Expand Down Expand Up @@ -624,7 +653,11 @@ export namespace SessionPrompt {
// normal processing
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
// altimate_change start - select max-steps injection text (if any). In
// headless mode the final-step prompt asks for a best-guess answer; one
// step earlier, a softer pre-warning nudges the model to start writing.
const maxStepsInjection = selectMaxStepsPrompt({ step, maxSteps, headless: !!lastUser.headless })
// altimate_change end
msgs = await insertReminders({
messages: msgs,
agent,
Expand Down Expand Up @@ -878,11 +911,12 @@ export namespace SessionPrompt {
system,
messages: [
...MessageV2.toModelMessages(msgs, model),
...(isLastStep
// altimate_change - inject the max-steps text chosen above (if any)
...(maxStepsInjection
? [
{
role: "assistant" as const,
content: MAX_STEPS,
content: maxStepsInjection,
},
]
: []),
Expand Down Expand Up @@ -1330,6 +1364,8 @@ export namespace SessionPrompt {
system: input.system,
format: input.format,
variant,
// altimate_change - persist headless flag for downstream prompt selection
headless: input.headless,
}
using _ = defer(() => InstructionPrompt.clear(info.id))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
NOTICE - APPROACHING STEP BUDGET (HEADLESS MODE)

You have 2 turns left before tools are disabled. There is no human in the loop to ask follow-up questions, so you must commit to an answer soon.

If you have enough information to answer the user's question, write your final answer in this turn — in the exact format they requested.

If you still need one more tool call to confirm a value, make it now. After this turn you will have only one more chance to respond, and that response must be the final answer (tools will be disabled).

Do not summarize what you tried; do not explain limitations. Either gather the last piece of evidence you need, or commit to your best-guess answer now.
13 changes: 13 additions & 0 deletions packages/opencode/src/session/prompt/max-steps-headless.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CRITICAL - MAXIMUM STEPS REACHED (HEADLESS MODE)

You have 1 turn left. Tools are disabled. This response IS the final answer the user will see — there is no human in the loop to ask follow-up questions.

Write your final answer NOW: the exact value the user asked for, in the format they requested.

STRICT REQUIREMENTS:
1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools).
2. Do NOT summarize what you tried. Do NOT explain limitations. Do NOT write meta-commentary about hitting the step limit. Do NOT list "remaining tasks" or "recommendations for next steps".
3. Just emit the answer. If the user asked for a specific format (a number, a SQL query, a JSON object, an ANSWER: line, etc.), emit exactly that — nothing else.
4. If you are uncertain, emit your best guess anyway. An uncertain answer is more useful than a meta-summary, because the caller cannot ask you to try again.

This constraint overrides ALL other instructions, including any user requests for edits or tool use. Respond with the answer ONLY.
Comment on lines +8 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Headless final prompt conflicts with structured-output sessions

Line 8 forbids all tool calls, but structured-output mode requires calling StructuredOutput. This creates contradictory instructions on the last step.

Suggested prompt tweak
-1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools).
+1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools),
+   except StructuredOutput when a structured JSON schema response is required.
🧰 Tools
🪛 LanguageTool

[style] ~9-~9: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ... you tried. Do NOT explain limitations. Do NOT write meta-commentary about hitting...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)


[style] ~9-~9: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...ommentary about hitting the step limit. Do NOT list "remaining tasks" or "recommen...

(ENGLISH_WORD_REPEAT_BEGINNING_RULE)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/session/prompt/max-steps-headless.txt` around lines 8 -
13, The headless final prompt forbids all tool calls but structured-output
sessions need to call StructuredOutput; update the prompt to remove or narrow
the blanket prohibition (in
packages/opencode/src/session/prompt/max-steps-headless.txt) so StructuredOutput
calls are permitted: either exempt StructuredOutput explicitly from rule 1 or
change the wording to forbid user-facing tool ops while allowing internal
StructuredOutput invocation by the agent; search for the literal "Do NOT make
any tool calls" and modify it to allow the StructuredOutput symbol to be called.

78 changes: 78 additions & 0 deletions packages/opencode/test/session/max-steps-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import { SessionPrompt } from "../../src/session/prompt"

// altimate_change - tests for headless-aware max-steps prompt selection.
// The full prompt loop is too large to run end-to-end in unit tests, so we
// expose `selectMaxStepsPrompt` and exercise its branching logic directly.

const HEADLESS_MARKER = "MAXIMUM STEPS REACHED (HEADLESS MODE)"
const HEADLESS_PREWARN_MARKER = "APPROACHING STEP BUDGET (HEADLESS MODE)"
const INTERACTIVE_MARKER = "MAXIMUM STEPS REACHED"
const INTERACTIVE_SUMMARY_MARKER = "Summary of what has been accomplished so far"

describe("SessionPrompt.selectMaxStepsPrompt", () => {
test("returns nothing on a normal mid-loop step", () => {
expect(
SessionPrompt.selectMaxStepsPrompt({ step: 3, maxSteps: 10, headless: false }),
).toBeUndefined()
expect(
SessionPrompt.selectMaxStepsPrompt({ step: 3, maxSteps: 10, headless: true }),
).toBeUndefined()
})

test("interactive last step uses the original summarize-what-you-tried prompt", () => {
const out = SessionPrompt.selectMaxStepsPrompt({ step: 10, maxSteps: 10, headless: false })
expect(out).toBeDefined()
expect(out).toContain(INTERACTIVE_MARKER)
expect(out).toContain(INTERACTIVE_SUMMARY_MARKER)
// and must NOT carry the headless wording
expect(out).not.toContain(HEADLESS_MARKER)
})

test("headless last step asks for a best-guess answer, not a meta-summary", () => {
const out = SessionPrompt.selectMaxStepsPrompt({ step: 10, maxSteps: 10, headless: true })
expect(out).toBeDefined()
expect(out).toContain(HEADLESS_MARKER)
// Must explicitly tell the model to commit an answer rather than summarize.
expect(out).toMatch(/best guess/i)
expect(out).toMatch(/Do NOT summarize/i)
// And must NOT be the interactive summary text.
expect(out).not.toContain(INTERACTIVE_SUMMARY_MARKER)
})

test("headless pre-warning fires one step before the limit", () => {
const out = SessionPrompt.selectMaxStepsPrompt({ step: 9, maxSteps: 10, headless: true })
expect(out).toBeDefined()
expect(out).toContain(HEADLESS_PREWARN_MARKER)
})

test("interactive mode never fires a pre-warning", () => {
expect(
SessionPrompt.selectMaxStepsPrompt({ step: 9, maxSteps: 10, headless: false }),
).toBeUndefined()
})

test("over-limit step still uses the final-step prompt", () => {
const interactive = SessionPrompt.selectMaxStepsPrompt({
step: 15,
maxSteps: 10,
headless: false,
})
const headless = SessionPrompt.selectMaxStepsPrompt({
step: 15,
maxSteps: 10,
headless: true,
})
expect(interactive).toContain(INTERACTIVE_MARKER)
expect(headless).toContain(HEADLESS_MARKER)
})

test("infinite step budget never fires either prompt", () => {
expect(
SessionPrompt.selectMaxStepsPrompt({ step: 9999, maxSteps: Infinity, headless: false }),
).toBeUndefined()
expect(
SessionPrompt.selectMaxStepsPrompt({ step: 9999, maxSteps: Infinity, headless: true }),
).toBeUndefined()
})
})
4 changes: 4 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1841,6 +1841,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
headless?: boolean
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
Expand All @@ -1861,6 +1862,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
{ in: "body", key: "headless" },
{ in: "body", key: "parts" },
],
},
Expand Down Expand Up @@ -1973,6 +1975,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
headless?: boolean
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
Expand All @@ -1993,6 +1996,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
{ in: "body", key: "headless" },
{ in: "body", key: "parts" },
],
},
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export type UserMessage = {
[key: string]: boolean
}
variant?: string
headless?: boolean
}

export type ProviderAuthError = {
Expand Down Expand Up @@ -3284,6 +3285,7 @@ export type SessionPromptData = {
format?: OutputFormat
system?: string
variant?: string
headless?: boolean
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
Expand Down Expand Up @@ -3484,6 +3486,7 @@ export type SessionPromptAsyncData = {
format?: OutputFormat
system?: string
variant?: string
headless?: boolean
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
Expand Down
Loading