From 063908362188c5e81b65744655310d72e4e5af1d Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 01:02:45 -0400 Subject: [PATCH 01/15] feat(core): add Ollama as a third AI assistant provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires Ollama into the full stack as a peer provider alongside Claude and Codex: - `packages/core/src/clients/ollama.ts`: New OllamaClient implementing IAssistantClient — streams responses via Ollama /api/chat NDJSON, yields MessageChunk tokens, surfaces clear error when no model is configured. - `packages/core/src/clients/factory.ts`: Register 'ollama' case; update error message to list all three supported types. - `packages/core/src/clients/index.ts`: Export OllamaClient. - `packages/core/src/config/config-types.ts`: Add OllamaAssistantDefaults (model, baseUrl); extend assistant union to 'claude' | 'codex' | 'ollama' across GlobalConfig, RepoConfig, MergedConfig, and SafeConfig (the last also gains an availableAssistants field). - `packages/core/src/config/config-loader.ts`: Initialize empty ollama defaults, merge global/repo ollama config, include ollama in updateGlobalConfig, add example config comment, accept 'ollama' in applyEnvOverrides. - `packages/core/src/db/conversations.ts`: Replace hardcoded 'claude' fallback with loadConfig().assistant so new conversations inherit the configured default provider. - `packages/core/src/orchestrator/orchestrator-agent.ts`: Forward config.assistants.ollama.model into AssistantRequestOptions for ollama conversations. - `packages/workflows/src/deps.ts`: Extend AssistantClientFactory and WorkflowConfig.assistant to include 'ollama'; add ollama block to WorkflowConfig.assistants. - `packages/workflows/src/model-validation.ts`: isModelCompatible accepts any model string for 'ollama' (pass-through). - `packages/workflows/src/schemas/`: dag-node and workflow schemas updated to include 'ollama' in provider enums. - `packages/workflows/src/loader.ts`, executor.ts, dag-executor.ts: thread 'ollama' through provider handling. - `packages/server/src/routes/api.ts` + config.schemas.ts: expose ollama config via /api/config and accept it in PATCH /api/config/global. - `packages/web/src/routes/SettingsPage.tsx`: Ollama section in Settings UI (model selector populated from /api/tags, baseUrl field). - `packages/web/src/lib/api.ts` + api.generated.d.ts: regenerated OpenAPI types to include ollama fields in SafeConfig. - `.env.example`: document OLLAMA_BASE_URL. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 +- packages/core/src/clients/factory.ts | 8 +- packages/core/src/clients/index.ts | 1 + packages/core/src/clients/ollama.ts | 164 ++++++++++++++ .../core/src/config/config-loader.test.ts | 2 +- packages/core/src/config/config-loader.ts | 33 ++- packages/core/src/config/config-types.ts | 21 +- packages/core/src/db/conversations.ts | 4 +- .../src/orchestrator/orchestrator-agent.ts | 3 + packages/server/src/routes/api.ts | 36 ++- .../src/routes/schemas/config.schemas.ts | 23 +- .../components/workflows/BuilderToolbar.tsx | 9 +- .../components/workflows/NodeInspector.tsx | 7 +- .../components/workflows/WorkflowBuilder.tsx | 2 +- packages/web/src/lib/api.generated.d.ts | 57 ++++- packages/web/src/lib/api.ts | 5 + packages/web/src/routes/SettingsPage.tsx | 207 +++++++++++++----- packages/workflows/src/dag-executor.test.ts | 8 +- packages/workflows/src/dag-executor.ts | 104 +++++---- packages/workflows/src/deps.ts | 13 +- packages/workflows/src/executor.ts | 7 +- packages/workflows/src/loader.ts | 4 +- packages/workflows/src/model-validation.ts | 6 +- packages/workflows/src/schemas/dag-node.ts | 2 +- packages/workflows/src/schemas/workflow.ts | 2 +- 25 files changed, 596 insertions(+), 136 deletions(-) create mode 100644 packages/core/src/clients/ollama.ts diff --git a/.env.example b/.env.example index 325e49a6fb..cdb4939dcb 100644 --- a/.env.example +++ b/.env.example @@ -24,9 +24,11 @@ CODEX_REFRESH_TOKEN= CODEX_ACCOUNT_ID= # CODEX_BIN_PATH= # Optional: path to Codex native binary (binary builds only) -# Default AI Assistant (claude | codex) +# Default AI Assistant (claude | codex | ollama) # Used for new conversations when no codebase specified +# For ollama: also set OLLAMA_BASE_URL if not using localhost:11434 DEFAULT_AI_ASSISTANT=claude +# OLLAMA_BASE_URL=http://localhost:11434 # Title Generation Model (optional) # Model used for generating conversation titles (lightweight task) diff --git a/packages/core/src/clients/factory.ts b/packages/core/src/clients/factory.ts index 027f9843fa..5afc61270c 100644 --- a/packages/core/src/clients/factory.ts +++ b/packages/core/src/clients/factory.ts @@ -7,6 +7,7 @@ import type { IAssistantClient } from '../types'; import { ClaudeClient } from './claude'; import { CodexClient } from './codex'; +import { OllamaClient } from './ollama'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -31,7 +32,12 @@ export function getAssistantClient(type: string): IAssistantClient { case 'codex': getLog().debug({ provider: 'codex' }, 'client_selected'); return new CodexClient(); + case 'ollama': + getLog().debug({ provider: 'ollama' }, 'client_selected'); + return new OllamaClient(); default: - throw new Error(`Unknown assistant type: ${type}. Supported types: 'claude', 'codex'`); + throw new Error( + `Unknown assistant type: ${type}. Supported types: 'claude', 'codex', 'ollama'` + ); } } diff --git a/packages/core/src/clients/index.ts b/packages/core/src/clients/index.ts index 98b1d10f20..7daa18cfa6 100644 --- a/packages/core/src/clients/index.ts +++ b/packages/core/src/clients/index.ts @@ -10,6 +10,7 @@ export { ClaudeClient } from './claude'; export { CodexClient } from './codex'; +export { OllamaClient } from './ollama'; export { getAssistantClient } from './factory'; // Re-export types for consumers importing from this submodule directly diff --git a/packages/core/src/clients/ollama.ts b/packages/core/src/clients/ollama.ts new file mode 100644 index 0000000000..645ba7d2b1 --- /dev/null +++ b/packages/core/src/clients/ollama.ts @@ -0,0 +1,164 @@ +/** + * Ollama local LLM client + * + * Calls the Ollama /api/chat endpoint for streaming chat completion. + * Uses the same direct-HTTP pattern as rubot's generation/ollama.py: + * POST to /api/chat with NDJSON streaming response. + * + * Limitations vs Claude/Codex: + * - No filesystem access or tool use (pure chat completion) + * - No session resumption (Ollama is stateless) + * - Only `model`, `systemPrompt`, and `abortSignal` from AssistantRequestOptions are used + * - `cwd` is ignored + */ +import type { IAssistantClient, AssistantRequestOptions, MessageChunk } from '../types'; +import { createLogger } from '@archon/paths'; + +/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('client.ollama'); + return cachedLog; +} + +const DEFAULT_BASE_URL = 'http://localhost:11434'; +const CHAT_PATH = '/api/chat'; + +/** Shape of each streamed NDJSON chunk from Ollama /api/chat */ +interface OllamaChatChunk { + model: string; + message?: { role: string; content: string }; + done: boolean; + done_reason?: string; + prompt_eval_count?: number; + eval_count?: number; +} + +/** + * Ollama AI assistant client. + * Implements IAssistantClient via the Ollama REST API. + */ +export class OllamaClient implements IAssistantClient { + private readonly baseUrl: string; + + constructor() { + this.baseUrl = process.env.OLLAMA_BASE_URL ?? DEFAULT_BASE_URL; + } + + /** + * Send a prompt to Ollama and stream the response as MessageChunks. + * Requires `options.model` to be set — Ollama has no default model. + */ + async *sendQuery( + prompt: string, + _cwd: string, + _resumeSessionId?: string, + options?: AssistantRequestOptions + ): AsyncGenerator { + const model = options?.model; + if (!model) { + throw new Error( + 'Ollama requires a model to be specified. ' + + 'Set `model` in your workflow or .archon/config.yaml assistants.ollama.model.' + ); + } + + const messages: { role: string; content: string }[] = []; + if (options?.systemPrompt && typeof options.systemPrompt === 'string') { + messages.push({ role: 'system', content: options.systemPrompt }); + } + messages.push({ role: 'user', content: prompt }); + + const url = `${this.baseUrl}${CHAT_PATH}`; + getLog().info({ model, url, messageCount: messages.length }, 'ollama.query_started'); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages, stream: true }), + signal: options?.abortSignal, + }); + } catch (err) { + const error = err as Error; + if (error.name === 'AbortError') throw new Error('Query aborted'); + throw new Error( + `Ollama connection failed at ${url}: ${error.message}. ` + + 'Is Ollama running? Try: ollama serve' + ); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Ollama API error ${response.status} ${response.statusText}${body ? `: ${body}` : ''}` + ); + } + + if (!response.body) { + throw new Error('Ollama API returned no response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let inputTokens = 0; + let outputTokens = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + // Keep incomplete last line in buffer + buffer = lines.pop() ?? ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let chunk: OllamaChatChunk; + try { + chunk = JSON.parse(trimmed) as OllamaChatChunk; + } catch { + getLog().warn({ line: trimmed }, 'ollama.unparseable_chunk'); + continue; + } + + if (!chunk.done) { + const content = chunk.message?.content ?? ''; + if (content) { + yield { type: 'assistant', content }; + } + } else { + inputTokens = chunk.prompt_eval_count ?? 0; + outputTokens = chunk.eval_count ?? 0; + getLog().info( + { model, inputTokens, outputTokens, doneReason: chunk.done_reason }, + 'ollama.query_completed' + ); + } + } + } + } finally { + reader.releaseLock(); + } + + yield { + type: 'result', + ...(inputTokens || outputTokens + ? { + tokens: { input: inputTokens, output: outputTokens, total: inputTokens + outputTokens }, + } + : {}), + }; + } + + /** Returns the assistant type identifier used by the factory and config. */ + getType(): string { + return 'ollama'; + } +} diff --git a/packages/core/src/config/config-loader.test.ts b/packages/core/src/config/config-loader.test.ts index da18deded7..8139f9e0ab 100644 --- a/packages/core/src/config/config-loader.test.ts +++ b/packages/core/src/config/config-loader.test.ts @@ -224,7 +224,7 @@ concurrency: const config = await loadConfig(); expect(config.assistant).toBe('claude'); - expect(config.assistants).toEqual({ claude: {}, codex: {} }); + expect(config.assistants).toEqual({ claude: {}, codex: {}, ollama: {} }); expect(config.streaming.telegram).toBe('stream'); expect(config.concurrency.maxConversations).toBe(10); }); diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index 8ee702c613..c64d07917e 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -88,6 +88,9 @@ const DEFAULT_CONFIG_CONTENT = `# Archon Global Configuration # webSearchMode: disabled # additionalDirectories: # - /absolute/path/to/other/repo +# ollama: +# model: llama3.2 +# baseUrl: http://localhost:11434 # optional, default # Streaming mode per platform (stream or batch) # streaming: @@ -194,6 +197,7 @@ function getDefaults(): MergedConfig { assistants: { claude: {}, codex: {}, + ollama: {}, }, streaming: { telegram: 'stream', @@ -232,7 +236,7 @@ function applyEnvOverrides(config: MergedConfig): MergedConfig { // Assistant override const envAssistant = process.env.DEFAULT_AI_ASSISTANT; - if (envAssistant === 'claude' || envAssistant === 'codex') { + if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'ollama') { config.assistant = envAssistant; } @@ -277,6 +281,7 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged assistants: { claude: { ...defaults.assistants.claude }, codex: { ...defaults.assistants.codex }, + ollama: { ...defaults.assistants.ollama }, }, }; @@ -302,6 +307,12 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged ...global.assistants.codex, }; } + if (global.assistants?.ollama) { + result.assistants.ollama = { + ...result.assistants.ollama, + ...global.assistants.ollama, + }; + } // Streaming preferences if (global.streaming) { @@ -339,6 +350,7 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { assistants: { claude: { ...merged.assistants.claude }, codex: { ...merged.assistants.codex }, + ollama: { ...merged.assistants.ollama }, }, }; @@ -359,6 +371,12 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { ...repo.assistants.codex, }; } + if (repo.assistants?.ollama) { + result.assistants.ollama = { + ...result.assistants.ollama, + ...repo.assistants.ollama, + }; + } // Commands config if (repo.commands) { @@ -479,6 +497,9 @@ export async function updateGlobalConfig(updates: Partial): Promis merged.assistants = { claude: { ...current.assistants?.claude, ...updates.assistants.claude }, codex: { ...current.assistants?.codex, ...updates.assistants.codex }, + ...(updates.assistants.ollama !== undefined + ? { ollama: { ...current.assistants?.ollama, ...updates.assistants.ollama } } + : {}), }; } @@ -517,9 +538,15 @@ export async function updateGlobalConfig(updates: Partial): Promis * Strips filesystem paths and any other server-internal fields. */ export function toSafeConfig(config: MergedConfig): SafeConfig { + const availableAssistants: ('claude' | 'codex' | 'ollama')[] = ['claude', 'ollama']; + if (process.env.CODEX_ID_TOKEN && process.env.CODEX_ACCESS_TOKEN) { + availableAssistants.push('codex'); + } + return { botName: config.botName, assistant: config.assistant, + availableAssistants, assistants: { claude: { model: config.assistants.claude.model, @@ -529,6 +556,10 @@ export function toSafeConfig(config: MergedConfig): SafeConfig { modelReasoningEffort: config.assistants.codex.modelReasoningEffort, webSearchMode: config.assistants.codex.webSearchMode, }, + ollama: { + model: config.assistants.ollama.model, + baseUrl: config.assistants.ollama.baseUrl, + }, }, streaming: { telegram: config.streaming.telegram, diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 3baa3dfdca..6079b8037c 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -30,6 +30,13 @@ export interface ClaudeAssistantDefaults { settingSources?: ('project' | 'user')[]; } +export interface OllamaAssistantDefaults { + model?: string; + /** Ollama server base URL. Overrides OLLAMA_BASE_URL env var. + * @default 'http://localhost:11434' */ + baseUrl?: string; +} + export interface GlobalConfig { /** * Bot display name (shown in messages) @@ -41,7 +48,7 @@ export interface GlobalConfig { * Default AI assistant when no codebase-specific preference * @default 'claude' */ - defaultAssistant?: 'claude' | 'codex'; + defaultAssistant?: 'claude' | 'codex' | 'ollama'; /** * Assistant-specific defaults (model, reasoning effort, etc.) @@ -49,6 +56,7 @@ export interface GlobalConfig { assistants?: { claude?: ClaudeAssistantDefaults; codex?: AssistantDefaults; + ollama?: OllamaAssistantDefaults; }; /** @@ -112,7 +120,7 @@ export interface RepoConfig { * AI assistant preference for this repository * Overrides global default */ - assistant?: 'claude' | 'codex'; + assistant?: 'claude' | 'codex' | 'ollama'; /** * Assistant-specific defaults for this repository @@ -120,6 +128,7 @@ export interface RepoConfig { assistants?: { claude?: ClaudeAssistantDefaults; codex?: AssistantDefaults; + ollama?: OllamaAssistantDefaults; }; /** @@ -215,10 +224,11 @@ export interface RepoConfig { */ export interface MergedConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; assistants: { claude: ClaudeAssistantDefaults; codex: AssistantDefaults; + ollama: OllamaAssistantDefaults; }; streaming: { telegram: 'stream' | 'batch'; @@ -279,10 +289,13 @@ export interface MergedConfig { */ export interface SafeConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; + /** Providers that are configured and available on this server. */ + availableAssistants: ('claude' | 'codex' | 'ollama')[]; assistants: { claude: Pick; codex: Pick; + ollama: Pick; }; streaming: { telegram: 'stream' | 'batch'; diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..faf49910a4 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -5,6 +5,7 @@ import { pool, getDialect } from './connection'; import type { Conversation } from '../types'; import { ConversationNotFoundError } from '../types'; import { createLogger } from '@archon/paths'; +import { loadConfig } from '../config/config-loader'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ let cachedLog: ReturnType | undefined; @@ -72,7 +73,8 @@ export async function getOrCreateConversation( // Check if we should inherit from a parent conversation (e.g., Discord thread inheriting from parent channel) let inheritedCodebaseId: string | null = null; let inheritedCwd: string | null = null; - let assistantType = process.env.DEFAULT_AI_ASSISTANT ?? 'claude'; + const config = await loadConfig(); + let assistantType: string = process.env.DEFAULT_AI_ASSISTANT ?? config.assistant; if (parentConversationId) { const parent = await pool.query( diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 97d989f47c..6af3e3fc73 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -762,6 +762,9 @@ export async function handleMessage( ...(conversation.ai_assistant_type === 'claude' && config.assistants.claude.settingSources ? { settingSources: config.assistants.claude.settingSources } : {}), + ...(conversation.ai_assistant_type === 'ollama' && config.assistants.ollama.model + ? { model: config.assistants.ollama.model } + : {}), }; const mode = platform.getStreamingMode(); diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index ed267c1d41..d441bc4342 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -121,6 +121,7 @@ import { updateAssistantConfigResponseSchema, configResponseSchema, codebaseEnvironmentsResponseSchema, + ollamaModelsResponseSchema, } from './schemas/config.schemas'; // Read app version: use build-time constant in binary, package.json in dev @@ -838,6 +839,23 @@ const getHealthRoute = createRoute({ }, }); +const getOllamaModelsRoute = createRoute({ + method: 'get', + path: '/api/ollama/models', + tags: ['System'], + summary: 'List locally installed Ollama models', + responses: { + 200: { + content: { + 'application/json': { + schema: ollamaModelsResponseSchema, + }, + }, + description: 'Ollama model list', + }, + }, +}); + const getUpdateCheckRoute = createRoute({ method: 'get', path: '/api/update-check', @@ -2545,10 +2563,11 @@ export function registerApiRoutes( if (body.assistant !== undefined) { updates.defaultAssistant = body.assistant; } - if (body.claude !== undefined || body.codex !== undefined) { + if (body.claude !== undefined || body.codex !== undefined || body.ollama !== undefined) { updates.assistants = { ...(body.claude ? { claude: body.claude } : {}), ...(body.codex ? { codex: body.codex } : {}), + ...(body.ollama ? { ollama: body.ollama } : {}), }; } @@ -2620,4 +2639,19 @@ export function registerApiRoutes( const result = await checkForUpdate(appVersion); return c.json(result ?? noUpdate); }); + + registerOpenApiRoute(getOllamaModelsRoute, async c => { + const baseUrl = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; + try { + const response = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) }); + if (!response.ok) { + return c.json({ models: [], available: false }); + } + const data = (await response.json()) as { models?: { name: string }[] }; + const models = (data.models ?? []).map(m => m.name); + return c.json({ models, available: true }); + } catch { + return c.json({ models: [], available: false }); + } + }); } diff --git a/packages/server/src/routes/schemas/config.schemas.ts b/packages/server/src/routes/schemas/config.schemas.ts index d3ba003366..8c212e369d 100644 --- a/packages/server/src/routes/schemas/config.schemas.ts +++ b/packages/server/src/routes/schemas/config.schemas.ts @@ -7,7 +7,8 @@ import { z } from '@hono/zod-openapi'; export const safeConfigSchema = z .object({ botName: z.string(), - assistant: z.enum(['claude', 'codex']), + assistant: z.enum(['claude', 'codex', 'ollama']), + availableAssistants: z.array(z.enum(['claude', 'codex', 'ollama'])), assistants: z.object({ claude: z.object({ model: z.string().optional() }), codex: z.object({ @@ -15,6 +16,10 @@ export const safeConfigSchema = z modelReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(), webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }), + ollama: z.object({ + model: z.string().optional(), + baseUrl: z.string().optional(), + }), }), streaming: z.object({ telegram: z.enum(['stream', 'batch']), @@ -34,7 +39,7 @@ export const safeConfigSchema = z /** Body for PATCH /api/config/assistants — all fields optional (partial update). */ export const updateAssistantConfigBodySchema = z .object({ - assistant: z.enum(['claude', 'codex']).optional(), + assistant: z.enum(['claude', 'codex', 'ollama']).optional(), claude: z .object({ model: z.string(), @@ -47,6 +52,12 @@ export const updateAssistantConfigBodySchema = z webSearchMode: z.enum(['disabled', 'cached', 'live']).optional(), }) .optional(), + ollama: z + .object({ + model: z.string().optional(), + baseUrl: z.string().optional(), + }) + .optional(), }) .openapi('UpdateAssistantConfigBody'); @@ -61,6 +72,14 @@ export const configResponseSchema = z /** @deprecated Use configResponseSchema instead. */ export const updateAssistantConfigResponseSchema = configResponseSchema; +/** Response for GET /api/ollama/models — list of locally installed model names. */ +export const ollamaModelsResponseSchema = z + .object({ + models: z.array(z.string()), + available: z.boolean(), + }) + .openapi('OllamaModelsResponse'); + /** A single isolation environment record. */ export const isolationEnvironmentSchema = z .object({ diff --git a/packages/web/src/components/workflows/BuilderToolbar.tsx b/packages/web/src/components/workflows/BuilderToolbar.tsx index 4be85cea58..41aed76a6a 100644 --- a/packages/web/src/components/workflows/BuilderToolbar.tsx +++ b/packages/web/src/components/workflows/BuilderToolbar.tsx @@ -11,14 +11,14 @@ export type ViewMode = 'hidden' | 'split' | 'full'; export interface BuilderToolbarProps { workflowName: string; workflowDescription: string; - provider: 'claude' | 'codex' | undefined; + provider: 'claude' | 'codex' | 'ollama' | undefined; model: string | undefined; hasUnsavedChanges: boolean; validationErrors: string[]; viewMode: ViewMode; onNameChange: (name: string) => void; onDescriptionChange: (desc: string) => void; - onProviderChange: (p: 'claude' | 'codex' | undefined) => void; + onProviderChange: (p: 'claude' | 'codex' | 'ollama' | undefined) => void; onModelChange: (m: string | undefined) => void; onViewModeChange: (mode: ViewMode) => void; onValidate: () => void; @@ -158,13 +158,16 @@ export function BuilderToolbar({ { onUpdate({ - provider: (e.target.value || undefined) as 'claude' | 'codex' | undefined, + provider: (e.target.value || undefined) as + | 'claude' + | 'codex' + | 'ollama' + | undefined, }); }} className={selectClass} @@ -329,6 +333,7 @@ function ExecutionTab({ + diff --git a/packages/web/src/components/workflows/WorkflowBuilder.tsx b/packages/web/src/components/workflows/WorkflowBuilder.tsx index 8a0d8abe3d..3aa01a508a 100644 --- a/packages/web/src/components/workflows/WorkflowBuilder.tsx +++ b/packages/web/src/components/workflows/WorkflowBuilder.tsx @@ -127,7 +127,7 @@ function WorkflowBuilderInner(): React.ReactElement { // Core state const [workflowName, setWorkflowName] = useState(''); const [workflowDescription, setWorkflowDescription] = useState(''); - const [provider, setProvider] = useState<'claude' | 'codex' | undefined>(undefined); + const [provider, setProvider] = useState<'claude' | 'codex' | 'ollama' | undefined>(undefined); const [model, setModel] = useState(undefined); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [validationErrors, setValidationErrors] = useState([]); diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index 193c619588..6fdcadb533 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -1948,6 +1948,42 @@ export interface paths { patch?: never; trace?: never; }; + '/api/ollama/models': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List locally installed Ollama models */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Ollama model list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OllamaModelsResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -2047,7 +2083,7 @@ export interface components { trigger_rule?: 'all_success' | 'one_success' | 'none_failed_min_one_success' | 'all_done'; model?: string; /** @enum {string} */ - provider?: 'claude' | 'codex'; + provider?: 'claude' | 'codex' | 'ollama'; /** @enum {string} */ context?: 'fresh' | 'shared'; output_format?: { @@ -2294,7 +2330,7 @@ export interface components { name: string; description: string; /** @enum {string} */ - provider?: 'claude' | 'codex'; + provider?: 'claude' | 'codex' | 'ollama'; model?: string; /** @enum {string} */ modelReasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; @@ -2487,7 +2523,8 @@ export interface components { SafeConfig: { botName: string; /** @enum {string} */ - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; + availableAssistants: ('claude' | 'codex' | 'ollama')[]; assistants: { claude: { model?: string; @@ -2499,6 +2536,10 @@ export interface components { /** @enum {string} */ webSearchMode?: 'disabled' | 'cached' | 'live'; }; + ollama: { + model?: string; + baseUrl?: string; + }; }; streaming: { /** @enum {string} */ @@ -2523,7 +2564,7 @@ export interface components { }; UpdateAssistantConfigBody: { /** @enum {string} */ - assistant?: 'claude' | 'codex'; + assistant?: 'claude' | 'codex' | 'ollama'; claude?: { model: string; }; @@ -2534,6 +2575,10 @@ export interface components { /** @enum {string} */ webSearchMode?: 'disabled' | 'cached' | 'live'; }; + ollama?: { + model?: string; + baseUrl?: string; + }; }; IsolationEnvironment: { id: string; @@ -2564,6 +2609,10 @@ export interface components { latestVersion: string; releaseUrl: string; }; + OllamaModelsResponse: { + models: string[]; + available: boolean; + }; }; responses: never; parameters: never; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 6c81aa66b1..dad627d681 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -453,6 +453,11 @@ export async function updateAssistantConfig( }); } +/** Fetch locally installed Ollama models. Returns empty list if Ollama is unreachable. */ +export async function getOllamaModels(): Promise<{ models: string[]; available: boolean }> { + return fetchJSON('/api/ollama/models'); +} + export type IsolationEnvironment = components['schemas']['IsolationEnvironment']; export async function getCodebaseEnvironments(codebaseId: string): Promise { diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 07a07690fc..6a079eb702 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -13,6 +13,7 @@ import { updateCodebase, deleteCodebase, updateAssistantConfig, + getOllamaModels, getCodebaseEnvVars, setCodebaseEnvVar, deleteCodebaseEnvVar, @@ -455,6 +456,16 @@ function ProjectsSection(): React.ReactElement { ); } +/** Provider labels shown in the assistant dropdown. */ +const ASSISTANT_LABELS: Record<'claude' | 'codex' | 'ollama', string> = { + claude: 'Claude', + codex: 'Codex', + ollama: 'Ollama', +}; + +/** Claude model options. */ +const CLAUDE_MODELS = ['sonnet', 'opus', 'haiku'] as const; + function AssistantConfigSection({ config }: { config: SafeConfigResponse }): React.ReactElement { const queryClient = useQueryClient(); const [assistant, setAssistant] = useState(config.assistant); @@ -466,14 +477,27 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea const [webSearch, setWebSearch] = useState<'disabled' | 'cached' | 'live'>( config.assistants.codex.webSearchMode ?? 'disabled' ); + const [ollamaModel, setOllamaModel] = useState(config.assistants.ollama.model ?? ''); + const [ollamaBaseUrl, setOllamaBaseUrl] = useState(config.assistants.ollama.baseUrl ?? ''); const [saveMsg, setSaveMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + /** Fetch installed Ollama models — only when Ollama is selected or available. */ + const { data: ollamaData } = useQuery({ + queryKey: ['ollamaModels'], + queryFn: getOllamaModels, + staleTime: 30_000, + enabled: assistant === 'ollama', + }); + const ollamaModels = ollamaData?.models ?? []; + const hasChanges = assistant !== config.assistant || claudeModel !== (config.assistants.claude.model ?? 'sonnet') || codexModel !== (config.assistants.codex.model ?? '') || reasoning !== (config.assistants.codex.modelReasoningEffort ?? 'medium') || - webSearch !== (config.assistants.codex.webSearchMode ?? 'disabled'); + webSearch !== (config.assistants.codex.webSearchMode ?? 'disabled') || + ollamaModel !== (config.assistants.ollama.model ?? '') || + ollamaBaseUrl !== (config.assistants.ollama.baseUrl ?? ''); useEffect(() => { setAssistant(config.assistant); @@ -481,6 +505,8 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea setCodexModel(config.assistants.codex.model ?? ''); setReasoning(config.assistants.codex.modelReasoningEffort ?? 'medium'); setWebSearch(config.assistants.codex.webSearchMode ?? 'disabled'); + setOllamaModel(config.assistants.ollama.model ?? ''); + setOllamaBaseUrl(config.assistants.ollama.baseUrl ?? ''); }, [config]); const mutation = useMutation({ @@ -501,16 +527,24 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea mutation.mutate({ assistant, claude: { model: claudeModel }, - // The generated type requires `model` when `codex` is present; omit the codex key - // entirely when no model is set so the server treats it as "no codex changes". ...(codexModel ? { codex: { model: codexModel, modelReasoningEffort: reasoning, webSearchMode: webSearch }, } : {}), + ...(ollamaModel || ollamaBaseUrl + ? { + ollama: { + ...(ollamaModel ? { model: ollamaModel } : {}), + ...(ollamaBaseUrl ? { baseUrl: ollamaBaseUrl } : {}), + }, + } + : {}), }); } + const available = config.availableAssistants; + return ( @@ -519,72 +553,133 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea
+ {/* Default assistant — only show configured providers */} - - + {/* Claude options */} + {assistant === 'claude' && ( + <> + + + + )} - - { - setCodexModel(e.target.value); - }} - placeholder="gpt-5.3-codex" - /> + {/* Codex options */} + {assistant === 'codex' && ( + <> + + { + setCodexModel(e.target.value); + }} + placeholder="gpt-5.3-codex" + /> - - + + + + + + + )} - - + {/* Ollama options */} + {assistant === 'ollama' && ( + <> + + {ollamaModels.length > 0 ? ( + + ) : ( + { + setOllamaModel(e.target.value); + }} + placeholder={ollamaData ? 'No models found — type a name' : 'Loading...'} + /> + )} + + + { + setOllamaBaseUrl(e.target.value); + }} + placeholder="http://localhost:11434" + /> + + )}
diff --git a/packages/workflows/src/dag-executor.test.ts b/packages/workflows/src/dag-executor.test.ts index 150ea4eeb7..9b6cc8378c 100644 --- a/packages/workflows/src/dag-executor.test.ts +++ b/packages/workflows/src/dag-executor.test.ts @@ -832,7 +832,9 @@ describe('executeDagWorkflow -- tool restrictions', () => { const sendMessage = platform.sendMessage as ReturnType; const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); - const warning = messages.find(m => m.includes('denied_tools') && m.includes('Codex')); + const warning = messages.find( + m => m.includes('denied_tools') && m.toLowerCase().includes('codex') + ); expect(warning).toBeDefined(); }); @@ -941,7 +943,7 @@ describe('executeDagWorkflow -- tool restrictions', () => { const sendMessage = platform.sendMessage as ReturnType; const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); - const warning = messages.find(m => m.includes('hooks') && m.includes('Codex')); + const warning = messages.find(m => m.includes('hooks') && m.toLowerCase().includes('codex')); expect(warning).toBeDefined(); }); }); @@ -2343,7 +2345,7 @@ describe('executeDagWorkflow -- skills options', () => { // Warning sent to user const sendMessage = platform.sendMessage as ReturnType; const messages = sendMessage.mock.calls.map((call: unknown[]) => call[1] as string); - const warning = messages.find(m => m.includes('skills') && m.includes('Codex')); + const warning = messages.find(m => m.includes('skills') && m.toLowerCase().includes('codex')); expect(warning).toBeDefined(); // No agents/agent passed to Codex sendQuery diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index facfbd1068..d70a27cbb7 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -362,7 +362,7 @@ function expandEnvVars(config: Record): { */ async function resolveNodeProviderAndModel( node: DagNode, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'ollama', workflowModel: string | undefined, config: WorkflowConfig, platform: IWorkflowPlatform, @@ -371,18 +371,19 @@ async function resolveNodeProviderAndModel( cwd: string, workflowLevelOptions: WorkflowLevelOptions ): Promise<{ - provider: 'claude' | 'codex'; + provider: 'claude' | 'codex' | 'ollama'; model: string | undefined; options: WorkflowAssistantOptions | undefined; }> { - let provider: 'claude' | 'codex'; + let provider: 'claude' | 'codex' | 'ollama'; if (node.provider) { provider = node.provider; } else if (node.model && isClaudeModel(node.model)) { provider = 'claude'; } else if (node.model) { - provider = 'codex'; + // Non-Claude model: inherit workflowProvider so Ollama users aren't silently rerouted to Codex + provider = workflowProvider === 'ollama' ? 'ollama' : 'codex'; } else { provider = workflowProvider; } @@ -397,30 +398,33 @@ async function resolveNodeProviderAndModel( ); } - // Warn if Codex node has allowed_tools or denied_tools (unsupported per-call) + // Warn if non-Claude node has allowed_tools or denied_tools (Claude-only feature) if ( - provider === 'codex' && + provider !== 'claude' && (node.allowed_tools !== undefined || node.denied_tools !== undefined) ) { - getLog().warn({ nodeId: node.id }, 'dag_node_tool_restrictions_ignored_codex'); + getLog().warn({ nodeId: node.id, provider }, 'dag_node_tool_restrictions_ignored'); const delivered = await safeSendMessage( platform, conversationId, - `Warning: Node '${node.id}' has allowed_tools/denied_tools set but uses Codex — per-node tool restrictions are not supported for Codex. Configure MCP servers globally in the Codex CLI config instead.`, + `Warning: Node '${node.id}' has allowed_tools/denied_tools set but uses ${provider} — per-node tool restrictions are Claude-only and will be ignored.`, { workflowId: workflowRunId, nodeName: node.id } ); if (!delivered) { - getLog().error({ nodeId: node.id, workflowRunId }, 'dag_node_codex_warning_delivery_failed'); + getLog().error( + { nodeId: node.id, workflowRunId }, + 'dag_node_tool_restrictions_warning_delivery_failed' + ); } } - // Warn if Codex node has hooks (unsupported) - if (provider === 'codex' && node.hooks) { - getLog().warn({ nodeId: node.id }, 'dag_node_hooks_ignored_codex'); + // Warn if non-Claude node has hooks (Claude-only) + if (provider !== 'claude' && node.hooks) { + getLog().warn({ nodeId: node.id, provider }, 'dag_node_hooks_ignored'); const delivered = await safeSendMessage( platform, conversationId, - `Warning: Node '${node.id}' has hooks set but uses Codex provider — hooks are Claude-only and will be ignored.`, + `Warning: Node '${node.id}' has hooks set but uses ${provider} — hooks are Claude-only and will be ignored.`, { workflowId: workflowRunId, nodeName: node.id } ); if (!delivered) { @@ -428,13 +432,13 @@ async function resolveNodeProviderAndModel( } } - // Warn if Codex node has mcp (unsupported per-call) - if (provider === 'codex' && node.mcp) { - getLog().warn({ nodeId: node.id }, 'dag.mcp_ignored_codex'); + // Warn if non-Claude node has mcp (Claude-only) + if (provider !== 'claude' && node.mcp) { + getLog().warn({ nodeId: node.id, provider }, 'dag.mcp_ignored'); const delivered = await safeSendMessage( platform, conversationId, - `Warning: Node '${node.id}' has mcp config but uses Codex — per-node MCP servers are not supported for Codex. Configure MCP servers globally in the Codex CLI config instead.`, + `Warning: Node '${node.id}' has mcp config but uses ${provider} — per-node MCP servers are Claude-only and will be ignored.`, { workflowId: workflowRunId, nodeName: node.id } ); if (!delivered) { @@ -442,13 +446,13 @@ async function resolveNodeProviderAndModel( } } - // Warn if Codex node has skills (unsupported) - if (provider === 'codex' && node.skills) { - getLog().warn({ nodeId: node.id }, 'dag.skills_ignored_codex'); + // Warn if non-Claude node has skills (Claude-only) + if (provider !== 'claude' && node.skills) { + getLog().warn({ nodeId: node.id, provider }, 'dag.skills_ignored'); const delivered = await safeSendMessage( platform, conversationId, - `Warning: Node '${node.id}' has skills set but uses Codex — per-node skills are not supported for Codex.`, + `Warning: Node '${node.id}' has skills set but uses ${provider} — per-node skills are Claude-only and will be ignored.`, { workflowId: workflowRunId, nodeName: node.id } ); if (!delivered) { @@ -456,8 +460,8 @@ async function resolveNodeProviderAndModel( } } - // Warn if Codex node has Claude-only SDK options (effort, thinking, maxBudgetUsd, systemPrompt, fallbackModel, betas, sandbox) - if (provider === 'codex') { + // Warn if non-Claude node has Claude-only SDK options (effort, thinking, maxBudgetUsd, systemPrompt, fallbackModel, betas, sandbox) + if (provider !== 'claude') { const claudeOnlyFields = [ ['effort', node.effort ?? workflowLevelOptions.effort], ['thinking', node.thinking ?? workflowLevelOptions.thinking], @@ -469,11 +473,11 @@ async function resolveNodeProviderAndModel( ] as const; const present = claudeOnlyFields.filter(([, val]) => val !== undefined).map(([name]) => name); if (present.length > 0) { - getLog().warn({ nodeId: node.id, fields: present }, 'dag.claude_options_ignored_codex'); + getLog().warn({ nodeId: node.id, fields: present, provider }, 'dag.claude_options_ignored'); const delivered = await safeSendMessage( platform, conversationId, - `Warning: Node '${node.id}' has Claude-only options (${present.join(', ')}) but uses Codex — these will be ignored.`, + `Warning: Node '${node.id}' has Claude-only options (${present.join(', ')}) but uses ${provider} — these will be ignored.`, { workflowId: workflowRunId, nodeName: node.id } ); if (!delivered) { @@ -496,6 +500,9 @@ async function resolveNodeProviderAndModel( if (node.output_format) { options.outputFormat = { type: 'json_schema', schema: node.output_format }; } + } else if (provider === 'ollama') { + // Ollama only needs the model name; all Claude/Codex-specific options are ignored + options = model ? { model } : undefined; } else { const claudeOptions: WorkflowAssistantOptions = {}; if (model) claudeOptions.model = model; @@ -716,7 +723,7 @@ async function executeNodeInternal( cwd: string, workflowRun: WorkflowRun, node: CommandNode | PromptNode, - provider: 'claude' | 'codex', + provider: 'claude' | 'codex' | 'ollama', nodeOptions: WorkflowAssistantOptions | undefined, artifactsDir: string, logDir: string, @@ -1667,26 +1674,30 @@ async function executeScriptNode( * Caller is responsible for resolving per-node overrides before passing model. */ function buildLoopNodeOptions( - provider: 'claude' | 'codex', + provider: 'claude' | 'codex' | 'ollama', model: string | undefined, config: WorkflowConfig ): WorkflowAssistantOptions | undefined { - const codexOptions = - provider === 'codex' - ? { - modelReasoningEffort: config.assistants.codex.modelReasoningEffort, - webSearchMode: config.assistants.codex.webSearchMode, - additionalDirectories: config.assistants.codex.additionalDirectories, - } - : undefined; + if (provider === 'codex') { + const codexOptions = { + modelReasoningEffort: config.assistants.codex.modelReasoningEffort, + webSearchMode: config.assistants.codex.webSearchMode, + additionalDirectories: config.assistants.codex.additionalDirectories, + }; + return { ...(model ? { model } : {}), ...codexOptions }; + } - const claudeOptions = - provider === 'claude' && config.assistants.claude.settingSources - ? { settingSources: config.assistants.claude.settingSources } - : undefined; + if (provider === 'ollama') { + // Ollama only needs the model name + return model ? { model } : undefined; + } - if (!model && !codexOptions && !claudeOptions) return undefined; - return { ...(model ? { model } : {}), ...codexOptions, ...claudeOptions }; + // Claude + const claudeOptions = config.assistants.claude.settingSources + ? { settingSources: config.assistants.claude.settingSources } + : undefined; + if (!model && !claudeOptions) return undefined; + return { ...(model ? { model } : {}), ...claudeOptions }; } /** @@ -1704,7 +1715,7 @@ async function executeLoopNode( cwd: string, workflowRun: WorkflowRun, node: LoopNode, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'ollama', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -2194,7 +2205,7 @@ async function executeApprovalNode( deps: WorkflowDeps, platform: IWorkflowPlatform, conversationId: string, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'ollama', workflowModel: string | undefined, cwd: string, artifactsDir: string, @@ -2364,7 +2375,7 @@ export async function executeDagWorkflow( cwd: string, workflow: { name: string; nodes: readonly DagNode[] } & WorkflowLevelOptions, workflowRun: WorkflowRun, - workflowProvider: 'claude' | 'codex', + workflowProvider: 'claude' | 'codex' | 'ollama', workflowModel: string | undefined, artifactsDir: string, logDir: string, @@ -2601,13 +2612,14 @@ export async function executeDagWorkflow( // 3b. Loop node dispatch — manages its own AI sessions and iteration if (isLoopNode(node)) { // Resolve per-node provider/model overrides (same logic as other node types) - let loopProvider: 'claude' | 'codex'; + let loopProvider: 'claude' | 'codex' | 'ollama'; if (node.provider) { loopProvider = node.provider; } else if (node.model && isClaudeModel(node.model)) { loopProvider = 'claude'; } else if (node.model) { - loopProvider = 'codex'; + // Non-Claude model: inherit workflowProvider so Ollama users aren't rerouted to Codex + loopProvider = workflowProvider === 'ollama' ? 'ollama' : 'codex'; } else { loopProvider = workflowProvider; } diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index ce586a177b..4006f26408 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -226,7 +226,9 @@ export interface IWorkflowAssistantClient { getType(): string; } -export type AssistantClientFactory = (provider: 'claude' | 'codex') => IWorkflowAssistantClient; +export type AssistantClientFactory = ( + provider: 'claude' | 'codex' | 'ollama' +) => IWorkflowAssistantClient; // --------------------------------------------------------------------------- // Narrow config interface (subset of MergedConfig) @@ -237,8 +239,8 @@ export type AssistantClientFactory = (provider: 'claude' | 'codex') => IWorkflow // --------------------------------------------------------------------------- export interface WorkflowConfig { - /** Default assistant provider ('claude' | 'codex') */ - assistant: 'claude' | 'codex'; + /** Default assistant provider ('claude' | 'codex' | 'ollama') */ + assistant: 'claude' | 'codex' | 'ollama'; baseBranch?: string; docsPath?: string; /** @@ -263,6 +265,11 @@ export interface WorkflowConfig { webSearchMode?: WebSearchMode; additionalDirectories?: string[]; }; + ollama?: { + model?: string; + /** Ollama server base URL. Defaults to OLLAMA_BASE_URL env var or http://localhost:11434. */ + baseUrl?: string; + }; }; } diff --git a/packages/workflows/src/executor.ts b/packages/workflows/src/executor.ts index e87ea9065b..5ad3c47a3f 100644 --- a/packages/workflows/src/executor.ts +++ b/packages/workflows/src/executor.ts @@ -277,8 +277,8 @@ export async function executeWorkflow( // Resolve provider and model once (used by all nodes) // When workflow sets a model but not a provider, infer provider from the model. - // e.g. model: sonnet → provider: claude, even if config.assistant is codex. - let resolvedProvider: 'claude' | 'codex'; + // e.g. model: sonnet → provider: claude, even if config.assistant is ollama. + let resolvedProvider: 'claude' | 'codex' | 'ollama'; let providerSource: string; if (workflow.provider) { resolvedProvider = workflow.provider; @@ -287,7 +287,8 @@ export async function executeWorkflow( resolvedProvider = 'claude'; providerSource = 'inferred from workflow model'; } else if (workflow.model) { - resolvedProvider = 'codex'; + // Non-Claude model: inherit config.assistant so Ollama users aren't silently rerouted to Codex + resolvedProvider = config.assistant === 'ollama' ? 'ollama' : 'codex'; providerSource = 'inferred from workflow model'; } else { resolvedProvider = config.assistant; diff --git a/packages/workflows/src/loader.ts b/packages/workflows/src/loader.ts index 0fd93cce1f..6805965141 100644 --- a/packages/workflows/src/loader.ts +++ b/packages/workflows/src/loader.ts @@ -271,7 +271,9 @@ export function parseWorkflow(content: string, filename: string): ParseResult { // Note: modelReasoningEffort and webSearchMode use warn-and-ignore for invalid values // (consistent with original behavior) rather than schema-level rejection. const provider = - raw.provider === 'claude' || raw.provider === 'codex' ? raw.provider : undefined; + raw.provider === 'claude' || raw.provider === 'codex' || raw.provider === 'ollama' + ? raw.provider + : undefined; const model = typeof raw.model === 'string' ? raw.model : undefined; // Validate model/provider compatibility at workflow level diff --git a/packages/workflows/src/model-validation.ts b/packages/workflows/src/model-validation.ts index b035582717..ea2a1508b7 100644 --- a/packages/workflows/src/model-validation.ts +++ b/packages/workflows/src/model-validation.ts @@ -8,9 +8,13 @@ export function isClaudeModel(model: string): boolean { ); } -export function isModelCompatible(provider: 'claude' | 'codex', model?: string): boolean { +export function isModelCompatible( + provider: 'claude' | 'codex' | 'ollama', + model?: string +): boolean { if (!model) return true; if (provider === 'claude') return isClaudeModel(model); + if (provider === 'ollama') return true; // Any model string is valid for Ollama // Codex: accept most models, but reject obvious Claude aliases/prefixes return !isClaudeModel(model); } diff --git a/packages/workflows/src/schemas/dag-node.ts b/packages/workflows/src/schemas/dag-node.ts index 82bd90ac86..4da98b2663 100644 --- a/packages/workflows/src/schemas/dag-node.ts +++ b/packages/workflows/src/schemas/dag-node.ts @@ -116,7 +116,7 @@ export const dagNodeBaseSchema = z.object({ when: z.string().optional(), trigger_rule: triggerRuleSchema.optional(), model: z.string().optional(), - provider: z.enum(['claude', 'codex']).optional(), + provider: z.enum(['claude', 'codex', 'ollama']).optional(), context: z.enum(['fresh', 'shared']).optional(), output_format: z.record(z.unknown()).optional(), allowed_tools: z.array(z.string()).optional(), diff --git a/packages/workflows/src/schemas/workflow.ts b/packages/workflows/src/schemas/workflow.ts index 008ef19a8f..2b3f6cd9cf 100644 --- a/packages/workflows/src/schemas/workflow.ts +++ b/packages/workflows/src/schemas/workflow.ts @@ -29,7 +29,7 @@ export type WebSearchMode = z.infer; export const workflowBaseSchema = z.object({ name: z.string().min(1), description: z.string().min(1), - provider: z.enum(['claude', 'codex']).optional(), + provider: z.enum(['claude', 'codex', 'ollama']).optional(), model: z.string().optional(), modelReasoningEffort: modelReasoningEffortSchema.optional(), webSearchMode: webSearchModeSchema.optional(), From 5b4e1cc56e7d17337109ad4adbf2e58977144715 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 01:15:26 -0400 Subject: [PATCH 02/15] test(core): fix conversations test isolation and batch split for config-loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `conversations.test.ts`: mock `config-loader` to return a stable default assistant ('claude') so the test doesn't depend on the developer's local .archon/config.yaml. Follows the existing pattern of mocking all external I/O that the module under test touches. The test "creates new conversation with default assistant type" validates config.assistant flows through — 'claude' is the documented factory default, not a constraint on real usage. - `package.json` test script: split `conversations.test.ts` into its own `bun test` invocation (before the big db/utils/config batch) to prevent mock.module() pollution. `conversations.test.ts` mocks `config-loader`; `config-loader.test.ts` spreads `realConfigLoader` — running both in the same process caused config-loader tests to pick up the mock's stub instead of the real module. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/package.json | 2 +- packages/core/src/db/conversations.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 8aa397ea33..7e11a9063e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", + "test": "bun test src/clients/codex-binary-guard.test.ts && bun test src/utils/codex-binary-resolver.test.ts && bun test src/utils/codex-binary-resolver-dev.test.ts && bun test src/clients/claude.test.ts src/clients/codex.test.ts src/clients/factory.test.ts && bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/conversations.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/connection.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/utils/env-leak-scanner.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts", "type-check": "bun x tsc --noEmit", "build": "echo 'No build needed - Bun runs TypeScript directly'" }, diff --git a/packages/core/src/db/conversations.test.ts b/packages/core/src/db/conversations.test.ts index e63ae767c5..4dc5c8da81 100644 --- a/packages/core/src/db/conversations.test.ts +++ b/packages/core/src/db/conversations.test.ts @@ -11,6 +11,16 @@ mock.module('./connection', () => ({ getDialect: () => mockPostgresDialect, })); +// Mock config-loader to return a stable default so that tests are not affected by +// whatever assistant the developer has configured in their local .archon/config.yaml. +// The test "uses DEFAULT_AI_ASSISTANT env var when set" covers env-override behavior. +// The test "creates new conversation with default assistant type" validates that the +// value from config.assistant flows through — 'claude' here is the documented default, +// not a statement that Ollama shouldn't be the default in a real deployment. +mock.module('../config/config-loader', () => ({ + loadConfig: mock(async () => ({ assistant: 'claude' })), +})); + import { getOrCreateConversation, updateConversation, From 59c3758161cee62bbcf4ee26321159efd8a91757 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 04:10:51 -0400 Subject: [PATCH 03/15] docs: add JSDoc coverage for Ollama-introduced symbols Documents all new and modified exported symbols introduced by the Ollama provider feature to satisfy the PR's 60% docstring coverage requirement: OllamaAssistantDefaults, AssistantClientFactory, isClaudeModel, isModelCompatible (model-validation.ts was pre-staged). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/config/config-types.ts | 4 ++++ packages/workflows/src/deps.ts | 1 + packages/workflows/src/model-validation.ts | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/packages/core/src/config/config-types.ts b/packages/core/src/config/config-types.ts index 6079b8037c..4f9145d3b1 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -30,6 +30,10 @@ export interface ClaudeAssistantDefaults { settingSources?: ('project' | 'user')[]; } +/** + * Defaults for the Ollama assistant provider. + * Configured under `assistants.ollama` in `.archon/config.yaml`. + */ export interface OllamaAssistantDefaults { model?: string; /** Ollama server base URL. Overrides OLLAMA_BASE_URL env var. diff --git a/packages/workflows/src/deps.ts b/packages/workflows/src/deps.ts index 4006f26408..c85ff2d8bd 100644 --- a/packages/workflows/src/deps.ts +++ b/packages/workflows/src/deps.ts @@ -226,6 +226,7 @@ export interface IWorkflowAssistantClient { getType(): string; } +/** Factory that instantiates the correct assistant client for a given provider. */ export type AssistantClientFactory = ( provider: 'claude' | 'codex' | 'ollama' ) => IWorkflowAssistantClient; diff --git a/packages/workflows/src/model-validation.ts b/packages/workflows/src/model-validation.ts index ea2a1508b7..7ead5c9dec 100644 --- a/packages/workflows/src/model-validation.ts +++ b/packages/workflows/src/model-validation.ts @@ -1,3 +1,16 @@ +/** + * Model validation utilities for provider/model compatibility checks. + * + * Used by the workflow loader (loader.ts) and DAG executor (dag-executor.ts) to + * reject invalid provider/model combinations at load time rather than at runtime. + */ + +/** + * Returns true if the given model string is a Claude-specific alias or prefix. + * + * Recognized Claude identifiers: `sonnet`, `opus`, `haiku`, `inherit`, or any + * string starting with `claude-`. Everything else is treated as non-Claude. + */ export function isClaudeModel(model: string): boolean { return ( model === 'sonnet' || @@ -8,6 +21,15 @@ export function isClaudeModel(model: string): boolean { ); } +/** + * Returns true if the given model string is compatible with the specified provider. + * + * Rules: + * - If `model` is undefined, any provider accepts it (inherit from config defaults). + * - Claude provider: accepts only Claude aliases/prefixes (see `isClaudeModel`). + * - Ollama provider: accepts any model string (Ollama model names are arbitrary). + * - Codex provider: accepts any model that is NOT a Claude alias/prefix. + */ export function isModelCompatible( provider: 'claude' | 'codex' | 'ollama', model?: string From d7e50c11c552ef5680959a47a5f27dd08a826ce9 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 04:45:55 -0400 Subject: [PATCH 04/15] fix(web): fall back to Claude in settings if Ollama is unreachable When the saved default assistant is 'ollama' but the model-list query comes back unavailable (server offline or unreachable), reset the displayed assistant selection to 'claude' so users aren't stuck on a non-functional provider. The saved setting is unchanged until Save is clicked. Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/routes/SettingsPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index 6a079eb702..adba820037 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -490,6 +490,13 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea }); const ollamaModels = ollamaData?.models ?? []; + /** Fall back to Claude if Ollama was the saved provider but is not reachable. */ + useEffect(() => { + if (ollamaData && !ollamaData.available && ollamaModels.length === 0) { + setAssistant('claude'); + } + }, [ollamaData]); + const hasChanges = assistant !== config.assistant || claudeModel !== (config.assistants.claude.model ?? 'sonnet') || From ab0cb3db5c63c983c4ffe8dfac2d65e107ac4e17 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 05:06:27 -0400 Subject: [PATCH 05/15] docs: add Ollama to assistant setup, configuration, and API reference - ai-assistants.md: new Ollama section (prerequisites, no-auth, config options, set-as-default) and updated assistant selection notes - getting-started/configuration.md: add ollama block to config example - reference/configuration.md: add ollama to defaultAssistant, global config YAML, DEFAULT_AI_ASSISTANT env var, and new OLLAMA_BASE_URL env var section - reference/api.md: document GET /api/ollama/models endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../docs/getting-started/ai-assistants.md | 42 ++++++++++++++++++- .../docs/getting-started/configuration.md | 3 ++ .../src/content/docs/reference/api.md | 1 + .../content/docs/reference/configuration.md | 13 +++++- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index c856c9ccd4..cc8c74f077 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -1,6 +1,6 @@ --- title: AI Assistants -description: Configure Claude Code and Codex as AI assistants for Archon. +description: Configure Claude Code, Codex, or Ollama as AI assistants for Archon. category: getting-started area: clients audience: [user] @@ -9,7 +9,7 @@ sidebar: order: 4 --- -You must configure **at least one** AI assistant. Both can be configured if desired. +You must configure **at least one** AI assistant. Multiple can be configured and switched between. ## Claude Code @@ -130,6 +130,43 @@ If you want Codex to be the default AI assistant for new conversations without c DEFAULT_AI_ASSISTANT=codex ``` +## Ollama + +**Best for local/offline use or private models.** + +Ollama runs models locally on your machine — no API key or account required. + +### Prerequisites + +1. [Install Ollama](https://ollama.com/download) +2. Start the server: `ollama serve` +3. Pull a model: `ollama pull gemma4:latest` (or any model from [ollama.com/library](https://ollama.com/library)) + +### No Authentication Needed + +Ollama requires no credentials. Archon connects to it over HTTP. + +### Ollama Configuration Options + +Configure Ollama in `.archon/config.yaml`: + +```yaml +assistants: + ollama: + model: gemma4:latest # A model is required — Ollama has no built-in default + baseUrl: http://localhost:11434 # Optional, this is the default +``` + +The `model` field is required — unlike Claude and Codex, there is no fallback. Archon will error at query time if none is set. + +`baseUrl` can also be set via the `OLLAMA_BASE_URL` environment variable, which takes precedence over the config file. + +### Set as Default (Optional) + +```ini +DEFAULT_AI_ASSISTANT=ollama +``` + ## How Assistant Selection Works - Assistant type is set per codebase via the `assistant` field in `.archon/config.yaml` or the `DEFAULT_AI_ASSISTANT` env var @@ -137,3 +174,4 @@ DEFAULT_AI_ASSISTANT=codex - `DEFAULT_AI_ASSISTANT` (optional) is used only for new conversations without codebase context - Workflows can override the assistant on a per-node basis with `provider` and `model` fields - Configuration priority: workflow-level options > config file defaults > SDK defaults +- In the Web UI Settings, if the saved default is `ollama` but Ollama is unreachable when the page loads, the UI falls back to Claude until Ollama becomes available again diff --git a/packages/docs-web/src/content/docs/getting-started/configuration.md b/packages/docs-web/src/content/docs/getting-started/configuration.md index ec836f1202..03e23e635a 100644 --- a/packages/docs-web/src/content/docs/getting-started/configuration.md +++ b/packages/docs-web/src/content/docs/getting-started/configuration.md @@ -35,6 +35,9 @@ assistants: codex: model: gpt-5.3-codex modelReasoningEffort: medium + ollama: + model: gemma4:latest # A model is required — Ollama has no built-in default + # baseUrl: http://localhost:11434 # Optional # docs: # path: packages/docs-web/src/content/docs # Optional: default is docs/ diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0e2fa8aa37..5cf9e9940f 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -357,6 +357,7 @@ curl -X PATCH http://localhost:3090/api/config/assistants \ | Method | Path | Description | |--------|------|-------------| | GET | `/api/update-check` | Check for available updates (binary builds only) | +| GET | `/api/ollama/models` | List locally installed Ollama models (3 s timeout; returns `{ models: string[], available: boolean }`) | Returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`. For non-binary (source) builds, always returns `updateAvailable: false` without making external requests. diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index c126b968f1..f3d86350a4 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -51,7 +51,7 @@ Create `~/.archon/config.yaml` for user-wide preferences: ```yaml # Default AI assistant -defaultAssistant: claude # or 'codex' +defaultAssistant: claude # or 'codex' or 'ollama' # Assistant defaults assistants: @@ -66,6 +66,9 @@ assistants: webSearchMode: disabled additionalDirectories: - /absolute/path/to/other/repo + ollama: + model: gemma4:latest # A model is required — Ollama has no built-in default + baseUrl: http://localhost:11434 # Optional, overrides OLLAMA_BASE_URL env var # Streaming preferences per platform streaming: @@ -187,7 +190,7 @@ Environment variables override all other configuration. They are organized by ca | `PORT` | HTTP server listen port | `3090` (auto-allocated in worktrees) | | `LOG_LEVEL` | Logging verbosity (`fatal`, `error`, `warn`, `info`, `debug`, `trace`) | `info` | | `BOT_DISPLAY_NAME` | Bot name shown in batch-mode "starting" messages | `Archon` | -| `DEFAULT_AI_ASSISTANT` | Default AI assistant (`claude` or `codex`) | `claude` | +| `DEFAULT_AI_ASSISTANT` | Default AI assistant (`claude`, `codex`, or `ollama`) | `claude` | | `MAX_CONCURRENT_CONVERSATIONS` | Maximum concurrent AI conversations | `10` | | `SESSION_RETENTION_DAYS` | Delete inactive sessions older than N days | `30` | | `ARCHON_SUPPRESS_NESTED_CLAUDE_WARNING` | When set to `1`, suppresses the stderr warning emitted when `archon` is run inside a Claude Code session | -- | @@ -213,6 +216,12 @@ When `CLAUDE_USE_GLOBAL_AUTH` is unset, Archon auto-detects: it uses explicit to | `CODEX_REFRESH_TOKEN` | Codex refresh token | -- | | `CODEX_ACCOUNT_ID` | Codex account ID | -- | +### AI Providers -- Ollama + +| Variable | Description | Default | +| --- | --- | --- | +| `OLLAMA_BASE_URL` | Ollama server base URL | `http://localhost:11434` | + ### Platform Adapters -- Slack | Variable | Description | Default | From e7173dc06d05ba85492822b04b6c6222127869d3 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 05:18:50 -0400 Subject: [PATCH 06/15] docs(ollama): remove inaccurate capability claims from file comment Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/clients/ollama.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/clients/ollama.ts b/packages/core/src/clients/ollama.ts index 645ba7d2b1..8d5aa140f7 100644 --- a/packages/core/src/clients/ollama.ts +++ b/packages/core/src/clients/ollama.ts @@ -1,15 +1,13 @@ /** * Ollama local LLM client * - * Calls the Ollama /api/chat endpoint for streaming chat completion. - * Uses the same direct-HTTP pattern as rubot's generation/ollama.py: - * POST to /api/chat with NDJSON streaming response. + * POSTs to the Ollama /api/chat endpoint with `stream: true` and reads + * the response as newline-delimited JSON. Each line is parsed as an + * OllamaChatChunk; content deltas are yielded as MessageChunks until + * the server sends `done: true` with final token counts. * - * Limitations vs Claude/Codex: - * - No filesystem access or tool use (pure chat completion) - * - No session resumption (Ollama is stateless) - * - Only `model`, `systemPrompt`, and `abortSignal` from AssistantRequestOptions are used - * - `cwd` is ignored + * Of the AssistantRequestOptions fields, `model`, `systemPrompt`, and + * `abortSignal` are forwarded to the /api/chat payload. */ import type { IAssistantClient, AssistantRequestOptions, MessageChunk } from '../types'; import { createLogger } from '@archon/paths'; From cbf1ac6e9a013ca79f9861a3a693fbe1268b6707 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 05:36:33 -0400 Subject: [PATCH 07/15] docs(ollama): reframe comments toward agentic extension, not limitation Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/clients/ollama.ts | 4 ++++ packages/workflows/src/dag-executor.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/clients/ollama.ts b/packages/core/src/clients/ollama.ts index 8d5aa140f7..0b829ffdf2 100644 --- a/packages/core/src/clients/ollama.ts +++ b/packages/core/src/clients/ollama.ts @@ -8,6 +8,10 @@ * * Of the AssistantRequestOptions fields, `model`, `systemPrompt`, and * `abortSignal` are forwarded to the /api/chat payload. + * + * Extending Ollama's agentic footprint — running local models as domain-expert + * nodes in multi-step workflows, cross-domain consults, and offline-capable + * pipeline steps — is a natural next direction for this integration. */ import type { IAssistantClient, AssistantRequestOptions, MessageChunk } from '../types'; import { createLogger } from '@archon/paths'; diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index d70a27cbb7..ef00b873c9 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -501,7 +501,7 @@ async function resolveNodeProviderAndModel( options.outputFormat = { type: 'json_schema', schema: node.output_format }; } } else if (provider === 'ollama') { - // Ollama only needs the model name; all Claude/Codex-specific options are ignored + // Ollama: pass model name; provider-specific options built per-SDK above options = model ? { model } : undefined; } else { const claudeOptions: WorkflowAssistantOptions = {}; From d01084f4b701c1e02197b3e9e647ac37e6bcbf7c Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 06:48:36 -0400 Subject: [PATCH 08/15] fix(core): thread ollama baseUrl from config through to client OllamaClient was only reading OLLAMA_BASE_URL at construction time, so the baseUrl saved via Settings UI (config.assistants.ollama.baseUrl) had no runtime effect. Thread it through AssistantRequestOptions and prefer options.baseUrl over the env-var default in sendQuery. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/clients/ollama.ts | 3 ++- packages/core/src/orchestrator/orchestrator-agent.ts | 9 +++++++-- packages/core/src/types/index.ts | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/src/clients/ollama.ts b/packages/core/src/clients/ollama.ts index 0b829ffdf2..25c44064ec 100644 --- a/packages/core/src/clients/ollama.ts +++ b/packages/core/src/clients/ollama.ts @@ -71,7 +71,8 @@ export class OllamaClient implements IAssistantClient { } messages.push({ role: 'user', content: prompt }); - const url = `${this.baseUrl}${CHAT_PATH}`; + const baseUrl = options?.baseUrl ?? this.baseUrl; + const url = `${baseUrl}${CHAT_PATH}`; getLog().info({ model, url, messageCount: messages.length }, 'ollama.query_started'); let response: Response; diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 6af3e3fc73..d1d2ce0acf 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -762,8 +762,13 @@ export async function handleMessage( ...(conversation.ai_assistant_type === 'claude' && config.assistants.claude.settingSources ? { settingSources: config.assistants.claude.settingSources } : {}), - ...(conversation.ai_assistant_type === 'ollama' && config.assistants.ollama.model - ? { model: config.assistants.ollama.model } + ...(conversation.ai_assistant_type === 'ollama' + ? { + ...(config.assistants.ollama.model ? { model: config.assistants.ollama.model } : {}), + ...(config.assistants.ollama.baseUrl + ? { baseUrl: config.assistants.ollama.baseUrl } + : {}), + } : {}), }; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 549891f35e..cca5d410b4 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -355,6 +355,12 @@ export interface AssistantRequestOptions { * Claude only — ignored for Codex. */ sandbox?: SandboxSettings; + /** + * Base URL for the Ollama REST API. + * Overrides OLLAMA_BASE_URL env var and the compiled-in default. + * Ollama only — ignored for Claude and Codex. + */ + baseUrl?: string; } /** From f293321c569e798ef718bdd8b84d035042a59ab8 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 07:01:23 -0400 Subject: [PATCH 09/15] fix(core): validate DEFAULT_AI_ASSISTANT env var before persisting Accepting arbitrary strings as ai_assistant_type could silently create conversations with unsupported providers. Narrow the type to the known union and throw a clear error on unrecognised values, matching the validation pattern already used in config-loader.ts. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/db/conversations.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index faf49910a4..15fce83899 100644 --- a/packages/core/src/db/conversations.ts +++ b/packages/core/src/db/conversations.ts @@ -74,7 +74,17 @@ export async function getOrCreateConversation( let inheritedCodebaseId: string | null = null; let inheritedCwd: string | null = null; const config = await loadConfig(); - let assistantType: string = process.env.DEFAULT_AI_ASSISTANT ?? config.assistant; + let assistantType: 'claude' | 'codex' | 'ollama' = config.assistant; + const envAssistant = process.env.DEFAULT_AI_ASSISTANT; + if (envAssistant) { + if (envAssistant === 'claude' || envAssistant === 'codex' || envAssistant === 'ollama') { + assistantType = envAssistant; + } else { + throw new Error( + `Invalid DEFAULT_AI_ASSISTANT: "${envAssistant}". Must be one of: claude, codex, ollama` + ); + } + } if (parentConversationId) { const parent = await pool.query( @@ -84,7 +94,7 @@ export async function getOrCreateConversation( if (parent.rows[0]) { inheritedCodebaseId = parent.rows[0].codebase_id; inheritedCwd = parent.rows[0].cwd; - assistantType = parent.rows[0].ai_assistant_type; + assistantType = parent.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama'; getLog().debug( { inheritedCodebaseId, inheritedCwd }, 'db.conversation_parent_context_inherited' @@ -102,7 +112,7 @@ export async function getOrCreateConversation( [codebaseId] ); if (codebase.rows[0]) { - assistantType = codebase.rows[0].ai_assistant_type; + assistantType = codebase.rows[0].ai_assistant_type as 'claude' | 'codex' | 'ollama'; } } From b458703d095f39f8daecc11d225f5050e2c5bd89 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 07:08:37 -0400 Subject: [PATCH 10/15] fix(server): use persisted ollama baseUrl in /api/ollama/models handler The model discovery endpoint was always probing OLLAMA_BASE_URL / localhost, ignoring the baseUrl saved via the Settings UI. Now reads config.assistants.ollama.baseUrl first, falling back to the env var and then the compiled-in default. Co-Authored-By: Claude Sonnet 4.6 --- packages/server/src/routes/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index d441bc4342..6ad95c4bee 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -2641,7 +2641,9 @@ export function registerApiRoutes( }); registerOpenApiRoute(getOllamaModelsRoute, async c => { - const baseUrl = process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; + const config = await loadConfig(); + const baseUrl = + config.assistants.ollama.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; try { const response = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) }); if (!response.ok) { From 4bb937e0e27401abfee2fdbc3633b6207c2704f7 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 07:11:35 -0400 Subject: [PATCH 11/15] fix(server): add structured logging to ollama model discovery Silent catch made Ollama connectivity issues impossible to debug. Add ollama.discovery_started/_completed/_failed log events with baseUrl and status context, following project Pino conventions. Co-Authored-By: Claude Sonnet 4.6 --- packages/server/src/routes/api.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 6ad95c4bee..8ea22d0d99 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -2644,15 +2644,19 @@ export function registerApiRoutes( const config = await loadConfig(); const baseUrl = config.assistants.ollama.baseUrl ?? process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'; + getLog().info({ baseUrl }, 'ollama.discovery_started'); try { const response = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) }); if (!response.ok) { + getLog().warn({ baseUrl, status: response.status }, 'ollama.discovery_failed'); return c.json({ models: [], available: false }); } const data = (await response.json()) as { models?: { name: string }[] }; const models = (data.models ?? []).map(m => m.name); + getLog().info({ baseUrl, modelCount: models.length }, 'ollama.discovery_completed'); return c.json({ models, available: true }); - } catch { + } catch (err) { + getLog().warn({ baseUrl, err }, 'ollama.discovery_failed'); return c.json({ models: [], available: false }); } }); From d1d8875c9b6ed8c014d01724da441dad45bc2fd4 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 07:34:32 -0400 Subject: [PATCH 12/15] fix(web,core): allow clearing saved ollama model and baseUrl from Settings The ollama block was conditionally omitted when both fields were empty, so the merge-based PATCH had no way to remove previously saved values. Always include the ollama block in the payload and switch updateGlobalConfig to replacement semantics for ollama so the stored state exactly reflects what the user submitted. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/config/config-loader.ts | 4 +--- packages/web/src/routes/SettingsPage.tsx | 12 ++++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/core/src/config/config-loader.ts b/packages/core/src/config/config-loader.ts index c64d07917e..5c5f7ab968 100644 --- a/packages/core/src/config/config-loader.ts +++ b/packages/core/src/config/config-loader.ts @@ -497,9 +497,7 @@ export async function updateGlobalConfig(updates: Partial): Promis merged.assistants = { claude: { ...current.assistants?.claude, ...updates.assistants.claude }, codex: { ...current.assistants?.codex, ...updates.assistants.codex }, - ...(updates.assistants.ollama !== undefined - ? { ollama: { ...current.assistants?.ollama, ...updates.assistants.ollama } } - : {}), + ...(updates.assistants.ollama !== undefined ? { ollama: updates.assistants.ollama } : {}), }; } diff --git a/packages/web/src/routes/SettingsPage.tsx b/packages/web/src/routes/SettingsPage.tsx index adba820037..9d18922251 100644 --- a/packages/web/src/routes/SettingsPage.tsx +++ b/packages/web/src/routes/SettingsPage.tsx @@ -539,14 +539,10 @@ function AssistantConfigSection({ config }: { config: SafeConfigResponse }): Rea codex: { model: codexModel, modelReasoningEffort: reasoning, webSearchMode: webSearch }, } : {}), - ...(ollamaModel || ollamaBaseUrl - ? { - ollama: { - ...(ollamaModel ? { model: ollamaModel } : {}), - ...(ollamaBaseUrl ? { baseUrl: ollamaBaseUrl } : {}), - }, - } - : {}), + ollama: { + ...(ollamaModel ? { model: ollamaModel } : {}), + ...(ollamaBaseUrl ? { baseUrl: ollamaBaseUrl } : {}), + }, }); } From 2ae3a276f480a3a962b30e239efa6d004c1474dc Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 09:11:25 -0400 Subject: [PATCH 13/15] fix(workflows): reject 'inherit' sentinel for ollama provider 'inherit' is a Claude-only alias meaning "use whatever the SDK is configured with". Passing it to Ollama sends the literal string to /api/chat and fails at runtime. Reject it at workflow validation time with a clear error instead. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflows/src/model-validation.test.ts | 12 ++++++++++++ packages/workflows/src/model-validation.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/workflows/src/model-validation.test.ts b/packages/workflows/src/model-validation.test.ts index a73b7586aa..05f7913ec0 100644 --- a/packages/workflows/src/model-validation.test.ts +++ b/packages/workflows/src/model-validation.test.ts @@ -65,5 +65,17 @@ describe('model-validation', () => { expect(isModelCompatible('claude', '')).toBe(true); expect(isModelCompatible('codex', '')).toBe(true); }); + + it('should accept arbitrary model names with ollama provider', () => { + expect(isModelCompatible('ollama', 'gemma4:latest')).toBe(true); + expect(isModelCompatible('ollama', 'llama3:8b')).toBe(true); + expect(isModelCompatible('ollama', 'mistral')).toBe(true); + }); + + it('should reject the inherit sentinel with ollama provider', () => { + // 'inherit' is Claude-only; passing it to Ollama would send the literal + // string "inherit" to /api/chat and fail at runtime + expect(isModelCompatible('ollama', 'inherit')).toBe(false); + }); }); }); diff --git a/packages/workflows/src/model-validation.ts b/packages/workflows/src/model-validation.ts index 7ead5c9dec..4d4ca7b4d2 100644 --- a/packages/workflows/src/model-validation.ts +++ b/packages/workflows/src/model-validation.ts @@ -27,7 +27,7 @@ export function isClaudeModel(model: string): boolean { * Rules: * - If `model` is undefined, any provider accepts it (inherit from config defaults). * - Claude provider: accepts only Claude aliases/prefixes (see `isClaudeModel`). - * - Ollama provider: accepts any model string (Ollama model names are arbitrary). + * - Ollama provider: accepts any model string except `'inherit'`, which is a Claude-only sentinel. * - Codex provider: accepts any model that is NOT a Claude alias/prefix. */ export function isModelCompatible( @@ -36,7 +36,7 @@ export function isModelCompatible( ): boolean { if (!model) return true; if (provider === 'claude') return isClaudeModel(model); - if (provider === 'ollama') return true; // Any model string is valid for Ollama + if (provider === 'ollama') return model !== 'inherit'; // 'inherit' is a Claude-only sentinel // Codex: accept most models, but reject obvious Claude aliases/prefixes return !isClaudeModel(model); } From 2e5aaa9720e2b62cfebdbc0088134abc5b41cce3 Mon Sep 17 00:00:00 2001 From: asmrtfm Date: Mon, 13 Apr 2026 09:54:26 -0400 Subject: [PATCH 14/15] feat(web): add always-visible model indicator to TopNav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows a pill in the nav bar on every page with the active provider and model (e.g. "ollama · gemma4:latest", "claude · sonnet"). Clicking it navigates to Settings. Falls back to provider name when no model is configured. The version label shifts right only when the indicator is absent (config loading or unconfigured). Co-Authored-By: Claude Sonnet 4.6 --- packages/web/src/components/layout/TopNav.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 45924f5004..c37e3ac5ba 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,7 +1,7 @@ import { NavLink, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; -import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; +import { listWorkflowRuns, getUpdateCheck, getConfig } from '@/lib/api'; import { cn } from '@/lib/utils'; const tabs = [ @@ -27,6 +27,20 @@ export function TopNav(): React.ReactElement { retry: false, }); + const { data: configData } = useQuery({ + queryKey: ['config'], + queryFn: getConfig, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }); + + const modelLabel = ((): string | null => { + if (!configData) return null; + const { assistant, assistants } = configData.config; + const model = assistants[assistant]?.model; + return model ? `${assistant} · ${model}` : assistant; + })(); + return (