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() + }) +})