From 187e3819a68d2d3b1963b58387c3556313ba551f Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 22:38:01 +0000 Subject: [PATCH] drop the reasoning effort when an Ollama agent model cannot think MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama rejects the whole request — `"" does not support thinking` — rather than ignoring a reasoning field a model has no answer for, both on native /api/chat (`think: true`) and through the OpenAI-compatible `reasoning_effort` that OpenCode's `agent.*.reasoningEffort` becomes. So an agent dispatched at `effort: medium` onto a non-reasoning local model died on its first turn with exit 1 and no output: a pr-reviewer Stage 2 run pinned to gemma3:27b (capabilities ["completion","vision"]) failed three times that way and blocked its task. Resolve the model's thinking capability once per spawn, on the provider every spawn path shares, so the two carriers of the level — the `--effort` argv and OpenCode's config block — drop it together. A failed /api/show probe or an empty capability list means *unknown*, not *unsupported*, and leaves the level alone; a provider pointed at a remote daemon is never probed, since ollamaManager only inspects the local one. Mirrors modelRejectsThinking in codeReview.js, which already solves this for the local reviewer's own calls. --- server/services/agentLifecycle.js | 19 ++++-- server/services/ollamaAgentContext.js | 68 ++++++++++++++++++++- server/services/ollamaAgentContext.test.js | 71 +++++++++++++++++++++- 3 files changed, 151 insertions(+), 7 deletions(-) diff --git a/server/services/agentLifecycle.js b/server/services/agentLifecycle.js index 9d067d01a6..ff5172edf7 100644 --- a/server/services/agentLifecycle.js +++ b/server/services/agentLifecycle.js @@ -61,6 +61,7 @@ import { composeProviderEnv } from '../lib/cliChildEnv.js'; import { cliProviderAuthDescriptor } from '../lib/processEnv.js'; import { PROVIDER_TYPES } from '../lib/aiToolkit/constants.js'; import { buildCliSpawnConfig, isClaudeCliProvider, isTuiProvider, getClaudeSettingsEnv, spawnDirectly } from './agentCliSpawning.js'; +import { dropUnsupportedOllamaThinking } from './ollamaAgentContext.js'; import { buildTuiSpawnConfig, spawnTuiAgent } from './agentTuiSpawning.js'; import { publicReviewProviderBlock, publicReviewPostureForProfile, PUBLIC_REVIEW_NO_TOOL_POSTURE } from '../lib/providerVendors.js'; import { PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE } from '../lib/agentExecutionProfiles.js'; @@ -907,7 +908,7 @@ async function runAgentSpawn(task) { // dynamic `agent.build` config instead of mutating saved provider state. const taskTemperature = task.metadata?.temperature === '' ? NaN : Number(task.metadata?.temperature); const taskThinking = task.metadata?.thinking; - const runProvider = { + const requestedProvider = { ...provider, ...(Number.isFinite(taskTemperature) && taskTemperature >= 0 && taskTemperature <= 2 ? { temperature: taskTemperature } @@ -915,9 +916,19 @@ async function runAgentSpawn(task) { ...([true, false, 'true', 'false'].includes(taskThinking) ? { thinking: taskThinking } : {}), ...(typeof task.metadata?.effort === 'string' ? { effort: task.metadata.effort } : {}), }; - // Per-task reasoning-effort override (task form / schedule config). The - // builders no-op it for providers without an effort control. - const taskEffort = task.metadata?.effort || null; + // Ollama 400s the whole request when a model that never implements thinking + // is asked to think, so a non-reasoning local model dispatched at any effort + // level dies on its first turn with exit 1 and no output. Resolved here, + // once, on the provider EVERY spawn path shares — so the two carriers of the + // level (the `--effort` argv and OpenCode's `agent.*.reasoningEffort` config) + // drop it together. `taskEffort` is the per-task reasoning-effort override + // (task form / schedule config); the builders no-op it for providers without + // an effort control. + const { provider: runProvider, effort: taskEffort } = await dropUnsupportedOllamaThinking( + requestedProvider, + selectedModel, + task.metadata?.effort || null, + ); // Codex counts the root orchestrator against its per-session thread cap. // Lift that cap to root + configured workers for cloud swarms so a six-way // claim run can actually fan out six issue agents. Never lift it for a diff --git a/server/services/ollamaAgentContext.js b/server/services/ollamaAgentContext.js index b14efbd40e..4c219b73c6 100644 --- a/server/services/ollamaAgentContext.js +++ b/server/services/ollamaAgentContext.js @@ -13,6 +13,11 @@ * VRAM-based auto-pick stands — but a too-small window is warned about up * front, because the alternative is a run that dies an hour in with * `exceed_context_size_error`. + * + * The same "prepare the daemon before the harness starts" role covers the + * model's reasoning capability: Ollama rejects a whole request when a model + * that never implements thinking is asked to think, so + * `dropUnsupportedOllamaThinking` resolves that ahead of the spawn too. */ import { isOllamaBackedProvider } from './providers.js' @@ -26,7 +31,7 @@ import { isSameOllamaDaemon, resolveOllamaContextLength } from '../lib/ollamaContext.js' -import { ensureContextWindow, getBaseUrl, getRuntimeContextLength } from './ollamaManager.js' +import { ensureContextWindow, getBaseUrl, getModelCapabilities, getRuntimeContextLength } from './ollamaManager.js' /** * Prepare the Ollama daemon for an agent harness run. @@ -77,3 +82,64 @@ export async function ensureOllamaAgentContext(provider, { env = process.env, mo if (warning) console.warn(warning) return { skipped: false, contextLength, applied: !!result.applied, warning } } + +/** + * Does this run's Ollama model reject a thinking request? + * + * `true` only when we KNOW it does — the daemon answered `/api/show` with a + * capability list that omits `thinking`. A failed probe (`null`) and an empty + * list both mean *unknown*, not *unsupported*, so they leave the controls + * alone rather than silently dropping a level the model does accept. Mirrors + * `modelRejectsThinking` in `codeReview.js`, which solves the same problem for + * the local code reviewer's own HTTP calls. + * + * A provider pointed at a REMOTE daemon is never probed: `ollamaManager` only + * inspects the local one, so its answer would describe the wrong host. + * + * @param {object|null} provider + * @param {string|null} model + * @returns {Promise} + */ +export async function ollamaModelRejectsThinking(provider, model) { + if (!model || !provider || !isOllamaBackedProvider(provider)) return false + if (!isSameOllamaDaemon(ollamaBaseFromProvider(provider), getBaseUrl())) return false + const capabilities = await getModelCapabilities(model).catch(() => null) + if (!Array.isArray(capabilities) || capabilities.length === 0) return false + return !capabilities.includes('thinking') +} + +/** + * Drop a run's reasoning controls when its Ollama model cannot think. + * + * Ollama rejects the WHOLE request rather than ignoring a field a model has no + * answer for — `"" does not support thinking`, both on native + * `/api/chat` (`think: true`) and through the OpenAI-compatible + * `reasoning_effort` an OpenCode `agent.*.reasoningEffort` becomes. So an + * agent dispatched at `effort: medium` onto a non-reasoning local model + * (gemma3, most plain chat models) dies on its first turn with exit 1 and no + * output, which is how a `pr-reviewer` stage pinned to `gemma3:27b` failed + * three times over. The level carries no information for such a model, so + * dropping it loses nothing; keeping it loses the run. + * + * Resolved once per spawn, on the provider every path shares, so the two + * carriers of the level — the `--effort` argv and OpenCode's config block — + * cannot disagree. `thinking: false` is preserved: that is a request NOT to + * think, which every model accepts. + * + * @param {object|null} provider - the run's provider, task overrides already merged + * @param {string|null} model - the model this run was dispatched with + * @param {string|null} [effort] - the run's effort level, as passed to the argv builders + * @returns {Promise<{provider: object|null, effort: string|null, dropped: boolean}>} + */ +export async function dropUnsupportedOllamaThinking(provider, model, effort = null) { + const keep = { provider, effort, dropped: false } + const wantsThinking = !!effort + || (typeof provider?.effort === 'string' && provider.effort.trim() !== '') + || provider?.thinking === true || provider?.thinking === 'true' + if (!wantsThinking) return keep + if (!await ollamaModelRejectsThinking(provider, model)) return keep + + const { effort: _requestedEffort, thinking: _requestedThinking, ...rest } = provider + console.warn(`⚠️ ${model} does not support thinking — running without a reasoning effort`) + return { provider: { ...rest, thinking: false }, effort: null, dropped: true } +} diff --git a/server/services/ollamaAgentContext.test.js b/server/services/ollamaAgentContext.test.js index ccab857f8a..79d39d055d 100644 --- a/server/services/ollamaAgentContext.test.js +++ b/server/services/ollamaAgentContext.test.js @@ -3,11 +3,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('./ollamaManager.js', () => ({ ensureContextWindow: vi.fn(), getRuntimeContextLength: vi.fn(), + getModelCapabilities: vi.fn(), getBaseUrl: vi.fn(() => 'http://localhost:11434') })) -import { ensureContextWindow, getRuntimeContextLength } from './ollamaManager.js' -import { ensureOllamaAgentContext } from './ollamaAgentContext.js' +import { ensureContextWindow, getModelCapabilities, getRuntimeContextLength } from './ollamaManager.js' +import { dropUnsupportedOllamaThinking, ensureOllamaAgentContext } from './ollamaAgentContext.js' const claudeOllamaTui = { id: 'claude-ollama-tui', @@ -89,3 +90,69 @@ describe('ensureOllamaAgentContext', () => { expect(ensureContextWindow).toHaveBeenCalledWith(65536) }) }) + +// Regression: a `pr-reviewer` stage dispatched at `effort: medium` onto +// `gemma3:27b` (capabilities `["completion","vision"]`) failed three times with +// `Error: "gemma3:27b" does not support thinking` — Ollama rejects the whole +// request rather than ignoring the level. +describe('dropUnsupportedOllamaThinking', () => { + const opencodeOllama = { id: 'opencode-ollama-tui', type: 'tui', command: 'opencode', ollamaBacked: true } + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('drops the effort when the model reports no thinking capability', async () => { + getModelCapabilities.mockResolvedValue(['completion', 'vision']) + const result = await dropUnsupportedOllamaThinking({ ...opencodeOllama, effort: 'medium' }, 'gemma3:27b', 'medium') + expect(result.dropped).toBe(true) + expect(result.effort).toBeNull() + expect(result.provider.effort).toBeUndefined() + expect(result.provider.thinking).toBe(false) + }) + + it('drops a thinking:true override for the same model', async () => { + getModelCapabilities.mockResolvedValue(['completion']) + const result = await dropUnsupportedOllamaThinking({ ...opencodeOllama, thinking: true }, 'gemma3:27b') + expect(result.dropped).toBe(true) + expect(result.provider.thinking).toBe(false) + }) + + it('keeps the effort for a model that does report thinking', async () => { + getModelCapabilities.mockResolvedValue(['completion', 'tools', 'thinking']) + const provider = { ...opencodeOllama, effort: 'high' } + expect(await dropUnsupportedOllamaThinking(provider, 'qwen3-coder:30b', 'high')) + .toEqual({ provider, effort: 'high', dropped: false }) + }) + + // A failed probe and an empty list both mean *unknown*, not *unsupported* — + // dropping on either would silently strip a level the model does accept. + it.each([[null], [[]]])('keeps the effort when capabilities are unknown (%j)', async (capabilities) => { + getModelCapabilities.mockResolvedValue(capabilities) + expect((await dropUnsupportedOllamaThinking({ ...opencodeOllama, effort: 'high' }, 'mystery:7b', 'high')).dropped).toBe(false) + }) + + it('never probes when the run asked for no thinking at all', async () => { + const provider = { ...opencodeOllama } + expect(await dropUnsupportedOllamaThinking(provider, 'gemma3:27b', null)) + .toEqual({ provider, effort: null, dropped: false }) + expect(getModelCapabilities).not.toHaveBeenCalled() + }) + + it('never probes for a provider that is not Ollama-backed', async () => { + getModelCapabilities.mockResolvedValue(['completion']) + const provider = { id: 'codex-cli', type: 'cli', command: 'codex', effort: 'high' } + expect((await dropUnsupportedOllamaThinking(provider, 'gpt-5.6', 'high')).dropped).toBe(false) + expect(getModelCapabilities).not.toHaveBeenCalled() + }) + + // ollamaManager only inspects the LOCAL daemon, so its capability answer + // would describe the wrong host. + it('never probes for a provider pointed at a remote Ollama host', async () => { + getModelCapabilities.mockResolvedValue(['completion']) + const provider = { ...opencodeOllama, effort: 'high', endpoint: 'http://198.51.100.7:11434/v1' } + expect((await dropUnsupportedOllamaThinking(provider, 'gemma3:27b', 'high')).dropped).toBe(false) + expect(getModelCapabilities).not.toHaveBeenCalled() + }) +})