From 6d161170588f1c2f2faa9280a47e291c003ac598 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 14 Jul 2026 21:52:32 +0800 Subject: [PATCH 1/6] fix(session): tighten empty-step guard so genuine empty tool calls always halt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unconditional empty-step guard in processor.ts BEFORE the doom-loop check. Genuine empty tool calls (undefined/null/empty object, even with harmless extra fields like {_meta}) are always counted regardless of error/summary/structured/finish state. When emptyStepCount hits EMPTY_STEP_THRESHOLD=3, halt with error. - Add emptyStepCount to ProcessorContext (persists across process calls) - Add isEmptyInput helper that filters out _meta-prefixed keys - Reset counter on non-empty input, increment on empty - Regression test: single LLM response with 3 empty tool calls → stop --- packages/opencode/src/session/processor.ts | 30 +++++++++ .../test/session/processor-effect.test.ts | 67 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71ce7a5db..85839599c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -27,6 +27,7 @@ import { isRecord } from "@/util/record" import { createTextNgramMonitor, type TextNgramMonitor } from "./prompt/text-ngram-detection" const DOOM_LOOP_THRESHOLD = 3 +const EMPTY_STEP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) export type Result = "overflow" | "stop" | "continue" | "text-repeat" @@ -149,6 +150,7 @@ interface ProcessorContext extends Input { stepPartIds: PartID[] textNgramMonitor: TextNgramMonitor | undefined textNgramRepeat: boolean + emptyStepCount: number } type StreamEvent = Event @@ -205,6 +207,7 @@ export const layer: Layer.Layer< stepPartIds: [], textNgramMonitor: undefined, textNgramRepeat: false, + emptyStepCount: 0, } let aborted = false // Only the main agent owns session-level status. Subagents (explore, @@ -323,6 +326,22 @@ export const layer: Layer.Layer< if (ctx.textNgramMonitor.append(text)) ctx.textNgramRepeat = true } + const isEmptyInput = (input: Record | undefined | null): boolean => { + if (input === undefined || input === null) return true + // Filter out meta/underscore-prefixed fields — they are harness + // bookkeeping, not actionable tool arguments. + const keys = Object.keys(input).filter((k) => !k.startsWith("_")) + if (keys.length === 0) return true + return keys.every((k) => { + const v = input[k] + if (v === undefined || v === null) return true + if (typeof v === "string") return v.trim().length === 0 + if (Array.isArray(v)) return v.length === 0 + if (typeof v === "object") return Object.keys(v as Record).length === 0 + return false + }) + } + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": @@ -419,6 +438,17 @@ export const layer: Layer.Layer< : value.providerMetadata, })) + // Unconditional empty-step guard: genuine empty tool calls always + // counted, regardless of error/summary/structured/finish state. + if (isEmptyInput(value.input)) { + ctx.emptyStepCount++ + if (ctx.emptyStepCount >= EMPTY_STEP_THRESHOLD) { + throw new Error(`Empty tool call loop detected: ${ctx.emptyStepCount} consecutive empty tool calls. Session terminated.`) + } + return + } + ctx.emptyStepCount = 0 + const parts = MessageV2.parts(ctx.assistantMessage.id) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 20c2b60f6..58aeb9ce4 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,6 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { tool, jsonSchema } from "ai" import path from "path" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" @@ -851,3 +852,69 @@ it.live("session.processor effect tests mark interruptions aborted without manua { git: true, config: (url) => providerCfg(url) }, ), ) + +it.live("session.processor effect tests stop on 3 consecutive empty tool calls", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + // Single response with 3 empty tool calls: two {}, one {_meta:'harmless'}. + yield* llm.push( + raw({ + head: [ + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { role: "assistant" } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "bash", arguments: "" } }] } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: "{}" } }] } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 1, id: "call_2", type: "function", function: { name: "bash", arguments: "" } }] } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: "{}" } }] } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 2, id: "call_3", type: "function", function: { name: "bash", arguments: "" } }] } }] }, + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: { tool_calls: [{ index: 2, function: { arguments: '{"_meta":"harmless"}' } }] } }] }, + ], + tail: [ + { id: "chatcmpl-test", object: "chat.completion.chunk", choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "empty steps") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + // Register a no-op tool so the AI SDK accepts the tool calls. + const noopTool = tool({ + description: "no-op", + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => ({ output: "", title: "", metadata: {} }), + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "empty steps" }], + tools: { bash: noopTool }, + }) + + expect(value).toBe("stop") + expect(handle.message.error).toBeDefined() + expect(handle.message.error?.name).toBe("UnknownError") + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) From 27e14b562a7bbd80882066f7c802828beccd81f1 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 14 Jul 2026 22:46:42 +0800 Subject: [PATCH 2/6] fix(session): replace threshold-halt with single-empty-call retry for empty tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the threshold-based empty-step guard (tolerate N, halt on N+1) with an immediate retry mechanism that mirrors autoRetryTextToolCall: - A SINGLE empty tool call now triggers an immediate retry: the bad assistant turn is discarded (error-tagged with EmptyToolCallError) and a synthetic user turn is appended to re-drive generation. - Bounded by EMPTY_TOOL_CALL_RETRY_LIMIT (default 2, mirrors TEXT_TOOL_CALL_RETRY_LIMIT). On exhaustion the error stays terminal. - The empty turn is discarded from context (toModelMessages skips error-tagged messages) so it cannot poison later context. Changes: - message-v2.ts: add EmptyToolCallError to error discriminated union - flag.ts: add MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT (default 2), remove MIMOCODE_EMPTY_STEP_MAX_RECOVERY - prompt.ts: replace handleEmptyStep (threshold-halt) with autoRetryEmptyToolCall (immediate retry), wire into all 3 classify sites (existing-assistant, fork, main) - processor.ts: remove processor-level threshold-halt guard (EMPTY_STEP_THRESHOLD, emptyStepCount, isEmptyInput), retry now handled at prompt loop level - empty-step-detection.ts: keep isEmptyStep + isEmptyInput (with _meta filtering), remove unused threshold constants and nudge messages - Tests: update to assert retry behavior (single empty call → retry + error-tagged discard, exhaustion → halt, recovery → valid answer) --- packages/opencode/src/flag/flag.ts | 7 +- packages/opencode/src/session/message-v2.ts | 2 + packages/opencode/src/session/processor.ts | 30 ---- packages/opencode/src/session/prompt.ts | 138 ++++++++---------- .../session/prompt/empty-step-detection.ts | 71 ++++----- .../empty-step-guard-integration.test.ts | 87 ++++++++--- .../invalid-output-continuation.test.ts | 11 +- .../test/session/processor-effect.test.ts | 12 +- 8 files changed, 172 insertions(+), 186 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ee9229992..fd53a1179 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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 diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index f32120fd2..8fb0d7476 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -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() })) @@ -453,6 +454,7 @@ export const Assistant = Base.extend({ ContextOverflowError.Schema, InvalidOutputError.Schema, TextToolCallError.Schema, + EmptyToolCallError.Schema, ContentFilterError.Schema, ModelError.Schema, APIError.Schema, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 85839599c..71ce7a5db 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -27,7 +27,6 @@ import { isRecord } from "@/util/record" import { createTextNgramMonitor, type TextNgramMonitor } from "./prompt/text-ngram-detection" const DOOM_LOOP_THRESHOLD = 3 -const EMPTY_STEP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) export type Result = "overflow" | "stop" | "continue" | "text-repeat" @@ -150,7 +149,6 @@ interface ProcessorContext extends Input { stepPartIds: PartID[] textNgramMonitor: TextNgramMonitor | undefined textNgramRepeat: boolean - emptyStepCount: number } type StreamEvent = Event @@ -207,7 +205,6 @@ export const layer: Layer.Layer< stepPartIds: [], textNgramMonitor: undefined, textNgramRepeat: false, - emptyStepCount: 0, } let aborted = false // Only the main agent owns session-level status. Subagents (explore, @@ -326,22 +323,6 @@ export const layer: Layer.Layer< if (ctx.textNgramMonitor.append(text)) ctx.textNgramRepeat = true } - const isEmptyInput = (input: Record | undefined | null): boolean => { - if (input === undefined || input === null) return true - // Filter out meta/underscore-prefixed fields — they are harness - // bookkeeping, not actionable tool arguments. - const keys = Object.keys(input).filter((k) => !k.startsWith("_")) - if (keys.length === 0) return true - return keys.every((k) => { - const v = input[k] - if (v === undefined || v === null) return true - if (typeof v === "string") return v.trim().length === 0 - if (Array.isArray(v)) return v.length === 0 - if (typeof v === "object") return Object.keys(v as Record).length === 0 - return false - }) - } - const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { case "start": @@ -438,17 +419,6 @@ export const layer: Layer.Layer< : value.providerMetadata, })) - // Unconditional empty-step guard: genuine empty tool calls always - // counted, regardless of error/summary/structured/finish state. - if (isEmptyInput(value.input)) { - ctx.emptyStepCount++ - if (ctx.emptyStepCount >= EMPTY_STEP_THRESHOLD) { - throw new Error(`Empty tool call loop detected: ${ctx.emptyStepCount} consecutive empty tool calls. Session terminated.`) - } - return - } - ctx.emptyStepCount = 0 - const parts = MessageV2.parts(ctx.assistantMessage.id) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 484c87031..f92de0b67 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,9 +56,6 @@ 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, } from "../session/prompt/empty-step-detection" import { builtinSkillRoot, matchDocumentSkills } from "@/skill/builtin/extract" @@ -248,6 +245,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" }) @@ -2142,19 +2140,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". @@ -2707,21 +2696,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 }) { @@ -2737,40 +2719,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)) 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, @@ -2780,14 +2756,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: [ + "", + "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.", + "", + ].join("\n"), } satisfies MessageV2.TextPart) - yield* slog.info("empty step: recovery injected", { streak: emptyStepStreak }) - return "continue" as const + return true }) @@ -2940,6 +2922,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 @@ -3493,13 +3480,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", @@ -3719,13 +3703,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", @@ -3875,9 +3856,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 diff --git a/packages/opencode/src/session/prompt/empty-step-detection.ts b/packages/opencode/src/session/prompt/empty-step-detection.ts index 6665cf9a1..67119a22c 100644 --- a/packages/opencode/src/session/prompt/empty-step-detection.ts +++ b/packages/opencode/src/session/prompt/empty-step-detection.ts @@ -1,26 +1,26 @@ -import { Flag } from "@/flag/flag" import type { MessageV2 } from "../message-v2" /** - * Empty / no-op tool-call loop guard. + * Empty / no-op tool-call detection. * - * Sibling to text-ngram-detection: the text-ngram ladder only inspects TEXT - * parts, so a model that spins by re-emitting content-free tool calls (or by - * "calling a tool" that produces no structured tool part at all) slips past it - * with no text to match. stepSignature (prompt.ts) also misses this: it signs - * only `part.type === "tool"` steps and returns undefined for a step with zero - * tool parts, so an empty terminal step is dropped from repeat counting instead - * of counted. This module fills that gap with a pure classifier + a soft→hard - * recovery ladder mirroring TEXT_NGRAM_MAX_RECOVERY. + * Detects two shapes of invalid assistant output: * - * This is a HARNESS backstop for a MODEL bug: a degraded model that keeps - * emitting empty/no-op steps will never self-correct from a reminder, so after - * a bounded number of soft nudges the harness must hard-halt the turn rather - * than spin forever. + * (a) The step emitted one or more client (non-providerExecuted) tool parts, + * but EVERY such tool part has an empty/invalid input — no keys, or only + * keys whose values are null/undefined/empty-string/whitespace. The model + * "called a tool" but passed nothing actionable. + * + * (b) The step produced NO client tool part at all AND no substantive text and + * no substantive reasoning — a fully empty terminal. + * + * A step that emits at least one tool part with real input, or any substantive + * text/reasoning, is NOT empty — the model is making some kind of progress. + * + * Provider-executed tool parts (e.g. server-side web search) are ignored for + * the "has a tool part" test: they are not client actions and their presence + * does not mean the model issued an actionable call. */ -export const EMPTY_STEP_MAX_RECOVERY = Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY - /** * Is this assistant step an empty / no-op tool call? * @@ -34,9 +34,7 @@ export const EMPTY_STEP_MAX_RECOVERY = Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY * * (b) The step produced NO client tool part at all AND no substantive text and * no substantive reasoning — a fully empty terminal. (This overlaps with - * classify's `invalid`/"empty output", but we count it here too so the - * hard-halt ladder can escalate on a run of them rather than only softly - * nudging via autoContinueInvalidOutput.) + * classify's `invalid`/"empty output".) * * A step that emits at least one tool part with real input, or any substantive * text/reasoning, is NOT empty — the model is making some kind of progress. @@ -71,13 +69,17 @@ export function isEmptyStep(parts: readonly MessageV2.Part[]): boolean { } /** - * An input object counts as empty when it has no keys, or every value is - * null/undefined/empty-string/whitespace-only. Nested objects/arrays with any - * content count as non-empty (the model passed *something*). + * An input object counts as empty when it has no (non-meta) keys, or every + * value is null/undefined/empty-string/whitespace-only. Keys prefixed with + * "_" (harness bookkeeping like _meta) are excluded from the check. + * Nested objects/arrays with any content count as non-empty (the model + * passed *something*). */ -function isEmptyInput(input: Record | undefined | null): boolean { +export function isEmptyInput(input: Record | undefined | null): boolean { if (input === undefined || input === null) return true - const keys = Object.keys(input) + // Filter out meta/underscore-prefixed fields — they are harness + // bookkeeping, not actionable tool arguments. + const keys = Object.keys(input).filter((k) => !k.startsWith("_")) if (keys.length === 0) return true return keys.every((k) => isEmptyValue(input[k])) } @@ -90,24 +92,3 @@ function isEmptyValue(value: unknown): boolean { // number / boolean → the model passed a real value. return false } - -export const EMPTY_STEP_RECOVERY_REMIND = [ - "", - "NO PROGRESS: your previous step made no valid tool call and produced no answer", - "(the tool call had empty/invalid arguments, or there was no tool call and no text).", - "Stop repeating an empty step. Do exactly ONE of these now:", - "- 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.", - "", -].join("\n") - -export const EMPTY_STEP_RECOVERY_REPLAN = [ - "", - "STILL NO PROGRESS: you are repeating empty/no-op tool calls after a reminder.", - "This is your final chance before the turn is halted. You MUST either:", - "1. Send a single valid tool call whose arguments are fully populated, or", - "2. Give the user a plain-text response explaining the result or the blocker.", - "Any further empty/argument-less tool call will terminate this turn.", - "", -].join("\n") diff --git a/packages/opencode/test/session/empty-step-guard-integration.test.ts b/packages/opencode/test/session/empty-step-guard-integration.test.ts index 2246d0855..ad55bbc37 100644 --- a/packages/opencode/test/session/empty-step-guard-integration.test.ts +++ b/packages/opencode/test/session/empty-step-guard-integration.test.ts @@ -1,18 +1,23 @@ /** - * Integration tests for the empty/no-op tool-call loop guard (handleEmptyStep + + * Integration tests for the empty tool-call retry guard (autoRetryEmptyToolCall + * isEmptyStep). Driven end-to-end through Session.prompt against a scripted * HTTP LLM stub — same harness as classify-integration.test.ts. * * Root cause this guards: a degraded model can spin by emitting empty/no-op * steps (empty terminal, or a tool call with empty arguments). TEXT_NGRAM only * inspects text and stepSignature drops zero-tool steps, so neither counts the - * loop. The guard escalates soft (remind → replan) up to - * EMPTY_STEP_MAX_RECOVERY, then HARD-HALTS the turn. + * loop. * - * EMPTY_STEP_MAX_RECOVERY defaults to 2, so the ladder is: - * step 1 (empty) → streak 1 → REMIND nudge, continue - * step 2 (empty) → streak 2 → REPLAN nudge, continue - * step 3 (empty) → streak 3 > 2 → terminal error, break + * Design: a SINGLE empty tool call is an invalid output — it IMMEDIATELY + * triggers a RETRY that makes the model re-emit a valid call. The bad + * assistant turn is DISCARDED (error-tagged) and a synthetic user turn is + * appended to re-drive generation. On exhaustion (EMPTY_TOOL_CALL_RETRY_LIMIT + * retries) the turn is terminated. + * + * EMPTY_TOOL_CALL_RETRY_LIMIT defaults to 2, so the ladder is: + * call 1 (empty) → retry 1 (error-tagged, synthetic user appended) + * call 2 (empty) → retry 2 (error-tagged, synthetic user appended) + * call 3 (empty) → exhaustion → terminal error, break * i.e. exactly 3 model calls before the turn is halted. */ @@ -22,7 +27,8 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { SessionPrompt } from "../../src/session/prompt" -import { EMPTY_STEP_MAX_RECOVERY } from "../../src/session/prompt/empty-step-detection" +import { MessageV2 } from "../../src/session/message-v2" +import { Flag } from "../../src/flag/flag" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" import { startScriptedLLMServer, emptyStopResponse, textStopResponse } from "../lib/scripted-llm-server" @@ -53,12 +59,13 @@ function writeConfig(dir: string, origin: string) { ) } -describe("empty/no-op tool-call loop guard — integration", () => { - test("repeated empty steps HARD-HALT the turn instead of looping forever", async () => { +describe("empty tool-call retry — integration", () => { + test("repeated empty steps exhaust retries and halt the turn", async () => { await using tmp = await tmpdir({ git: true }) // Every response is an empty stop step. The stub repeats its last entry - // forever, so if the guard failed to halt this would spin indefinitely - // (the test would hang / time out). We assert it terminates. + // forever, so if the retry guard failed to halt this would spin indefinitely + // (the test would hang / time out). We assert it terminates after retries + // are exhausted. const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }]) try { await writeConfig(tmp.path, stub.origin) @@ -78,8 +85,8 @@ describe("empty/no-op tool-call loop guard — integration", () => { // Turn terminated (did not hang) with an error on the assistant. expect(result.info.role).toBe("assistant") if (result.info.role === "assistant") expect(result.info.error).toBeDefined() - // Bounded: EMPTY_STEP_MAX_RECOVERY soft nudges + 1 halting step. - expect(stub.captures.length).toBe(EMPTY_STEP_MAX_RECOVERY + 1) + // Bounded: EMPTY_TOOL_CALL_RETRY_LIMIT retries + 1 initial call. + expect(stub.captures.length).toBe(Flag.MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT + 1) }), ), }) @@ -88,12 +95,12 @@ describe("empty/no-op tool-call loop guard — integration", () => { } }) - test("a single empty step recovers when the next step produces a real answer (no halt)", async () => { + test("a single empty step triggers retry and recovers on next valid answer", async () => { await using tmp = await tmpdir({ git: true }) const stub = startScriptedLLMServer([ - // step 1: empty terminal → streak 1 → REMIND nudge, continue + // call 1: empty terminal → retry (error-tagged, synthetic user appended) { lines: emptyStopResponse() }, - // step 2: real answer → streak reset, loop exits cleanly + // call 2: real answer → loop exits cleanly { lines: textStopResponse("here is the real answer") }, ]) try { @@ -111,6 +118,7 @@ describe("empty/no-op tool-call loop guard — integration", () => { agent: "build", parts: [{ type: "text", text: "Do the task." }], }) + // First empty call → retry; second call → valid text. 2 total LLM calls. expect(stub.captures.length).toBe(2) expect(result.info.role).toBe("assistant") if (result.info.role === "assistant") expect(result.info.error).toBeUndefined() @@ -122,4 +130,49 @@ describe("empty/no-op tool-call loop guard — integration", () => { await stub.stop() } }) + + test("single empty step is discarded (error-tagged) from context", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = startScriptedLLMServer([ + { lines: emptyStopResponse() }, + { lines: textStopResponse("final answer after retry") }, + ]) + try { + await writeConfig(tmp.path, stub.origin) + await Instance.provide({ + directory: tmp.path, + fn: () => + run( + Effect.gen(function* () { + const sessions = yield* Session.Service + const prompt = yield* SessionPrompt.Service + const session = yield* sessions.create({ title: "empty-step-discard" }) + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Do the task." }], + }) + // The first (empty) assistant turn should be error-tagged (discarded) + // and not kept in context. The second turn should be the valid one. + const messages = yield* sessions.messages({ sessionID: session.id, agentID: "main" }) + const assistantMessages = messages.filter( + (m) => m.info.role === "assistant", + ) as Array<{ info: MessageV2.Assistant; parts: MessageV2.Part[] }> + // First assistant message should have an error (discarded) + expect(assistantMessages.length).toBeGreaterThanOrEqual(1) + if (assistantMessages[0]) { + expect(assistantMessages[0].info.error).toBeDefined() + expect(assistantMessages[0].info.error?.name).toBe("EmptyToolCallError") + } + // Final result should have no error + if (result.info.role === "assistant") { + expect(result.info.error).toBeUndefined() + } + }), + ), + }) + } finally { + await stub.stop() + } + }) }) diff --git a/packages/opencode/test/session/invalid-output-continuation.test.ts b/packages/opencode/test/session/invalid-output-continuation.test.ts index 5e58969a5..06224a6c8 100644 --- a/packages/opencode/test/session/invalid-output-continuation.test.ts +++ b/packages/opencode/test/session/invalid-output-continuation.test.ts @@ -115,12 +115,11 @@ describe("invalid-output continuation — integration", () => { } }) - test("repeated empty output is caught by the empty-step guard and halts the turn", async () => { + test("repeated empty output exhausts retry limit and halts the turn", async () => { await using tmp = await tmpdir({ git: true }) // Server repeats the last entry, so every call returns an empty stop. - // The empty/no-op tool-call guard (empty-step-detection) intercepts these - // empty terminals BEFORE autoContinueInvalidOutput and hard-halts the turn - // after EMPTY_STEP_MAX_RECOVERY soft nudges + 1 halting step. + // The empty tool-call retry guard (autoRetryEmptyToolCall) intercepts these + // and retries up to EMPTY_TOOL_CALL_RETRY_LIMIT, then terminates the turn. const stub = startScriptedLLMServer([{ lines: emptyStopResponse() }]) try { await writeConfig(tmp.path, stub.origin) @@ -137,8 +136,8 @@ describe("invalid-output continuation — integration", () => { agent: "build", parts: [{ type: "text", text: "Answer my question." }], }) - // EMPTY_STEP_MAX_RECOVERY soft nudges + 1 halting step. - expect(stub.captures.length).toBe(Flag.MIMOCODE_EMPTY_STEP_MAX_RECOVERY + 1) + // EMPTY_TOOL_CALL_RETRY_LIMIT retries + 1 initial call. + expect(stub.captures.length).toBe(Flag.MIMOCODE_EMPTY_TOOL_CALL_RETRY_LIMIT + 1) expect(result.info.role).toBe("assistant") if (result.info.role === "assistant") { expect(result.info.error).toBeDefined() diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 58aeb9ce4..7563fb49d 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -853,13 +853,15 @@ it.live("session.processor effect tests mark interruptions aborted without manua ), ) -it.live("session.processor effect tests stop on 3 consecutive empty tool calls", () => +it.live("session.processor effect tests: empty tool calls execute normally (retry handled by prompt loop)", () => provideTmpdirServer( ({ dir, llm }) => Effect.gen(function* () { const { processors, session, provider } = yield* boot() // Single response with 3 empty tool calls: two {}, one {_meta:'harmless'}. + // The processor no longer has a threshold-halt guard — empty tool calls + // execute normally. Retry logic lives in the prompt loop (autoRetryEmptyToolCall). yield* llm.push( raw({ head: [ @@ -911,9 +913,11 @@ it.live("session.processor effect tests stop on 3 consecutive empty tool calls", tools: { bash: noopTool }, }) - expect(value).toBe("stop") - expect(handle.message.error).toBeDefined() - expect(handle.message.error?.name).toBe("UnknownError") + // Processor no longer halts on empty tool calls — it returns "continue" + // (there are pending tool parts) so the prompt loop can handle retries. + expect(value).toBe("continue") + // No error on the message — processor executed the tools normally. + expect(handle.message.error).toBeUndefined() }), { git: true, config: (url) => providerCfg(url) }, ), From 8c260c541f9b8b4fc6ef2eb77228b8f558873260 Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 14 Jul 2026 23:18:40 +0800 Subject: [PATCH 3/6] fix(session): make isEmptyStep schema-aware to avoid false-positiving on no-arg tools isEmptyStep case (a) flagged every tool call with {} as empty, including legitimate no-arg tools (plan_exit, plan_enter, future z.object({}) tools). These tools accept no parameters - {} is the only valid call - but the guard treated it as empty, triggering a retry loop that could never succeed. Two fixes: 1. isEmptyStep now accepts an opts.toolHasArgs callback that checks whether a tools parameter schema has required fields. When toolHasArgs returns false (schema is z.object({}) or all-optional), {} input is NOT flagged. 2. Substantive text or reasoning alongside a tool call now prevents the empty classification - fixing the early-return bug where case (a) returned before the text/reasoning check at :60. prompt.ts builds the lookup from the tool registry (z.ZodObject shape introspection) and passes it to isEmptyStep via autoRetryEmptyToolCall. Regression tests: - No-arg tool called with {} - NOT flagged (with toolHasArgs) - Args-accepting tool called with {} - still flagged - Tool call + substantive text - NOT flagged (text overrides) - Legacy (no opts) - backwards-compatible, all {} inputs flagged --- packages/opencode/src/session/prompt.ts | 25 ++++++++- .../session/prompt/empty-step-detection.ts | 49 +++++++++++++---- .../test/session/empty-step-detection.test.ts | 53 ++++++++++++++++++- 3 files changed, 114 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f92de0b67..e0bda8361 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -57,6 +57,7 @@ import { } from "../session/prompt/text-ngram-detection" import { isEmptyStep, + type IsEmptyStepOptions, } from "../session/prompt/empty-step-detection" import { builtinSkillRoot, matchDocumentSkills } from "@/skill/builtin/extract" import { ToolRegistry } from "../tool" @@ -305,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() + 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 + 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() @@ -2722,7 +2745,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return false } const parts = MessageV2.parts(input.assistant.id) - if (!isEmptyStep(parts)) return false + 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. diff --git a/packages/opencode/src/session/prompt/empty-step-detection.ts b/packages/opencode/src/session/prompt/empty-step-detection.ts index 67119a22c..6f136055e 100644 --- a/packages/opencode/src/session/prompt/empty-step-detection.ts +++ b/packages/opencode/src/session/prompt/empty-step-detection.ts @@ -21,16 +21,31 @@ import type { MessageV2 } from "../message-v2" * does not mean the model issued an actionable call. */ +export interface IsEmptyStepOptions { + /** + * Given a tool name, return whether the tool's parameter schema accepts + * required arguments. Tools whose schema is z.object({}) (no required + * params) or all-optional always return valid calls with {} — they must + * NOT be flagged as empty. + * + * When omitted the check degrades to the legacy behaviour (all {} inputs + * flagged), which is backwards-compatible but will false-positive on + * no-arg tools like plan_exit / plan_enter. + */ + toolHasArgs?: (toolName: string) => boolean +} + /** * Is this assistant step an empty / no-op tool call? * * Two shapes count as empty (mirrors the task's definition): * * (a) The step emitted one or more client (non-providerExecuted) tool parts, - * but EVERY such tool part has an empty/invalid input — no keys, or only - * keys whose values are null/undefined/empty-string/whitespace. The model - * "called a tool" but passed nothing actionable, so the call cannot make - * progress and re-looping just repeats it. + * but EVERY such tool part has an empty/invalid input AND the tool + * actually accepts required arguments. A no-arg tool (schema z.object({})) + * called with {} is legitimate and is NOT flagged. Additionally, if the + * step carries substantive text or reasoning alongside the tool call(s), + * it is not treated as empty. * * (b) The step produced NO client tool part at all AND no substantive text and * no substantive reasoning — a fully empty terminal. (This overlaps with @@ -43,29 +58,43 @@ import type { MessageV2 } from "../message-v2" * the "has a tool part" test: they are not client actions and their presence * does not mean the model issued an actionable call. */ -export function isEmptyStep(parts: readonly MessageV2.Part[]): boolean { +export function isEmptyStep(parts: readonly MessageV2.Part[], opts?: IsEmptyStepOptions): boolean { const clientToolParts = parts.filter( (part): part is Extract => part.type === "tool" && !part.metadata?.providerExecuted, ) if (clientToolParts.length > 0) { - // (a) Every client tool part has an empty/invalid input. - return clientToolParts.every((part) => isEmptyInput(part.state.input)) + // If the step carries substantive text or reasoning alongside tool calls, + // the model is making progress — don't treat as empty regardless of input. + if (hasSubstantiveContent(parts)) return false + + // (a) Every client tool part has an empty/invalid input AND actually + // accepts required arguments. A no-arg tool (schema z.object({})) + // called with {} is a legitimate invocation — skip it. + return clientToolParts.every((part) => { + // If we can't introspect the schema, fall through to legacy behaviour. + const hasArgs = opts?.toolHasArgs?.(part.tool) ?? true + return hasArgs && isEmptyInput(part.state.input) + }) } // (b) No client tool part — empty only if there is also no substantive // text and no substantive reasoning (a pure-empty terminal). A step with a // real text answer or real reasoning is a legitimate (non-loop) outcome. + return !hasSubstantiveContent(parts) +} + +/** Does this step carry any substantive text or reasoning? */ +function hasSubstantiveContent(parts: readonly MessageV2.Part[]): boolean { const hasSubstantiveText = parts.some( (part) => part.type === "text" && !part.synthetic && !part.ignored && part.text.trim().length > 0, ) - if (hasSubstantiveText) return false + if (hasSubstantiveText) return true const hasSubstantiveReasoning = parts.some( (part) => part.type === "reasoning" && part.text.trim().length > 0, ) - if (hasSubstantiveReasoning) return false - return true + return hasSubstantiveReasoning } /** diff --git a/packages/opencode/test/session/empty-step-detection.test.ts b/packages/opencode/test/session/empty-step-detection.test.ts index 02835f1ab..2c5bbb5d5 100644 --- a/packages/opencode/test/session/empty-step-detection.test.ts +++ b/packages/opencode/test/session/empty-step-detection.test.ts @@ -5,10 +5,10 @@ import type { MessageV2 } from "../../src/session/message-v2" // Minimal part builders — only the fields isEmptyStep inspects. Cast through // unknown so we don't have to satisfy the full PartBase shape (id/messageID/…) // that isEmptyStep never reads. -function toolPart(input: Record, opts?: { providerExecuted?: boolean; status?: string }) { +function toolPart(input: Record, opts?: { providerExecuted?: boolean; status?: string; name?: string }) { return { type: "tool", - tool: "read", + tool: opts?.name ?? "read", metadata: opts?.providerExecuted ? { providerExecuted: true } : undefined, state: { status: opts?.status ?? "completed", input }, } as unknown as MessageV2.Part @@ -27,6 +27,9 @@ function reasoningPart(text: string) { return { type: "reasoning", text } as unknown as MessageV2.Part } +// toolHasArgs lookup: "plan_exit" and "plan_enter" have no required params. +const toolHasArgs = (name: string) => name !== "plan_exit" && name !== "plan_enter" + describe("isEmptyStep — case (a): tool call with empty/invalid input", () => { test("tool call with no keys is empty", () => { expect(isEmptyStep([toolPart({})])).toBe(true) @@ -64,6 +67,52 @@ describe("isEmptyStep — case (a): tool call with empty/invalid input", () => { }) }) +describe("isEmptyStep — schema-aware: no-arg tools are not flagged", () => { + test("no-arg tool (plan_exit) called with {} is NOT flagged empty", () => { + expect(isEmptyStep([toolPart({}, { name: "plan_exit" })], { toolHasArgs })).toBe(false) + }) + + test("no-arg tool (plan_enter) called with {} is NOT flagged empty", () => { + expect(isEmptyStep([toolPart({}, { name: "plan_enter" })], { toolHasArgs })).toBe(false) + }) + + test("args-accepting tool (read) called with {} IS still flagged empty", () => { + expect(isEmptyStep([toolPart({}, { name: "read" })], { toolHasArgs })).toBe(true) + }) + + test("without toolHasArgs, legacy behaviour: all {} inputs flagged", () => { + // No opts → defaults to hasArgs=true for all tools (backwards-compat). + expect(isEmptyStep([toolPart({}, { name: "plan_exit" })])).toBe(true) + }) + + test("no-arg tool with substantive text alongside is NOT empty", () => { + expect( + isEmptyStep( + [textPart("Exiting plan mode."), toolPart({}, { name: "plan_exit" })], + { toolHasArgs }, + ), + ).toBe(false) + }) + + test("args-accepting tool with substantive text alongside is NOT empty", () => { + expect( + isEmptyStep( + [textPart("Reading the file."), toolPart({}, { name: "read" })], + { toolHasArgs }, + ), + ).toBe(false) + }) + + test("no-arg tool with substantive reasoning alongside is NOT empty", () => { + expect( + isEmptyStep( + [reasoningPart("Let me exit plan mode."), toolPart({}, { name: "plan_exit" })], + { toolHasArgs }, + ), + ).toBe(false) + }) +}) + describe("isEmptyStep — case (b): empty terminal (no valid tool part, no output)", () => { test("completely empty parts array is empty", () => { expect(isEmptyStep([])).toBe(true) From 0fd80d0dab62778bce505f5a013ec219b2786392 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 15:28:08 +0800 Subject: [PATCH 4/6] design: Orchestrator route-first redesign (route-to-existing as primary, create as fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add design document for the Orchestrator route-first redesign. Core thesis: the Orchestrator is a router, not a creator — its default action should be 'route to an existing session' (session send), with create as a fallback when no existing session fits. Key design points: - New session route operation as first-class routing primitive - Harness injects live active-sessions context into orchestrator prompt - create demoted to fallback (only when no existing session matches) - orchestrator.txt decision guidance rewritten from decompose→dispatch to route→(create only if none fits) - 4-phase implementation roadmap: context injection → prompt rewrite → route primitive → deprecated path cleanup --- ...07-14-orchestrator-route-first-redesign.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md new file mode 100644 index 000000000..ccbf33007 --- /dev/null +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -0,0 +1,317 @@ +--- +date: 2026-07-14 +topic: orchestrator-route-first-redesign +--- + +# Orchestrator Route-First Redesign + +## Problem Frame + +The MiMoCode Orchestrator (`src/agent/agent.ts:231`, gated by `MIMOCODE_EXPERIMENTAL_ORCHESTRATOR`) is an experimental persistent coordinator that delegates work to background child sessions via the `session` tool. Its current architecture suffers from a **create-first default** that causes session explosion. + +### Symptom: Session Explosion + +In practice, the Orchestrator面对同一条主题的反复工作请求时, 每次都倾向于 `session create` 新建子会话, 而不是复用已有的。一个典型场景: + +1. 用户说 "fix the login bug" → Orchestrator creates child A for "fix login bug" +2. 用户说 "also handle the signup flow" → Orchestrator creates child B (could have been routed to A) +3. 用户 says "one more thing about auth" → Orchestrator creates child C (again, A or B could handle this) + +结果: 三个子会话做本质上同主题的工作, 每个都有独立的上下文和内存, 没有共享任何进展。 + +### Root Cause: create 耦合了路由和创建 + +当前 `session create` 命令同时承担两个职责: +- **路由决策**: 这条任务该交给哪个已存在的会话? +- **创建行为**: 如果没有合适的, 新建一个 + +`--topic` 机制是对此的修补 — 它在 create 内部加了一层 find-or-reuse, 但: +1. **topic 字符串匹配不可靠**: LLM 传什么 topic 取决于 prompt engineering, 语义漂移是必然的 (PR #1727 去掉了严格 topic 字符串匹配, 是止血不是根本解) +2. **topic 必填只保证"有值"不保证"语义正确"**: Orchestrator 可以给同一个主题传不同的 topic 值, 匹配就失效了 +3. **复用 ≠ 给 create 找一个 key**: 真正的复用是"从现有会话里选一个最合适的发过去", 不是"给新会话打个标签以便下次匹配" + +### Why Topic Matching Cannot Work (Any Variant) + +| Variant | Why It Fails | +|---------|-------------| +| Exact string match | LLM 不可能每次都传完全相同的字符串 | +| Fuzzy / semantic match | 需要 embedding 或 LLM 判断, 增加延迟和复杂度, 且仍然依赖 LLM 正确提取"主题" | +| Topic 必填 | 保证有值, 不保证语义正确; LLM 会乱传 | +| Task-ID 绑定 | task 是廉价的, 一个 session 本该服务多个 task; task↔session 非一一对应 | +| Topic hierarchy | 过度工程; 真正需要的只是"看一眼活会话列表, 选一个发过去" | + +**核心洞察**: 所有 topic 变体都错在同一个假设 — 把复用当成"给 create 找一个 key"。但真正的复用模式是 **人看聊天列表选一个发消息** — 你不会给每个聊天窗口打标签然后按标签匹配, 你看一眼列表就知道该发给谁。 + +## First-Principles Analysis + +### Orchestrator 的本质: 传声筒/路由器 + +Orchestrator 不是 "decompose → dispatch (create)" 模型。它的本质是: + +> **面对一条工作, 决定"传给哪个已存在的会话"** + +这个决策的输入是: +- 活会话清单 (谁在线, 在做什么, 做到哪了) +- 当前任务的语义 +- 会话之间的依赖关系 + +决策的输出是: +- route-to-existing: 把任务发给某个已有会话 (`session send`) +- create-as-fallback: 清单里没合适的 → 新建一个, 加入清单 + +### 当前模型 vs 目标模型 + +``` +Current: user task → decompose → create (default) → (maybe topic reuse) + ↑ create 是一等操作 + +Target: user task → route-to-existing (default) → create (fallback only) + ↑ route/send 是一等操作 +``` + +### 类比: 人如何管理多会话 + +一个人面对多个聊天窗口时: +1. 看一眼所有活跃窗口 (session list) +2. 根据消息内容判断该发给谁 (route decision) +3. 如果没有合适的窗口, 新开一个 (create as fallback) + +人不会: 收到消息 → 新建窗口 → 给窗口打标签 → 期望下次能按标签找到。 + +## Target Design + +### R1: 一等 route 原语 + +新增 `session route` 操作, 作为 Orchestrator 的 **默认第一动作**: + +``` +session route +``` + +**行为**: +1. 自动获取活会话清单 (内置于 route 实现, 不需要 Orchestrator 手动 list) +2. 基于任务语义 + 会话清单, 由 harness 注入的上下文辅助决策 +3. 如果匹配到合适的已有会话 → `session send` 到该会话, 返回路由结果 +4. 如果没有合适的 → 返回 "no match, recommend create" + 建议的 mode/dir 参数 + +**关键区别**: route 是 **决策操作**, 不是创建操作。它的输出是 "我选了会话 X, 因为 Y" 或 "没有合适的, 建议新建"。 + +### R2: Harness 注入活会话清单 + +Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: + +**注入内容** (每次 Orchestrator turn 开始时): + +```xml + + + Working on: OAuth token refresh logic. 3 commits on mimocode/fix-login. + + + Completed: schema设计完成, 等待用户确认后实施。 + + + Last activity: 15min ago. May need nudge. + + +``` + +**注入位置**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)。在 agent prompt 组装完成后、plugin transform 前, 注入一个 `` block。这个 block 由 `session list` 的数据自动生成, 不需要 Orchestrator 主动调用。 + +**内容来源**: +- `sessions.children(ctx.sessionID)` 获取子会话列表 +- `actorReg.get()` 获取 actor 状态 (mode, status, last turn time) +- `deriveLiveness()` 计算进度状态 +- 每个会话的最近任务摘要 (从 session title + last message 提取) + +### R3: create 降级为 fallback + +`session create` 保留但语义变化: + +- **之前**: create 是默认操作, Orchestrator 的第一反应 +- **之后**: create 是 "route 发现没有合适会话时的 fallback" +- `--topic` 机制保留但降级为可选的 hint, 不再是路由的核心 + +Orchestrator 的决策流程变为: + +``` +1. 收到用户任务 +2. 看 (自动注入, 不需要 list 调用) +3. 判断: 有没有一个现有会话适合处理这个任务? + ├─ Yes → session send + └─ No → session create [新建后加入清单] +4. 返回结果给用户 +``` + +### R4: orchestrator.txt 决策指引重写 + +orchestrator.txt 的核心变化: + +| Section | Before | After | +|---------|--------|-------| +| 核心循环 | decompose → dispatch (create) | route → (create only if none fits) | +| session tool 参考 | create 是主要操作 | send 是主要操作, create 是 fallback | +| 复用指引 | "reuse a standing session per theme" via topic | "route to existing sessions" — 看清单选一个 | +| 新增 | — | "route decision" section: 如何从活会话清单中选择 | + +## Code Impact Analysis + +### 1. session 工具原语重排 + +**File**: `packages/opencode/src/tool/session.ts` + +| Current | Change | Impact | +|---------|--------|--------| +| `create` (line 613-739) | 保留, 移除 topic find-or-reuse 逻辑 (lines 621-661), 降级为纯创建 | 中等 — topic 逻辑移出 | +| `send` (line 742-810) | 保留不变, 成为主要操作 | 无 | +| `list` (line 813-883) | 保留, 新增 `summary` 返回格式供 context 注入使用 | 低 — 新增输出格式 | +| `topicOf` (line 187) | 保留但标记 deprecated; 不再是路由核心 | 低 | +| `tagTitle` (line 192) | 保留但标记 deprecated | 低 | +| **新增** `route` | 新操作: 获取清单 → 匹配 → send 或 recommend create | 高 — 核心新逻辑 | + +`route` 操作的伪代码: + +```typescript +if (op.action === "route") { + // 1. Get active sessions (same enrichment as list) + const children = yield* sessions.children(ctx.sessionID) + const enriched = yield* Effect.forEach(children, ...) + const peers = enriched.filter(/* real peers only */) + + // 2. Build routing context + const sessions Summary = peers.map(({ child, actor }) => ({ + id: child.id, + title: child.title, + mode: actor?.agent, + status: deriveLiveness(actor, now), + dir: child.directory, + })) + + // 3. LLM-assisted matching (or heuristic) + const match = findBestMatch(op.task, sessionsSummary) + + if (match) { + // 4a. Route to existing + yield* inboxSvc.send({ receiverSessionID: match.id, ... content: op.task }) + return { output: `Routed to ${match.id} (${match.title})`, ... } + } else { + // 4b. No match — recommend create + return { + output: `No existing session matches. Recommend: session create with mode=${op.suggestedMode}, dir=${op.suggestedDir}`, + metadata: { recommendCreate: true, ... } + } + } +} +``` + +### 2. Harness 向 Orchestrator 注入活会话清单 + +**File**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`) + +在 `buildSystemArray` 中, 对 orchestrator agent 类型, 注入 `` block: + +```typescript +// After agent prompt assembly (line 260), before plugin transform (line 292) +if (input.agent.name === "orchestrator") { + const sessionCtx = yield* buildActiveSessionsContext(input.sessionID) + if (sessionCtx) system.push(sessionCtx) +} +``` + +`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 但输出为 XML 格式而非人类可读的列表。 + +**注入时机**: 每次 Orchestrator 发起 LLM 请求时, system prompt 中包含最新的活会话快照。这意味着 Orchestrator 在做路由决策时, **不需要调用 `session list`** — 清单已经在上下文里了。 + +### 3. orchestrator.txt 决策指引 + +**File**: `packages/opencode/src/session/prompt/orchestrator.txt` + +核心重写部分: + +- **Line 1-5 (Identity)**: 强调 "route-first coordinator", 而非 "decompose-and-dispatch leader" +- **Line 22-30 (The loop)**: 循环改为 "understand → route (to existing or create) → yield → integrate → report" +- **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback +- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "route to existing — see active-sessions context" +- **新增 Route Decision section**: 指导 Orchestrator 如何利用 `` 上下文做路由决策 + +### 4. 涉及文件汇总 + +| File | Change Type | Description | +|------|-------------|-------------| +| `packages/opencode/src/tool/session.ts` | **修改** | 新增 `route` 操作; `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | +| `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context | +| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first | +| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 session.ts) | +| `packages/opencode/src/tool/session.ts` (schemas) | **修改** | Zod schema 新增 `routeOperation` | +| `packages/opencode/src/tool/session.ts` (KNOWN_VERBS) | **修改** | 加入 `"route"` | + +## Implementation Roadmap + +### Phase 1: Context Injection (harness 层, 不改产品行为) + +**Goal**: Orchestrator 的 system prompt 中自动包含活会话清单, 但不改变任何路由行为。 + +1. 在 `llm.ts:buildSystemArray` 中, 对 orchestrator agent 注入 `` XML block +2. 数据来源复用 `sessions.children` + `actorReg.get` + `deriveLiveness` (已有逻辑) +3. Orchestrator 现在能"看到"活会话列表, 但仍使用旧的 create-first 流程 +4. **验证**: Orchestrator 的回复中能引用具体会话 ID 和状态 (证明它看到了清单) + +**风险**: 注入增加 system prompt 大小。需要监控 token 使用。活会话数量通常 <10, 增量 <500 tokens。 + +### Phase 2: orchestrator.txt 重写 (prompt 层, 改变行为) + +**Goal**: 通过 prompt 引导, 让 Orchestrator 优先 route-to-existing 而非 create。 + +1. 重写 orchestrator.txt 的核心循环和决策指引 +2. 新增 "Route Decision" section: 如何从 `` 中选择目标 +3. 将 `send` 提升为主要操作, `create` 标注为 fallback +4. **验证**: Orchestrator 面对同主题的第二个任务时, 优先尝试 `session send` 到已有会话 + +**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。Phase 3 通过硬编码 route 原语来加强。 + +### Phase 3: route 原语 (工具层, 硬编码路由) + +**Goal**: 新增 `session route` 操作, 将路由逻辑从 prompt 引导提升为工具级实现。 + +1. 在 `session.ts` 新增 `route` verb 和对应的 Zod schema +2. 实现: 获取清单 → 匹配 (可先用启发式, 后续可用 LLM) → send 或 recommend-create +3. route 操作内置于工具, 不依赖 LLM 做路由决策 (消除 LLM 传错 topic 的问题) +4. **验证**: `session route "fix login bug"` 自动选择正确的已有会话 + +**可选增强**: +- Phase 3a: 启发式匹配 (基于 title 关键词 + mode + dir) +- Phase 3b: LLM-assisted matching (把任务 + 清单交给 LLM 做选择, 更准确但有延迟) + +### Phase 4: 清理 deprecated 路径 + +1. `--topic` 参数标记 deprecated, 保留向后兼容但不再推荐 +2. `topicOf` / `tagTitle` 辅助函数标记 deprecated +3. orchestrator.txt 中移除旧的 topic-based reuse 指引 +4. 更新 harness 文档 (`docs/harness/MiMo Orchestrator Mode.md`) + +## Scope Boundaries + +- **本设计不涉及**: 并发路由冲突处理 (多个 Orchestrator 实例路由到同一会话)、跨 Orchestrator 会话路由、session 持久化 schema 变更 +- **本设计不实现**: 只出设计文档 + 实施路线, 不改产品代码 +- **向后兼容**: `session create` 保持可用, `--topic` 保留但 deprecated, 现有 Orchestrator 行为在 Phase 1-2 期间不变 + +## Key Decisions + +- **route 作为一等操作**: 路由逻辑内置于工具层, 不依赖 LLM 正确传 topic — 消除了 topic 匹配的根本不可靠性 +- **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌, 而非需要主动调用 list — 降低认知负担 +- **分阶段实施**: Phase 1-2 是 prompt/harness 层变更, 风险低; Phase 3 是工具层变更, 需要更多测试; Phase 4 是清理 + +## Dependencies / Assumptions + +- Orchestrator 当前是 experimental (flag-gated), 本 redesign 在 experimental 阶段实施, 无需 migration +- `sessions.children` + `actorReg.get` + `deriveLiveness` 已经提供了足够的会话状态数据 +- 活会话数量通常 <20, context injection 的 token 开销可接受 + +## References + +- `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/topicOf/tagTitle) +- `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词 +- `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray) +- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义 +- `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档 +- PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign) From ffa2b08e43f2fff694d01842f5b2d3519b125cef Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 16:21:51 +0800 Subject: [PATCH 5/6] =?UTF-8?q?design:=20AI-route=20revision=20=E2=80=94?= =?UTF-8?q?=20remove=20tool-level=20matching,=20AI=20routes=20via=20inject?= =?UTF-8?q?ed=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision addressing user feedback: the Orchestrator is itself an AI that understands semantics — routing decisions should be made by the AI, not by tool-level matching algorithms. Key changes: - Removed session route operation and findBestMatch pseudocode entirely - New Core Principle: 'AI Routes, Tools Provide + Execute' - Added 'Why Tool-Level Matching Cannot Work' section - R1 is now context injection (harness provides the list) - R2 is orchestrator.txt rewrite guiding AI to route-first - No new tool verb needed — AI uses existing session send/create - Phase 3 changed from 'hardcoded route primitive' to 'optional prompt strengthening if Phase 2 guidance is insufficient' - Phase 1 + Phase 2 are the main body; no tool-layer matching ever --- ...07-14-orchestrator-route-first-redesign.md | 184 +++++++++--------- 1 file changed, 93 insertions(+), 91 deletions(-) diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md index ccbf33007..ceabd03b7 100644 --- a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -1,6 +1,9 @@ --- date: 2026-07-14 topic: orchestrator-route-first-redesign +revisions: + - date: 2026-07-15 + change: "AI-route revision: removed tool-level matching (findBestMatch/heuristic/embedding). Route decision is entirely AI-side — harness injects , prompt guides AI to route-first, AI uses existing session send/create directly. No new route tool operation." --- # Orchestrator Route-First Redesign @@ -42,6 +45,16 @@ In practice, the Orchestrator面对同一条主题的反复工作请求时, 每 **核心洞察**: 所有 topic 变体都错在同一个假设 — 把复用当成"给 create 找一个 key"。但真正的复用模式是 **人看聊天列表选一个发消息** — 你不会给每个聊天窗口打标签然后按标签匹配, 你看一眼列表就知道该发给谁。 +### Why Tool-Level Matching Also Cannot Work + +初版设计曾提出 `session route` 操作, 内置 `findBestMatch` (启发式/embedding/LLM-assisted) 做自动匹配。这也是错的: + +- **Orchestrator 本身就是 AI** — 它能理解语义、判断相关性、权衡上下文。让工具层用机械匹配替代 AI 的语义判断, 是倒退。 +- **匹配逻辑无法覆盖所有场景**: "这个任务该交给谁" 取决于任务内容、会话历史、用户意图、依赖关系 — 这些是 AI 的强项, 不是算法的强项。 +- **增加一层抽象但没有增加能力**: 工具层匹配只是把 AI 的路由决策权抢走, 然后用一个更差的决策替代。 + +**正确分工**: 工具层提供 **信息** (活会话清单) 和 **执行** (send/create), AI 做 **决策** (路由到谁)。 + ## First-Principles Analysis ### Orchestrator 的本质: 传声筒/路由器 @@ -65,38 +78,38 @@ Orchestrator 不是 "decompose → dispatch (create)" 模型。它的本质是: Current: user task → decompose → create (default) → (maybe topic reuse) ↑ create 是一等操作 -Target: user task → route-to-existing (default) → create (fallback only) - ↑ route/send 是一等操作 +Target: user task → AI reads → route (send) or create + ↑ AI 做路由决策, 工具只提供清单+执行 ``` ### 类比: 人如何管理多会话 一个人面对多个聊天窗口时: -1. 看一眼所有活跃窗口 (session list) -2. 根据消息内容判断该发给谁 (route decision) +1. 看一眼所有活跃窗口 (自动注入的清单) +2. 根据消息内容判断该发给谁 (AI 的语义判断) 3. 如果没有合适的窗口, 新开一个 (create as fallback) 人不会: 收到消息 → 新建窗口 → 给窗口打标签 → 期望下次能按标签找到。 +人也不会: 收到消息 → 让算法自动匹配 → 发给匹配结果。 + +人会: 看一眼列表, 自己决定发给谁。 ## Target Design -### R1: 一等 route 原语 +### Core Principle: AI Routes, Tools Provide + Execute -新增 `session route` 操作, 作为 Orchestrator 的 **默认第一动作**: +整个设计的核心原则: -``` -session route -``` +> **路由决策是 AI 的职责。工具层只负责两件事: (1) 提供活会话清单作为 AI 的决策输入; (2) 执行 AI 选定的 send/create 操作。** -**行为**: -1. 自动获取活会话清单 (内置于 route 实现, 不需要 Orchestrator 手动 list) -2. 基于任务语义 + 会话清单, 由 harness 注入的上下文辅助决策 -3. 如果匹配到合适的已有会话 → `session send` 到该会话, 返回路由结果 -4. 如果没有合适的 → 返回 "no match, recommend create" + 建议的 mode/dir 参数 +没有独立的 `route` 工具操作。没有 `findBestMatch`。没有启发式匹配。没有 embedding 相似度。AI 看着清单, 自己决定 send 给谁。 -**关键区别**: route 是 **决策操作**, 不是创建操作。它的输出是 "我选了会话 X, 因为 Y" 或 "没有合适的, 建议新建"。 +这意味着: +- **不需要新的 tool verb** — AI 直接用现有的 `session send` 和 `session create` +- **不需要工具层的匹配逻辑** — 路由决策完全在 prompt + AI 层 +- **最小化代码变更** — 核心变更是 (1) context injection, (2) prompt rewrite -### R2: Harness 注入活会话清单 +### R1: Harness 注入活会话清单 Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: @@ -124,12 +137,46 @@ Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊 - `deriveLiveness()` 计算进度状态 - 每个会话的最近任务摘要 (从 session title + last message 提取) +### R2: orchestrator.txt 决策指引重写 + +orchestrator.txt 的核心变化 — 让 AI 自己做路由决策: + +| Section | Before | After | +|---------|--------|-------| +| 核心循环 | decompose → dispatch (create) | understand → **route** (AI reads list, decides send or create) → yield → integrate → report | +| session tool 参考 | create 是主要操作 | **send 是主要操作**, create 是 fallback | +| 复用指引 | "reuse a standing session per theme" via topic | "see `` in your context — pick the best match and `session send`" | +| 新增 Route Decision | — | AI 如何从清单中选择: 看 title/mode/status/dir, 结合任务语义判断 | + +**orchestrator.txt 新增 Route Decision section 的内容指引**: + +``` +## Routing: route to existing sessions first + +Your system prompt contains an block listing ALL your live +child sessions with their id, title, mode, status, directory, and recent activity. +This is your fleet — use it. + +When a new task arrives, your FIRST action is to decide: does an existing session +already own this work? Look at and evaluate: +- Which session's title/theme matches this task's domain? +- Which session's mode (build/plan/compose) is appropriate? +- Is the session idle (ready for new work) or progressing (can accept follow-up)? +- Does the session's directory match where this work belongs? + +If you find a good match → `session send ` (route to existing). +If no session fits → `session create ` (create as fallback). + +DO NOT create a new session when an existing one can handle the work. +One session serving multiple related tasks is the norm, not the exception. +``` + ### R3: create 降级为 fallback `session create` 保留但语义变化: - **之前**: create 是默认操作, Orchestrator 的第一反应 -- **之后**: create 是 "route 发现没有合适会话时的 fallback" +- **之后**: create 是 "AI 判断没有合适会话时的 fallback" - `--topic` 机制保留但降级为可选的 hint, 不再是路由的核心 Orchestrator 的决策流程变为: @@ -137,26 +184,15 @@ Orchestrator 的决策流程变为: ``` 1. 收到用户任务 2. 看 (自动注入, 不需要 list 调用) -3. 判断: 有没有一个现有会话适合处理这个任务? - ├─ Yes → session send - └─ No → session create [新建后加入清单] +3. AI 判断: 有没有一个现有会话适合处理这个任务? + ├─ Yes → session send (AI 自己选 ID) + └─ No → session create (AI 自己决定参数) 4. 返回结果给用户 ``` -### R4: orchestrator.txt 决策指引重写 - -orchestrator.txt 的核心变化: - -| Section | Before | After | -|---------|--------|-------| -| 核心循环 | decompose → dispatch (create) | route → (create only if none fits) | -| session tool 参考 | create 是主要操作 | send 是主要操作, create 是 fallback | -| 复用指引 | "reuse a standing session per theme" via topic | "route to existing sessions" — 看清单选一个 | -| 新增 | — | "route decision" section: 如何从活会话清单中选择 | - ## Code Impact Analysis -### 1. session 工具原语重排 +### 1. session 工具: 无新 verb, 仅清理 **File**: `packages/opencode/src/tool/session.ts` @@ -167,42 +203,8 @@ orchestrator.txt 的核心变化: | `list` (line 813-883) | 保留, 新增 `summary` 返回格式供 context 注入使用 | 低 — 新增输出格式 | | `topicOf` (line 187) | 保留但标记 deprecated; 不再是路由核心 | 低 | | `tagTitle` (line 192) | 保留但标记 deprecated | 低 | -| **新增** `route` | 新操作: 获取清单 → 匹配 → send 或 recommend create | 高 — 核心新逻辑 | -`route` 操作的伪代码: - -```typescript -if (op.action === "route") { - // 1. Get active sessions (same enrichment as list) - const children = yield* sessions.children(ctx.sessionID) - const enriched = yield* Effect.forEach(children, ...) - const peers = enriched.filter(/* real peers only */) - - // 2. Build routing context - const sessions Summary = peers.map(({ child, actor }) => ({ - id: child.id, - title: child.title, - mode: actor?.agent, - status: deriveLiveness(actor, now), - dir: child.directory, - })) - - // 3. LLM-assisted matching (or heuristic) - const match = findBestMatch(op.task, sessionsSummary) - - if (match) { - // 4a. Route to existing - yield* inboxSvc.send({ receiverSessionID: match.id, ... content: op.task }) - return { output: `Routed to ${match.id} (${match.title})`, ... } - } else { - // 4b. No match — recommend create - return { - output: `No existing session matches. Recommend: session create with mode=${op.suggestedMode}, dir=${op.suggestedDir}`, - metadata: { recommendCreate: true, ... } - } - } -} -``` +**关键: 没有新的 tool verb**。AI 直接用 `session send` 执行路由, 用 `session create` 作为 fallback。工具层零新增 API。 ### 2. Harness 向 Orchestrator 注入活会话清单 @@ -229,21 +231,21 @@ if (input.agent.name === "orchestrator") { 核心重写部分: - **Line 1-5 (Identity)**: 强调 "route-first coordinator", 而非 "decompose-and-dispatch leader" -- **Line 22-30 (The loop)**: 循环改为 "understand → route (to existing or create) → yield → integrate → report" +- **Line 22-30 (The loop)**: 循环改为 "understand → route (AI reads list, decides send or create) → yield → integrate → report" - **Line 48-59 (session tool reference)**: `send` 提升为主要操作, `create` 标注为 fallback -- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "route to existing — see active-sessions context" -- **新增 Route Decision section**: 指导 Orchestrator 如何利用 `` 上下文做路由决策 +- **Line 82-88 (Reuse section)**: 从 "reuse per theme via topic" 改为 "see `` — pick the best match and send" +- **新增 Route Decision section**: 指导 AI 如何利用 `` 上下文做路由决策 (见 R2) ### 4. 涉及文件汇总 | File | Change Type | Description | |------|-------------|-------------| -| `packages/opencode/src/tool/session.ts` | **修改** | 新增 `route` 操作; `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | | `packages/opencode/src/session/llm.ts` | **修改** | `buildSystemArray` 中注入 `` context | -| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first | -| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 session.ts) | -| `packages/opencode/src/tool/session.ts` (schemas) | **修改** | Zod schema 新增 `routeOperation` | -| `packages/opencode/src/tool/session.ts` (KNOWN_VERBS) | **修改** | 加入 `"route"` | +| `packages/opencode/src/session/prompt/orchestrator.txt` | **修改** | 决策指引从 create-first 改为 route-first; 新增 Route Decision section | +| `packages/opencode/src/tool/session.ts` | **修改** | `create` 中移除 topic find-or-reuse; `list` 新增 summary 格式 | +| `packages/opencode/src/session/prompt.ts` | **小改** | `buildActiveSessionsContext` 新函数 (可放此处或 llm.ts) | + +**注意**: 没有新增 Zod schema, 没有新增 KNOWN_VERBS, 没有新增 tool verb。核心变更是 context injection + prompt rewrite。 ## Implementation Roadmap @@ -260,27 +262,26 @@ if (input.agent.name === "orchestrator") { ### Phase 2: orchestrator.txt 重写 (prompt 层, 改变行为) -**Goal**: 通过 prompt 引导, 让 Orchestrator 优先 route-to-existing 而非 create。 +**Goal**: 通过 prompt 引导, 让 AI 优先 route-to-existing 而非 create。这是 **主体工作**。 1. 重写 orchestrator.txt 的核心循环和决策指引 -2. 新增 "Route Decision" section: 如何从 `` 中选择目标 +2. 新增 "Route Decision" section: AI 如何从 `` 中选择目标 3. 将 `send` 提升为主要操作, `create` 标注为 fallback -4. **验证**: Orchestrator 面对同主题的第二个任务时, 优先尝试 `session send` 到已有会话 +4. 移除旧的 topic-based reuse 指引 +5. **验证**: Orchestrator 面对同主题的第二个任务时, 优先 `session send` 到已有会话 -**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。Phase 3 通过硬编码 route 原语来加强。 +**风险**: prompt 引导是"软约束" — LLM 可能仍然偶尔 create。但这是 AI 路由的正确模型: 不是强制, 而是引导。如果引导不够强, 迭代 prompt (加 more explicit examples/constraints) 而非引入工具层匹配。 -### Phase 3: route 原语 (工具层, 硬编码路由) +### Phase 3: 可选加强 (如果 Phase 2 的 prompt 引导不够) -**Goal**: 新增 `session route` 操作, 将路由逻辑从 prompt 引导提升为工具级实现。 +**Goal**: 如果纯 prompt 引导后 Orchestrator 仍然过度 create, 加强引导而非引入匹配。 -1. 在 `session.ts` 新增 `route` verb 和对应的 Zod schema -2. 实现: 获取清单 → 匹配 (可先用启发式, 后续可用 LLM) → send 或 recommend-create -3. route 操作内置于工具, 不依赖 LLM 做路由决策 (消除 LLM 传错 topic 的问题) -4. **验证**: `session route "fix login bug"` 自动选择正确的已有会话 +可能的加强手段 (按优先级): +1. **更强的 prompt 约束**: 在 orchestrator.txt 中加明确的 "MUST check active-sessions before create" + 反面示例 +2. **create 前拦截**: 在 `session create` 的工具实现中, 如果 `` 中有高度相关的会话, 返回 warning 而非直接创建 (注意: 这仍然是 AI 看到 warning 后自己决定, 不是工具自动匹配) +3. **指标监控**: 跟踪 create vs send 比率, 如果 create 率过高则迭代 prompt -**可选增强**: -- Phase 3a: 启发式匹配 (基于 title 关键词 + mode + dir) -- Phase 3b: LLM-assisted matching (把任务 + 清单交给 LLM 做选择, 更准确但有延迟) +**不做的事**: 启发式匹配、embedding 相似度、工具层自动路由。这些都违反 "AI routes" 原则。 ### Phase 4: 清理 deprecated 路径 @@ -297,9 +298,10 @@ if (input.agent.name === "orchestrator") { ## Key Decisions -- **route 作为一等操作**: 路由逻辑内置于工具层, 不依赖 LLM 正确传 topic — 消除了 topic 匹配的根本不可靠性 +- **AI 路由, 工具不匹配**: 路由决策完全由 AI 做 — 基于注入的 `` 清单和任务语义。工具层不实现任何匹配逻辑 (findBestMatch/heuristic/embedding)。AI 是最好的路由器。 +- **不需要新的 route 工具操作**: AI 直接用现有的 `session send` 执行路由, 用 `session create` 作为 fallback。最小化代码变更。 - **context injection 而非 on-demand query**: 活会话清单注入 system prompt, 让 Orchestrator 每次 turn 都能看到全貌, 而非需要主动调用 list — 降低认知负担 -- **分阶段实施**: Phase 1-2 是 prompt/harness 层变更, 风险低; Phase 3 是工具层变更, 需要更多测试; Phase 4 是清理 +- **prompt 引导而非硬编码**: 路由行为通过 prompt 迭代优化, 而非工具层强制。如果引导不够, 加强 prompt 而非引入匹配算法。 ## Dependencies / Assumptions @@ -312,6 +314,6 @@ if (input.agent.name === "orchestrator") { - `packages/opencode/src/tool/session.ts` — session tool 实现 (create/send/list/topicOf/tagTitle) - `packages/opencode/src/session/prompt/orchestrator.txt` — orchestrator 系统提示词 - `packages/opencode/src/session/llm.ts:240-306` — system prompt 组装 (buildSystemArray) -- `packages/opencode/src/agent/agent.ts:231-251` — orchestrator agent 定义 +- `packages/opencode/src/agent/agent.ts:231-251` — orchestrestrator agent 定义 - `docs/harness/MiMo Orchestrator Mode.md` — orchestrator 模式文档 - PR #1727 — 去掉 topic 字符串匹配 (止血, 非本 redesign) From f826382e4a74afd370199ce201bb9ed00e6c2192 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 18:07:12 +0800 Subject: [PATCH 6/6] =?UTF-8?q?design:=20add=20R1.1=20injection=20strategy?= =?UTF-8?q?=20=E2=80=94=20compact=20summary=20+=20on-demand=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New section addressing the context bloat problem: injecting full session details every turn wastes tokens on non-routing turns and scales poorly. R1.1 analyzes 5 injection strategies (on-demand pull, full detail, compact summary, conditional, incremental) and recommends compact summary + on-demand detail: - Default: inject compact one-liner per session (id|title|mode|status) only for non-terminal sessions (~30 tokens/session) - On-demand: AI uses session ask/status for details when needed - No dir or recent-activity in the injected block (confirmation-level info, not decision-level) Also updated R1 example to match compact format, R2 prompt example to reference compact format, and Code Impact section accordingly. --- ...07-14-orchestrator-route-first-redesign.md | 90 +++++++++++++++---- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md index ceabd03b7..8d5f62f9f 100644 --- a/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md +++ b/docs/compose/specs/2026-07-14-orchestrator-route-first-redesign.md @@ -113,29 +113,82 @@ Target: user task → AI reads → route (send) or create Orchestrator 的 system prompt 需要注入 **活会话上下文**, 像人看聊天列表一样: -**注入内容** (每次 Orchestrator turn 开始时): +**注入内容** (每次 Orchestrator turn 开始时, 极简摘要格式): ```xml - - Working on: OAuth token refresh logic. 3 commits on mimocode/fix-login. - - - Completed: schema设计完成, 等待用户确认后实施。 - - - Last activity: 15min ago. May need nudge. - + ses_abc123 | Fix login bug | build | progressing + ses_def456 | Design billing schema | compose | idle + ses_ghi789 | Triage repo issues | build | stalled ``` +每个会话一行: `id | title | mode | status`。只有 4 个字段, 没有 dir 和最近任务详情。AI 需要详情时, 自己调用 `session ask` 或 `session status` 按需查询。详见 R1.1 注入策略。 + **注入位置**: `packages/opencode/src/session/llm.ts:240-306` (`buildSystemArray`)。在 agent prompt 组装完成后、plugin transform 前, 注入一个 `` block。这个 block 由 `session list` 的数据自动生成, 不需要 Orchestrator 主动调用。 **内容来源**: - `sessions.children(ctx.sessionID)` 获取子会话列表 -- `actorReg.get()` 获取 actor 状态 (mode, status, last turn time) -- `deriveLiveness()` 计算进度状态 -- 每个会话的最近任务摘要 (从 session title + last message 提取) +- `actorReg.get()` 获取 actor 状态 (mode, agent type) +- `deriveLiveness()` 计算进度状态 (progressing/stalled/idle/terminal) +- Terminal 状态 (success/failed/cancelled) 的会话不注入 — 只列活跃会话 + + +### R1.1: `` Injection Strategy + +R1 描述了注入什么, 但没有回答 **怎么注入** — 特别是: 是每轮全量注入, 还是有更聪明的策略? 这个问题在会话数增长后变得关键。 + +#### 问题: 全量详情注入的代价 + +如果每轮 turn 都把完整的 `` (含 dir、最近任务详情等) 注入 system prompt: +- **Context 膨胀**: N 个会话 × 每个 ~100 tokens = N×100 tokens, 每轮重复。20 个会话就是 ~2000 tokens/轮。 +- **重复浪费**: 大部分 turn (正和某子会话对话、做非路由工作) 根本不需要全量清单。Orchestrator 和 child A 对话时, B/C/D/E 的详情是噪音。 +- **Cache 失效**: prompt cache 依赖 system prompt 前缀稳定; 清单每轮变 (状态/新会话) 导致 cache 频繁失效。 + +#### 方案对比 + +| 方案 | 描述 | 优点 | 缺点 | +|------|------|------|------| +| **A: 按需拉取** | 不注入, 提供轻量 `session list` 动作让 AI "要路由才查" | 零常驻开销 | 回到靠 LLM 自觉去查 — 用户已批评过依赖自觉; AI 可能忘记查就直接 create | +| **B: 全量详情注入** | 每轮注入完整清单 (id/title/mode/status/dir/最近任务) | AI 始终有完整信息 | Context 膨胀; 大部分 turn 浪费; cache 失效 | +| **C: 极简摘要注入** | 每轮注入极简清单 (id/title/mode/status, 一行一会话, 无 dir/详情) | 低成本 (N 行 ≈ N×30 tokens); AI 有足够信息做路由决策; 需要详情时自己 ask | 信息密度低于 B, 但路由决策通常不需要 dir/详情 | +| **D: 条件注入** | 只在"新工作到达需路由决策"的 turn 注入, 非每轮 | 精准 | 需要判定"何时该注入" — 增加判定逻辑复杂度 | +| **E: 增量注入** | 只注入变化 (新会话/状态变更), 非每轮全量 | 低带宽 | 需要 diff 逻辑; AI 可能丢失已消失会话的信息; 实现复杂 | + +#### 推荐: 极简摘要 + 按需详情 (C 为主, A 为辅) + +**默认注入极简摘要** (方案 C), AI 需要详情时 **按需查询** (方案 A 作为补充): + +```xml + + ses_abc123 | Fix login bug | build | progressing + ses_def456 | Design billing schema | compose | idle + ses_ghi789 | Triage repo issues | build | stalled + +``` + +**为什么这组最优**: + +1. **极简摘要足够做路由决策**: 路由只需要 "谁在线、在做什么、什么模式"。id + title + mode + status 四个字段覆盖了 90% 的路由判断。Dir 和最近任务详情是 "确认级" 信息, 不是 "决策级" 信息 — AI 先凭摘要选定目标, 需要确认时再 `session ask` 或 `session status` 查详情。 + +2. **成本可控**: 一行 ~30 tokens。10 个会话 = ~300 tokens, 20 个会话 = ~600 tokens。相比全量详情 (10 个会话 ~1000 tokens) 小一个数量级。即使 50 个会话也只 ~1500 tokens, 可接受。 + +3. **天然过滤已归档会话**: 只列非 terminal 状态 (progressing/stalled/idle) 的会话。已 success/failed/cancelled 的会话不注入 — 它们不需要路由, 且会无限膨胀清单。需要查询已归档会话时, AI 自己 `session list` 或 `session ask`。 + +4. **不依赖 LLM 自觉**: 与方案 A 纯按需不同, 极简摘要是 **默认注入** — AI 每轮 turn 都能看到清单, 不需要记住去查。只是清单是精简版, 不是完整版。 + +5. **Prompt cache 友好**: 极简摘要变化频率低于全量详情 (status 变化 < 详情变化)。且因为体量小, 即使 cache 失效, 重建成本也低。 + +**AI 需要详情时的按需路径**: + +``` +AI 看极简摘要 → 选定目标会话 → 需要确认细节? + ├─ 不需要 → session send (直接路由) + └─ 需要 → session status 或 session ask (按需查详情) +``` + +**实现**: `buildActiveSessionsContext` 函数输出极简格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态。注入位置不变 (`buildSystemArray`, orchestrator agent 类型)。 + ### R2: orchestrator.txt 决策指引重写 @@ -153,8 +206,8 @@ orchestrator.txt 的核心变化 — 让 AI 自己做路由决策: ``` ## Routing: route to existing sessions first -Your system prompt contains an block listing ALL your live -child sessions with their id, title, mode, status, directory, and recent activity. +Your system prompt contains an block listing your live +child sessions in compact format: id | title | mode | status. This is your fleet — use it. When a new task arrives, your FIRST action is to decide: does an existing session @@ -162,7 +215,10 @@ already own this work? Look at and evaluate: - Which session's title/theme matches this task's domain? - Which session's mode (build/plan/compose) is appropriate? - Is the session idle (ready for new work) or progressing (can accept follow-up)? -- Does the session's directory match where this work belongs? + +If you need more detail about a session (its directory, recent commits, etc.), +use `session status ` or `session ask ` — the compact list gives you +enough to route; details are on-demand. If you find a good match → `session send ` (route to existing). If no session fits → `session create ` (create as fallback). @@ -220,7 +276,7 @@ if (input.agent.name === "orchestrator") { } ``` -`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 但输出为 XML 格式而非人类可读的列表。 +`buildActiveSessionsContext` 是一个新函数, 复用 `list` 操作的数据获取逻辑 (lines 820-826), 输出极简 XML 格式 (一行一会话, 只含 id/title/mode/status), 过滤 terminal 状态会话。详见 R1.1 注入策略。 **注入时机**: 每次 Orchestrator 发起 LLM 请求时, system prompt 中包含最新的活会话快照。这意味着 Orchestrator 在做路由决策时, **不需要调用 `session list`** — 清单已经在上下文里了。