Steer a turn instead of waiting it out

06f0b332fa21 · AtlantisPleb · · parent 9f40ae9f7ebb

Steer a turn instead of waiting it out

Queueing put a message at the end of the turn. That is not steering: by the time
the model reads it, the work the reader wanted to redirect is already done.

Codex drains a pending-input queue at the top of each iteration of its turn
loop, so a message submitted while the model runs is read at the next step
rather than at the end. Our tool loop has the same shape — a turn is a loop of
model calls, and between two of them is a place another message can join without
stopping anything — so it does the same now.

`ReplySource` gains an optional `steer`. Both lanes take one: the message is
held until the top of the next round and then pushed onto the transcript as an
ordinary user turn, because that is what it is. A source that cannot take one
returns false and the session holds it to the end of the turn as before, so
nothing is dropped either way.

The interface says which happened — "Steering: the model reads this at its next
step" against "Queued" — because they are different promises and a reader
deciding whether to also press escape needs to know which one they got.

The test asserts the message reaches a later round and not the first, and that
the turn is still running when it is accepted. It fails when the drain is
removed.

396 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/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • added packages/openagents-cli/test/coder-steer.test.ts

Diff

6 files changed, +121 -3

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": 2436,
7
    "filesScanned": 2437,
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:213d97f4fd62f412e9feabce849ebb61ba854f8faad1fd31941e0f8ec982cc63",
4
  "sourceDigest": "sha256:8b8a0cd69a97c37f05aad3a00b188183c4aaf167865ab050878a3ea987f5da5d",
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 (36 tracked test files)"
1879
          "ref": "packages/openagents-cli (37 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-ollama.ts modified +20

