Skip to content
Closed
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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/changes-2026-01-07.md
Original file line number Diff line number Diff line change
@@ -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).

42 changes: 42 additions & 0 deletions docs/reasoning-only-fallback-plan.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions docs/todo-loop-guard.md
Original file line number Diff line number Diff line change
@@ -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: 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`
- 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.
16 changes: 16 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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"

Expand Down Expand Up @@ -180,6 +181,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 ?? {})) {
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/agent/prompt/final.txt
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -80,7 +81,8 @@ export namespace ModelsDev {
const file = Bun.file(filepath)
const result = await file.json().catch(() => {})
if (result) return result as Record<string, Provider>
const json = await data()
const dataFn = typeof macroData === "function" ? macroData : runtimeData
const json = await dataFn()
return JSON.parse(json) as Record<string, Provider>
}

Expand Down
54 changes: 54 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,57 @@ export namespace Provider {
log.info("init")

const configProviders = Object.entries(config.provider ?? {})
const providersWithExplicitBaseURL = new Set<string>()
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<string, any>) {
// 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"]) {
Expand All @@ -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
Expand All @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export namespace LLM {
}

async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
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)) {
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof create>>
Expand Down Expand Up @@ -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, {
Expand Down
Loading