From eb77fbc13b382eb46c5158165124c6e015449a21 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:55:24 +0000 Subject: [PATCH 1/3] fix(cli): prevent agent loop hang when stream ends without stop_reason Some Anthropic-compatible providers terminate the response stream with a final message_delta that carries stop_reason: null followed immediately by message_stop. The AI SDK then never sets a finishReason on the assistant message, the prompt loop's break check stays false, and the agent keeps issuing new model calls indefinitely. Default the missing finish to "unknown" so the existing exit condition fires when no tool calls were emitted. --- .changeset/sturdy-finish-guard.md | 5 +++++ packages/opencode/src/session/prompt.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changeset/sturdy-finish-guard.md diff --git a/.changeset/sturdy-finish-guard.md b/.changeset/sturdy-finish-guard.md new file mode 100644 index 00000000000..c308018de97 --- /dev/null +++ b/.changeset/sturdy-finish-guard.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent an infinite agent loop when a provider ends the response stream without a terminal stop reason. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f8dd0dbd2fe..967db90dcb0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1600,6 +1600,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the toolChoice: format.type === "json_schema" ? "required" : undefined, }) + // kilocode_change start - guard against providers that end the stream + // without a terminal stop_reason (e.g. an Anthropic-style message_delta + // with stop_reason: null followed immediately by message_stop). Without + // a finishReason, the loop-exit check below sees a falsy `finish` and + // keeps stepping forever. Default to "unknown" so the regular break + // condition fires when there are no tool calls, while still allowing + // the loop to continue when tool calls were emitted. + handle.message.finish = handle.message.finish ?? "unknown" + // kilocode_change end + if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" From d8642deec6ec53530da94046ab41f2f69185ae68 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 14:59:51 +0000 Subject: [PATCH 2/3] fix(cli): persist defaulted finish reason so loop break check sees it The loop reloads messages from storage at the top of each iteration via filterCompactedEffect, so an in-memory mutation on handle.message does not propagate. Persist the defaulted finish via updateMessage so the next iteration's break check actually observes it. --- packages/opencode/src/session/prompt.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 967db90dcb0..5d79e000645 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1603,11 +1603,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the // kilocode_change start - guard against providers that end the stream // without a terminal stop_reason (e.g. an Anthropic-style message_delta // with stop_reason: null followed immediately by message_stop). Without - // a finishReason, the loop-exit check below sees a falsy `finish` and - // keeps stepping forever. Default to "unknown" so the regular break - // condition fires when there are no tool calls, while still allowing - // the loop to continue when tool calls were emitted. - handle.message.finish = handle.message.finish ?? "unknown" + // a finishReason, the loop-exit check at the top of the next iteration + // sees a falsy `finish` (loaded from storage via filterCompactedEffect) + // and keeps stepping forever. Default to "unknown" and persist so the + // regular break condition fires when there are no tool calls — the + // tool-call path is unaffected because `!hasToolCalls` keeps the break + // gated when the assistant did emit tool calls. + if (!handle.message.finish) { + handle.message.finish = "unknown" + yield* sessions.updateMessage(handle.message) + } // kilocode_change end if (structured !== undefined) { From 4398150bdc6860ba173e4e4a7862d24d98f2aa70 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 15:12:15 +0000 Subject: [PATCH 3/3] fix(cli): defer finish-reason default until after compact branch Setting handle.message.finish before the result === "compact" branch broke guardCompactionAttempt (which fills in "error" with ??=) and the overflow detection at compaction.create. Move the default to right before "continue" and gate it on result !== "compact" so the compaction paths see the original undefined value. --- packages/opencode/src/session/prompt.ts | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5d79e000645..ca5549d13ff 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1600,21 +1600,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the toolChoice: format.type === "json_schema" ? "required" : undefined, }) - // kilocode_change start - guard against providers that end the stream - // without a terminal stop_reason (e.g. an Anthropic-style message_delta - // with stop_reason: null followed immediately by message_stop). Without - // a finishReason, the loop-exit check at the top of the next iteration - // sees a falsy `finish` (loaded from storage via filterCompactedEffect) - // and keeps stepping forever. Default to "unknown" and persist so the - // regular break condition fires when there are no tool calls — the - // tool-call path is unaffected because `!hasToolCalls` keeps the break - // gated when the assistant did emit tool calls. - if (!handle.message.finish) { - handle.message.finish = "unknown" - yield* sessions.updateMessage(handle.message) - } - // kilocode_change end - if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" @@ -1672,6 +1657,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } // kilocode_change end + // kilocode_change start - guard against providers that end the stream + // without a terminal stop_reason (e.g. an Anthropic-style message_delta + // with stop_reason: null followed immediately by message_stop). Without + // a finishReason, the loop-exit check at the top of the next iteration + // sees a falsy `finish` (loaded from storage via filterCompactedEffect) + // and keeps stepping forever. Default to "unknown" and persist so the + // regular break condition fires when there are no tool calls. Skipped + // for the compact path so guardCompactionAttempt can still fill in + // "error" on exhaustion. Tool-call turns already get "tool-calls" from + // the AI SDK; even without it, !hasToolCalls keeps the break gated. + if (result !== "compact" && !handle.message.finish) { + handle.message.finish = "unknown" + yield* sessions.updateMessage(handle.message) + } + // kilocode_change end return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) if (outcome === "break") break