Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/opencode/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -360,6 +372,7 @@ export namespace MessageV2 {
AuthError.Schema,
NamedError.Unknown.Schema,
OutputLengthError.Schema,
ReasoningStuckError.Schema,
AbortedError.Schema,
APIError.Schema,
])
Expand Down Expand Up @@ -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(
{
Expand Down
88 changes: 87 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,28 @@ export namespace SessionProcessor {
try {
let currentText: MessageV2.TextPart | undefined
let reasoningMap: Record<string, MessageV2.ReasoningPart> = {}
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()
Expand Down Expand Up @@ -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 <think> / 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

Expand All @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -335,6 +420,7 @@ export namespace SessionProcessor {
continue
}
if (needsCompaction) break
if (blocked) break
}
} catch (e: any) {
log.error("process", {
Expand Down
26 changes: 24 additions & 2 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading