Keep a turn's reasoning on the transcript it sends back

b807f814e6c5 · AtlantisPleb · · parent 94e4cad8e703

Keep a turn's reasoning on the transcript it sends back

The transcript carried what the model said and dropped what it thought, and an
earlier note called that a saving. It is not. A model that cannot see how it
reached its last answer reasons its way there again, and a transcript missing
the reasoning is not the turn that happened — it is a summary of the turn with
the working removed.

The assembled reasoning now travels with the assistant message, which the
client's own `Message` type already carries as `thinking`. Only the deltas are
discarded, and only because they are how the text arrived rather than what it
is: a turn keeps its reasoning, not the fragments it streamed in.

What this costs is real and is the reader's to weigh, not this layer's to decide
by omission. Whether a long history should later be condensed is a separate
question from whether it should be recorded, and recording it is what makes the
other question answerable.

424 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 packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts

Diff

2 files changed, +56 -2

packages/openagents-cli/src/coder-ollama.ts modified +9 -2

@@ -403,6 +403,7 @@ export class OllamaReplySource implements ReplySource {

403 403
404 404
      const calls: OllamaToolCall[] = [];
405 405
      let assistant = "";
406
      let reasoning = "";
406 407
407 408
      // The last round is answered without tools. Reaching the ceiling with the
408 409
      // tools still on the table produced a turn that stopped mid-work and said

@@ -451,6 +452,7 @@ export class OllamaReplySource implements ReplySource {

451 452
452 453
          const thinking = chunk.message.thinking;
453 454
          if (typeof thinking === "string" && thinking.length > 0) {
455
            reasoning += thinking;
454 456
            yield { type: "reasoning", value: thinking };
455 457
          }
456 458

@@ -481,11 +483,16 @@ export class OllamaReplySource implements ReplySource {

481 483
482 484
      if (signal.aborted) return;
483 485
484
      // Whatever the model said before asking is kept with the calls, so the
485
      // next round sees its own turn as it happened.
486
      // Whatever the model said before asking is kept with the calls, and so is
487
      // what it thought. Reasoning is part of the turn, not decoration on it: a
488
      // model that cannot see how it reached the last answer re-reasons its way
489
      // there, and a transcript missing it is not the turn that happened. Only
490
      // the deltas are discarded, and only because they are how the text
491
      // arrived rather than what it is.
486 492
      this.transcript.push({
487 493
        role: "assistant",
488 494
        content: assistant,
495
        ...(reasoning.length === 0 ? {} : { thinking: reasoning }),
489 496
        ...(calls.length === 0 ? {} : { tool_calls: calls }),
490 497
      });
491 498
packages/openagents-cli/test/coder-ollama.test.ts modified +47

@@ -135,6 +135,53 @@ describe("an ollama turn that calls a tool", () => {

135 135
  });
136 136
137 137
138
139
  it("keeps the turn's reasoning on the transcript it sends back", async () => {
140
    const { source, stub } = sourceWith([
141
      [
142
        chunk({ thinking: "I should read the file first." }),
143
        chunk({
144
          content: "",
145
          tool_calls: [{ function: { name: "t", arguments: {} } }],
146
        }),
147
        chunk({}, true),
148
      ],
149
      [chunk({ content: "done" }, true)],
150
    ]);
151
    source.useTools([
152
      { name: "t", description: "d", parameters: {}, run: () => Promise.resolve("ok") },
153
    ]);
154
155
    await collect(source, "go");
156
157
    const messages = stub.requests[1]?.["messages"] as ReadonlyArray<Record<string, unknown>>;
158
    const assistant = messages.find((message) => message["role"] === "assistant");
159
160
    // Reasoning is part of the turn rather than decoration on it: a model that
161
    // cannot see how it reached the last answer re-reasons its way there.
162
    expect(assistant?.["thinking"]).toBe("I should read the file first.");
163
  });
164
165
  it("sends no thinking when the model produced none", async () => {
166
    const { source, stub } = sourceWith([
167
      [
168
        chunk({ content: "", tool_calls: [{ function: { name: "t", arguments: {} } }] }),
169
        chunk({}, true),
170
      ],
171
      [chunk({ content: "done" }, true)],
172
    ]);
173
    source.useTools([
174
      { name: "t", description: "d", parameters: {}, run: () => Promise.resolve("ok") },
175
    ]);
176
177
    await collect(source, "go");
178
179
    const messages = stub.requests[1]?.["messages"] as ReadonlyArray<Record<string, unknown>>;
180
    expect(messages.find((message) => message["role"] === "assistant")).not.toHaveProperty(
181
      "thinking",
182
    );
183
  });
184
138 185
  it("reports the turn's cost, summed over the rounds it took", async () => {
139 186
    const calls: Record<string, unknown>[] = [];
140 187
    const { source } = sourceWith([

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