@@ -146,6 +146,13 @@ export class OllamaReplySource implements ReplySource {

146 146
  private readonly modelName: string;
147 147
  private readonly transcript: WireMessage[] = [];
148 148
  private tools: ReadonlyArray<CoderTool> = [];
149
  /**
150
   * Messages that arrived mid-turn, waiting for the next step of it.
151
   *
152
   * Read between two model calls rather than at the end of the turn, which is
153
   * the difference between steering a model and waiting one out.
154
   */
155
  private steered: string[] = [];
149 156
  private callCount = 0;
150 157
151 158
  get model(): string {

@@ -216,6 +223,12 @@ export class OllamaReplySource implements ReplySource {

216 223
   * which model is the part a reader needs, and so is knowing the transcript
217 224
   * survives.
218 225
   */
226
  /** Take a message for the next step of the running turn. */
227
  steer(text: string): boolean {
228
    this.steered.push(text);
229
    return true;
230
  }
231
219 232
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
220 233
    try {
221 234
      yield* this.turn(prompt, signal);

@@ -251,6 +264,13 @@ export class OllamaReplySource implements ReplySource {

251 264
    for (let step = 0; step < MAX_TOOL_STEPS; step += 1) {
252 265
      if (signal.aborted) return;
253 266
267
      // Anything the reader said since the last step joins here, before the
268
      // model is asked again. It reads as an ordinary turn in the conversation,
269
      // because that is what it is.
270
      for (const said of this.steered.splice(0)) {
271
        this.transcript.push({ role: "user", content: said });
272
      }
273
254 274
      const calls: OllamaToolCall[] = [];
255 275
      let assistant = "";
256 276
packages/openagents-cli/src/coder-session.ts modified +22

@@ -196,6 +196,18 @@ export interface ReplySource {

196 196
   * session then declares no tools rather than declaring tools nothing runs.
197 197
   */
198 198
  useTools?(tools: ReadonlyArray<CoderTool>): void;
199
  /**
200
   * Take a message mid-turn, to be read at the next step of the running turn.
201
   *
202
   * A turn is a loop of model calls, and between two of them is a place where
203
   * another message can join without stopping anything. That is what steering
204
   * is: not interrupting the model, and not waiting for it to finish, but
205
   * putting a sentence where it will be read next.
206
   *
207
   * Returns false when the source cannot take one, and the caller then holds it
208
   * until the turn ends rather than dropping it.
209
   */
210
  steer?(text: string): boolean;
199 211
  /**
200 212
   * The standing context this source sends with every turn, as text.
201 213
   *

@@ -505,6 +517,16 @@ export class CoderSession {

505 517
    // that silently ignores the key is one that cannot be steered at all.
506 518
    if (this.controller !== undefined) {
507 519
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
520
521
      // Steering first: a source that runs a loop of model calls can read this
522
      // at its next step, so the model sees it while it is still working. A
523
      // source that cannot holds it until the turn ends instead of dropping it.
524
      if (this.source.steer?.(prompt) === true) {
525
        this.notice("Steering: the model reads this at its next step.");
526
        this.emit();
527
        return;
528
      }
529
508 530
      this.pending.push(prompt);
509 531
      this.notice(
510 532
        this.pending.length === 1
packages/openagents-cli/src/coder-thread.ts modified +19

@@ -228,6 +228,13 @@ export class ThreadReplySource implements ReplySource {

228 228
  private tools: ReadonlyArray<CoderTool> = [];
229 229
  /** Set for the one round that must answer rather than call another tool. */
230 230
  private mustAnswer = false;
231
  /**
232
   * Messages that arrived mid-turn, waiting for the next step of it.
233
   *
234
   * Read between two model calls rather than at the end of the turn, which is
235
   * the difference between steering a model and waiting one out.
236
   */
237
  private steered: string[] = [];
231 238
232 239
  constructor(private readonly state: SourceState) {
233 240
    this.threadId = state.threadId;

@@ -313,6 +320,12 @@ export class ThreadReplySource implements ReplySource {

313 320
    };
314 321
  }
315 322
323
  /** Take a message for the next step of the running turn. */
324
  steer(text: string): boolean {
325
    this.steered.push(text);
326
    return true;
327
  }
328
316 329
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
317 330
    // Per turn, not per session: a turn that had to answer without tools must
318 331
    // not leave the next one without them.

@@ -321,6 +334,12 @@ export class ThreadReplySource implements ReplySource {

321 334
322 335
    try {
323 336
      for (let step = 0; ; step += 1) {
337
        // Anything the reader said since the last step joins here, before the
338
        // model is asked again.
339
        for (const said of this.steered.splice(0)) {
340
          this.transcript.push({ role: "user", content: said });
341
        }
342
324 343
        const calls: WireCall[] = [];
325 344
        let assistant = "";
326 345
packages/openagents-cli/test/coder-steer.test.ts added +57

@@ -0,0 +1,57 @@

1
import { describe, expect, it } from "vitest";
2
import { CoderSession } from "../src/coder-session.js";
3
import { OllamaReplySource } from "../src/coder-ollama.js";
4
5
const chunk = (message: Record<string, unknown>, done = false) => ({ message, done });
6
7
describe("steering a running turn", () => {
8
  it("puts the message in front of the model at its next step", async () => {
9
    const requests: Record<string, unknown>[] = [];
10
    let round = 0;
11
    const gate: Array<() => void> = [];
12
13
    const source = new OllamaReplySource({ model: "m" });
14
    (source as unknown as { client: unknown }).client = {
15
      chat: async (request: Record<string, unknown>) => {
16
        requests.push(JSON.parse(JSON.stringify(request)) as Record<string, unknown>);
17
        const mine = round++;
18
        // Round 0 asks for a tool and waits, so a steer can arrive mid-turn.
19
        const pieces =
20
          mine === 0
21
            ? [chunk({ content: "", tool_calls: [{ function: { name: "t", arguments: {} } }] }), chunk({}, true)]
22
            : [chunk({ content: "done" }, true)];
23
        return Object.assign(
24
          (async function* () {
25
            if (mine === 0) await new Promise<void>((r) => gate.push(r));
26
            for (const p of pieces) yield p;
27
          })(),
28
          { abort: () => {} },
29
        );
30
      },
31
    };
32
    source.useTools([
33
      { name: "t", description: "d", parameters: {}, run: () => Promise.resolve("ok") },
34
    ]);
35
36
    const session = new CoderSession(source, "repo", "main");
37
    const turn = session.submit("original question");
38
    await new Promise((r) => setTimeout(r, 10));
39
40
    await session.submit("actually, do it the other way");
41
    // Not stopped: the turn is still running.
42
    expect(session.snapshot().running).toBe(true);
43
44
    gate.shift()?.();
45
    await turn;
46
    await new Promise((r) => setTimeout(r, 20));
47
48
    const later = requests.at(-1)?.["messages"] as ReadonlyArray<Record<string, unknown>>;
49
    const said = later.map((m) => m["content"]);
50
    expect(said).toContain("actually, do it the other way");
51
    // It arrived at a later step, not at the start.
52
    const first = requests[0]?.["messages"] as ReadonlyArray<Record<string, unknown>>;
53
    expect(first.map((m) => m["content"])).not.toContain("actually, do it the other way");
54
    expect(session.snapshot().entries.filter((e) => e.role === "notice").map((e) => e.text))
55
      .toContainEqual(expect.stringContaining("Steering"));
56
  });
57
});

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