Answer from a local model by default, and export the conversation

f296672580f7 · AtlantisPleb · · parent 737b03d61372

Answer from a local model by default, and export the conversation

Two things, both about a session not asking for what it can work out.

`--model` is no longer needed to reach Ollama. With no model named, a machine
already running one answers from it. That is the cheaper and more private
default, and it is the one a reader who installed Ollama meant: naming the model
every time to get the model they already chose is a flag carrying no decision.
The most recently modified model wins, which with one installed is no choice at
all and with several is the one last pulled. The hosted backends stay one
`--model` away and `--offline` asks for neither.

The probe runs before the first prompt of every session, so it has a short
deadline and treats a refusal, a timeout, and an empty library as the same
answer: nothing to answer from. A machine with no Ollama pays 15ms for the
question, and one behind a black hole pays the 300ms deadline.

`/export` writes the conversation as an ATIF trajectory under
`~/.openagents/exports` and puts the path on the clipboard. ATIF is what the
rest of this system already speaks -- `OpenAgents.DataRights.AtifExport` writes
an account's conversation as ATIF v1.7, and the trajectories other agents ship
carry the same envelope -- so a coder session should not be the one thing in the
workspace its own tools cannot read. The output validates against the reference
Pydantic models in the ATIF implementation the format's own docs point at, not
merely against this reading of the spec.

The mapping is narrow and says what it leaves out. A `you` entry is a user step,
an assistant entry an agent step, reasoning attaches to the step it preceded,
and a tool entry becomes that step's `tool_calls` and `observation`. Notices are
the interface talking to the reader and never reached a model, so they are not
steps -- they are kept under `extra`, where they still explain the steps either
side of them. `/export`, `/system` and `/skills` are left out for the same
reason: a trajectory recording `/export` as a turn is describing the act of
exporting rather than the work. Arguments that will not parse are kept under a
key that says so, because a trajectory that silently loses what a model asked
for is worse than one admitting it could not read it.

Entries now carry the time they opened. A trajectory is a sequence of timed
steps, and reconstructing that from the order alone would be inventing it.

Written under the home directory rather than the repository: a record of what
happened is not a change to the work and should not turn up in anyone's
`git status`. The clipboard is best effort -- pbcopy, clip, or one of the
Wayland and X tools -- and the path is printed either way.

324 tests pass, 15 new: ten on the mapping, four on the probe's failure modes,
and one that `/export` never reaches the model.

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

Diff

11 files changed, +631 -18

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": 2431,
7
    "filesScanned": 2432,
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:78a1326f50e5901e25bd56e2bed33f1635fcb75d3dbb89b8bfd8ef4558d00fd3",
4
  "sourceDigest": "sha256:5ecdc0a7a3a28075c8356671025554ef9c69e4cdc9c25e80eb9b6377bfb567af",
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 (31 tracked test files)"
1879
          "ref": "packages/openagents-cli (32 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +21 -5

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

28 28
import { CoderTaskRegistry } from "./coder-tasks.js";
29 29
import { runCoderUi } from "./coder-ui.js";
30 30
import { backendIds } from "./coder-backends.js";
31
import { OllamaReplySource, isOllamaModelFlag, parseOllamaModelFlag } from "./coder-ollama.js";
31
import {
32
  discoverOllamaModel,
33
  isOllamaModelFlag,
34
  OllamaReplySource,
35
  parseOllamaModelFlag,
36
} from "./coder-ollama.js";
32 37
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
33 38
import { delegateTool, skillTool } from "./coder-tools.js";
34 39
import { loadSkillSelection } from "./coder-skills.js";

