Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0639083
feat(core): add Ollama as a third AI assistant provider
asmrtfm Apr 13, 2026
5b4e1cc
test(core): fix conversations test isolation and batch split for conf…
asmrtfm Apr 13, 2026
59c3758
docs: add JSDoc coverage for Ollama-introduced symbols
asmrtfm Apr 13, 2026
d7e50c1
fix(web): fall back to Claude in settings if Ollama is unreachable
asmrtfm Apr 13, 2026
ab0cb3d
docs: add Ollama to assistant setup, configuration, and API reference
asmrtfm Apr 13, 2026
e7173dc
docs(ollama): remove inaccurate capability claims from file comment
asmrtfm Apr 13, 2026
cbf1ac6
docs(ollama): reframe comments toward agentic extension, not limitation
asmrtfm Apr 13, 2026
d01084f
fix(core): thread ollama baseUrl from config through to client
asmrtfm Apr 13, 2026
f293321
fix(core): validate DEFAULT_AI_ASSISTANT env var before persisting
asmrtfm Apr 13, 2026
b458703
fix(server): use persisted ollama baseUrl in /api/ollama/models handler
asmrtfm Apr 13, 2026
4bb937e
fix(server): add structured logging to ollama model discovery
asmrtfm Apr 13, 2026
d1d8875
fix(web,core): allow clearing saved ollama model and baseUrl from Set…
asmrtfm Apr 13, 2026
2ae3a27
fix(workflows): reject 'inherit' sentinel for ollama provider
asmrtfm Apr 13, 2026
fe1439f
feat(core): merge feat/ollama-support into feat/model-indicator
asmrtfm Apr 13, 2026
2e5aaa9
feat(web): add always-visible model indicator to TopNav
asmrtfm Apr 13, 2026
4a62b68
test(core): restore conversations.test.ts batch isolation for config-…
asmrtfm Apr 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
31 changes: 30 additions & 1 deletion packages/core/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -194,6 +197,7 @@ function getDefaults(): MergedConfig {
assistants: {
claude: {},
codex: {},
ollama: {},
},
streaming: {
telegram: 'stream',
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -277,6 +281,7 @@ function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): Merged
assistants: {
claude: { ...defaults.assistants.claude },
codex: { ...defaults.assistants.codex },
ollama: { ...defaults.assistants.ollama },
},
};

Expand All @@ -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) {
Expand Down Expand Up @@ -339,6 +350,7 @@ function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig {
assistants: {
claude: { ...merged.assistants.claude },
codex: { ...merged.assistants.codex },
ollama: { ...merged.assistants.ollama },
},
};

Expand All @@ -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) {
Expand Down Expand Up @@ -479,6 +497,7 @@ export async function updateGlobalConfig(updates: Partial<GlobalConfig>): 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 } : {}),
};
}

Expand Down Expand Up @@ -517,9 +536,15 @@ export async function updateGlobalConfig(updates: Partial<GlobalConfig>): 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');
}
Comment on lines +539 to +542
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

availableAssistants now exposes Ollama on every install.

SettingsPage.tsx renders its provider dropdown from this array, so hard-coding ['claude', 'ollama'] makes Ollama visible even when there is no assistants.ollama block configured. That breaks the additive/"only when configured" behavior described for this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/config/config-loader.ts` around lines 541 - 544, The
availableAssistants array is hard-coded to include 'ollama' and thus exposes it
even when no assistants.ollama config exists; change construction of
availableAssistants to start from the actual configured assistants (e.g., the
parsed config object’s assistants keys) and only push 'codex' if
process.env.CODEX_ID_TOKEN && process.env.CODEX_ACCESS_TOKEN are present;
specifically, replace the hard-coded ['claude','ollama'] with code that reads
the config's assistants (e.g., Object.keys(config.assistants || {}) filtered to
supported names) to populate availableAssistants and then conditionally add
'codex' as before.


return {
botName: config.botName,
assistant: config.assistant,
availableAssistants,
assistants: {
claude: {
model: config.assistants.claude.model,
Expand All @@ -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,
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/config/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -41,14 +52,15 @@ 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.)
*/
assistants?: {
claude?: ClaudeCodexProviderDefaults;
codex?: CodexProviderDefaults;
ollama?: OllamaAssistantDefaults;
};

/**
Expand Down Expand Up @@ -112,14 +124,15 @@ 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
*/
assistants?: {
claude?: ClaudeCodexProviderDefaults;
codex?: CodexProviderDefaults;
ollama?: OllamaAssistantDefaults;
};

/**
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<ClaudeCodexProviderDefaults, 'model'>;
codex: Pick<CodexProviderDefaults, 'model' | 'modelReasoningEffort' | 'webSearchMode'>;
ollama: Pick<OllamaAssistantDefaults, 'model' | 'baseUrl'>;
};
streaming: {
telegram: 'stream' | 'batch';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/db/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })),
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

import {
getOrCreateConversation,
updateConversation,
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/db/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createLogger> | undefined;
Expand Down Expand Up @@ -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<Conversation>(
Expand All @@ -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'
Expand All @@ -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';
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand All @@ -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'`
);
}
}
1 change: 1 addition & 0 deletions packages/core/src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading