diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index d7120aa5e..75868a322 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -2,6 +2,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Config } from "../config/config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" +import { MessageV2 } from "../session/message-v2" // kilocode_change import { UI } from "./ui" export function FormatError(input: unknown) { @@ -38,6 +39,12 @@ export function FormatError(input: unknown) { ].join("\n") if (UI.CancelledError.isInstance(input)) return "" + + // kilocode_change start + if (MessageV2.ReasoningStuckError.isInstance(input)) { + return `Model got stuck producing reasoning only (chars: ${input.data.chars}, threshold: ${input.data.threshold}). Try retrying, switching models, or reducing task complexity.` + } + // kilocode_change end } export function FormatUnknownError(input: unknown): string { diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6358c6c5e..8c7bd2d02 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,6 +15,18 @@ import type { Provider } from "@/provider/provider" export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) + + // kilocode_change start + export const ReasoningStuckError = NamedError.create( + "MessageReasoningStuckError", + z.object({ + message: z.string(), + threshold: z.number(), + chars: z.number(), + finish: z.string().optional(), + }), + ) + // kilocode_change end export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const AuthError = NamedError.create( "ProviderAuthError", @@ -360,6 +372,7 @@ export namespace MessageV2 { AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema, + ReasoningStuckError.Schema, AbortedError.Schema, APIError.Schema, ]) @@ -676,6 +689,8 @@ export namespace MessageV2 { ).toObject() case MessageV2.OutputLengthError.isInstance(e): return e + case MessageV2.ReasoningStuckError.isInstance(e): + return e case LoadAPIKeyError.isInstance(e): return new MessageV2.AuthError( { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 270710561..9f590bee3 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -50,7 +50,28 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = await LLM.stream(streamInput) + let reasoningChars = 0 + let produced = false + + const limit = streamInput.model.limit.output || 0 + // kilocode_change start + // Heuristic guardrail: output limits are token-based, but we only have streaming deltas here. + // Use a conservative character threshold derived from configured output token limit (clamped). + const reasoningCharThreshold = Math.max( + 8_000, + Math.min(64_000, limit > 0 ? Math.floor(limit * 0.5) : 16_000), + ) + // kilocode_change end + + const controller = new AbortController() + streamInput.abort.addEventListener( + "abort", + () => { + controller.abort() + }, + { once: true }, + ) + const stream = await LLM.stream({ ...streamInput, abort: controller.signal }) for await (const value of stream.fullStream) { input.abort.throwIfAborted() @@ -80,8 +101,35 @@ export namespace SessionProcessor { if (value.id in reasoningMap) { const part = reasoningMap[value.id] part.text += value.text + reasoningChars += value.text.length if (value.providerMetadata) part.metadata = value.providerMetadata if (part.text) await Session.updatePart({ part, delta: value.text }) + + // kilocode_change start - prevent infinite reasoning-only streams + // Some providers/models can get stuck streaming / reasoning without ever producing text or tool calls. + // If this happens, stop early so the session loop can exit. + if (!produced && reasoningChars >= reasoningCharThreshold) { + const now = Date.now() + input.assistantMessage.error = new MessageV2.ReasoningStuckError({ + message: "Model got stuck producing reasoning only", + threshold: reasoningCharThreshold, + chars: reasoningChars, + finish: input.assistantMessage.finish, + }).toObject() + input.assistantMessage.time.completed = now + await Session.updateMessage(input.assistantMessage) + blocked = true + controller.abort() + for (const k of Object.keys(reasoningMap)) { + const p = reasoningMap[k] + p.text = p.text.trimEnd() + p.time.end = now + await Session.updatePart(p) + delete reasoningMap[k] + } + break + } + // kilocode_change end } break @@ -101,6 +149,7 @@ export namespace SessionProcessor { break case "tool-input-start": + produced = true const part = await Session.updatePart({ id: toolcalls[value.id]?.id ?? Identifier.ascending("part"), messageID: input.assistantMessage.id, @@ -124,6 +173,7 @@ export namespace SessionProcessor { break case "tool-call": { + produced = true const match = toolcalls[value.toolCallId] if (match) { const part = await Session.updatePart({ @@ -170,6 +220,7 @@ export namespace SessionProcessor { break } case "tool-result": { + produced = true const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { await Session.updatePart({ @@ -194,6 +245,7 @@ export namespace SessionProcessor { } case "tool-error": { + produced = true const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { await Session.updatePart({ @@ -253,6 +305,38 @@ export namespace SessionProcessor { cost: usage.cost, }) await Session.updateMessage(input.assistantMessage) + + // kilocode_change start - prevent infinite reasoning-only streams + // If a step finishes with only reasoning (no text, no tools), treat it as terminal to avoid endless looping. + if (!produced && reasoningChars > 0 && !input.assistantMessage.error) { + const now = Date.now() + input.assistantMessage.error = new MessageV2.ReasoningStuckError({ + message: "Model got stuck producing reasoning only", + threshold: reasoningCharThreshold, + chars: reasoningChars, + finish: input.assistantMessage.finish, + }).toObject() + input.assistantMessage.time.completed = now + await Session.updateMessage(input.assistantMessage) + blocked = true + controller.abort() + for (const k of Object.keys(reasoningMap)) { + const p = reasoningMap[k] + p.text = p.text.trimEnd() + p.time.end = now + await Session.updatePart(p) + delete reasoningMap[k] + } + + if (currentText && currentText.time) { + currentText.text = currentText.text.trimEnd() + currentText.time.end = now + await Session.updatePart(currentText) + currentText = undefined + } + } + // kilocode_change end + if (snapshot) { const patch = await Snapshot.patch(snapshot) if (patch.files.length) { @@ -292,6 +376,7 @@ export namespace SessionProcessor { case "text-delta": if (currentText) { + if (value.text) produced = true currentText.text += value.text if (value.providerMetadata) currentText.metadata = value.providerMetadata if (currentText.text) @@ -335,6 +420,7 @@ export namespace SessionProcessor { continue } if (needsCompaction) break + if (blocked) break } } catch (e: any) { log.error("process", { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a1a1d481b..9774bbbe7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -144,6 +144,16 @@ export type MessageOutputLengthError = { } } +export type MessageReasoningStuckError = { + name: "MessageReasoningStuckError" + data: { + message: string + threshold: number + chars: number + finish?: string + } +} + export type MessageAbortedError = { name: "MessageAbortedError" data: { @@ -175,7 +185,13 @@ export type AssistantMessage = { created: number completed?: number } - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageReasoningStuckError + | MessageAbortedError + | ApiError parentID: string modelID: string providerID: string @@ -818,7 +834,13 @@ export type EventSessionError = { type: "session.error" properties: { sessionID?: string - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageReasoningStuckError + | MessageAbortedError + | ApiError } }