Add Ollama local model support to openagents coder

586edf191fb2 · AtlantisPleb · · parent b50ab6ccea2f

Add Ollama local model support to openagents coder

Adds an Ollama backend so openagents coder can chat with locally-served
models. The --model flag accepts ollama:<name>; the CLI then uses the
ollama-js client against http://127.0.0.1:11434 and yields the same
ReplySource chunks as the thread-backed path.

Closes #21.
Closes
#21

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/package.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-ollama.ts
  • modified pnpm-lock.yaml

Diff

4 files changed, +172 -53

packages/openagents-cli/package.json modified +1

@@ -55,6 +55,7 @@

55 55
    "@effect/platform-node": "catalog:",
56 56
    "@effect/platform-node-shared": "4.0.0-beta.94",
57 57
    "effect": "catalog:",
58
    "ollama": "^0.6.3",
58 59
    "ws": "8.21.1"
59 60
  },
60 61
  "devDependencies": {
packages/openagents-cli/src/cli.ts modified +65 -31

@@ -27,6 +27,7 @@ import { CoderSession, DummyReplySource } from "./coder-session.js";

27 27
import { CoderTaskRegistry } from "./coder-tasks.js";
28 28
import { runCoderUi } from "./coder-ui.js";
29 29
import { backendIds } from "./coder-backends.js";
30
import { OllamaReplySource, isOllamaModelFlag, parseOllamaModelFlag } from "./coder-ollama.js";
30 31
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
31 32
import { delegateTool } from "./coder-tools.js";
32 33
import { describeWorkspace } from "./coder-workspace.js";

@@ -1415,13 +1416,16 @@ const coderReasoningFlag = Flag.choice("reasoning", [

1415 1416
  Flag.optional,
1416 1417
  Flag.withDescription("Reasoning effort recorded on the thread as its admitted execution shape"),
1417 1418
);
1418
// The accepted values are still the chat API's published backends. A thread's
1419
// grant pins its own model and `POST /api/v3/threads` publishes no model
1420
// parameter, so naming one here cannot change which model answers; the session
1421
// says so rather than letting the flag look like it worked.
1422
const coderModelFlag = Flag.choice("model", backendIds() as string[]).pipe(
1419
// `--model` can name an `ollama:<model>` local model or a chat API backend.
1420
// For a chat API backend a thread's grant still pins its own model and
1421
// `POST /api/v3/threads` publishes no model parameter, so naming one cannot
1422
// change which model answers. For `ollama:<model>` the local Ollama server is
1423
// used directly and the named model is the one that runs.
1424
const coderModelFlag = Flag.string("model").pipe(
1423 1425
  Flag.optional,
1424
  Flag.withDescription("A chat API backend. The thread's grant pins its own model"),
1426
  Flag.withDescription(
1427
    "A model name. Use `ollama:<model>` for a local Ollama server, or a chat API backend id",
1428
  ),
1425 1429
);
1426 1430
1427 1431
/**

@@ -1640,35 +1644,58 @@ const coderCommand = Command.make(

1640 1644
      const workspace = describeWorkspace();
1641 1645
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1642 1646
1647
      // A `--model` value that starts with `ollama:` goes to the local Ollama
1648
      // server and needs no account credential.
1649
      const wantsOllama = Option.isSome(model) && isOllamaModelFlag(model.value);
1650
      const ollamaName =
1651
        wantsOllama && Option.isSome(model) ? parseOllamaModelFlag(model.value) : undefined;
1652
1653
      // Any other `--model` value still has to name a published backend. The
1654
      // flag takes a string so an `ollama:` prefix can reach the local server,
1655
      // which costs the enum `Flag.choice` used to enforce, so the check moves
1656
      // here rather than disappearing.
1657
      if (Option.isSome(model) && !wantsOllama && !backendIds().includes(model.value)) {
1658
        return yield* new InputError({
1659
          message: `Unknown model ${model.value}. Use ollama:<model> for a local Ollama server, or one of: ${backendIds().join(", ")}.`,
1660
        });
1661
      }
1662
1643 1663
      // The session opens a thread of its own and spends that thread's grant,
1644 1664
      // so the CLI still holds no provider key and nothing typed here reaches
1645 1665
      // the account's conversation. Without a credential it falls back to the
1646 1666
      // stand-in and says so rather than failing.
1647
      const stored = offline
1648
        ? Option.none()
1649
        : yield* findToken(endpoint.origin).pipe(
1650
            Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1651
              Effect.succeed(Option.none()),
1652
            ),
1653
          );
1667
      const stored =
1668
        offline || wantsOllama
1669
          ? Option.none()
1670
          : yield* findToken(endpoint.origin).pipe(
1671
              Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1672
                Effect.succeed(Option.none()),
1673
              ),
1674
            );
1654 1675
1655
      const thread = Option.isSome(stored)
1656
        ? yield* Effect.tryPromise({
1657
            try: () =>
1658
              openThread({
1659
                origin: endpoint.origin,
1660
                token: Redacted.value(stored.value.token),
1661
                objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1662
                reasoning: Option.getOrUndefined(reasoning),
1663
              }),
1664
            // The server's own code and sentence, which is what turns a ninth
1665
            // concurrent session from an obscure failure into an instruction
1666
            // naming the ceiling and how many threads the account is holding.
1667
            catch: (cause) => coderRefusal(endpoint.origin, cause),
1668
          })
1669
        : undefined;
1676
      const thread =
1677
        Option.isSome(stored) && !wantsOllama
1678
          ? yield* Effect.tryPromise({
1679
              try: () =>
1680
                openThread({
1681
                  origin: endpoint.origin,
1682
                  token: Redacted.value(stored.value.token),
1683
                  objective: `openagents coder in ${workspace.repository} on ${workspace.branch}`,
1684
                  reasoning: Option.getOrUndefined(reasoning),
1685
                }),
1686
              // The server's own code and sentence, which is what turns a ninth
1687
              // concurrent session from an obscure failure into an instruction
1688
              // naming the ceiling and how many threads the account is holding.
1689
              catch: (cause) => coderRefusal(endpoint.origin, cause),
1690
            })
1691
          : undefined;
1670 1692
1671
      const source = thread ?? new DummyReplySource();
1693
      // A `--model ollama:<name>` session answers from the local Ollama server,
1694
      // so it takes neither a thread nor the stand-in.
1695
      const source =
1696
        wantsOllama && ollamaName !== undefined
1697
          ? new OllamaReplySource({ model: ollamaName })
1698
          : (thread ?? new DummyReplySource());
1672 1699
1673 1700
      // Children get their own thread on their own model. The conversation
1674 1701
      // stays on the model it opened with, and a fan-out spends a budget the

@@ -1720,17 +1747,24 @@ const coderCommand = Command.make(

1720 1747
        session.notice(`This session cannot delegate: ${childThread.reason}`);
1721 1748
      }
1722 1749
1723
      if (Option.isNone(stored) && !offline) {
1750
      if (Option.isNone(stored) && !offline && !wantsOllama) {
1724 1751
        session.notice(
1725 1752
          "No stored credential, so replies come from the built-in stand-in. " +
1726 1753
            "Run `openagents auth login` to reach a real model.",
1727 1754
        );
1728 1755
      }
1729 1756
1757
      if (wantsOllama && ollamaName === undefined) {
1758
        session.notice(
1759
          "`--model ollama:` is missing a model name. Use `ollama:<model>`, " +
1760
            "for example `ollama:qwen3.8:27b-mtp-q8_0`.",
1761
        );
1762
      }
1763
1730 1764
      // A grant pins the model the proxy will use, and the thread route takes
1731 1765
      // no model parameter, so a named backend cannot reach this turn. Saying
1732 1766
      // nothing would leave a reader with a flag that appeared to work.
1733
      if (thread !== undefined && Option.isSome(model)) {
1767
      if (thread !== undefined && Option.isSome(model) && !wantsOllama) {
1734 1768
        session.notice(
1735 1769
          `This thread's grant pins ${thread.model}. \`--model\` names a chat API ` +
1736 1770
            "backend, which the inference proxy does not route to, so it had no effect.",
packages/openagents-cli/src/coder-ollama.ts added +92

@@ -0,0 +1,92 @@

1
/**
2
 * A reply source that calls a local Ollama server.
3
 *
4
 * The `ollama` client is used directly, not through the OpenAgents proxy, so
5
 * the caller's machine must already be running an Ollama server. The default
6
 * endpoint is `http://127.0.0.1:11434` and the model is read from the
7
 * `ollama:<name>` shape of the `--model` flag.
8
 *
9
 * Ollama chat messages are kept locally in this source; nothing is sent to the
10
 * OpenAgents chat API. A local model spends no metered budget, so `budget` is
11
 * left undefined.
12
 */
13
14
import { Ollama } from "ollama";
15
16
import type { ReplyChunk, ReplySource } from "./coder-session.js";
17
18
const DEFAULT_HOST = "http://127.0.0.1:11434";
19
20
export interface OllamaOptions {
21
  /** The Ollama model name, without the `ollama:` prefix. */
22
  readonly model: string;
23
  /** The Ollama server endpoint. Defaults to `http://127.0.0.1:11434`. */
24
  readonly host?: string | undefined;
25
}
26
27
/** True when `--model` names an Ollama source. */
28
export const isOllamaModelFlag = (value: string): boolean => value.startsWith("ollama:");
29
30
/** Extract the Ollama model name from an `ollama:<name>` flag value. */
31
export const parseOllamaModelFlag = (value: string): string | undefined => {
32
  const match = /^ollama:(.+)$/.exec(value);
33
  return match?.[1]?.trim();
34
};
35
36
interface WireMessage {
37
  readonly role: "user" | "assistant";
38
  readonly content: string;
39
}
40
41
export class OllamaReplySource implements ReplySource {
42
  private readonly client: Ollama;
43
  private readonly modelName: string;
44
  private readonly transcript: WireMessage[] = [];
45
46
  get model(): string {
47
    return `Ollama ${this.modelName}`;
48
  }
49
50
  constructor(options: OllamaOptions) {
51
    this.client = new Ollama({ host: options.host ?? DEFAULT_HOST });
52
    this.modelName = options.model;
53
  }
54
55
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
56
    this.transcript.push({ role: "user", content: prompt });
57
58
    let assistant = "";
59
    const stream = await this.client.chat({
60
      model: this.modelName,
61
      messages: this.transcript,
62
      stream: true,
63
    });
64
65
    const onAbort = () => stream.abort();
66
    signal.addEventListener("abort", onAbort, { once: true });
67
68
    try {
69
      for await (const chunk of stream) {
70
        if (signal.aborted) break;
71
72
        const thinking = chunk.message.thinking;
73
        if (typeof thinking === "string" && thinking.length > 0) {
74
          yield { type: "reasoning", value: thinking };
75
        }
76
77
        const content = chunk.message.content;
78
        if (typeof content === "string" && content.length > 0) {
79
          assistant += content;
80
          yield { type: "text", value: content };
81
        }
82
83
        if (chunk.done) break;
84
      }
85
    } finally {
86
      signal.removeEventListener("abort", onAbort);
87
      if (assistant.length > 0) {
88
        this.transcript.push({ role: "assistant", content: assistant });
89
      }
90
    }
91
  }
92
}
pnpm-lock.yaml modified +14 -22

@@ -2217,6 +2217,9 @@ importers:

2217 2217
      effect:
2218 2218
        specifier: 4.0.0-beta.94
2219 2219
        version: 4.0.0-beta.94
2220
      ollama:
2221
        specifier: ^0.6.3
2222
        version: 0.6.3
2220 2223
      ws:
2221 2224
        specifier: 8.21.1
2222 2225
        version: 8.21.1

@@ -8426,6 +8429,9 @@ packages:

8426 8429
  ofetch@1.5.1:
8427 8430
    resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
8428 8431
8432
  ollama@0.6.3:
8433
    resolution: {integrity: sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg==}
8434
8429 8435
  on-exit-leak-free@2.1.2:
8430 8436
    resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
8431 8437
    engines: {node: '>=14.0.0'}

@@ -13361,13 +13367,14 @@ snapshots:

13361 13367
    dependencies:
13362 13368
      '@testing-library/dom': 10.4.1
13363 13369
      '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
13364
      '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)
13370
      '@vitest/browser': 4.1.10(vite@8.1.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.10)
13365 13371
      vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6)
13366 13372
    transitivePeerDependencies:
13367 13373
      - bufferutil
13368 13374
      - msw
13369 13375
      - utf-8-validate
13370 13376
      - vite
13377
    optional: true
13371 13378
13372 13379
  '@vitest/browser-preview@4.1.10(vite@8.1.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.10)':
13373 13380
    dependencies:

@@ -13380,24 +13387,6 @@ snapshots:

13380 13387
      - msw
13381 13388
      - utf-8-validate
13382 13389
      - vite
13383
    optional: true
13384
13385
  '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)':
13386
    dependencies:
13387
      '@blazediff/core': 1.9.1
13388
      '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))
13389
      '@vitest/utils': 4.1.10
13390
      magic-string: 0.30.21
13391
      pngjs: 7.0.0
13392
      sirv: 3.0.2
13393
      tinyrainbow: 3.1.0
13394
      vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(happy-dom@20.10.6)
13395
      ws: 8.21.1
13396
    transitivePeerDependencies:
13397
      - bufferutil
13398
      - msw
13399
      - utf-8-validate
13400
      - vite
13401 13390
13402 13391
  '@vitest/browser@4.1.10(vite@8.1.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.10)':
13403 13392
    dependencies:

@@ -13415,7 +13404,6 @@ snapshots:

13415 13404
      - msw
13416 13405
      - utf-8-validate
13417 13406
      - vite
13418
    optional: true
13419 13407
13420 13408
  '@vitest/expect@4.1.10':
13421 13409
    dependencies:

@@ -16094,6 +16082,10 @@ snapshots:

16094 16082
      node-fetch-native: 1.6.7
16095 16083
      ufo: 1.6.4
16096 16084
16085
  ollama@0.6.3:
16086
    dependencies:
16087
      whatwg-fetch: 3.6.20
16088
16097 16089
  on-exit-leak-free@2.1.2: {}
16098 16090
16099 16091
  on-finished@2.3.0:

@@ -17436,8 +17428,8 @@ snapshots:

17436 17428
    dependencies:
17437 17429
      '@oxc-project/types': 0.138.0
17438 17430
      '@oxlint/plugins': 1.68.0
17439
      '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)
17440
      '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.10)
17431
      '@vitest/browser': 4.1.10(vite@8.1.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.10)
17432
      '@vitest/browser-preview': 4.1.10(vite@8.1.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(yaml@2.9.0))(vitest@4.1.10)
17441 17433
      '@vitest/expect': 4.1.10
17442 17434
      '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.4(@types/node@24.13.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.20.6)(typescript@6.0.3)(yaml@2.9.0))
17443 17435
      '@vitest/pretty-format': 4.1.10

This page updates live while a promote is in flight · changelog