From 832554bb4882dbcffa5d1ba9c35ae8c167cb4778 Mon Sep 17 00:00:00 2001 From: salacoste Date: Wed, 7 Jan 2026 15:50:00 +0300 Subject: [PATCH 1/3] fix: add final-response fallback and todo guard Handle reasoning/tool-only replies by routing through a final agent and disabling tools per message. Pause automatic TODO continuation unless the user explicitly asks to continue, and track todo_state in storage. Add documentation for both fixes and adjust a test to avoid bun test.concurrent. --- bun.lock | 2 +- docs/changes-2026-01-07.md | 45 ++++++ docs/reasoning-only-fallback-plan.md | 42 ++++++ docs/todo-loop-guard.md | 36 +++++ packages/opencode/src/agent/agent.ts | 16 ++ packages/opencode/src/agent/prompt/final.txt | 8 + packages/opencode/src/session/llm.ts | 6 + packages/opencode/src/session/prompt.ts | 145 ++++++++++++++++++- packages/opencode/src/session/todo.ts | 35 ++++- packages/opencode/src/tool/todo.ts | 1 + packages/opencode/test/session/retry.test.ts | 2 +- 11 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 docs/changes-2026-01-07.md create mode 100644 docs/reasoning-only-fallback-plan.md create mode 100644 docs/todo-loop-guard.md create mode 100644 packages/opencode/src/agent/prompt/final.txt diff --git a/bun.lock b/bun.lock index 84cb3b37489..b90fe9d8536 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opencode", diff --git a/docs/changes-2026-01-07.md b/docs/changes-2026-01-07.md new file mode 100644 index 00000000000..1d63f3008b4 --- /dev/null +++ b/docs/changes-2026-01-07.md @@ -0,0 +1,45 @@ +# OpenCode Fixes - 2026-01-07 + +## Summary +This update addresses two user-facing issues observed in OpenCode sessions: +1) Missing final responses when the model returns reasoning-only or tool-only output. +2) TODO continuation loops when a session has pending todos but the user did not ask to continue. + +## Issue A: Reasoning-only / Tool-only Responses + +### Problem +Some providers return responses that contain only reasoning blocks or tool calls without any visible text. The TUI filters these out, so the user sees no final answer. + +### Fix +- Detect assistant messages that finish without any visible text but contain reasoning/tool parts. +- Enqueue a synthetic user message that asks for a final response. +- Route to a hidden `final` agent with tools disabled and a prompt that enforces plain text output. + +### Files +- `packages/opencode/src/session/prompt.ts` +- `packages/opencode/src/session/llm.ts` +- `packages/opencode/src/agent/agent.ts` +- `packages/opencode/src/agent/prompt/final.txt` +- `docs/reasoning-only-fallback-plan.md` + +## Issue B: TODO Continuation Loop + +### Problem +If a session has pending todos, strong TODO-focused prompts can cause the model to repeatedly continue the todo list, even when the user did not explicitly ask to resume. This appears as a loop. + +### Fix +- Add a per-session TODO state (`todo_state`) with `paused` tracking. +- When a new user message arrives: + - If there are pending todos and the message is not a continuation request, pause todo continuation and inject a short system guard. + - If the user explicitly asks to continue, unpause. +- Clear the pause state on any `todowrite` update. + +### Files +- `packages/opencode/src/session/todo.ts` +- `packages/opencode/src/tool/todo.ts` +- `packages/opencode/src/session/prompt.ts` +- `docs/todo-loop-guard.md` + +## Tests +- `bun test` in `packages/opencode` (all passing). + diff --git a/docs/reasoning-only-fallback-plan.md b/docs/reasoning-only-fallback-plan.md new file mode 100644 index 00000000000..5b2981ec8a1 --- /dev/null +++ b/docs/reasoning-only-fallback-plan.md @@ -0,0 +1,42 @@ +# Reasoning-Only Response Fallback + +## Problem +OpenCode sometimes receives assistant responses that contain only reasoning blocks or tool calls without any visible text. The TUI filters out reasoning-only/tool-only content, so the user sees no final answer in the console. + +## Goal +Ensure every assistant response yields a user-facing text reply whenever possible, without breaking the existing session flow. + +## Approach (Implemented) +- Detect assistant messages that finished successfully but have **no visible text parts** and contain **reasoning or tool parts**. +- Before the loop exits, enqueue a synthetic user message that asks for a final response. +- Route that message through a hidden `final` agent with tools disabled. +- Mark the synthetic fallback message so it is not re-triggered. +- Log a warning when the fallback triggers for observability. + +## Architecture Notes +- Fallback detection lives in `SessionPrompt.loop` and uses `MessageV2` parts to determine if text is visible. +- A synthetic user message is inserted via `Session.updateMessage`/`Session.updatePart` to reuse the normal processing path. +- The `final` agent has a focused prompt and deny-all permissions. +- Per-message tool overrides support `{"*": false}` to disable all tools for the fallback. + +## Key Code Paths +- `packages/opencode/src/session/prompt.ts` + - Detect reasoning/tool-only assistant responses. + - Enqueue synthetic fallback user message with metadata marker. + - Apply per-message tool overrides including wildcard disable. +- `packages/opencode/src/agent/agent.ts` + - Add hidden `final` agent. +- `packages/opencode/src/agent/prompt/final.txt` + - Prompt that enforces a final text response with no tool calls. +- `packages/opencode/src/session/llm.ts` + - Support `user.tools["*"] === false` to disable tools at the stream layer. + +## Guardrails +- The fallback message is marked with metadata (`finalFallback`) to avoid repeated fallback loops. +- If the fallback agent still returns reasoning-only, the loop exits normally to avoid infinite retries. + +## Suggested Tests +- Reasoning-only response triggers fallback and produces visible text. +- Tool-only response triggers fallback and produces visible text. +- Fallback message is not re-triggered. +- Normal text response does not trigger fallback. diff --git a/docs/todo-loop-guard.md b/docs/todo-loop-guard.md new file mode 100644 index 00000000000..89de3b6aa46 --- /dev/null +++ b/docs/todo-loop-guard.md @@ -0,0 +1,36 @@ +# Todo Loop Guard + +## Problem +When a session has pending todos, the model can keep trying to continue them even when the user did not ask to resume. With strong TODO-focused system prompts and custom instructions, this can cause repeated “continue TODO” outputs and a perception of looping. + +## Goals +- Prevent automatic continuation of old todos unless the user explicitly asks. +- Preserve the ability to use todos for new work. +- Keep behavior predictable and safe for UI and CLI. + +## Approach +1. Track a lightweight per-session TODO state (`paused`, `updatedAt`, `lastUpdatedMessageID`). +2. When a new user message arrives: + - If there are pending todos and the message is **not** a continuation request, mark the todo list as `paused`. + - If the user explicitly asks to continue/resume, unpause. +3. When todos are paused and still pending, inject a short system-level guard: + - Do **not** continue pending todos unless the user explicitly asks. + - Ask a brief clarification if needed. +4. When `todowrite` updates the list, clear the paused flag. + +## Continuation Detection +Treat as explicit continuation if user text contains: +- English: `continue`, `resume`, `proceed`, `keep going`, `next step` +- Russian: `продолж`, `дальше`, `продолжай` + +## Files +- `packages/opencode/src/session/todo.ts` + - Add state storage and helpers. +- `packages/opencode/src/tool/todo.ts` + - Clear paused state on `todowrite`. +- `packages/opencode/src/session/prompt.ts` + - Evaluate pending todos, continuation intent, and inject a pause guard. + +## Notes +- No UI changes required. The guard is a server-side safety net. +- This does **not** auto-cancel todos; it only pauses continuation until explicit user intent. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cc8942c2aef..676692c8105 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -10,6 +10,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" +import PROMPT_FINAL from "./prompt/final.txt" import { PermissionNext } from "@/permission/next" import { mergeDeep, pipe, sortBy, values } from "remeda" @@ -164,6 +165,21 @@ export namespace Agent { ), prompt: PROMPT_SUMMARY, }, + final: { + name: "final", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + "*": "deny", + }), + user, + ), + prompt: PROMPT_FINAL, + }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { diff --git a/packages/opencode/src/agent/prompt/final.txt b/packages/opencode/src/agent/prompt/final.txt new file mode 100644 index 00000000000..7e2a76c6338 --- /dev/null +++ b/packages/opencode/src/agent/prompt/final.txt @@ -0,0 +1,8 @@ +Provide the final user-facing response to the last request. + +Rules: +- Output plain text only. +- Do not call tools or mention tool execution. +- Do not include reasoning or internal thoughts. +- Keep the response concise and in the user's language. +- If a brief clarification is required, ask one short question. diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 7e2967e31ba..782ff4ce2e8 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -201,6 +201,12 @@ export namespace LLM { } async function resolveTools(input: Pick) { + if (input.user.tools?.["*"] === false) { + for (const key of Object.keys(input.tools)) { + if (key !== "invalid") delete input.tools[key] + } + return input.tools + } const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) for (const tool of Object.keys(input.tools)) { if (input.user.tools?.[tool] === false || disabled.has(tool)) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 09155c86e7d..3f0201303a1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -44,6 +44,7 @@ import { SessionStatus } from "./status" import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" +import { Todo } from "./todo" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -229,6 +230,49 @@ export namespace SessionPrompt { return parts } + const FINAL_FALLBACK_METADATA = "finalFallback" + const TODO_CONTINUE_RE = + /\b(continue|resume|proceed|keep going|next step)\b|(?:\u043f\u0440\u043e\u0434\u043e\u043b\u0436|\u0434\u0430\u043b\u044c\u0448\u0435)/i + const TODO_PAUSE_SYSTEM = + "There is a pending todo list from earlier. Do not continue it unless the user explicitly asks to continue. Ask a brief clarification if needed." + + function hasVisibleText(parts: MessageV2.Part[]) { + return parts.some( + (part) => + part.type === "text" && !part.synthetic && !part.ignored && part.text.trim().length > 0, + ) + } + + function hasReasoningOrTool(parts: MessageV2.Part[]) { + return parts.some((part) => part.type === "reasoning" || part.type === "tool") + } + + function isFinalFallbackRequest(msg?: MessageV2.WithParts) { + return ( + msg?.parts.some( + (part) => part.type === "text" && part.metadata && part.metadata[FINAL_FALLBACK_METADATA], + ) ?? false + ) + } + + function getUserText(msg?: MessageV2.WithParts) { + if (!msg) return "" + return msg.parts + .filter((part) => part.type === "text" && !part.synthetic && !part.ignored) + .map((part) => (part as MessageV2.TextPart).text) + .join("\n") + .trim() + } + + function wantsTodoContinuation(text: string) { + if (!text) return false + return TODO_CONTINUE_RE.test(text) + } + + function hasPendingTodos(todos: Todo.Info[]) { + return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") + } + function start(sessionID: string) { const s = state() if (s[sessionID]) return @@ -273,14 +317,22 @@ export namespace SessionPrompt { if (abort.aborted) break let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + let lastUserMsg: MessageV2.WithParts | undefined + let lastAssistantMsg: MessageV2.WithParts | undefined let lastUser: MessageV2.User | undefined let lastAssistant: MessageV2.Assistant | undefined let lastFinished: MessageV2.Assistant | undefined let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User - if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant + if (!lastUserMsg && msg.info.role === "user") { + lastUserMsg = msg + lastUser = msg.info as MessageV2.User + } + if (!lastAssistantMsg && msg.info.role === "assistant") { + lastAssistantMsg = msg + lastAssistant = msg.info as MessageV2.Assistant + } if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant if (lastUser && lastFinished) break @@ -289,13 +341,30 @@ export namespace SessionPrompt { tasks.push(...task) } } + if (lastUserMsg && !lastUser) lastUser = lastUserMsg.info as MessageV2.User + if (lastAssistantMsg && !lastAssistant) lastAssistant = lastAssistantMsg.info as MessageV2.Assistant if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + + const todos = await Todo.get(sessionID) + const pendingTodos = hasPendingTodos(todos) + const userText = getUserText(lastUserMsg) + const wantsContinuation = pendingTodos && wantsTodoContinuation(userText) + const todoState = pendingTodos ? await Todo.getState(sessionID) : { paused: false } + if (pendingTodos && !wantsContinuation && !todoState.paused) { + await Todo.updateState(sessionID, { paused: true, pausedAt: Date.now() }) + } + if (pendingTodos && wantsContinuation && todoState.paused) { + await Todo.updateState(sessionID, { paused: false }) + } if ( lastAssistant?.finish && !["tool-calls", "unknown"].includes(lastAssistant.finish) && lastUser.id < lastAssistant.id ) { + if (await maybeQueueFinalResponse({ sessionID, lastUserMsg, lastAssistantMsg })) { + continue + } log.info("exiting loop", { sessionID }) break } @@ -590,12 +659,13 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + const todoGuard = pendingTodos && !wantsContinuation ? [TODO_PAUSE_SYSTEM] : [] const result = await processor.process({ user: lastUser, agent, abort, sessionID, - system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom()), ...todoGuard], messages: [ ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep @@ -822,6 +892,18 @@ export namespace SessionPrompt { } } + if (input.tools) { + if (input.tools["*"] === false) { + for (const key of Object.keys(tools)) { + if (key !== "invalid") delete tools[key] + } + } else { + for (const [key, enabled] of Object.entries(input.tools)) { + if (enabled === false) delete tools[key] + } + } + } + return tools } @@ -1194,6 +1276,63 @@ export namespace SessionPrompt { } } + async function enqueueFinalResponse(input: { sessionID: string; user: MessageV2.User; reason: string }) { + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: input.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "final", + model: input.user.model, + system: input.user.system, + variant: input.user.variant, + tools: { + "*": false, + }, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + metadata: { + [FINAL_FALLBACK_METADATA]: true, + reason: input.reason, + }, + text: "Provide the final response in plain text without tool calls.", + } satisfies MessageV2.TextPart) + } + + async function maybeQueueFinalResponse(input: { + sessionID: string + lastUserMsg?: MessageV2.WithParts + lastAssistantMsg?: MessageV2.WithParts + }) { + if (!input.lastUserMsg || !input.lastAssistantMsg) return false + if (isFinalFallbackRequest(input.lastUserMsg)) return false + const assistant = input.lastAssistantMsg.info as MessageV2.Assistant + if (assistant.error) return false + if (hasVisibleText(input.lastAssistantMsg.parts)) return false + if (!hasReasoningOrTool(input.lastAssistantMsg.parts)) return false + + const hasReasoning = input.lastAssistantMsg.parts.some((part) => part.type === "reasoning") + const hasTool = input.lastAssistantMsg.parts.some((part) => part.type === "tool") + const reason = hasTool ? (hasReasoning ? "reasoning_tool_only" : "tool_only") : "reasoning_only" + const user = input.lastUserMsg.info as MessageV2.User + log.warn("final response fallback", { + sessionID: input.sessionID, + reason, + userMessageID: user.id, + assistantMessageID: assistant.id, + }) + await enqueueFinalResponse({ sessionID: input.sessionID, user, reason }) + return true + } + function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) { const userMessage = input.messages.findLast((msg) => msg.info.role === "user") if (!userMessage) return input.messages diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index aa7df7e981a..3addcef31ba 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -14,6 +14,16 @@ export namespace Todo { .meta({ ref: "Todo" }) export type Info = z.infer + export const State = z + .object({ + paused: z.boolean().default(false), + pausedAt: z.number().optional(), + updatedAt: z.number().optional(), + lastUpdatedMessageID: z.string().optional(), + }) + .meta({ ref: "TodoState" }) + export type State = z.infer + export const Event = { Updated: BusEvent.define( "todo.updated", @@ -24,8 +34,13 @@ export namespace Todo { ), } - export async function update(input: { sessionID: string; todos: Info[] }) { + export async function update(input: { sessionID: string; todos: Info[]; messageID?: string }) { await Storage.write(["todo", input.sessionID], input.todos) + await updateState(input.sessionID, { + paused: false, + updatedAt: Date.now(), + lastUpdatedMessageID: input.messageID, + }) Bus.publish(Event.Updated, input) } @@ -34,4 +49,22 @@ export namespace Todo { .then((x) => x || []) .catch(() => []) } + + export async function getState(sessionID: string) { + return Storage.read(["todo_state", sessionID]) + .then((x) => x || { paused: false }) + .catch(() => ({ paused: false })) + } + + export async function updateState(sessionID: string, patch: Partial) { + const current = await getState(sessionID) + const next: State = { + paused: patch.paused ?? current.paused ?? false, + pausedAt: patch.pausedAt ?? current.pausedAt, + updatedAt: patch.updatedAt ?? current.updatedAt, + lastUpdatedMessageID: patch.lastUpdatedMessageID ?? current.lastUpdatedMessageID, + } + await Storage.write(["todo_state", sessionID], next) + return next + } } diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 440f1563c70..6e1a8087540 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -19,6 +19,7 @@ export const TodoWriteTool = Tool.define("todowrite", { await Todo.update({ sessionID: ctx.sessionID, todos: params.todos, + messageID: ctx.messageID, }) return { title: `${params.todos.filter((x) => x.status !== "completed").length} todos`, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index b130e927e4a..a0a2a016322 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -81,7 +81,7 @@ describe("session.retry.delay", () => { }) describe("session.message-v2.fromError", () => { - test.concurrent( + test( "converts ECONNRESET socket errors to retryable APIError", async () => { using server = Bun.serve({ From f0be2467b32f03e5b773a41cfbd1ca5c41312976 Mon Sep 17 00:00:00 2001 From: salacoste Date: Wed, 7 Jan 2026 15:50:44 +0300 Subject: [PATCH 2/3] fix: stabilize todo state typing Use a typed default todo_state to avoid union inference during typecheck. --- packages/opencode/src/session/todo.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 3addcef31ba..49dedcab200 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -4,6 +4,9 @@ import z from "zod" import { Storage } from "../storage/storage" export namespace Todo { + const DEFAULT_STATE: State = { + paused: false, + } export const Info = z .object({ content: z.string().describe("Brief description of the task"), @@ -52,8 +55,8 @@ export namespace Todo { export async function getState(sessionID: string) { return Storage.read(["todo_state", sessionID]) - .then((x) => x || { paused: false }) - .catch(() => ({ paused: false })) + .then((x) => x || DEFAULT_STATE) + .catch(() => DEFAULT_STATE) } export async function updateState(sessionID: string, patch: Partial) { From fb0e9025e8d0efd160a02d39ee1e392941cecaa2 Mon Sep 17 00:00:00 2001 From: salacoste Date: Fri, 9 Jan 2026 20:43:16 +0300 Subject: [PATCH 3/3] fix(opencode): avoid todo loop and env proxy override --- docs/todo-loop-guard.md | 4 +- packages/opencode/src/provider/models.ts | 6 +- packages/opencode/src/provider/provider.ts | 54 +++++++++++++++ packages/opencode/src/session/message-v2.ts | 17 +++++ packages/opencode/src/session/processor.ts | 21 +++--- packages/opencode/src/session/prompt.ts | 11 ++-- .../opencode/src/session/todo-continuation.ts | 37 +++++++++++ .../opencode/test/provider/provider.test.ts | 65 +++++++++++++++++++ packages/opencode/test/session/retry.test.ts | 13 ++++ .../test/session/todo-continuation.test.ts | 50 ++++++++++++++ 10 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 packages/opencode/src/session/todo-continuation.ts create mode 100644 packages/opencode/test/session/todo-continuation.test.ts diff --git a/docs/todo-loop-guard.md b/docs/todo-loop-guard.md index 89de3b6aa46..1c9316cc4c6 100644 --- a/docs/todo-loop-guard.md +++ b/docs/todo-loop-guard.md @@ -20,8 +20,8 @@ When a session has pending todos, the model can keep trying to continue them eve ## Continuation Detection Treat as explicit continuation if user text contains: -- English: `continue`, `resume`, `proceed`, `keep going`, `next step` -- Russian: `продолж`, `дальше`, `продолжай` +- English: a continuation verb **and** an explicit reference to the todo list (e.g. `continue todos`, `resume the todo list`) +- Russian: `продолж.../дальше` **and** an explicit reference to todo/tasks (e.g. `продолжай туду`, `дальше по списку задач`) ## Files - `packages/opencode/src/session/todo.ts` diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 796dcb7c238..04213ac3c4b 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -2,7 +2,8 @@ import { Global } from "../global" import { Log } from "../util/log" import path from "path" import z from "zod" -import { data } from "./models-macro" with { type: "macro" } +import { data as macroData } from "./models-macro" with { type: "macro" } +import { data as runtimeData } from "./models-macro" import { Installation } from "../installation" import { Flag } from "../flag/flag" @@ -80,7 +81,8 @@ export namespace ModelsDev { const file = Bun.file(filepath) const result = await file.json().catch(() => {}) if (result) return result as Record - const json = await data() + const dataFn = typeof macroData === "function" ? macroData : runtimeData + const json = await dataFn() return JSON.parse(json) as Record } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9b01eae9e9b..bc2fc92a684 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -627,6 +627,57 @@ export namespace Provider { log.info("init") const configProviders = Object.entries(config.provider ?? {}) + const providersWithExplicitBaseURL = new Set() + for (const [providerID, provider] of configProviders) { + // When baseURL is explicitly configured, prefer config over inherited env vars by default. + // Users can still opt-in to env override via OPENCODE_ALLOW_PROXY_ENV_OVERRIDE=1. + const baseURL = (provider as any)?.options?.baseURL + if (typeof baseURL === "string" && baseURL.length > 0) { + providersWithExplicitBaseURL.add(providerID) + } + } + const allowProxyEnvOverride = Env.get("OPENCODE_ALLOW_PROXY_ENV_OVERRIDE") === "1" + + function overrideLocalProxyBaseURL(providerID: string, options: Record) { + // Some setups run Anthropic/Google through a local proxy (e.g. Antigravity). If the proxy port is dynamic, + // allow env to override the host/port while preserving the configured path (/v1, /v1beta, etc). + if (providerID !== "anthropic" && providerID !== "google") return + if (providersWithExplicitBaseURL.has(providerID) && !allowProxyEnvOverride) return + + const current = options?.baseURL + if (typeof current !== "string" || current.length === 0) return + + const envProxy = + Env.get("ANTHROPIC_PROXY_URL") ?? + (Env.get("ANTHROPIC_PROXY_PORT") ? `http://127.0.0.1:${Env.get("ANTHROPIC_PROXY_PORT")}` : undefined) + if (!envProxy) return + + let currentURL: URL + let envURL: URL + try { + currentURL = new URL(current) + envURL = new URL(envProxy) + } catch { + return + } + + const isLocal = + currentURL.hostname === "127.0.0.1" || currentURL.hostname === "localhost" || currentURL.hostname === "::1" + if (!isLocal) return + + if (!envURL.port) return + + const next = new URL(currentURL.toString()) + next.protocol = envURL.protocol + next.hostname = envURL.hostname + next.port = envURL.port + + const normalized = next.toString().replace(/\/$/, "") + if (normalized !== current) { + options.baseURL = normalized + log.debug("overrode local proxy baseURL from env", { providerID, baseURL: options.baseURL }) + } + } // Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot if (database["github-copilot"]) { @@ -647,12 +698,14 @@ export namespace Provider { if (existing) { // @ts-expect-error providers[providerID] = mergeDeep(existing, provider) + overrideLocalProxyBaseURL(providerID, providers[providerID].options ?? {}) return } const match = database[providerID] if (!match) return // @ts-expect-error providers[providerID] = mergeDeep(match, provider) + overrideLocalProxyBaseURL(providerID, providers[providerID].options ?? {}) } // extend database from config @@ -666,6 +719,7 @@ export namespace Provider { source: "config", models: existing?.models ?? {}, } + overrideLocalProxyBaseURL(providerID, parsed.options ?? {}) for (const [modelID, model] of Object.entries(provider.models ?? {})) { const existingModel = parsed.models[model.id ?? modelID] diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2dff17a5efa..311dd780522 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -633,6 +633,23 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() + case (e as any)?.code === "ECONNREFUSED" || (e as any)?.code === "ConnectionRefused": { + const error = e as any + const target = typeof error.path === "string" && error.path.length > 0 ? error.path : undefined + return new MessageV2.APIError( + { + message: target ? `Unable to connect to ${target}` : "Connection refused", + isRetryable: true, + metadata: { + code: error.code ?? "", + syscall: error.syscall ?? "", + message: error.message ?? "", + path: target ?? "", + }, + }, + { cause: e as any }, + ).toObject() + } case APICallError.isInstance(e): const message = iife(() => { let msg = e.message diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 71db7f13677..9f2970e6e81 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -18,6 +18,7 @@ import { Question } from "@/question" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 + const MAX_RETRY_ATTEMPTS = 6 const log = Log.create({ service: "session.processor" }) export type Info = Awaited> @@ -345,15 +346,17 @@ export namespace SessionProcessor { const retry = SessionRetry.retryable(error) if (retry !== undefined) { attempt++ - const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) - SessionStatus.set(input.sessionID, { - type: "retry", - attempt, - message: retry, - next: Date.now() + delay, - }) - await SessionRetry.sleep(delay, input.abort).catch(() => {}) - continue + if (attempt <= MAX_RETRY_ATTEMPTS) { + const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) + SessionStatus.set(input.sessionID, { + type: "retry", + attempt, + message: retry, + next: Date.now() + delay, + }) + await SessionRetry.sleep(delay, input.abort).catch(() => {}) + continue + } } input.assistantMessage.error = error Bus.publish(Session.Event.Error, { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fc1a011b66c..962cec5199b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -45,6 +45,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Todo } from "./todo" +import { hasTodoKeywords, isTodoContinuationRequest } from "./todo-continuation" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -231,8 +232,6 @@ export namespace SessionPrompt { } const FINAL_FALLBACK_METADATA = "finalFallback" - const TODO_CONTINUE_RE = - /\b(continue|resume|proceed|keep going|next step)\b|(?:\u043f\u0440\u043e\u0434\u043e\u043b\u0436|\u0434\u0430\u043b\u044c\u0448\u0435)/i const TODO_PAUSE_SYSTEM = "There is a pending todo list from earlier. Do not continue it unless the user explicitly asks to continue. Ask a brief clarification if needed." @@ -266,7 +265,7 @@ export namespace SessionPrompt { function wantsTodoContinuation(text: string) { if (!text) return false - return TODO_CONTINUE_RE.test(text) + return isTodoContinuationRequest(text) } function hasPendingTodos(todos: Todo.Info[]) { @@ -349,6 +348,7 @@ export namespace SessionPrompt { const todos = await Todo.get(sessionID) const pendingTodos = hasPendingTodos(todos) const userText = getUserText(lastUserMsg) + const userMentionsTodos = pendingTodos && hasTodoKeywords(userText) const wantsContinuation = pendingTodos && wantsTodoContinuation(userText) const todoState = pendingTodos ? await Todo.getState(sessionID) : { paused: false } if (pendingTodos && !wantsContinuation && !todoState.paused) { @@ -615,7 +615,6 @@ export namespace SessionPrompt { }) // Check if user explicitly invoked an agent via @ in this turn - const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const tools = await resolveTools({ @@ -626,6 +625,10 @@ export namespace SessionPrompt { processor, bypassAgentCheck, }) + if (pendingTodos && !userMentionsTodos) { + delete tools["todoread"] + delete tools["todowrite"] + } if (step === 1) { SessionSummary.summarize({ diff --git a/packages/opencode/src/session/todo-continuation.ts b/packages/opencode/src/session/todo-continuation.ts new file mode 100644 index 00000000000..48afb61c383 --- /dev/null +++ b/packages/opencode/src/session/todo-continuation.ts @@ -0,0 +1,37 @@ +const CONTINUE_ANYWHERE_RE = + /\b(continue|resume|proceed|keep going|next step)\b|(?:\u043f\u0440\u043e\u0434\u043e\u043b\u0436|\u0434\u0430\u043b\u044c\u0448\u0435)/i + +const CONTINUE_START_RE = + /^\s*(?:\u0434\u0430\u0432\u0430\u0439\s+)?(?:\u043f\u0440\u043e\u0434\u043e\u043b\u0436\w*|\u0434\u0430\u043b\u044c\u0448\u0435)\b/i + +const CONTINUE_START_EN_RE = /^\s*(?:continue|resume|proceed|keep going|next step)\b/i + +const TODO_KEYWORDS_RE = + /\b(todo|todos|todo list|task list|tasks)\b|(?:\u0442\u0443\u0434\u0443|\u0437\u0430\u0434\u0430\u0447\w*|\u0441\u043f\u0438\u0441\u043e\u043a\s+\u0437\u0430\u0434\u0430\u0447)/i + +export function hasTodoKeywords(text: string) { + return TODO_KEYWORDS_RE.test(text) +} + +export function isTodoContinuationRequest(text: string) { + const trimmed = text.trim() + if (!trimmed) return false + + const lines = trimmed.split(/\r?\n/) + const firstLine = lines.find((line) => line.trim().length > 0) ?? "" + + // Require an explicit reference to todos/tasks to avoid treating generic "continue" as "continue the todo list". + const hasTodos = hasTodoKeywords(trimmed) + const startsWithContinue = CONTINUE_START_RE.test(firstLine) || CONTINUE_START_EN_RE.test(firstLine) + + if (startsWithContinue && hasTodos) { + return true + } + + // Avoid false positives when users paste logs / transcripts containing words like "continue"/"продолжим" + // somewhere in a long message. + const isShort = trimmed.length <= 80 && lines.length <= 3 + if (!isShort) return false + + return hasTodos && CONTINUE_ANYWHERE_RE.test(trimmed) +} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index f6d2df9dd5b..9b8c67311e7 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -447,6 +447,71 @@ test("provider with baseURL from config", async () => { }) }) +test("anthropic local proxy baseURL from config is not overridden by env by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + anthropic: { + options: { + baseURL: "http://127.0.0.1:8045/v1", + apiKey: "config-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("ANTHROPIC_PROXY_URL", "http://127.0.0.1:7187") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic"]).toBeDefined() + expect(providers["anthropic"].options.baseURL).toBe("http://127.0.0.1:8045/v1") + }, + }) +}) + +test("anthropic local proxy baseURL can be overridden via env when OPENCODE_ALLOW_PROXY_ENV_OVERRIDE=1", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + anthropic: { + options: { + baseURL: "http://127.0.0.1:8045/v1", + apiKey: "config-api-key", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("OPENCODE_ALLOW_PROXY_ENV_OVERRIDE", "1") + Env.set("ANTHROPIC_PROXY_URL", "http://127.0.0.1:7187") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["anthropic"]).toBeDefined() + expect(providers["anthropic"].options.baseURL).toBe("http://127.0.0.1:7187/v1") + }, + }) +}) + test("model cost defaults to zero when not specified", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index a0a2a016322..abd3df918b5 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -128,4 +128,17 @@ describe("session.message-v2.fromError", () => { expect(retryable).toBeDefined() expect(retryable).toBe("Connection reset by server") }) + + test("ConnectionRefused errors are converted to retryable APIError", () => { + const e = new Error("Unable to connect. Is the computer able to access the url?") + ;(e as any).code = "ConnectionRefused" + ;(e as any).path = "http://127.0.0.1:8045/v1/messages" + + const result = MessageV2.fromError(e, { providerID: "test" }) + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.message).toBe("Unable to connect to http://127.0.0.1:8045/v1/messages") + expect((result as MessageV2.APIError).data.metadata?.code).toBe("ConnectionRefused") + expect((result as MessageV2.APIError).data.metadata?.path).toBe("http://127.0.0.1:8045/v1/messages") + }) }) diff --git a/packages/opencode/test/session/todo-continuation.test.ts b/packages/opencode/test/session/todo-continuation.test.ts new file mode 100644 index 00000000000..33d2ef3ab5e --- /dev/null +++ b/packages/opencode/test/session/todo-continuation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { hasTodoKeywords, isTodoContinuationRequest } from "../../src/session/todo-continuation" + +describe("isTodoContinuationRequest", () => { + test("accepts explicit short continuation requests", () => { + expect(isTodoContinuationRequest("Давай продолжим todo")).toBe(true) + expect(isTodoContinuationRequest("продолжай туду")).toBe(true) + expect(isTodoContinuationRequest("дальше по списку задач")).toBe(true) + expect(isTodoContinuationRequest("continue todos")).toBe(true) + expect(isTodoContinuationRequest("continue the todo list with step 2")).toBe(true) + }) + + test("hasTodoKeywords detects todo/task references", () => { + expect(hasTodoKeywords("continue")).toBe(false) + expect(hasTodoKeywords("продолжай")).toBe(false) + expect(hasTodoKeywords("todo")).toBe(true) + expect(hasTodoKeywords("tudU")).toBe(false) + expect(hasTodoKeywords("туду")).toBe(true) + expect(hasTodoKeywords("список задач")).toBe(true) + expect(hasTodoKeywords("task list")).toBe(true) + }) + + test("does not treat generic continuation as todo continuation", () => { + expect(isTodoContinuationRequest("Давай продолжим")).toBe(false) + expect(isTodoContinuationRequest("продолжай")).toBe(false) + expect(isTodoContinuationRequest("continue")).toBe(false) + expect(isTodoContinuationRequest("continue with step 2")).toBe(false) + }) + + test("rejects long messages that only mention continuation words inside logs", () => { + expect( + isTodoContinuationRequest( + [ + "У нас приложение Open Code зацикливалось.", + "Вот вывод консоли пользователя:", + "Thinking: Пользователь говорит \"Давай продолжим\".", + "Система напоминает о незавершенных задачах.", + ].join("\n"), + ), + ).toBe(false) + }) + + test("rejects non-leading continuation words in longer messages", () => { + expect( + isTodoContinuationRequest( + "Я видел в логе слово continue, но это не просьба продолжить туду. Давай разберемся, что сломалось.", + ), + ).toBe(false) + }) +})