Let a local model delegate

fc1b2d94063b · AtlantisPleb · · parent 586edf191fb2

Let a local model delegate

`openagents coder --model ollama:<name>` answered from a local model but could
not delegate, while the same session on a thread could. The gap was not the
model: `qwen3.8:27b` reports the `tools` capability and emits a correct
`delegate` call when it is offered one. The gap was that tools were declared on
the thread rather than on whatever was answering, and `OllamaReplySource`
implemented `reply` only, so a local session had no tool runtime to reach.

Tools are the client's, not the transport's, so `ReplySource` now carries an
optional `useTools`. A source opts in by running the calls a model asks for and
reporting them as chunks; one that cannot leaves it undefined, and the session
then declares no tools rather than declaring tools nothing runs.

`OllamaReplySource` implements it with the same shape as the thread lane: a turn
is a loop, the model answers or asks for tools and then answers once it has seen
what they returned, and `MAX_TOOL_STEPS` stops a model that only ever delegates.
Two differences are Ollama's, not ours. It hands back parsed arguments where the
chat lane sends JSON source, so the chunk carries the source the renderer shows
and the tool receives the object. It numbers no calls, so the id is this
session's own, stable between a `tool_call` and its `tool_result`.

An Ollama session now reads the stored credential it used to skip. It still
opens no thread of its own -- the parent answers locally, spends nothing, and
sends nothing to the chat API -- but children hold a server grant, so a local
session delegates exactly as a thread session does. Without a credential it
behaves as before. `--child-model` still runs children on a named model instead.

The request now carries a snapshot of the transcript. It was passing the live
array, which kept growing while the round streamed.

Verified against the running server: a local qwen parent called `delegate`, a
granted child answered, and the parent reported it. 276 tests pass.

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 docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • added packages/openagents-cli/test/coder-ollama.test.ts

Diff

6 files changed, +347 -38

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2429,
7
    "filesScanned": 2430,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:51b6ad8b75a35b806924320c7d4d9f762f729c7e744412cb96ca5b6b9d81907a",
4
  "sourceDigest": "sha256:30573ab546f371af545bfe2cfedba47c5ec547e2acc61c8d9ce115761d3f7d4a",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (29 tracked test files)"
1879
          "ref": "packages/openagents-cli (30 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +9 -5

@@ -23,6 +23,7 @@ import { DelegateFleet, describePrompt, OpencodeHarness } from "./coder-delegate

23 23
import { fleetPlainLines } from "./coder-fleet.js";
24 24
import { runCoderPlain } from "./coder-plain.js";
25 25
import type { CoderDelegation } from "./coder-session.js";
26
import type { ReplySource } from "./coder-session.js";
26 27
import { CoderSession, DummyReplySource } from "./coder-session.js";
27 28
import { CoderTaskRegistry } from "./coder-tasks.js";
28 29
import { runCoderUi } from "./coder-ui.js";

