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/package.json b/packages/core/package.json index 9199551431..5522885e2c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "./state/*": "./src/state/*.ts" }, "scripts": { - "test": "bun test src/providers/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/providers/claude.test.ts src/providers/codex.test.ts src/providers/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/providers/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/providers/claude.test.ts src/providers/codex.test.ts src/providers/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/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..5c5f7ab968 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,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: updates.assistants.ollama } : {}), }; } @@ -517,9 +536,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 +554,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 290ba48228..36249300af 100644 --- a/packages/core/src/config/config-types.ts +++ b/packages/core/src/config/config-types.ts @@ -30,6 +30,17 @@ export interface ClaudeCodexProviderDefaults { 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. + * @default 'http://localhost:11434' */ + baseUrl?: string; +} + export interface GlobalConfig { /** * Bot display name (shown in messages) @@ -41,7 +52,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 +60,7 @@ export interface GlobalConfig { assistants?: { claude?: ClaudeCodexProviderDefaults; codex?: CodexProviderDefaults; + ollama?: OllamaAssistantDefaults; }; /** @@ -112,7 +124,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 +132,7 @@ export interface RepoConfig { assistants?: { claude?: ClaudeCodexProviderDefaults; codex?: CodexProviderDefaults; + ollama?: OllamaAssistantDefaults; }; /** @@ -215,10 +228,11 @@ export interface RepoConfig { */ export interface MergedConfig { botName: string; - assistant: 'claude' | 'codex'; + assistant: 'claude' | 'codex' | 'ollama'; assistants: { claude: ClaudeCodexProviderDefaults; codex: CodexProviderDefaults; + ollama: OllamaAssistantDefaults; }; streaming: { telegram: 'stream' | 'batch'; @@ -279,10 +293,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.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, diff --git a/packages/core/src/db/conversations.ts b/packages/core/src/db/conversations.ts index 0a7a237da3..15fce83899 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,18 @@ 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: '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( @@ -82,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' @@ -100,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'; } } diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index 86f704b2fc..9f8fcb4d49 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -762,6 +762,14 @@ 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 } : {}), + ...(config.assistants.ollama.baseUrl + ? { baseUrl: config.assistants.ollama.baseUrl } + : {}), + } + : {}), }; const mode = platform.getStreamingMode(); diff --git a/packages/core/src/providers/factory.ts b/packages/core/src/providers/factory.ts index 9e3b60f3bf..c01afcc9c7 100644 --- a/packages/core/src/providers/factory.ts +++ b/packages/core/src/providers/factory.ts @@ -7,6 +7,7 @@ import type { IAgentProvider } from '../types'; import { ClaudeProvider } from './claude'; import { CodexProvider } from './codex'; +import { OllamaProvider } from './ollama'; import { createLogger } from '@archon/paths'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -31,7 +32,12 @@ export function getAgentProvider(type: string): IAgentProvider { case 'codex': getLog().debug({ provider: 'codex' }, 'provider_selected'); return new CodexProvider(); + case 'ollama': + getLog().debug({ provider: 'ollama' }, 'provider_selected'); + return new OllamaProvider(); default: - throw new Error(`Unknown provider type: ${type}. Supported types: 'claude', 'codex'`); + throw new Error( + `Unknown provider type: ${type}. Supported types: 'claude', 'codex', 'ollama'` + ); } } diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index 55c0a55160..7e4f32b309 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -10,6 +10,7 @@ export { ClaudeProvider } from './claude'; export { CodexProvider } from './codex'; +export { OllamaProvider } from './ollama'; export { getAgentProvider } from './factory'; // Re-export types for consumers importing from this submodule directly diff --git a/packages/core/src/providers/ollama.ts b/packages/core/src/providers/ollama.ts new file mode 100644 index 0000000000..cc55e40d4e --- /dev/null +++ b/packages/core/src/providers/ollama.ts @@ -0,0 +1,167 @@ +/** + * Ollama local LLM client + * + * 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. + * + * Of the AgentRequestOptions 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 { IAgentProvider, AgentRequestOptions, 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('provider.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 agent provider. + * Implements IAgentProvider via the Ollama REST API. + */ +export class OllamaProvider implements IAgentProvider { + 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?: AgentRequestOptions + ): 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 baseUrl = options?.baseUrl ?? this.baseUrl; + const url = `${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/types/index.ts b/packages/core/src/types/index.ts index 095c04a73a..2d49c4962a 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -355,6 +355,12 @@ export interface AgentRequestOptions { * 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; } /** 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 | diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index ed267c1d41..8ea22d0d99 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,25 @@ export function registerApiRoutes( const result = await checkForUpdate(appVersion); return c.json(result ?? noUpdate); }); + + registerOpenApiRoute(getOllamaModelsRoute, async c => { + 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 (err) { + getLog().warn({ baseUrl, err }, 'ollama.discovery_failed'); + 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/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 (