Skip to content
Open
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
375 changes: 375 additions & 0 deletions docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md

Large diffs are not rendered by default.

7 changes: 3 additions & 4 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,9 @@ export const Flag = {
MIMOCODE_OUTPUT_LENGTH_CONTINUATION_LIMIT: number("MIMOCODE_OUTPUT_LENGTH_CONTINUATION_LIMIT") ?? 3,
MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT: number("MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT") ?? 2,
MIMOCODE_TEXT_TOOL_CALL_RETRY_LIMIT: number("MIMOCODE_TEXT_TOOL_CALL_RETRY_LIMIT") ?? 2,
// Empty/no-op tool-call loop guard: number of soft nudges (remind → replan)
// before the harness hard-halts the turn. N consecutive empty steps beyond
// this many recovery attempts terminates the turn. Mirrors TEXT_NGRAM_MAX_RECOVERY.
MIMOCODE_EMPTY_STEP_MAX_RECOVERY: number("MIMOCODE_EMPTY_STEP_MAX_RECOVERY") ?? 2,
// Empty tool-call retry limit: number of retries for a single empty tool
// call before the harness terminates the turn. Mirrors TEXT_TOOL_CALL_RETRY_LIMIT.
MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT: number("MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT") ?? 2,

// Consecutive-block repetition detection for streamed reasoning + text.
// A block of at least N tokens repeating REPEAT_THRESHOLD times consecutively
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const ContextOverflowError = NamedError.create(
)
export const InvalidOutputError = NamedError.create("InvalidOutputError", z.object({ message: z.string() }))
export const TextToolCallError = NamedError.create("TextToolCallError", z.object({ message: z.string() }))
export const EmptyToolCallError = NamedError.create("EmptyToolCallError", z.object({ message: z.string() }))
export const ContentFilterError = NamedError.create("ContentFilterError", z.object({ message: z.string() }))
export const ModelError = NamedError.create("ModelError", z.object({ message: z.string() }))

Expand Down Expand Up @@ -453,6 +454,7 @@ export const Assistant = Base.extend({
ContextOverflowError.Schema,
InvalidOutputError.Schema,
TextToolCallError.Schema,
EmptyToolCallError.Schema,
ContentFilterError.Schema,
ModelError.Schema,
APIError.Schema,
Expand Down
161 changes: 81 additions & 80 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ import {
TEXT_NGRAM_RECOVERY_REPLAN,
} from "../session/prompt/text-ngram-detection"
import {
EMPTY_STEP_MAX_RECOVERY,
EMPTY_STEP_RECOVERY_REMIND,
EMPTY_STEP_RECOVERY_REPLAN,
isEmptyStep,
type IsEmptyStepOptions,
} from "../session/prompt/empty-step-detection"
import { builtinSkillRoot, matchDocumentSkills } from "@/skill/builtin/extract"
import { ToolRegistry } from "../tool"
Expand Down Expand Up @@ -248,6 +246,7 @@ const PREDICT_NUDGE = `Based on the conversation above, write the user's most li
const OUTPUT_LENGTH_CONTINUATION_LIMIT = Flag.MIMOCODE_OUTPUT_LENGTH_CONTINUATION_LIMIT
const INVALID_OUTPUT_CONTINUATION_LIMIT = Flag.MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT
const TEXT_TOOL_CALL_RETRY_LIMIT = Flag.MIMOCODE_TEXT_TOOL_CALL_RETRY_LIMIT
const EMPTY_TOOL_CALL_RETRY_LIMIT = Flag.MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT

const log = Log.create({ service: "session.prompt" })

Expand Down Expand Up @@ -307,6 +306,28 @@ export const layer = Layer.effect(
const actorRegistry = yield* ActorRegistry.Service
const inbox = yield* Inbox.Service

// Build a lookup: tool name → whether its parameter schema has required
// fields. Tools whose schema is z.object({}) (no required params) are
// called with {} legitimately and must NOT be flagged by isEmptyStep.
const toolDefs = yield* registry.all()
const toolHasRequiredArgs = new Map<string, boolean>()
for (const def of toolDefs) {
const schema = def.parameters
// z.object({}) has an empty shape — no required params.
if (schema instanceof z.ZodObject) {
const shape = schema.shape as Record<string, unknown>
const keys = Object.keys(shape)
// Tool has required args if any shape key is NOT wrapped in ZodOptional.
const hasRequired = keys.some((k) => !(shape[k] instanceof z.ZodOptional))
toolHasRequiredArgs.set(def.id, hasRequired)
} else {
// Unknown schema shape — assume it accepts args (safe default).
toolHasRequiredArgs.set(def.id, true)
}
}
const toolHasArgs: IsEmptyStepOptions["toolHasArgs"] = (name) =>
toolHasRequiredArgs.get(name) ?? true

// Track sessions that have already shown the "loaded instructions" toast so we
// surface it once per primary session rather than on every run-loop turn.
const instructionsNotified = new Set<SessionID>()
Expand Down Expand Up @@ -2142,19 +2163,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
// prose text instead of a structured tool_use). Local to runLoop so each
// fresh user turn starts clean.
let textToolCallRetries = 0
// Consecutive empty/no-op tool-call steps in this turn. Counts steps
// where the model "called a tool" with empty/invalid input, or produced
// no valid tool part and no substantive output at all (see isEmptyStep).
// A single non-empty step resets it. Escalates soft (remind → replan)
// then hard-halts once it exceeds EMPTY_STEP_MAX_RECOVERY, mirroring the
// text-ngram ladder. Local to runLoop so a fresh user turn starts clean.
let emptyStepStreak = 0
// Set true when a guard hard-halts the turn (currently the empty-step
// guard). A hard halt is terminal: it must break out immediately and
// NOT be re-entered by the taskGate / goalGate ReAct gates, which would
// otherwise inject a fresh user turn and re-drive a still-degraded model
// into the same loop.
let hardHalt = false
// Bounded retries for empty tool calls (model called a tool with
// empty/invalid arguments, or produced a fully empty terminal).
// Mirrors textToolCallRetries. Local to runLoop, resets per user turn.
let emptyToolCallRetries = 0
const resolvedAgentID = agentID ?? "main"
// Tracks plugin-driven cancellation (session.pre OR any session.userQuery.pre)
// so session.post reports outcome="cancelled" instead of "error".
Expand Down Expand Up @@ -2707,21 +2719,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return true
})

// Empty/no-op tool-call loop guard. Symmetric across main and fork
// branches, mirroring handleTextRepeat's soft→hard ladder but keyed on
// *empty steps* (empty/invalid tool input, or a fully empty terminal)
// rather than repeated text n-grams — the gap TEXT_NGRAM and
// stepSignature both miss (an empty tool call has no text to match and
// is dropped by stepSignature's undefined path).
//
// Returns:
// "none" — the step was NOT empty; streak reset, caller continues
// normal classification.
// "continue" — empty step, still within the soft-nudge budget; a
// remind/replan reminder was injected, caller should loop.
// "halt" — empty streak exceeded EMPTY_STEP_MAX_RECOVERY; a
// terminal error was published, caller must break.
const handleEmptyStep = Effect.fn("SessionPrompt.handleEmptyStep")(function* (input: {
// Empty tool-call retry. A single empty tool call (empty/invalid
// arguments, or a fully empty terminal) is an invalid output — it must
// IMMEDIATELY trigger a RETRY that makes the model re-emit a valid call.
// The bad assistant turn is DISCARDED from history (error-tagged), and a
// synthetic user turn is appended to re-drive generation — mirrors
// autoRetryTextToolCall exactly. On exhaustion the error stays terminal.
// Returns true ⇒ continue; false ⇒ break.
const autoRetryEmptyToolCall = Effect.fn("SessionPrompt.autoRetryEmptyToolCall")(function* (input: {
lastUser: MessageV2.User
assistant: MessageV2.Assistant
}) {
Expand All @@ -2737,40 +2742,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the
input.assistant.finish === "content-filter" ||
input.assistant.finish === "error"
) {
return "none" as const
return false
}
const parts = MessageV2.parts(input.assistant.id)
if (!isEmptyStep(parts)) {
emptyStepStreak = 0
return "none" as const
}
emptyStepStreak++
if (emptyStepStreak > EMPTY_STEP_MAX_RECOVERY) {
yield* slog.info("empty step: max recovery exceeded, terminating", { streak: emptyStepStreak })
hardHalt = true
// Discard the empty turn from request history so it can neither
// strand the conversation on an assistant prefill nor poison later
// context (toModelMessages skips a message whose info.error is set).
if (!input.assistant.error) {
input.assistant.error = new NamedError.Unknown({
message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`,
}).toObject()
yield* sessions.updateMessage(input.assistant)
}
if (!isEmptyStep(parts, { toolHasArgs })) return false
// Discard the bad turn from request history: toModelMessages skips a
// message whose info.error is set, so it can neither strand the
// conversation on an assistant turn nor poison later context.
input.assistant.error = new MessageV2.EmptyToolCallError({
message: "Model emitted an empty or argument-less tool call.",
}).toObject()
yield* sessions.updateMessage(input.assistant)
if (emptyToolCallRetries >= EMPTY_TOOL_CALL_RETRY_LIMIT) {
yield* bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({
message: `Empty tool call loop detected: ${emptyStepStreak} consecutive empty/no-op steps after ${EMPTY_STEP_MAX_RECOVERY} recovery attempts. Session terminated.`,
}).toObject(),
sessionID: input.assistant.sessionID,
error: input.assistant.error,
})
return "halt" as const
return false
}
const recoveryText =
emptyStepStreak === 1 ? EMPTY_STEP_RECOVERY_REMIND : EMPTY_STEP_RECOVERY_REPLAN
const reentry = yield* sessions.updateMessage({
emptyToolCallRetries++
yield* slog.info("retrying empty tool call", { attempt: emptyToolCallRetries })
// Append a synthetic user turn so the discarded assistant becomes stale
// (classify staleness guard) AND the loop reaches generation — mirrors
// autoRetryTextToolCall. Without this the loop re-enters, re-detects
// the same turn, and burns retries with zero model calls.
const msg = yield* sessions.updateMessage({
id: MessageID.ascending(),
role: "user" as const,
sessionID,
sessionID: input.lastUser.sessionID,
agentID: input.lastUser.agentID,
agent: input.lastUser.agent,
model: input.lastUser.model,
Expand All @@ -2780,14 +2779,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: reentry.id,
sessionID,
messageID: msg.id,
sessionID: msg.sessionID,
type: "text",
synthetic: true,
text: recoveryText,
text: [
"<system-reminder>",
"Your previous tool call had empty or invalid arguments.",
"Re-issue a valid tool call with complete, non-empty arguments,",
"or reply to the user directly with plain text.",
"Do NOT emit another empty or argument-less tool call.",
"</system-reminder>",
].join("\n"),
} satisfies MessageV2.TextPart)
yield* slog.info("empty step: recovery injected", { streak: emptyStepStreak })
return "continue" as const
return true
})