@@ -1664,8 +1665,11 @@ const coderCommand = Command.make(

1664 1665
      // so the CLI still holds no provider key and nothing typed here reaches
1665 1666
      // the account's conversation. Without a credential it falls back to the
1666 1667
      // stand-in and says so rather than failing.
1668
      // An Ollama session reads the credential too, though it opens no thread of
1669
      // its own. The parent answers locally; children still spend a server
1670
      // grant, so a local session delegates exactly as a thread session does.
1667 1671
      const stored =
1668
        offline || wantsOllama
1672
        offline
1669 1673
          ? Option.none()
1670 1674
          : yield* findToken(endpoint.origin).pipe(
1671 1675
              Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>

@@ -1692,7 +1696,7 @@ const coderCommand = Command.make(

1692 1696
1693 1697
      // A `--model ollama:<name>` session answers from the local Ollama server,
1694 1698
      // so it takes neither a thread nor the stand-in.
1695
      const source =
1699
      const source: ReplySource =
1696 1700
        wantsOllama && ollamaName !== undefined
1697 1701
          ? new OllamaReplySource({ model: ollamaName })
1698 1702
          : (thread ?? new DummyReplySource());

@@ -1701,7 +1705,7 @@ const coderCommand = Command.make(

1701 1705
      // stays on the model it opened with, and a fan-out spends a budget the
1702 1706
      // reader's next question does not share.
1703 1707
      const childThread =
1704
        thread !== undefined && Option.isSome(stored)
1708
        Option.isSome(stored)
1705 1709
          ? yield* Effect.promise(() =>
1706 1710
              openChildThread({
1707 1711
                origin: endpoint.origin,

@@ -1736,8 +1740,8 @@ const coderCommand = Command.make(

1736 1740
      // remember a slash command. A turn that needs three agents asks for them
1737 1741
      // mid-sentence, and `/delegate` stays as the way to launch a fan-out
1738 1742
      // without spending a turn to ask for one.
1739
      if (thread !== undefined && setup !== undefined) {
1740
        thread.useTools([delegateTool(setup.delegation)]);
1743
      if (setup !== undefined) {
1744
        source.useTools?.([delegateTool(setup.delegation)]);
1741 1745
      }
1742 1746
1743 1747
      // Delegation is off rather than quietly running children on the
packages/openagents-cli/src/coder-ollama.ts modified +150 -30

@@ -12,11 +12,22 @@

12 12
 */
13 13
14 14
import { Ollama } from "ollama";
15
import type { Message as OllamaMessage, Tool as OllamaTool, ToolCall as OllamaToolCall } from "ollama";
15 16
16 17
import type { ReplyChunk, ReplySource } from "./coder-session.js";
18
import type { CoderTool } from "./coder-tools.js";
17 19
18 20
const DEFAULT_HOST = "http://127.0.0.1:11434";
19 21
22
/**
23
 * How many times one turn may call tools before it has to answer.
24
 *
25
 * The same ceiling the thread lane uses, for the same reason: a model that
26
 * keeps delegating never reports to the reader. A local model spends no metered
27
 * budget, but it does spend the reader's wall clock and the children's.
28
 */
29
const MAX_TOOL_STEPS = 6;
30
20 31
export interface OllamaOptions {
21 32
  /** The Ollama model name, without the `ollama:` prefix. */
22 33
  readonly model: string;

@@ -33,15 +44,21 @@ export const parseOllamaModelFlag = (value: string): string | undefined => {

33 44
  return match?.[1]?.trim();
34 45
};
35 46
36
interface WireMessage {
37
  readonly role: "user" | "assistant";
38
  readonly content: string;
39
}
47
/**
48
 * The transcript this source keeps, in Ollama's own message shape.
49
 *
50
 * A tool exchange is kept as the assistant turn that asked and a `tool` turn
51
 * carrying the output, which is what Ollama's chat API takes back. A model that
52
 * cannot see what its own call returned calls it again.
53
 */
54
type WireMessage = OllamaMessage;
40 55
41 56
export class OllamaReplySource implements ReplySource {
42 57
  private readonly client: Ollama;
43 58
  private readonly modelName: string;
44 59
  private readonly transcript: WireMessage[] = [];
60
  private tools: ReadonlyArray<CoderTool> = [];
61
  private callCount = 0;
45 62
46 63
  get model(): string {
47 64
    return `Ollama ${this.modelName}`;

@@ -52,41 +69,144 @@ export class OllamaReplySource implements ReplySource {

52 69
    this.modelName = options.model;
53 70
  }
54 71
72
  /**
73
   * Declare the tools the model may call.
74
   *
75
   * Set after construction for the same reason the thread lane sets them then:
76
   * the tools need things built after the source exists, such as the fleet a
77
   * `delegate` call submits to.
78
   */
79
  useTools(tools: ReadonlyArray<CoderTool>): void {
80
    this.tools = tools;
81
  }
82
55 83
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
56 84
    this.transcript.push({ role: "user", content: prompt });
57 85
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 });
86
    // A turn is a loop, not a single call: the model may answer, or it may ask
87
    // for tools and then answer once it has seen what they returned. The
88
    // ceiling is what stops a model that only ever delegates.
89
    for (let step = 0; step < MAX_TOOL_STEPS; step += 1) {
90
      if (signal.aborted) return;
91
92
      const calls: OllamaToolCall[] = [];
93
      let assistant = "";
94
95
      const stream = await this.client.chat({
96
        model: this.modelName,
97
        // A snapshot, not the live array: the transcript grows while the round
98
        // streams, and a request that keeps growing after it was sent is a
99
        // request nobody can reason about.
100
        messages: [...this.transcript],
101
        stream: true,
102
        ...(this.tools.length === 0
103
          ? {}
104
          : {
105
              tools: this.tools.map((tool) => ({
106
                type: "function",
107
                function: {
108
                  name: tool.name,
109
                  description: tool.description,
110
                  // The client's type for a schema is narrower than JSON
111
                  // Schema. The server takes the schema as written.
112
                  parameters: tool.parameters as NonNullable<OllamaTool["function"]["parameters"]>,
113
                },
114
              })),
115
            }),
116
      });
117
118
      const onAbort = () => stream.abort();
119
      signal.addEventListener("abort", onAbort, { once: true });
120
121
      try {
122
        for await (const chunk of stream) {
123
          if (signal.aborted) break;
124
125
          const thinking = chunk.message.thinking;
126
          if (typeof thinking === "string" && thinking.length > 0) {
127
            yield { type: "reasoning", value: thinking };
128
          }
129
130
          const content = chunk.message.content;
131
          if (typeof content === "string" && content.length > 0) {
132
            assistant += content;
133
            yield { type: "text", value: content };
134
          }
135
136
          // Ollama sends whole calls rather than the fragments the chat lane
137
          // folds together, so they are collected as they arrive.
138
          const toolCalls = chunk.message.tool_calls;
139
          if (Array.isArray(toolCalls)) calls.push(...toolCalls);
140
141
          if (chunk.done) break;
142
        }
143
      } finally {
144
        signal.removeEventListener("abort", onAbort);
145
      }
67 146
68
    try {
69
      for await (const chunk of stream) {
70
        if (signal.aborted) break;
147
      if (signal.aborted) return;
71 148
72
        const thinking = chunk.message.thinking;
73
        if (typeof thinking === "string" && thinking.length > 0) {
74
          yield { type: "reasoning", value: thinking };
75
        }
149
      // Whatever the model said before asking is kept with the calls, so the
150
      // next round sees its own turn as it happened.
151
      this.transcript.push({
152
        role: "assistant",
153
        content: assistant,
154
        ...(calls.length === 0 ? {} : { tool_calls: calls }),
155
      });
76 156
77
        const content = chunk.message.content;
78
        if (typeof content === "string" && content.length > 0) {
79
          assistant += content;
80
          yield { type: "text", value: content };
81
        }
157
      if (calls.length === 0) return;
82 158
83
        if (chunk.done) break;
159
      for (const call of calls) {
160
        if (signal.aborted) return;
161
        yield* this.invoke(call, signal);
84 162
      }
85
    } finally {
86
      signal.removeEventListener("abort", onAbort);
87
      if (assistant.length > 0) {
88
        this.transcript.push({ role: "assistant", content: assistant });
163
    }
164
165
    // The ceiling was reached. Say so rather than ending on a tool result the
166
    // reader has to interpret as an answer.
167
    yield {
168
      type: "text",
169
      value: `\n\nStopped after ${String(MAX_TOOL_STEPS)} rounds of tool calls without an answer.`,
170
    };
171
  }
172
173
  /**
174
   * Run one call, report it, and put the result on the transcript.
175
   *
176
   * A tool that throws is reported as a failed call rather than ending the
177
   * turn: the model can act on "that needs a prompt" and cannot act on a turn
178
   * that died.
179
   */
180
  private async *invoke(call: OllamaToolCall, signal: AbortSignal): AsyncIterable<ReplyChunk> {
181
    const name = call.function.name;
182
    // Ollama hands back parsed arguments where the chat lane sends JSON source.
183
    // The chunk carries the source because that is what the renderer shows.
184
    const args = (call.function.arguments ?? {}) as Record<string, unknown>;
185
    // Ollama numbers no calls, so the id is this session's own. It only has to
186
    // be stable between the `tool_call` chunk and its `tool_result`.
187
    this.callCount += 1;
188
    const callId = `${name}-${String(this.callCount)}`;
189
190
    yield { type: "tool_call", callId, name, arguments: JSON.stringify(args, undefined, 2) };
191
192
    const tool = this.tools.find((candidate) => candidate.name === name);
193
    let output: string;
194
    let failure: string | undefined;
195
196
    if (tool === undefined) {
197
      failure = `This session has no \`${name}\` tool.`;
198
      output = failure;
199
    } else {
200
      try {
201
        output = await tool.run(args, signal);
202
      } catch (cause) {
203
        failure = cause instanceof Error ? cause.message : String(cause);
204
        output = failure;
89 205
      }
90 206
    }
207
208
    yield { type: "tool_result", callId, output, error: failure };
209
210
    this.transcript.push({ role: "tool", content: output, tool_name: name });
91 211
  }
92 212
}
packages/openagents-cli/src/coder-session.ts modified +10

@@ -22,6 +22,7 @@

22 22
import type { DelegationOutcome, DelegationRequest } from "./coder-delegate.js";
23 23
import { parseDelegateCommand } from "./coder-delegate.js";
24 24
import type { CoderTask, CoderTaskId, CoderTaskRegistry } from "./coder-tasks.js";
25
import type { CoderTool } from "./coder-tools.js";
25 26
26 27
/** What a reply source produces. One entry kind per member. */
27 28
export type ReplyChunk =

@@ -122,6 +123,15 @@ export interface ReplySource {

122 123
   * only where pressing it would do something.
123 124
   */
124 125
  cycleBackend?(): string;
126
  /**
127
   * Declare the tools the model may call.
128
   *
129
   * Optional because the tool runtime is the client's, not the transport's: a
130
   * source reaches it by running the calls a model asks for and reporting them
131
   * as chunks. A source that cannot do that leaves this undefined, and the
132
   * session then declares no tools rather than declaring tools nothing runs.
133
   */
134
  useTools?(tools: ReadonlyArray<CoderTool>): void;
125 135
  /**
126 136
   * Yield the reply to `prompt` in chunks. Rendering appends each chunk as it
127 137
   * arrives, so a slow source shows partial text rather than nothing.
packages/openagents-cli/test/coder-ollama.test.ts added +175

@@ -0,0 +1,175 @@

1
import { describe, expect, it, vi } from "vitest";
2
3
import type { ReplyChunk } from "../src/coder-session.js";
4
import type { CoderTool } from "../src/coder-tools.js";
5
import {
6
  isOllamaModelFlag,
7
  OllamaReplySource,
8
  parseOllamaModelFlag,
9
} from "../src/coder-ollama.js";
10
11
const MODEL = "qwen3.8:27b-mtp-q8_0";
12
13
/** One streamed Ollama chunk, in the shape the client yields. */
14
const chunk = (message: Record<string, unknown>, done = false) => ({ message, done });
15
16
/**
17
 * Stand in for the Ollama client's `chat`, one scripted stream per round.
18
 *
19
 * The source drives a turn as a loop, so a tool test needs a second round to
20
 * answer with. Each call also records the request, which is how the assertions
21
 * about the declared tools and the transcript are made.
22
 */
23
const client = (rounds: ReadonlyArray<ReadonlyArray<ReturnType<typeof chunk>>>) => {
24
  const requests: Record<string, unknown>[] = [];
25
  let round = 0;
26
  const chat = vi.fn(async (request: Record<string, unknown>) => {
27
    requests.push(request);
28
    const scripted = rounds[round] ?? [];
29
    round += 1;
30
    return Object.assign(
31
      (async function* () {
32
        for (const piece of scripted) yield piece;
33
      })(),
34
      { abort: () => {} },
35
    );
36
  });
37
  return { chat, requests };
38
};
39
40
const sourceWith = (rounds: ReadonlyArray<ReadonlyArray<ReturnType<typeof chunk>>>) => {
41
  const stub = client(rounds);
42
  const source = new OllamaReplySource({ model: MODEL });
43
  // The client is constructed in the constructor, so the stub replaces it.
44
  (source as unknown as { client: unknown }).client = { chat: stub.chat };
45
  return { source, stub };
46
};
47
48
const collect = async (source: OllamaReplySource, prompt: string): Promise<ReplyChunk[]> => {
49
  const chunks: ReplyChunk[] = [];
50
  for await (const piece of source.reply(prompt, new AbortController().signal)) {
51
    chunks.push(piece);
52
  }
53
  return chunks;
54
};
55
56
describe("the ollama model flag", () => {
57
  it("recognizes an ollama model and takes its name", () => {
58
    expect(isOllamaModelFlag("ollama:qwen3")).toBe(true);
59
    expect(isOllamaModelFlag("ox-alpha")).toBe(false);
60
    expect(parseOllamaModelFlag("ollama:qwen3")).toBe("qwen3");
61
  });
62
});
63
64
describe("an ollama reply", () => {
65
  it("streams text and reasoning without declaring tools it does not have", async () => {
66
    const { source, stub } = sourceWith([
67
      [chunk({ thinking: "weighing it" }), chunk({ content: "Hello" }), chunk({}, true)],
68
    ]);
69
70
    const chunks = await collect(source, "hi");
71
72
    expect(chunks).toEqual([
73
      { type: "reasoning", value: "weighing it" },
74
      { type: "text", value: "Hello" },
75
    ]);
76
    expect(stub.requests[0]).not.toHaveProperty("tools");
77
  });
78
});
79
80
/** A `delegate` stand-in that records the arguments the model sent. */
81
const delegate = (calls: Record<string, unknown>[]): CoderTool => ({
82
  name: "delegate",
83
  description: "Run a prompt on child agents.",
84
  parameters: { type: "object", properties: { prompt: { type: "string" } } },
85
  run: async (args) => {
86
    calls.push(args);
87
    return "child 1 said PONG";
88
  },
89
});
90
91
describe("an ollama turn that calls a tool", () => {
92
  const CALLING = [
93
    chunk({
94
      content: "",
95
      tool_calls: [{ function: { name: "delegate", arguments: { prompt: "say PONG" } } }],
96
    }),
97
    chunk({}, true),
98
  ];
99
100
  it("declares the tools, runs the call, and answers from its result", async () => {
101
    const calls: Record<string, unknown>[] = [];
102
    const { source, stub } = sourceWith([CALLING, [chunk({ content: "They said PONG." }, true)]]);
103
    source.useTools([delegate(calls)]);
104
105
    const chunks = await collect(source, "delegate this");
106
107
    // The tool ran with the arguments the model sent, parsed.
108
    expect(calls).toEqual([{ prompt: "say PONG" }]);
109
110
    const call = chunks.find((piece) => piece.type === "tool_call");
111
    const result = chunks.find((piece) => piece.type === "tool_result");
112
    expect(call).toMatchObject({ name: "delegate" });
113
    expect(result).toMatchObject({ output: "child 1 said PONG", error: undefined });
114
    // The call and its result share an id, which is how a renderer pairs them.
115
    expect(result).toMatchObject({ callId: (call as { callId: string }).callId });
116
    // The turn continued rather than ending on the tool result.
117
    expect(chunks.at(-1)).toEqual({ type: "text", value: "They said PONG." });
118
119
    // The tool was declared, and the second round carried the exchange back.
120
    expect(stub.requests[0]).toHaveProperty("tools");
121
    const messages = stub.requests[1]?.["messages"] as ReadonlyArray<Record<string, unknown>>;
122
    expect(messages.map((message) => message["role"])).toEqual(["user", "assistant", "tool"]);
123
    expect(messages.at(-1)).toMatchObject({ content: "child 1 said PONG", tool_name: "delegate" });
124
  });
125
126
  it("reports a tool that throws instead of ending the turn", async () => {
127
    const { source } = sourceWith([CALLING, [chunk({ content: "It failed." }, true)]]);
128
    source.useTools([
129
      {
130
        name: "delegate",
131
        description: "Run a prompt on child agents.",
132
        parameters: { type: "object" },
133
        run: () => Promise.reject(new Error("the fleet is full")),
134
      },
135
    ]);
136
137
    const chunks = await collect(source, "delegate this");
138
139
    expect(chunks.find((piece) => piece.type === "tool_result")).toMatchObject({
140
      error: "the fleet is full",
141
      output: "the fleet is full",
142
    });
143
    expect(chunks.at(-1)).toEqual({ type: "text", value: "It failed." });
144
  });
145
146
  it("tells the model when it asks for a tool the session does not have", async () => {
147
    const { source } = sourceWith([
148
      [
149
        chunk({ content: "", tool_calls: [{ function: { name: "nope", arguments: {} } }] }),
150
        chunk({}, true),
151
      ],
152
      [chunk({ content: "Understood." }, true)],
153
    ]);
154
    source.useTools([]);
155
156
    const chunks = await collect(source, "call nope");
157
158
    expect(chunks.find((piece) => piece.type === "tool_result")).toMatchObject({
159
      error: "This session has no `nope` tool.",
160
    });
161
  });
162
163
  it("stops after the tool-call ceiling rather than looping forever", async () => {
164
    const rounds = Array.from({ length: 8 }, () => CALLING);
165
    const { source, stub } = sourceWith(rounds);
166
    source.useTools([delegate([])]);
167
168
    const chunks = await collect(source, "keep going");
169
170
    // Six rounds, then a sentence saying why it stopped.
171
    expect(stub.chat).toHaveBeenCalledTimes(6);
172
    expect(chunks.at(-1)).toMatchObject({ type: "text" });
173
    expect((chunks.at(-1) as { value: string }).value).toContain("Stopped after 6 rounds");
174
  });
175
});

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