@@ -1648,17 +1653,28 @@ const coderCommand = Command.make(

1648 1653
1649 1654
      // A `--model` value that starts with `ollama:` goes to the local Ollama
1650 1655
      // server and needs no account credential.
1651
      const wantsOllama = Option.isSome(model) && isOllamaModelFlag(model.value);
1656
      //
1657
      // With no `--model` at all, a machine already running Ollama answers from
1658
      // it. That is the cheaper and more private default, and it is the one a
1659
      // reader who installed Ollama meant: naming the model every time to get
1660
      // the model they already chose is a flag that carries no decision. The
1661
      // hosted backends stay one `--model` away, and `--offline` asks for
1662
      // neither.
1663
      const named = Option.getOrUndefined(model);
1664
      const localModel =
1665
        named === undefined && !offline ? yield* Effect.promise(() => discoverOllamaModel()) : undefined;
1666
1667
      const wantsOllama = named === undefined ? localModel !== undefined : isOllamaModelFlag(named);
1652 1668
      const ollamaName =
1653
        wantsOllama && Option.isSome(model) ? parseOllamaModelFlag(model.value) : undefined;
1669
        named === undefined ? localModel : isOllamaModelFlag(named) ? parseOllamaModelFlag(named) : undefined;
1654 1670
1655 1671
      // Any other `--model` value still has to name a published backend. The
1656 1672
      // flag takes a string so an `ollama:` prefix can reach the local server,
1657 1673
      // which costs the enum `Flag.choice` used to enforce, so the check moves
1658 1674
      // here rather than disappearing.
1659
      if (Option.isSome(model) && !wantsOllama && !backendIds().includes(model.value)) {
1675
      if (named !== undefined && !wantsOllama && !backendIds().includes(named)) {
1660 1676
        return yield* new InputError({
1661
          message: `Unknown model ${model.value}. Use ollama:<model> for a local Ollama server, or one of: ${backendIds().join(", ")}.`,
1677
          message: `Unknown model ${named}. Use ollama:<model> for a local Ollama server, or one of: ${backendIds().join(", ")}.`,
1662 1678
        });
1663 1679
      }
1664 1680
packages/openagents-cli/src/coder-export.ts added +235

@@ -0,0 +1,235 @@

1
/**
2
 * Write the conversation as an ATIF trajectory.
3
 *
4
 * ATIF -- the Agent Trajectory Interchange Format -- is the shape the rest of
5
 * this system already speaks: `OpenAgents.DataRights.AtifExport` writes an
6
 * account's conversation as ATIF v1.7, and the trajectories other agents ship
7
 * carry the same envelope. Writing anything else here would make a coder
8
 * session the one thing in the workspace that cannot be read by the tools that
9
 * read everything else.
10
 *
11
 * The mapping is deliberately narrow. A `you` entry is a `user` step; an
12
 * assistant entry is an `agent` step; reasoning attaches to the step it
13
 * preceded; a tool entry becomes that step's `tool_calls` and `observation`.
14
 * Notices are the interface talking to the reader, not the model, so they are
15
 * not steps -- they are recorded under `extra` where a reader can still see
16
 * them without a consumer mistaking them for turns.
17
 */
18
19
import { spawnSync } from "node:child_process";
20
import { mkdirSync, writeFileSync } from "node:fs";
21
import { homedir } from "node:os";
22
import { join } from "node:path";
23
24
import type { CoderEntry, CoderSnapshot } from "./coder-session.js";
25
26
const SCHEMA_VERSION = "ATIF-v1.7";
27
28
/**
29
 * Lines the interface answers itself.
30
 *
31
 * They appear in the transcript because the reader typed them, but no model
32
 * ever saw them, so they are not steps: a trajectory that records `/export` as
33
 * a turn is describing the act of exporting rather than the work. `/delegate`
34
 * is not here, because it starts real work whose results the model does read.
35
 */
36
const INTERFACE_COMMANDS = /^\/(export|system|skills)\s*$/;
37
const AGENT_NAME = "openagents-coder";
38
39
/** One ATIF step, as the format defines it. */
40
interface AtifStep {
41
  step_id: number;
42
  timestamp: string;
43
  source: "system" | "user" | "agent";
44
  message: string;
45
  model_name?: string;
46
  reasoning_content?: string;
47
  tool_calls?: ReadonlyArray<{
48
    tool_call_id: string;
49
    function_name: string;
50
    arguments: Record<string, unknown>;
51
  }>;
52
  observation?: { results: ReadonlyArray<{ source_call_id: string; content: string }> };
53
}
54
55
export interface ExportedTrajectory {
56
  /** Where the file was written. */
57
  readonly path: string;
58
  /** Whether the path reached the system clipboard. */
59
  readonly copied: boolean;
60
  /** How many steps it holds. */
61
  readonly steps: number;
62
}
63
64
/** A file name that sorts by time and says what it is. */
65
const fileName = (repository: string, at: Date): string => {
66
  const stamp = at.toISOString().replace(/[:.]/g, "-").replace("Z", "Z");
67
  const safe = repository.replace(/[^A-Za-z0-9._-]/g, "-");
68
  return `${stamp}-${safe}-atif.json`;
69
};
70
71
/**
72
 * Arguments as an object, which is what ATIF's `arguments` field is.
73
 *
74
 * The interface holds them as the JSON source it renders. Source that will not
75
 * parse is kept rather than dropped, under a key that says it is unparsed: a
76
 * trajectory that silently loses what a model asked for is worse than one that
77
 * says it could not read it.
78
 */
79
const argumentsOf = (source: string): Record<string, unknown> => {
80
  try {
81
    const parsed = JSON.parse(source) as unknown;
82
    return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
83
      ? (parsed as Record<string, unknown>)
84
      : { value: parsed };
85
  } catch {
86
    return { unparsed_arguments: source };
87
  }
88
};
89
90
/** Fold the transcript into ATIF steps. */
91
const stepsOf = (entries: ReadonlyArray<CoderEntry>, model: string): ReadonlyArray<AtifStep> => {
92
  const steps: AtifStep[] = [];
93
  /** Reasoning arrives before the turn it belongs to and attaches to it. */
94
  let pendingReasoning: string | undefined;
95
96
  for (const entry of entries) {
97
    const timestamp = new Date(entry.at).toISOString();
98
99
    if (entry.role === "notice") continue;
100
101
    if (entry.role === "reasoning") {
102
      pendingReasoning = pendingReasoning === undefined ? entry.text : `${pendingReasoning}\n${entry.text}`;
103
      continue;
104
    }
105
106
    if (entry.role === "you") {
107
      if (INTERFACE_COMMANDS.test(entry.text.trim())) continue;
108
      steps.push({ step_id: steps.length + 1, timestamp, source: "user", message: entry.text });
109
      continue;
110
    }
111
112
    if (entry.role === "tool" && entry.tool !== undefined) {
113
      const { callId, name, arguments: args, output, error } = entry.tool;
114
      steps.push({
115
        step_id: steps.length + 1,
116
        timestamp,
117
        source: "agent",
118
        message: "",
119
        model_name: model,
120
        ...(pendingReasoning === undefined ? {} : { reasoning_content: pendingReasoning }),
121
        tool_calls: [
122
          { tool_call_id: callId, function_name: name, arguments: argumentsOf(args) },
123
        ],
124
        observation: {
125
          results: [{ source_call_id: callId, content: error ?? output ?? "" }],
126
        },
127
      });
128
      pendingReasoning = undefined;
129
      continue;
130
    }
131
132
    if (entry.role === "assistant") {
133
      // An assistant entry opened and never filled is the caret the interface
134
      // shows while a chunk is in flight, not a turn the model took.
135
      if (entry.text.length === 0) continue;
136
      steps.push({
137
        step_id: steps.length + 1,
138
        timestamp,
139
        source: "agent",
140
        message: entry.text,
141
        model_name: model,
142
        ...(pendingReasoning === undefined ? {} : { reasoning_content: pendingReasoning }),
143
      });
144
      pendingReasoning = undefined;
145
    }
146
  }
147
148
  return steps;
149
};
150
151
/**
152
 * Put a path on the system clipboard.
153
 *
154
 * Best effort by design: a clipboard that is not there is not a reason to fail
155
 * an export that has already been written. The caller is told whether it
156
 * landed, and prints the path either way.
157
 */
158
const copyToClipboard = (text: string): boolean => {
159
  const candidates: ReadonlyArray<readonly [string, ReadonlyArray<string>]> =
160
    process.platform === "darwin"
161
      ? [["pbcopy", []]]
162
      : process.platform === "win32"
163
        ? [["clip", []]]
164
        : [
165
            ["wl-copy", []],
166
            ["xclip", ["-selection", "clipboard"]],
167
            ["xsel", ["--clipboard", "--input"]],
168
          ];
169
170
  for (const [command, args] of candidates) {
171
    try {
172
      const result = spawnSync(command, [...args], { input: text });
173
      if (result.status === 0) return true;
174
    } catch {
175
      continue;
176
    }
177
  }
178
  return false;
179
};
180
181
/**
182
 * Write the conversation as ATIF and put its path on the clipboard.
183
 *
184
 * Written under the home directory rather than the repository: a trajectory is
185
 * a record of what happened, not a change to the work, and it should not turn
186
 * up in anyone's `git status`.
187
 */
188
export function exportTrajectory(
189
  snapshot: CoderSnapshot,
190
  options: {
191
    readonly model: string;
192
    readonly toolDefinitions?: ReadonlyArray<Record<string, unknown>> | undefined;
193
    readonly version: string;
194
    readonly now?: Date | undefined;
195
    readonly directory?: string | undefined;
196
  },
197
): ExportedTrajectory {
198
  const at = options.now ?? new Date();
199
  const directory = options.directory ?? join(homedir(), ".openagents", "exports");
200
  const steps = stepsOf(snapshot.entries, options.model);
201
202
  const document = {
203
    schema_version: SCHEMA_VERSION,
204
    session_id: `${snapshot.repository}-${at.toISOString()}`,
205
    trajectory_id: `${snapshot.repository}-${at.toISOString()}`,
206
    agent: {
207
      name: AGENT_NAME,
208
      version: options.version,
209
      model_name: options.model,
210
      ...(options.toolDefinitions === undefined
211
        ? {}
212
        : { tool_definitions: options.toolDefinitions }),
213
    },
214
    steps,
215
    final_metrics: { total_steps: steps.length },
216
    extra: {
217
      exporter: "openagents.coder.atif_export.v1",
218
      exported_at: at.toISOString(),
219
      repository: snapshot.repository,
220
      branch: snapshot.branch,
221
      // The interface's own lines. Kept because they carry refusals and
222
      // failures that explain the steps either side of them, and separate
223
      // because they were never sent to the model.
224
      notices: snapshot.entries
225
        .filter((entry) => entry.role === "notice")
226
        .map((entry) => ({ timestamp: new Date(entry.at).toISOString(), text: entry.text })),
227
    },
228
  };
229
230
  mkdirSync(directory, { recursive: true });
231
  const path = join(directory, fileName(snapshot.repository, at));
232
  writeFileSync(path, `${JSON.stringify(document, undefined, 2)}\n`, "utf8");
233
234
  return { path, copied: copyToClipboard(path), steps: steps.length };
235
}
packages/openagents-cli/src/coder-ollama.ts modified +46

@@ -35,6 +35,44 @@ export interface OllamaOptions {

35 35
  readonly host?: string | undefined;
36 36
}
37 37
38
/**
39
 * The local model to answer from, or undefined when there is no server.
40
 *
41
 * Probed with a short deadline because it runs before the first prompt on every
42
 * session: a machine with no Ollama on it must not pay for the question. A
43
 * refusal, a timeout, and an empty library are the same answer -- nothing to
44
 * answer from -- so the caller gets `undefined` for all three rather than a
45
 * failure to handle.
46
 *
47
 * The most recently modified model wins. With one model installed there is no
48
 * choice to make, and with several the one most recently pulled is the one the
49
 * reader was last working with.
50
 */
51
export const discoverOllamaModel = async (
52
  host: string = DEFAULT_HOST,
53
  timeoutMs = 300,
54
): Promise<string | undefined> => {
55
  const deadline = AbortSignal.timeout(timeoutMs);
56
  try {
57
    const response = await fetch(new URL("/api/tags", host), { signal: deadline });
58
    if (!response.ok) return undefined;
59
    const body = (await response.json()) as {
60
      models?: ReadonlyArray<{ name?: unknown; modified_at?: unknown }>;
61
    };
62
    const models = (body.models ?? []).filter(
63
      (model): model is { name: string; modified_at?: string } => typeof model.name === "string",
64
    );
65
    if (models.length === 0) return undefined;
66
    // Sorting a fresh array, so nothing shared is mutated.
67
    // eslint-disable-next-line unicorn/no-array-sort -- the spread is the copy
68
    return [...models].sort((left, right) =>
69
      String(right.modified_at ?? "").localeCompare(String(left.modified_at ?? "")),
70
    )[0]?.name;
71
  } catch {
72
    return undefined;
73
  }
74
};
75
38 76
/** True when `--model` names an Ollama source. */
39 77
export const isOllamaModelFlag = (value: string): boolean => value.startsWith("ollama:");
40 78

@@ -142,6 +180,14 @@ export class OllamaReplySource implements ReplySource {

142 180
    return parts.join("\n");
143 181
  }
144 182
183
  /** The tools as declared, in the shape ATIF records them. */
184
  toolDefinitions(): ReadonlyArray<Record<string, unknown>> {
185
    return this.tools.map((tool) => ({
186
      type: "function",
187
      function: { name: tool.name, description: tool.description, parameters: tool.parameters },
188
    }));
189
  }
190
145 191
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
146 192
    // Built on the first turn rather than in the constructor: the tools are
147 193
    // declared after construction, and the prompt is derived from them.
packages/openagents-cli/src/coder-session.ts modified +49 -8

@@ -21,6 +21,8 @@

21 21
22 22
import type { DelegationOutcome, DelegationRequest } from "./coder-delegate.js";
23 23
import { parseDelegateCommand } from "./coder-delegate.js";
24
import { exportTrajectory } from "./coder-export.js";
25
import { VERSION } from "./version.js";
24 26
import type { CoderTask, CoderTaskId, CoderTaskRegistry } from "./coder-tasks.js";
25 27
import type { CoderTool } from "./coder-tools.js";
26 28

@@ -55,6 +57,14 @@ export interface CoderToolCall {

55 57
/** One entry in the transcript. */
56 58
export interface CoderEntry {
57 59
  readonly role: "you" | "assistant" | "notice" | "tool" | "reasoning";
60
  /**
61
   * When this entry was opened, in epoch milliseconds.
62
   *
63
   * Kept because a trajectory is a sequence of timed steps: `/export` has to
64
   * say when each turn happened, and reconstructing that from the order alone
65
   * would be inventing it.
66
   */
67
  readonly at: number;
58 68
  /** Rendered text. Streaming entries grow while chunks arrive. */
59 69
  text: string;
60 70
  /** False while chunks are still arriving, so a renderer can show a caret. */

@@ -141,6 +151,14 @@ export interface ReplySource {

141 151
   * does not compose its own context leaves this undefined.
142 152
   */
143 153
  describeContext?(): string;
154
  /**
155
   * The tools as declared, in the shape a trajectory records them.
156
   *
157
   * Optional for the same reason `describeContext` is: a source that declares
158
   * nothing has nothing to report, and an export then simply omits the field
159
   * the format already makes optional.
160
   */
161
  toolDefinitions?(): ReadonlyArray<Record<string, unknown>>;
144 162
  /**
145 163
   * Yield the reply to `prompt` in chunks. Rendering appends each chunk as it
146 164
   * arrives, so a slow source shows partial text rather than nothing.

@@ -346,7 +364,7 @@ export class CoderSession {

346 364
  }
347 365
348 366
  notice(text: string): void {
349
    this.entries.push({ role: "notice", text, settled: true });
367
    this.entries.push({ role: "notice", text, settled: true, at: Date.now() });
350 368
    this.emit();
351 369
  }
352 370

@@ -371,7 +389,7 @@ export class CoderSession {

371 389
    // shows it as a notice, and sends nothing. A reader checking what the model
372 390
    // was told should not have to change what the model was told to find out.
373 391
    if (/^\/system\s*$/.test(prompt.trim())) {
374
      this.entries.push({ role: "you", text: prompt, settled: true });
392
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
375 393
      const context = this.source.describeContext?.();
376 394
      this.notice(
377 395
        context === undefined

@@ -382,9 +400,31 @@ export class CoderSession {

382 400
      return;
383 401
    }
384 402
403
    // `/export` is not a turn either: it writes what has already happened.
404
    if (/^\/export\s*$/.test(prompt.trim())) {
405
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
406
      try {
407
        const written = exportTrajectory(this.snapshot(), {
408
          model: this.source.model,
409
          toolDefinitions: this.source.toolDefinitions?.(),
410
          version: VERSION,
411
        });
412
        this.notice(
413
          `Exported ${String(written.steps)} step${written.steps === 1 ? "" : "s"} as ATIF to ${written.path}` +
414
            (written.copied ? " (path copied to the clipboard)." : "."),
415
        );
416
      } catch (cause) {
417
        this.notice(
418
          `The export could not be written: ${cause instanceof Error ? cause.message : String(cause)}`,
419
        );
420
      }
421
      this.emit();
422
      return;
423
    }
424
385 425
    const delegate = parseDelegateCommand(prompt);
386 426
    if (delegate !== undefined) {
387
      this.entries.push({ role: "you", text: prompt, settled: true });
427
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
388 428
      this.startDelegation(delegate.count, delegate.prompt, delegate.description);
389 429
      this.emit();
390 430
      return;

@@ -392,11 +432,11 @@ export class CoderSession {

392 432
393 433
    if (this.controller !== undefined) return;
394 434
395
    this.entries.push({ role: "you", text: prompt, settled: true });
435
    this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
396 436
    // An empty assistant entry from the start, so the interface shows a caret
397 437
    // rather than nothing while the first chunk is in flight. It is withdrawn
398 438
    // if the turn opens with reasoning or a tool call instead of text.
399
    const opening: CoderEntry = { role: "assistant", text: "", settled: false };
439
    const opening: CoderEntry = { role: "assistant", text: "", settled: false, at: Date.now() };
400 440
    this.entries.push(opening);
401 441
402 442
    /** The entry each streaming chunk kind is currently appending to. */

@@ -427,7 +467,7 @@ export class CoderSession {

427 467
          if (text === undefined) {
428 468
            settle(reasoning);
429 469
            reasoning = undefined;
430
            text = { role: "assistant", text: "", settled: false };
470
            text = { role: "assistant", text: "", settled: false, at: Date.now() };
431 471
            this.entries.push(text);
432 472
          }
433 473
          text.text += chunk.value;

@@ -436,7 +476,7 @@ export class CoderSession {

436 476
            if (text === opening) withdrawOpening();
437 477
            settle(text);
438 478
            text = undefined;
439
            reasoning = { role: "reasoning", text: "", settled: false };
479
            reasoning = { role: "reasoning", text: "", settled: false, at: Date.now() };
440 480
            this.entries.push(reasoning);
441 481
          }
442 482
          reasoning.text += chunk.value;

@@ -450,6 +490,7 @@ export class CoderSession {

450 490
            role: "tool",
451 491
            text: chunk.name,
452 492
            settled: false,
493
            at: Date.now(),
453 494
            tool: {
454 495
              callId: chunk.callId,
455 496
              name: chunk.name,

@@ -481,7 +522,7 @@ export class CoderSession {

481 522
        this.entries.splice(this.entries.indexOf(text), 1);
482 523
        text = undefined;
483 524
      }
484
      this.entries.push({ role: "notice", text: message, settled: true });
525
      this.entries.push({ role: "notice", text: message, settled: true, at: Date.now() });
485 526
    } finally {
486 527
      for (const entry of this.entries) {
487 528
        if (entry.settled) continue;
packages/openagents-cli/src/coder-thread.ts modified +8

@@ -266,6 +266,14 @@ export class ThreadReplySource implements ReplySource {

266 266
   * process never saw. A `/system` that guessed would be worse than one that
267 267
   * admits the boundary.
268 268
   */
269
  /** The tools as declared, in the shape ATIF records them. */
270
  toolDefinitions(): ReadonlyArray<Record<string, unknown>> {
271
    return this.tools.map((tool) => ({
272
      type: "function",
273
      function: { name: tool.name, description: tool.description, parameters: tool.parameters },
274
    }));
275
  }
276
269 277
  describeContext(): string {
270 278
    const declarations =
271 279
      this.tools.length === 0
packages/openagents-cli/src/coder-ui.ts modified +2 -1

@@ -918,7 +918,8 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

918 918
    session.notice(
919 919
      "openagents coder — development build. Type a message and press enter. " +
920 920
        "Ctrl+D quits, Esc interrupts a reply. `/system` shows what the model is told, " +
921
        "`/skills` chooses which skills it is offered.",
921
        "`/skills` chooses which skills it is offered, `/export` writes the conversation " +
922
        "as ATIF.",
922 923
    );
923 924
    render();
924 925
  });
packages/openagents-cli/test/coder-export.test.ts added +192

@@ -0,0 +1,192 @@

1
import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { exportTrajectory } from "../src/coder-export.js";
7
import type { CoderEntry, CoderSnapshot } from "../src/coder-session.js";
8
9
const AT = Date.parse("2026-08-24T14:00:00.000Z");
10
11
const entry = (partial: Partial<CoderEntry> & Pick<CoderEntry, "role">): CoderEntry => ({
12
  text: "",
13
  settled: true,
14
  at: AT,
15
  ...partial,
16
});
17
18
const snapshot = (entries: ReadonlyArray<CoderEntry>): CoderSnapshot =>
19
  ({
20
    entries,
21
    repository: "openagents.com",
22
    branch: "main",
23
    model: "Ollama qwen",
24
    turns: 1,
25
    running: false,
26
    tasks: [],
27
  }) as unknown as CoderSnapshot;
28
29
const write = (entries: ReadonlyArray<CoderEntry>) => {
30
  const directory = mkdtempSync(join(tmpdir(), "coder-export-"));
31
  const result = exportTrajectory(snapshot(entries), {
32
    model: "Ollama qwen",
33
    version: "0.3.5",
34
    now: new Date(AT),
35
    directory,
36
  });
37
  return {
38
    result,
39
    directory,
40
    document: JSON.parse(readFileSync(result.path, "utf8")) as Record<string, unknown>,
41
  };
42
};
43
44
describe("exporting a conversation as ATIF", () => {
45
  it("writes the envelope the rest of the system reads", () => {
46
    const { document, result } = write([entry({ role: "you", text: "hello" })]);
47
48
    expect(document["schema_version"]).toBe("ATIF-v1.7");
49
    expect(document["agent"]).toMatchObject({ name: "openagents-coder", version: "0.3.5" });
50
    expect(document["final_metrics"]).toMatchObject({ total_steps: 1 });
51
    expect(result.path).toContain("openagents.com-atif.json");
52
  });
53
54
  it("numbers steps from one and names each source", () => {
55
    const { document } = write([
56
      entry({ role: "you", text: "ask" }),
57
      entry({ role: "assistant", text: "answer" }),
58
    ]);
59
60
    expect(document["steps"]).toEqual([
61
      expect.objectContaining({ step_id: 1, source: "user", message: "ask" }),
62
      expect.objectContaining({ step_id: 2, source: "agent", message: "answer" }),
63
    ]);
64
  });
65
66
  it("carries a tool call and its result on one agent step", () => {
67
    const { document } = write([
68
      entry({
69
        role: "tool",
70
        text: "skill",
71
        tool: {
72
          callId: "call-1",
73
          name: "skill",
74
          arguments: '{"name":"house-style"}',
75
          output: "Use sentence case.",
76
          error: undefined,
77
          status: "succeeded",
78
        },
79
      }),
80
    ]);
81
82
    const [step] = document["steps"] as ReadonlyArray<Record<string, unknown>>;
83
    expect(step).toMatchObject({
84
      source: "agent",
85
      message: "",
86
      tool_calls: [
87
        { tool_call_id: "call-1", function_name: "skill", arguments: { name: "house-style" } },
88
      ],
89
      observation: { results: [{ source_call_id: "call-1", content: "Use sentence case." }] },
90
    });
91
  });
92
93
  it("reports a failed call by its error, not as an empty result", () => {
94
    const { document } = write([
95
      entry({
96
        role: "tool",
97
        text: "delegate",
98
        tool: {
99
          callId: "call-2",
100
          name: "delegate",
101
          arguments: "{}",
102
          output: undefined,
103
          error: "the fleet is full",
104
          status: "failed",
105
        },
106
      }),
107
    ]);
108
109
    const [step] = document["steps"] as ReadonlyArray<Record<string, unknown>>;
110
    expect(step).toMatchObject({
111
      observation: { results: [{ content: "the fleet is full" }] },
112
    });
113
  });
114
115
  it("keeps arguments it cannot parse rather than dropping them", () => {
116
    const { document } = write([
117
      entry({
118
        role: "tool",
119
        text: "skill",
120
        tool: {
121
          callId: "call-3",
122
          name: "skill",
123
          arguments: "{not json",
124
          output: "",
125
          error: undefined,
126
          status: "succeeded",
127
        },
128
      }),
129
    ]);
130
131
    const [step] = document["steps"] as ReadonlyArray<Record<string, unknown>>;
132
    // A trajectory that silently loses what a model asked for is worse than one
133
    // that says it could not read it.
134
    expect(step).toMatchObject({
135
      tool_calls: [{ arguments: { unparsed_arguments: "{not json" } }],
136
    });
137
  });
138
139
  it("attaches reasoning to the step it preceded", () => {
140
    const { document } = write([
141
      entry({ role: "reasoning", text: "weighing it" }),
142
      entry({ role: "assistant", text: "answer" }),
143
    ]);
144
145
    expect(document["steps"]).toEqual([
146
      expect.objectContaining({ source: "agent", reasoning_content: "weighing it" }),
147
    ]);
148
  });
149
150
  it("keeps notices out of the steps and in the record", () => {
151
    const { document } = write([
152
      entry({ role: "you", text: "ask" }),
153
      entry({ role: "notice", text: "Delegation refused (quota)." }),
154
    ]);
155
156
    // A notice is the interface talking to the reader. It never reached the
157
    // model, so it is not a step, but it explains the steps either side of it.
158
    expect(document["steps"]).toHaveLength(1);
159
    expect((document["extra"] as Record<string, unknown>)["notices"]).toEqual([
160
      expect.objectContaining({ text: "Delegation refused (quota)." }),
161
    ]);
162
  });
163
164
  it("leaves out the commands the interface answers itself", () => {
165
    const { document } = write([
166
      entry({ role: "you", text: "/export" }),
167
      entry({ role: "you", text: "/system" }),
168
      entry({ role: "you", text: "/skills" }),
169
      entry({ role: "you", text: "a real question" }),
170
    ]);
171
172
    expect(document["steps"]).toEqual([
173
      expect.objectContaining({ message: "a real question" }),
174
    ]);
175
  });
176
177
  it("leaves out the empty entry the interface opens for the caret", () => {
178
    const { document } = write([
179
      entry({ role: "assistant", text: "", settled: false }),
180
      entry({ role: "assistant", text: "real" }),
181
    ]);
182
183
    expect(document["steps"]).toHaveLength(1);
184
  });
185
186
  it("writes one file per export, named so they sort by time", () => {
187
    const { directory } = write([entry({ role: "you", text: "one" })]);
188
189
    const [name] = readdirSync(directory);
190
    expect(name).toMatch(/^2026-08-24T14-00-00-000Z-openagents\.com-atif\.json$/);
191
  });
192
});
packages/openagents-cli/test/coder-ollama.test.ts modified +47 -1

@@ -1,8 +1,9 @@

1
import { describe, expect, it, vi } from "vitest";
1
import { afterEach, describe, expect, it, vi } from "vitest";
2 2
3 3
import type { ReplyChunk } from "../src/coder-session.js";
4 4
import type { CoderTool } from "../src/coder-tools.js";
5 5
import {
6
  discoverOllamaModel,
6 7
  isOllamaModelFlag,
7 8
  OllamaReplySource,
8 9
  parseOllamaModelFlag,

@@ -246,3 +247,48 @@ describe("what a local session tells the model about itself", () => {

246 247
    ]);
247 248
  });
248 249
});
250
251
describe("finding a local model to default to", () => {
252
  const serve = (body: unknown, status = 200) =>
253
    vi.spyOn(globalThis, "fetch").mockResolvedValue(
254
      new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }),
255
    );
256
257
  afterEach(() => {
258
    vi.restoreAllMocks();
259
  });
260
261
  it("takes the most recently modified model", async () => {
262
    serve({
263
      models: [
264
        { name: "older", modified_at: "2026-01-01T00:00:00Z" },
265
        { name: "newest", modified_at: "2026-08-01T00:00:00Z" },
266
        { name: "middle", modified_at: "2026-04-01T00:00:00Z" },
267
      ],
268
    });
269
270
    // With one model there is no choice; with several the one most recently
271
    // pulled is the one the reader was last working with.
272
    await expect(discoverOllamaModel()).resolves.toBe("newest");
273
  });
274
275
  it("finds nothing when the library is empty", async () => {
276
    serve({ models: [] });
277
278
    await expect(discoverOllamaModel()).resolves.toBeUndefined();
279
  });
280
281
  it("finds nothing when the server refuses", async () => {
282
    serve({}, 500);
283
284
    await expect(discoverOllamaModel()).resolves.toBeUndefined();
285
  });
286
287
  it("finds nothing when there is no server, rather than failing", async () => {
288
    vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNREFUSED"));
289
290
    // A machine with no Ollama on it is the common case, and it must cost the
291
    // session nothing but the deadline.
292
    await expect(discoverOllamaModel()).resolves.toBeUndefined();
293
  });
294
});
packages/openagents-cli/test/coder-session.test.ts modified +28

@@ -1,3 +1,4 @@

1
import { rmSync } from "node:fs";
1 2
import { describe, expect, it } from "vitest";
2 3
3 4
import {

@@ -364,3 +365,30 @@ describe("the /system command", () => {

364 365
    expect(reply.prompts).toEqual(["what is in your /system prompt"]);
365 366
  });
366 367
});
368
369
describe("the /export command", () => {
370
  it("writes the conversation and never reaches the model", async () => {
371
    const prompts: string[] = [];
372
    const reply: ReplySource = {
373
      model: "scripted",
374
      // eslint-disable-next-line require-yield -- a turn that must not happen
375
      async *reply(prompt) {
376
        prompts.push(prompt);
377
      },
378
    };
379
    const session = new CoderSession(reply, "repo", "main");
380
381
    await session.submit("/export");
382
383
    const { entries, turns } = session.snapshot();
384
    expect(entries.map((entry) => entry.role)).toEqual(["you", "notice"]);
385
    expect(entries[1]?.text).toContain("as ATIF to");
386
    expect(prompts).toEqual([]);
387
    expect(turns).toBe(0);
388
389
    // The wiring writes a real file. Take it away again: a test suite should
390
    // not leave anything behind in the directory a person exports into.
391
    const written = /as ATIF to (\S+\.json)/.exec(entries[1]?.text ?? "")?.[1];
392
    if (written !== undefined) rmSync(written, { force: true });
393
  });
394
});

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