Expand Down Expand Up @@ -2940,6 +2945,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
yield* slog.info("exiting loop", { classification: classification.type })
break
}
// Empty tool-call retry (existing-assistant branch). A single
// empty tool call triggers immediate retry — discard bad turn +
// synthetic user message + continue. Catches empty tool calls
// before classify routes them to "continue" (pending tool parts).
if (yield* autoRetryEmptyToolCall({ lastUser, assistant: lastAssistant })) continue
if (classification.type === "think-only" || classification.type === "invalid") {
const reason = classification.type === "invalid" ? classification.reason : "think-only"
if (yield* autoContinueInvalidOutput({ lastUser, assistant: lastAssistant, reason })) continue
Expand Down Expand Up @@ -3493,13 +3503,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return "break" as const
}

// Empty/no-op tool-call loop guard (fork branch). Intercept before
// classify would `continue` an empty tool-calls step: soft-nudge
// within budget, hard-halt once exceeded. A non-empty step returns
// "none" and falls through to normal classification.
const forkEmptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message })
if (forkEmptyStep === "halt") return "break" as const
if (forkEmptyStep === "continue") return "continue" as const
// Empty tool-call retry (fork branch). A single empty tool call
// triggers immediate retry — discard bad turn + synthetic user
// message + continue. On exhaustion, break.
if (yield* autoRetryEmptyToolCall({ lastUser, assistant: handle.message })) return "continue" as const

const forkClassification = classifyAssistantStep({
phase: "after-process",
Expand Down Expand Up @@ -3719,13 +3726,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return "break" as const
}

// Empty/no-op tool-call loop guard (main branch). Intercept before
// classify would `continue` an empty tool-calls step: soft-nudge
// within budget, hard-halt once exceeded. A non-empty step returns
// "none" and falls through to normal classification.
const emptyStep = yield* handleEmptyStep({ lastUser, assistant: handle.message })
if (emptyStep === "halt") return "break" as const
if (emptyStep === "continue") return "continue" as const
// Empty tool-call retry (main branch). A single empty tool call
// triggers immediate retry — discard bad turn + synthetic user
// message + continue. On exhaustion, break.
if (yield* autoRetryEmptyToolCall({ lastUser, assistant: handle.message })) return "continue" as const

const classification = classifyAssistantStep({
phase: "after-process",
Expand Down Expand Up @@ -3875,9 +3879,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}

if (outcome === "break") {
// A hard halt is terminal — skip the ReAct re-entry gates so a
// degraded model can't be re-driven into the same empty loop.
if (hardHalt) break
if (yield* taskGate(lastUser)) continue
if (yield* goalGate(lastUser)) continue
break
Expand Down
Loading
Loading