Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 19 additions & 1 deletion packages/opencode/src/cli/cmd/run-accounting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export type WhyHarnessStopped = "budget-exhausted" | "timeout" | "error" | "idle
// idle-done confirm challenge (idle_heuristic). "none" = the session ended
// without any completion assertion — bare finishReason "stop" is NEVER
// reported as done.
export type DoneReason = "explicit_done" | "idle_heuristic" | "none"
// altimate_change start — "blocked" = the model ended on a `BLOCKED: <reason>`
// line: it reported that it cannot proceed, so the task did not happen.
export type DoneReason = "explicit_done" | "idle_heuristic" | "blocked" | "none"
// altimate_change end
export type Termination = {
why_model_stopped: WhyModelStopped
why_harness_stopped: WhyHarnessStopped
Expand Down Expand Up @@ -107,6 +110,9 @@ export function create() {
let lastTextMessageID: string | undefined
// altimate_change end
let lastTextExplicitDone = false
// altimate_change start — run-mode blocked ending
let lastTextBlocked = false
// altimate_change end
let lastTextFromChallenge = false
let budgetExhausted = false
// altimate_change start — an answered time-budget finalization recovers the exit status only
Expand Down Expand Up @@ -187,6 +193,9 @@ export function create() {
if (isCompactionStep(messageID)) return
if (synthetic) return
lastTextExplicitDone = SessionTermination.isExplicitDone(text)
// altimate_change start — run-mode blocked ending
lastTextBlocked = !lastTextExplicitDone && SessionTermination.isBlocked(text)
// altimate_change end
lastTextMessageID = messageID
lastTextFromChallenge = lastTextExplicitDone && challengeReplyActive
},
Expand Down Expand Up @@ -333,6 +342,15 @@ export function create() {
// the idle-done confirm challenge, it is honestly attributed to the
// heuristic, not to unprompted model completion.
const done: DoneReason = (() => {
// altimate_change start — a blocked ending on the finish message is reported, never as done
if (
lastFinishReason === "stop" &&
lastTextBlocked &&
lastTextMessageID !== undefined &&
lastTextMessageID === lastFinishMessageID
)
return "blocked"
// altimate_change end
if (lastFinishReason !== "stop" || !explicitDoneOnFinishMessage) return "none"
return lastTextFromChallenge ? "idle_heuristic" : "explicit_done"
})()
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/cli/cmd/run-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Everything is still kept so a run that ended without a final reply (silent turn,
// error line) writes what it has instead of the placeholder.

import { DONE_TOKEN } from "../../session/termination"
import { DONE_TOKEN, stripCompletionToken } from "../../session/termination"

export const NO_TEXT_OUTPUT = "(no text output — tool-only response)"

Expand All @@ -17,6 +17,9 @@ const ACKNOWLEDGEMENT = new RegExp(`^${DONE_TOKEN}\\.?$`, "i")
type CollectedText = { text: string; partID?: string }

export class OutputCollector {
// In run mode the completion token is a signal to the harness, not part of the answer.
constructor(private readonly options: { stripCompletionToken?: boolean } = {}) {}

private all: CollectedText[] = []
private final: CollectedText[] = []
private previous: CollectedText[] = []
Expand All @@ -30,7 +33,12 @@ export class OutputCollector {

/** Completed assistant text. Synthetic/compaction text is kept for the fallback only. */
text(text: string, opts: { synthetic?: boolean; compaction?: boolean; partID?: string } = {}) {
const trimmed = text.trim()
const trimmed = (this.options.stripCompletionToken ? stripCompletionToken(text) : text).trim()
if (!trimmed && text.trim() && !opts.synthetic && !opts.compaction && this.final.length === 0) {
// A bare completion token acknowledges the reply it confirms.
this.final = this.previous
return
}
if (!trimmed) return
const part = { text: trimmed, partID: opts.partID }
this.all.push(part)
Expand Down
31 changes: 24 additions & 7 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,10 @@ You are speaking to a non-technical business executive. Follow these rules stric
- "Query Duration" not "total_elapsed_time" — "Data Processed" not "bytes_scanned" — "Monthly Cost" not "credits_used * 3.00"`

async function execute(sdk: OpencodeClient) {
// altimate_change start — collect completed assistant text for run output
const outputParts = new OutputCollector()
// altimate_change start — collect completed assistant text for run output; in run mode the
// completion token is stripped at every output boundary
const stripDone = Flag.ALTIMATE_RUN_MODE
const outputParts = new OutputCollector({ stripCompletionToken: stripDone })
// altimate_change end
// altimate_change start — a turn must end with text (#1334). Track whether
// the assistant said anything at all, and the last tool failure, so a
Expand Down Expand Up @@ -1224,15 +1226,18 @@ You are speaking to a non-technical business executive. Follow these rules stric
}
// altimate_change end
// altimate_change start — upstream_fix: --output collects text in JSON mode too
const text = part.text.trim()
if (args.output && text) {
outputParts.text(text, {
if (args.output && part.text.trim()) {
outputParts.text(part.text, {
partID: part.id,
synthetic: part.synthetic === true,
compaction: accounting.isCompactionStep(part.messageID),
})
}
if (emit("text", { part })) continue
// Run mode: stdout and JSON text events carry the answer without the
// completion token; done_reason still records it.
const shown = stripDone ? SessionTermination.stripCompletionToken(part.text) : part.text
if (emit("text", { part: shown === part.text ? part : { ...part, text: shown } })) continue
const text = shown.trim()
if (!text) continue
// altimate_change end
if (!process.stdout.isTTY) {
Expand Down Expand Up @@ -2006,7 +2011,10 @@ You are speaking to a non-technical business executive. Follow these rules stric
// branch with a normal text-only stop. That has already returned from
// SessionPrompt.loop, so enqueue one explicit continuation turn instead
// of silently finalizing the run at rc 0 with done_reason=none.
if (!accounting.fatal && !challengeConfirmed) {
// altimate_change start — a blocked reply to the challenge is a final report, not a decline
const challengeBlocked = accounting.termination().done_reason === "blocked"
if (!accounting.fatal && !challengeConfirmed && !challengeBlocked) {
// altimate_change end
NudgeArbiter.register(sessionID, {
source: "termination_challenge",
kind: "continue_after_decline",
Expand Down Expand Up @@ -2232,6 +2240,15 @@ You are speaking to a non-technical business executive. Follow these rules stric
// (not process.exit) so pending stdout/trace writes still flush.
if (accounting.fatal) process.exitCode = 1
// altimate_change end
// altimate_change start — a blocked run did not do its task: exit nonzero and say why
// (done_reason=blocked is also in the termination record/JSON event).
if (!accounting.fatal && termination.done_reason === "blocked") {
process.exitCode = 1
if (args.format !== "json") {
process.stderr.write("\n! run blocked: the model reported it cannot proceed (done_reason=blocked)\n")
}
}
// altimate_change end
}

if (args.attach) {
Expand Down
14 changes: 9 additions & 5 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1765,16 +1765,18 @@ When constructing the summary, try to stick to this template:
: ""
// altimate_change end
// altimate_change start — completion-aware termination path:
// (b) the continue message carries the three-option completion-aware nudge
// (continue / ask for clarification / assert DONE), giving a finished
// session a termination path. Delivered via the NudgeArbiter (Global
// (b) the run-mode continue message carries the completion-aware nudge
// (continue / assert DONE / report BLOCKED — no clarification option,
// since nobody can answer in a headless run), giving a finished or
// blocked session a termination path. Delivered via the NudgeArbiter (Global
// rule 5): this injection point registers its candidate and takes the
// single winner, so pending lower-precedence directives (starvation
// breaker, budget reminder) are consumed here and the injected turn
// never carries two system-authored directive blocks. The termination
// nudge has top precedence, so it always wins at this site.
// (d) the overflow notice is mechanism-accurate — the old text falsely
// blamed "large media attachments" (see SessionTermination.OVERFLOW_NOTICE).
// blamed "large media attachments" (see SessionTermination.OVERFLOW_NOTICE);
// in run mode it does not ask the absent user to re-supply anything.
let continuation =
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
if (Flag.ALTIMATE_RUN_MODE) {
Expand All @@ -1791,7 +1793,9 @@ When constructing the summary, try to stick to this template:
NudgeArbiter.take(input.sessionID, input.nudgeGeneration)?.text ?? SessionTermination.COMPLETION_NUDGE
}
const text =
(input.overflow ? SessionTermination.OVERFLOW_NOTICE + "\n\n" : "") +
(input.overflow
? SessionTermination.overflowNotice({ runMode: Flag.ALTIMATE_RUN_MODE }) + "\n\n"
: "") +
continuation +
// altimate_change end
// altimate_change start — state ledger
Expand Down
13 changes: 7 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2071,12 +2071,13 @@ export namespace SessionPrompt {
// used to sit in builder.txt, but builder is a PRIMARY agent, so it also
// reached interactive chat, where nothing interprets or strips the token
// and the user saw a literal DONE on every final answer. Scoped to run
// mode AND to builder, which reproduces the previous run-mode behaviour
// exactly — builder was the only agent prompt that carried it.
const completionInstruction = SessionTermination.completionInstruction({
runMode: Flag.ALTIMATE_RUN_MODE,
agent: agent.name,
})
// mode, where it reaches every agent but plan — including custom agents and
// task subagents, whose child sessions are prompted through this same loop.
// A structured-output turn ends by capturing the schema result instead.
const completionInstruction =
lastUser.format?.type === "json_schema"
? undefined
: SessionTermination.completionInstruction({ runMode: Flag.ALTIMATE_RUN_MODE, agent: agent.name })
if (completionInstruction) system.push(completionInstruction)
// altimate_change end
const format = lastUser.format ?? { type: "text" }
Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/src/session/task-output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { MessageV2 } from "./message-v2"
import type { MessageID, SessionID } from "./schema"
// altimate_change start — run-mode task report without the completion token
import { stripCompletionToken } from "./termination"
// altimate_change end

export async function interruptedTaskOutput(sessionID: SessionID, parentID?: MessageID): Promise<string | undefined> {
for await (const message of MessageV2.stream(sessionID)) {
Expand All @@ -22,3 +25,27 @@ export async function interruptedTaskOutput(sessionID: SessionID, parentID?: Mes
].join("\n")
}
}

// altimate_change start — run-mode task report without the completion token
/**
* Run-mode task report: the child's final text without the completion token. When the
* child's last reply is a bare token (the post-compaction nudge's acknowledgement), the
* report is its most recent earlier non-summary assistant text in this prompt.
*/
export async function runModeTaskReport(sessionID: SessionID, text: string, promptID?: MessageID): Promise<string> {
const stripped = stripCompletionToken(text)
if (stripped.trim() || !text.trim()) return stripped
for await (const message of MessageV2.stream(sessionID)) {
if (promptID && message.info.id === promptID) break
if (message.info.role !== "assistant" || message.info.summary) continue
const report = stripCompletionToken(
message.parts
.filter((part): part is MessageV2.TextPart => part.type === "text" && !part.ignored && !part.synthetic)
.map((part) => part.text)
.join("\n"),
).trim()
if (report) return report
}
return stripped
}
// altimate_change end
Loading
Loading