Skip to content
Merged
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
19 changes: 15 additions & 4 deletions server/services/agentLifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -907,17 +908,27 @@ 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 }
: {}),
...([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
Expand Down
68 changes: 67 additions & 1 deletion server/services/ollamaAgentContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -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<boolean>}
*/
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 — `"<model>" 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 }
}
71 changes: 69 additions & 2 deletions server/services/ollamaAgentContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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()
})
})