Run delegated children in this process, on Ox Alpha

27526bf8a6f2 · AtlantisPleb · · parent 62ec83f22e94

Run delegated children in this process, on Ox Alpha

Children ran on `opencode`: a second coding agent, installed separately, with
its own credentials, its own model catalog, its own tool loop, and its own idea
of what a coding agent is. It worked, and it cost a process per child and an
unbounded amount of behaviour nobody here chose — a child answering from
whichever model that install happened to have, with whichever tools that
version happened to ship.

A self-hosted child is the same loop the parent already runs, smaller: the
grant the server minted for children, pinned to Ox Alpha, one deliberate tool,
and this process's own turn loop. No second agent to install, no second
credential, and the model is the one the conversation asked for. The proxy is
the only thing it talks to, so no provider key reaches this process either way.

`shell` is the whole toolset. It reads, writes, searches, lists, and runs
tests, which is the work a child is given; the parent's other tools are either
the parent's own business — a child that delegates is a fan-out nobody asked
for — or a way of reaching the account, which is not a child's to spend.

`ox-alpha` now names this lane rather than opencode's route to the same model.
It is the same model either way, and running it here costs nothing extra.
opencode's own slug still resolves, so nothing that worked stops, and opencode
remains the fallback for a session with no grant to spend. A session without
one is refused the self lane rather than quietly answered by a different agent
under the name it asked for.

Retries resume rather than restart, which the harness supports by holding each
child's transcript by session id: a child whose provider drops after twenty
tool calls carries on instead of re-reading and re-editing everything.

Separately, the conversation leads with Gemini 3.7 Flash and Luna is the
backup, following the server's catalog (openagents.com bec1f48). Ox Alpha was
the default for one build and is not: it answered "sup" with nothing at all.
Where it stalls as a child the cost is one child, not the conversation.

A stale notice is gone with them. It told the reader `--model` "had no effect
because the thread route takes no model parameter", which stopped being true
when the thread route started taking one.

675 tests pass. Verified against a running server: a delegated child runs in
this process on Ox Alpha, executes `shell`, and reports its output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TRDRrfL1khQhQtNr3SRrA
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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-backends.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/src/coder-self-harness.ts
  • modified packages/openagents-cli/test/coder-backends.test.ts
  • modified packages/openagents-cli/test/coder-delegate-lanes.test.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • added packages/openagents-cli/test/coder-self-harness.test.ts

Diff

10 files changed, +639 -48

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": 2454,
7
    "filesScanned": 2455,
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:3fdcfedaf7211556caf02d4b9566d017ffbe1b92e761595b065e85d47b5576e2",
4
  "sourceDigest": "sha256:e3bf4b2c23dae0ee9b20eb8a0ce09bae4d32b9b57aa8d94ababaa57716efa9fd",
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 (54 tracked test files)"
1879
          "ref": "packages/openagents-cli (55 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +57 -17

@@ -17,6 +17,20 @@ import {

17 17
import { ApiTransport } from "./api-transport.js";
18 18
import { BrowserLauncher } from "./browser-launcher.js";
19 19
import type { ChildGrant } from "./coder-child-gateway.js";
20
import { SelfHarness } from "./coder-self-harness.js";
21
22
/**
23
 * The grant, where the caller has already established there is one.
24
 *
25
 * `laneFor` is only reached for the self lane through `fleetFor`, which refuses
26
 * that lane without a grant, so this cannot be nothing — but the type does not
27
 * know that, and inventing an empty grant to satisfy it would send a child at
28
 * the proxy with no authority.
29
 */
30
const nonNullGrant = (grant: ChildGrant | undefined): ChildGrant => {
31
  if (grant === undefined) throw new Error("The self lane needs the children's grant.");
32
  return grant;
33
};
20 34
import { startChildGateway } from "./coder-child-gateway.js";
21 35
import { writeChildHarnessConfig } from "./coder-child-config.js";
22 36
import type { DelegationOutcome } from "./coder-delegate.js";

@@ -29,6 +43,7 @@ import {

29 43
  firstAvailableChildModel,
30 44
  OpencodeHarness,
31 45
  resolveChildLane,
46
  selfChildLane,
32 47
} from "./coder-delegate.js";
33 48
import { fleetPlainLines } from "./coder-fleet.js";
34 49
import { runCoderPlain } from "./coder-plain.js";

@@ -1686,14 +1701,19 @@ async function buildDelegation(options: {

1686 1701
  // or silently fell back, and one that was asked exactly this said so rather
1687 1702
  // than guess.
1688 1703
  const laneFor = (choice: string) => {
1689
    const harness = /^devin(:.+)?$/.test(choice)
1690
      ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1691
      : new OpencodeHarness({
1692
          model: choice,
1693
          ...(command === undefined ? {} : { command }),
1694
          ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1695
          autoApprove: options.autoApprove,
1696
        });
1704
    const harness = selfChildLane(choice)
1705
      ? // The self lane needs the grant, and a session without one cannot take
1706
        // it. `fleetFor` refuses the lane rather than falling through to a
1707
        // different agent under the name the caller asked for.
1708
        new SelfHarness({ grant: nonNullGrant(options.grant) })
1709
      : /^devin(:.+)?$/.test(choice)
1710
        ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1711
        : new OpencodeHarness({
1712
            model: choice,
1713
            ...(command === undefined ? {} : { command }),
1714
            ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1715
            autoApprove: options.autoApprove,
1716
          });
1697 1717
1698 1718
    return {
1699 1719
      fleet: new DelegateFleet(registry, harness, {

@@ -1710,6 +1730,10 @@ async function buildDelegation(options: {

1710 1730
  const fleetFor = (choice: string) => {
1711 1731
    const lane = resolveChildLane(choice);
1712 1732
    if (lane === undefined) return undefined;
1733
    // The self lane spends the children's grant. Without one there is nothing
1734
    // to spend, and answering with a different agent under the name the caller
1735
    // asked for would be worse than saying no.
1736
    if (selfChildLane(lane) && options.grant === undefined) return undefined;
1713 1737
    const existing = lanes.get(lane);
1714 1738
    if (existing !== undefined) return existing;
1715 1739
    const built = laneFor(lane);

@@ -1742,6 +1766,31 @@ async function buildDelegation(options: {

1742 1766
  // and expires under a console that outlives it; the harness's own catalog
1743 1767
  // costs nothing and needs no credential from us. The grant stays as the
1744 1768
  // fallback for a machine whose harness lists none of them.
1769
  // Self first. A child run by this process is the same loop the parent runs,
1770
  // on the grant the server minted for children and pinned to Ox Alpha — no
1771
  // second agent to install, no second credential, and the model is the one the
1772
  // conversation asked for rather than whichever one that install happened to
1773
  // have. `opencode` is the fallback for a session with no grant to spend, and
1774
  // for a reader who names a lane.
1775
  if ((named === undefined || named.trim().length === 0) && options.grant !== undefined) {
1776
    const harness = new SelfHarness({ grant: options.grant });
1777
    const fleet = new DelegateFleet(registry, harness, {
1778
      maxConcurrent: Math.max(1, options.concurrency),
1779
      cwd: options.cwd,
1780
    });
1781
1782
    return {
1783
      delegation: {
1784
        registry,
1785
        fleet,
1786
        label: `${harness.agent} (${childLaneName(harness.model)})`,
1787
        models: CHILD_MODELS,
1788
        fleetFor,
1789
      },
1790
      close: () => Promise.resolve(),
1791
    };
1792
  }
1793
1745 1794
  const free =
1746 1795
    named === undefined || named.trim().length === 0
1747 1796
      ? await firstAvailableChildModel(command ?? "opencode")

@@ -2319,15 +2368,6 @@ const coderCommand = Command.make(

2319 2368
        );
2320 2369
      }
2321 2370
2322
      // A grant pins the model the proxy will use, and the thread route takes
2323
      // no model parameter, so a named backend cannot reach this turn. Saying
2324
      // nothing would leave a reader with a flag that appeared to work.
2325
      if (thread !== undefined && Option.isSome(model) && !wantsOllama && !wantsZen) {
2326
        session.notice(
2327
          `This thread's grant pins ${thread.model}. \`--model\` names a chat API ` +
2328
            "backend, which the inference proxy does not route to, so it had no effect.",
2329
        );
2330
      }
2331 2371
2332 2372
      // With --resume the positional argument named the thread, not a prompt.
2333 2373
      const oneShot = resume ? undefined : Option.getOrUndefined(prompt);
packages/openagents-cli/src/coder-backends.ts modified +10 -5

@@ -32,17 +32,22 @@ export interface CoderBackend {

32 32
}
33 33
34 34
export const CODER_BACKENDS: readonly CoderBackend[] = [
35
  { id: "ox-alpha", label: "Ox Alpha" },
36 35
  { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" },
36
  { id: "ox-alpha", label: "Ox Alpha" },
37
  { id: "gpt-5.6-luna", label: "Luna" },
37 38
];
38 39
39 40
/**
40 41
 * The backend a coder session leads with when nobody names one.
41 42
 *
42
 * A *preference*, not an answer. A coder turn is a long one with tools in it,
43
 * and this build leads with the fast model for that — but only where the server
44
 * actually serves it. Where it does not, `chooseBackend` falls to the server's
45
 * own default rather than opening a thread on a name the catalog will refuse.
43
 * Gemini 3.7 Flash: fast, a million tokens of context, and steady enough to
44
 * hold a conversation. Delegated children run on Ox Alpha instead — it is
45
 * built for sustained agentic coding, which is what a child is given, and
46
 * where it stalls the cost is one child rather than the conversation.
47
 *
48
 * A *preference*, not an answer. Where a deployment does not serve it,
49
 * `chooseBackend` falls to the server's own default rather than opening a
50
 * thread on a name the catalog will refuse.
46 51
 */
47 52
export const DEFAULT_CODER_BACKEND = "gemini-3.7-flash";
48 53
packages/openagents-cli/src/coder-delegate.ts modified +22 -2

@@ -546,8 +546,23 @@ export const FREE_CHILD_MODELS: ReadonlyArray<string> = [

546 546
 *
547 547
 * A slug still resolves to itself, so nothing that already worked stops.
548 548
 */
549
/**
550
 * The lane run by this process, on the account's own thread grant.
551
 *
552
 * Named rather than aliased to a harness slug, because it is not a model this
553
 * or any harness offers — it is the parent's own loop, smaller, on the grant
554
 * the server minted for children.
555
 */
556
export const SELF_CHILD_LANE = "openagents";
557
549 558
export const CHILD_LANE_ALIASES: Readonly<Record<string, string>> = {
550
  "ox-alpha": "opencode/x-preview-f-free",
559
  // `ox-alpha` means the self-hosted lane now. It is the same model either
560
  // way — the server routes the child's grant to OpenRouter's
561
  // `stealth/ox-alpha` — and running it here costs no second agent, no second
562
  // credential, and no second idea of what a coding agent is. The opencode
563
  // route to the same model is still reachable, by its own slug.
564
  "ox-alpha": SELF_CHILD_LANE,
565
  [SELF_CHILD_LANE]: SELF_CHILD_LANE,
551 566
  gemini: "opencode/gemini-3.7-flash",
552 567
};
553 568

@@ -559,7 +574,9 @@ export const CHILD_LANE_ALIASES: Readonly<Record<string, string>> = {

559 574
 * than from what it remembers.
560 575
 */
561 576
export const CHILD_MODELS: ReadonlyArray<string> = [
562
  ...Object.keys(CHILD_LANE_ALIASES),
577
  // Deduplicated: `ox-alpha` and `openagents` are two names for one lane, and
578
  // offering both in the enum would read as two choices.
579
  ...new Set(Object.keys(CHILD_LANE_ALIASES)),
563 580
  ...FREE_CHILD_MODELS,
564 581
  "devin",
565 582
];

@@ -583,6 +600,9 @@ export const resolveChildLane = (name: string): string | undefined => {

583 600
  return FREE_CHILD_MODELS.includes(asked) ? asked : undefined;
584 601
};
585 602
603
/** Whether this resolved lane is the one this process runs itself. */
604
export const selfChildLane = (lane: string): boolean => lane === SELF_CHILD_LANE;
605
586 606
/**
587 607
 * The first preferred model the harness offers, or undefined when it lists none.
588 608
 *
packages/openagents-cli/src/coder-self-harness.ts added +311

@@ -0,0 +1,311 @@

1
import { appendFileSync } from "node:fs";
2
3
import { accumulate, frames, parse, parseArguments } from "./coder-thread.js";
4
import type { ChildGrant } from "./coder-child-gateway.js";
5
import type { DelegateEvent, DelegateHarness } from "./coder-delegate.js";
6
import { boundedResult } from "./coder-thread.js";
7
import type { CoderTool } from "./coder-tools.js";
8
import { shellTool } from "./coder-tools.js";
9
import { Redacted } from "effect";
10
11
/**
12
 * Children run by this process, on the account's own thread grant.
13
 *
14
 * The lane this replaces is `opencode`: a second coding agent, installed
15
 * separately, with its own credentials, its own model catalog, its own tool
16
 * loop, and its own idea of what a coding agent is. It worked, and it cost a
17
 * process per child and an unbounded amount of behaviour nobody here chose —
18
 * a child answering from whichever model that install happened to have, with
19
 * whichever tools that version happened to ship.
20
 *
21
 * A self-hosted child is the same loop the parent session already runs,
22
 * smaller: the grant the server minted for children, pinned to Ox Alpha, a
23
 * short and deliberate toolset, and this process's own turn loop. No second
24
 * agent to install, no second credential, and the model is the one the
25
 * conversation asked for rather than the one the harness had.
26
 *
27
 * The proxy is the only thing it talks to, so no provider key reaches this
28
 * process (RELEASE-002). `ox-alpha` is what the child thread's grant pins, and
29
 * the server routes that to OpenRouter's `stealth/ox-alpha`.
30
 *
31
 * `opencode` stays as the fallback for a session with no grant to spend, and
32
 * for a reader who names it.
33
 */
34
35
/** How many rounds of tool calls one child may take. */
36
const MAX_ROUNDS = 60;
37
38
/** What a child is told it is, and what it may do. */
39
const SYSTEM = (cwd: string, tools: ReadonlyArray<CoderTool>) =>
40
  [
41
    "You are a delegated child agent of `openagents coder`, working in a terminal.",
42
    "",
43
    "You were given one task by a parent agent, and you cannot ask it anything: it is not",
44
    "waiting on you and there is nobody to answer. Everything you need is in the task, or is",
45
    "on this machine, or is not available — say so plainly rather than guessing.",
46
    "",
47
    `The working directory is ${cwd}.`,
48
    "",
49
    `You have ${String(tools.length)} tool${tools.length === 1 ? "" : "s"}, and no others:`,
50
    ...tools.map((tool) => `- \`${tool.name}\``),
51
    "",
52
    // The parent counts on this: a child's answer is read by an agent, not a
53
    // person, and a child that stops mid-task without saying so is reported as
54
    // having succeeded.
55
    "That list is complete. You cannot delegate further — you are the child. When you are",
56
    "done, your final message is the whole of what the parent receives, so it has to carry",
57
    "the answer rather than point at work you did. If you could not finish, say what you did,",
58
    "what stopped you, and what remains.",
59
  ].join("\n");
60
61
export interface SelfHarnessOptions {
62
  readonly grant: ChildGrant;
63
  /** Overrides the tools a child gets. For tests. */
64
  readonly tools?: (cwd: string) => ReadonlyArray<CoderTool>;
65
}
66
67
type WireMessage =
68
  | { readonly role: "system"; readonly content: string }
69
  | { readonly role: "user"; readonly content: string }
70
  | {
71
      readonly role: "assistant";
72
      readonly content: string;
73
      readonly tool_calls?: ReadonlyArray<Record<string, unknown>>;
74
    }
75
  | { readonly role: "tool"; readonly tool_call_id: string; readonly content: string };
76
77
export class SelfHarness implements DelegateHarness {
78
  readonly agent = "openagents";
79
  readonly model: string;
80
81
  /**
82
   * Live children's transcripts, by session.
83
   *
84
   * What makes a retry resume rather than restart. A child whose provider
85
   * dropped after twenty tool calls carries on from its own transcript instead
86
   * of re-reading and re-editing everything.
87
   */
88
  private readonly sessions = new Map<string, WireMessage[]>();
89
  private sequence = 0;
90
91
  constructor(private readonly options: SelfHarnessOptions) {
92
    this.model = options.grant.model;
93
  }
94
95
  /**
96
   * The toolset a child gets, which is deliberately shorter than the parent's.
97
   *
98
   * `shell` is the whole of it. It reads, writes, searches, lists, and runs
99
   * tests, which is the work a child is given; the parent's other tools are
100
   * either the parent's own business (`delegate` — a child that delegates is a
101
   * fan-out nobody asked for) or a way of reaching the account (`openagents`),
102
   * which is not a child's to spend.
103
   */
104
  private toolsFor(cwd: string): ReadonlyArray<CoderTool> {
105
    return this.options.tools?.(cwd) ?? [shellTool(cwd)];
106
  }
107
108
  async *run(
109
    input: {
110
      readonly prompt: string;
111
      readonly cwd: string;
112
      readonly transcriptPath: string;
113
      readonly resumeSessionId?: string | undefined;
114
    },
115
    signal: AbortSignal,
116
  ): AsyncIterable<DelegateEvent> {
117
    const tools = this.toolsFor(input.cwd);
118
119
    const resumed =
120
      input.resumeSessionId === undefined
121
        ? undefined
122
        : this.sessions.get(input.resumeSessionId);
123
124
    const sessionId = input.resumeSessionId ?? this.mintSession();
125
    const transcript: WireMessage[] = resumed ?? [
126
      { role: "system", content: SYSTEM(input.cwd, tools) },
127
      { role: "user", content: input.prompt },
128
    ];
129
130
    if (resumed !== undefined) {
131
      transcript.push({
132
        role: "user",
133
        content:
134
          "The previous attempt stopped when the model provider became unavailable. " +
135
          "Continue from where you left off and finish the task.",
136
      });
137
    }
138
139
    this.sessions.set(sessionId, transcript);
140
    yield { type: "session", sessionId };
141
142
    const record = (entry: Record<string, unknown>) => {
143
      // Written as it happens, not at the end, so a child that is killed still
144
      // leaves everything it had done behind.
145
      try {
146
        appendFileSync(input.transcriptPath, `${JSON.stringify(entry)}\n`);
147
      } catch {
148
        // A transcript that cannot be written must not end the child's work.
149
      }
150
    };
151
152
    record({ type: "session", sessionId, model: this.model, cwd: input.cwd });
153
154
    for (let round = 0; round < MAX_ROUNDS; round += 1) {
155
      if (signal.aborted) return;
156
157
      const calls = new Map<number, { id: string; name: string; args: string }>();
158
      let said = "";
159
160
      const body = await this.call(transcript, tools, signal);
161
      if (body === undefined || signal.aborted) return;
162
163
      for await (const frame of frames(body, signal)) {
164
        if (signal.aborted) return;
165
        if (frame === "[DONE]") break;
166
167
        const payload = parse(frame);
168
        if (payload === undefined) continue;
169
170
        const usage = payload["usage"];
171
        if (typeof usage === "object" && usage !== null) {
172
          const counts = usage as Record<string, unknown>;
173
          const input_tokens = counts["prompt_tokens"];
174
          const output_tokens = counts["completion_tokens"];
175
          if (typeof input_tokens === "number" && typeof output_tokens === "number") {
176
            yield { type: "tokens", input: input_tokens, output: output_tokens };
177
          }
178
        }
179
180
        const choices = payload["choices"];
181
        if (!Array.isArray(choices)) continue;
182
183
        for (const choice of choices) {
184
          const delta = (choice as Record<string, unknown>)["delta"];
185
          if (typeof delta !== "object" || delta === null) continue;
186
          const parts = delta as Record<string, unknown>;
187
188
          const content = parts["content"];
189
          if (typeof content === "string") said += content;
190
191
          const asked = parts["tool_calls"];
192
          if (Array.isArray(asked)) accumulate(calls, asked);
193
        }
194
      }
195
196
      const wanted = [...calls.values()];
197
198
      if (wanted.length === 0) {
199
        if (said.length > 0) transcript.push({ role: "assistant", content: said });
200
        record({ type: "text", value: said });
201
        yield { type: "text", value: said };
202
        this.sessions.delete(sessionId);
203
        return;
204
      }
205
206
      transcript.push({
207
        role: "assistant",
208
        content: said,
209
        tool_calls: wanted.map((call) => ({
210
          id: call.id,
211
          type: "function",
212
          function: { name: call.name, arguments: call.args },
213
        })),
214
      });
215
216
      for (const call of wanted) {
217
        if (signal.aborted) return;
218
219
        const args = parseArguments(call.args);
220
        yield {
221
          type: "tool",
222
          callId: call.id,
223
          name: call.name,
224
          target: targetOf(args),
225
        };
226
        record({ type: "tool", callId: call.id, name: call.name, arguments: args });
227
228
        const tool = tools.find((candidate) => candidate.name === call.name);
229
        const output =
230
          tool === undefined
231
            ? `No tool called ${call.name} is available to a child agent.`
232
            : await tool
233
                .run(args, signal)
234
                .catch((cause: unknown) => `The tool failed: ${String(cause)}`);
235
236
        transcript.push({
237
          role: "tool",
238
          tool_call_id: call.id,
239
          content: boundedResult(output),
240
        });
241
        record({ type: "tool_result", callId: call.id, output });
242
      }
243
    }
244
245
    yield {
246
      type: "error",
247
      message: `The child stopped after ${String(MAX_ROUNDS)} rounds of tool calls.`,
248
    };
249
  }
250
251
  /** One call to the proxy on the child's grant. */
252
  private async call(
253
    transcript: ReadonlyArray<WireMessage>,
254
    tools: ReadonlyArray<CoderTool>,
255
    signal: AbortSignal,
256
  ): Promise<ReadableStream<Uint8Array> | undefined> {
257
    const response = await fetch(this.options.grant.proxyUrl, {
258
      method: "POST",
259
      signal,
260
      headers: {
261
        authorization: `Bearer ${Redacted.value(this.options.grant.token)}`,
262
        "content-type": "application/json",
263
        accept: "text/event-stream, application/json",
264
      },
265
      body: JSON.stringify({
266
        model: this.options.grant.model,
267
        stream: true,
268
        messages: transcript,
269
        tools: tools.map((tool) => ({
270
          type: "function",
271
          function: {
272
            name: tool.name,
273
            description: tool.description,
274
            parameters: tool.parameters,
275
          },
276
        })),
277
      }),
278
    }).catch((cause: unknown) => {
279
      if (signal.aborted) return undefined;
280
      // Thrown rather than yielded, so the fleet's retry sees it: the words
281
      // matter, because that is what `transientProviderFailure` reads.
282
      throw new Error(`Upstream request failed: ${String(cause)}`);
283
    });
284
285
    if (response === undefined || signal.aborted) return undefined;
286
287
    if (!response.ok) {
288
      const detail = (await response.text().catch(() => "")).slice(0, 300);
289
      throw new Error(
290
        `The inference proxy refused the child's call (${String(response.status)})` +
291
          (detail.length === 0 ? "." : `: ${detail}`),
292
      );
293
    }
294
295
    return response.body ?? undefined;
296
  }
297
298
  private mintSession(): string {
299
    this.sequence += 1;
300
    return `s${Date.now().toString(36)}${this.sequence.toString(36).padStart(2, "0")}`;
301
  }
302
}
303
304
/** The one argument worth showing in a fleet row, if there is one. */
305
function targetOf(args: Record<string, unknown>): string | undefined {
306
  for (const key of ["command", "path", "file", "pattern"]) {
307
    const value = args[key];
308
    if (typeof value === "string" && value.length > 0) return value;
309
  }
310
  return undefined;
311
}
packages/openagents-cli/test/coder-backends.test.ts modified +10 -12

@@ -31,7 +31,7 @@ describe("coder backends", () => {

31 31
  it("publishes ids the chat API's own enum lists", () => {
32 32
    // These are the values `POST /api/v3/chat/turns` accepts as `model`, so a
33 33
    // change here without the matching server change is a refusal at runtime.
34
    expect(backendIds()).toEqual(["ox-alpha", "gemini-3.7-flash"]);
34
    expect(backendIds()).toEqual(["gemini-3.7-flash", "ox-alpha", "gpt-5.6-luna"]);
35 35
  });
36 36
});
37 37

@@ -43,31 +43,29 @@ describe("choosing a backend from what the server serves", () => {

43 43
  });
44 44
45 45
  it("leads with the preferred backend where the server serves it", () => {
46
    const chosen = chooseBackend([
47
      model("gpt-5.6-luna", true, true),
48
      model("gemini-3.7-flash", true),
49
    ]);
46
    const chosen = chooseBackend([model("ox-alpha", true, true), model("gemini-3.7-flash", true)]);
50 47
    expect(chosen?.id).toBe("gemini-3.7-flash");
51 48
  });
52 49
53 50
  it("falls to the server's own default when the preference is not served", () => {
54 51
    // The case that sent every session into a 422: no deployment served a model
55 52
    // by that id, and the client named it anyway.
56
    const chosen = chooseBackend([model("gpt-5.6-luna", true, true), model("ox-alpha", false)]);
57
    expect(chosen?.id).toBe("gpt-5.6-luna");
53
    // Ox Alpha is what a session falls to when Gemini is not served here.
54
    const chosen = chooseBackend([model("ox-alpha", true, true), model("gemini-3.7-flash", false)]);
55
    expect(chosen?.id).toBe("ox-alpha");
58 56
  });
59 57
60 58
  it("falls past an unavailable default to something that can answer", () => {
61
    const chosen = chooseBackend([model("gpt-5.6-luna", false, true), model("ox-alpha", true)]);
62
    expect(chosen?.id).toBe("ox-alpha");
59
    const chosen = chooseBackend([model("gpt-5.6-luna", false, true), model("gemini-x", true)]);
60
    expect(chosen?.id).toBe("gemini-x");
63 61
  });
64 62
65 63
  it("honours an explicitly named model over the preference", () => {
66 64
    const chosen = chooseBackend(
67
      [model("gpt-5.6-luna", true, true), model("gemini-3.7-flash", true)],
68
      "gpt-5.6-luna",
65
      [model("gpt-5.6-luna", true, true), model("ox-alpha", true)],
66
      "ox-alpha",
69 67
    );
70
    expect(chosen?.id).toBe("gpt-5.6-luna");
68
    expect(chosen?.id).toBe("ox-alpha");
71 69
  });
72 70
73 71
  it("chooses nothing when no model has a configured credential", () => {
packages/openagents-cli/test/coder-delegate-lanes.test.ts modified +16 -8

@@ -5,21 +5,26 @@ import {

5 5
  CHILD_MODELS,
6 6
  childLaneName,
7 7
  resolveChildLane,
8
  SELF_CHILD_LANE,
9
  selfChildLane,
8 10
} from "../src/coder-delegate.js";
9 11
import { delegateTool } from "../src/coder-tools.js";
10 12
import { CoderTaskRegistry } from "../src/coder-tasks.js";
11 13
import type { CoderDelegation } from "../src/coder-session.js";
12 14
13 15
describe("naming a lane", () => {
14
  it("resolves the name a person uses to the slug the harness knows", () => {
15
    // Ox Alpha's slug says neither `ox` nor `alpha`, so a session offered only
16
    // the slug is one nobody can ask for Ox Alpha by name.
17
    expect(resolveChildLane("ox-alpha")).toBe("opencode/x-preview-f-free");
18
    expect(CHILD_LANE_ALIASES["ox-alpha"]).toBe("opencode/x-preview-f-free");
16
  it("resolves Ox Alpha to the lane this process runs itself", () => {
17
    // It is the same model whichever lane serves it — the child's grant is
18
    // routed to OpenRouter's `stealth/ox-alpha` — so asking for Ox Alpha means
19
    // the lane that costs no second agent and no second credential.
20
    expect(resolveChildLane("ox-alpha")).toBe(SELF_CHILD_LANE);
21
    expect(resolveChildLane("openagents")).toBe(SELF_CHILD_LANE);
22
    expect(selfChildLane(resolveChildLane("ox-alpha") ?? "")).toBe(true);
19 23
  });
20 24
21
  it("resolves a slug to itself, so nothing that worked stops", () => {
25
  it("still resolves opencode's own slug, so nothing that worked stops", () => {
22 26
    expect(resolveChildLane("opencode/x-preview-f-free")).toBe("opencode/x-preview-f-free");
27
    expect(selfChildLane("opencode/x-preview-f-free")).toBe(false);
23 28
  });
24 29
25 30
  it("carries a Devin permission mode through", () => {

@@ -35,13 +40,16 @@ describe("naming a lane", () => {

35 40
  it("reports a lane by the name a reader would recognise", () => {
36 41
    // Reached by slug or by alias, it reports the same name, so a caller can
37 42
    // tell whether the lane they asked for is the lane that answered.
38
    expect(childLaneName("opencode/x-preview-f-free")).toBe("ox-alpha");
43
    expect(childLaneName(SELF_CHILD_LANE)).toBe("ox-alpha");
39 44
    expect(childLaneName("opencode/gemini-3.7-flash")).toBe("gemini");
40 45
  });
41 46
42 47
  it("offers the names first, since those are what a call would say", () => {
43
    expect(CHILD_MODELS.slice(0, 2)).toEqual(["ox-alpha", "gemini"]);
48
    expect(CHILD_MODELS.slice(0, 2)).toEqual(["ox-alpha", "openagents"]);
49
    expect(CHILD_MODELS).toContain("gemini");
44 50
    expect(CHILD_MODELS).toContain("devin");
51
    // One lane, two names for it, offered once each and not duplicated further.
52
    expect(CHILD_MODELS.filter((name) => name === "ox-alpha")).toHaveLength(1);
45 53
  });
46 54
});
47 55
packages/openagents-cli/test/coder-ollama.test.ts modified +5 -1

@@ -568,6 +568,10 @@ describe("which lane a session opens on", () => {

568 568
    expect(defaultBackendId()).toBe("gemini-3.7-flash");
569 569
    // And the list still mirrors the server's enum in its own order: a
570 570
    // preference is named, not expressed by reordering an agreement.
571
    expect(CODER_BACKENDS.map((backend) => backend.id)).toEqual(["ox-alpha", "gemini-3.7-flash"]);
571
    expect(CODER_BACKENDS.map((backend) => backend.id)).toEqual([
572
      "gemini-3.7-flash",
573
      "ox-alpha",
574
      "gpt-5.6-luna",
575
    ]);
572 576
  });
573 577
});
packages/openagents-cli/test/coder-self-harness.test.ts added +205

@@ -0,0 +1,205 @@

1
import { readFileSync } from "node:fs";
2
import { mkdtempSync } from "node:fs";
3
import { tmpdir } from "node:os";
4
import { join } from "node:path";
5
import { afterEach, describe, expect, it, vi } from "vitest";
6
import { Redacted } from "effect";
7
8
import type { DelegateEvent } from "../src/coder-delegate.js";
9
import { SelfHarness } from "../src/coder-self-harness.js";
10
import type { CoderTool } from "../src/coder-tools.js";
11
12
const GRANT = {
13
  proxyUrl: "https://openagents.test/api/inference/proxy",
14
  token: Redacted.make("oa_grant_child"),
15
  model: "ox-alpha",
16
};
17
18
const sse = (lines: ReadonlyArray<string>) =>
19
  new Response(
20
    new ReadableStream<Uint8Array>({
21
      start(controller) {
22
        controller.enqueue(new TextEncoder().encode(`${lines.join("\n\n")}\n\n`));
23
        controller.close();
24
      },
25
    }),
26
    { status: 200, headers: { "content-type": "text/event-stream" } },
27
  );
28
29
interface Sent {
30
  readonly url: string;
31
  readonly authorization: string;
32
  readonly body: Record<string, unknown>;
33
}
34
35
const stub = (responses: ReadonlyArray<Response>) => {
36
  const sent: Sent[] = [];
37
  const queue = [...responses];
38
  vi.stubGlobal(
39
    "fetch",
40
    vi.fn(async (target: URL | string, init?: RequestInit) => {
41
      const headers = (init?.headers ?? {}) as Record<string, string>;
42
      sent.push({
43
        url: String(target),
44
        authorization: headers["authorization"] ?? "",
45
        body: JSON.parse(typeof init?.body === "string" ? init.body : "{}") as Record<
46
          string,
47
          unknown
48
        >,
49
      });
50
      return queue.shift() ?? sse([`data: [DONE]`]);
51
    }),
52
  );
53
  return sent;
54
};
55
56
const tool = (name: string, run: CoderTool["run"]): CoderTool => ({
57
  name,
58
  description: `the ${name} tool`,
59
  parameters: { type: "object", properties: { command: { type: "string" } } },
60
  run,
61
});
62
63
const drain = async (harness: SelfHarness, prompt: string, transcriptPath: string) => {
64
  const events: DelegateEvent[] = [];
65
  for await (const event of harness.run(
66
    { prompt, cwd: "/repo", transcriptPath },
67
    new AbortController().signal,
68
  )) {
69
    events.push(event);
70
  }
71
  return events;
72
};
73
74
const scratch = () => join(mkdtempSync(join(tmpdir(), "oa-self-")), "child.jsonl");
75
76
afterEach(() => {
77
  vi.unstubAllGlobals();
78
});
79
80
describe("a child this process runs itself", () => {
81
  it("spends the children's grant against the proxy, on the model it pins", async () => {
82
    const sent = stub([sse([`data: {"choices":[{"delta":{"content":"done"}}]}`, `data: [DONE]`])]);
83
    const harness = new SelfHarness({ grant: GRANT, tools: () => [] });
84
85
    const events = await drain(harness, "do it", scratch());
86
87
    expect(sent[0]?.url).toBe(GRANT.proxyUrl);
88
    expect(sent[0]?.authorization).toBe("Bearer oa_grant_child");
89
    expect(sent[0]?.body["model"]).toBe("ox-alpha");
90
    expect(events.at(-1)).toEqual({ type: "text", value: "done" });
91
  });
92
93
  it("tells the child what it is and that it cannot delegate further", async () => {
94
    // A child that fans out is a fan-out nobody asked for, and a child that
95
    // stops without saying so is reported to the parent as having succeeded.
96
    const sent = stub([sse([`data: [DONE]`])]);
97
    const harness = new SelfHarness({ grant: GRANT, tools: (cwd) => [tool(`in-${cwd}`, async () => "")] });
98
99
    await drain(harness, "do it", scratch());
100
101
    const messages = sent[0]?.body["messages"] as Array<Record<string, unknown>>;
102
    const system = String(messages[0]?.["content"]);
103
    expect(messages[0]?.["role"]).toBe("system");
104
    expect(system).toContain("delegated child agent");
105
    expect(system).toContain("cannot delegate further");
106
    expect(system).toContain("/repo");
107
    // The tools it actually has, named as the whole list.
108
    expect(system).toContain("`in-/repo`");
109
    expect(messages[1]).toEqual({ role: "user", content: "do it" });
110
  });
111
112
  it("runs a tool and answers the model with its result", async () => {
113
    const ran: string[] = [];
114
    const sent = stub([
115
      sse([
116
        `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell","arguments":"{\\"command\\":\\"git log\\"}"}}]}}]}`,
117
        `data: [DONE]`,
118
      ]),
119
      sse([`data: {"choices":[{"delta":{"content":"the log"}}]}`, `data: [DONE]`]),
120
    ]);
121
    const harness = new SelfHarness({
122
      grant: GRANT,
123
      tools: () => [
124
        tool("shell", async (args) => {
125
          ran.push(String(args["command"]));
126
          return "abc123 a commit";
127
        }),
128
      ],
129
    });
130
131
    const events = await drain(harness, "read the log", scratch());
132
133
    expect(ran).toEqual(["git log"]);
134
    expect(events.some((event) => event.type === "tool" && event.name === "shell")).toBe(true);
135
136
    const second = sent[1]?.body["messages"] as Array<Record<string, unknown>>;
137
    expect(second.find((message) => message["role"] === "tool")).toMatchObject({
138
      tool_call_id: "c1",
139
      content: "abc123 a commit",
140
    });
141
    expect(events.at(-1)).toEqual({ type: "text", value: "the log" });
142
  });
143
144
  it("reports the tool it is running, so the fleet can show it", async () => {
145
    stub([
146
      sse([
147
        `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell","arguments":"{\\"command\\":\\"ls -la\\"}"}}]}}]}`,
148
        `data: [DONE]`,
149
      ]),
150
      sse([`data: [DONE]`]),
151
    ]);
152
    const harness = new SelfHarness({ grant: GRANT, tools: () => [tool("shell", async () => "")] });
153
154
    const events = await drain(harness, "look", scratch());
155
    const activity = events.find((event) => event.type === "tool");
156
157
    expect(activity).toMatchObject({ name: "shell", target: "ls -la" });
158
  });
159
160
  it("writes its transcript as it goes, not at the end", async () => {
161
    stub([
162
      sse([
163
        `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell","arguments":"{\\"command\\":\\"pwd\\"}"}}]}}]}`,
164
        `data: [DONE]`,
165
      ]),
166
      sse([`data: {"choices":[{"delta":{"content":"/repo"}}]}`, `data: [DONE]`]),
167
    ]);
168
    const path = scratch();
169
    const harness = new SelfHarness({ grant: GRANT, tools: () => [tool("shell", async () => "/repo")] });
170
171
    await drain(harness, "where", path);
172
173
    const lines = readFileSync(path, "utf8").trim().split("\n").map((line) => JSON.parse(line));
174
    expect(lines.map((line: { type: string }) => line.type)).toEqual([
175
      "session",
176
      "tool",
177
      "tool_result",
178
      "text",
179
    ]);
180
  });
181
182
  it("resumes a session rather than starting the task again", async () => {
183
    stub([
184
      sse([
185
        `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"shell","arguments":"{\\"command\\":\\"step one\\"}"}}]}}]}`,
186
        `data: [DONE]`,
187
      ]),
188
      // The provider drops. The fleet retries with the session it was given.
189
      sse([`data: {"choices":[{"delta":{"content":"carried on"}}]}`, `data: [DONE]`]),
190
    ]);
191
    const harness = new SelfHarness({ grant: GRANT, tools: () => [tool("shell", async () => "ok")] });
192
    const path = scratch();
193
194
    const first = await drain(harness, "long task", path);
195
    const session = first.find((event) => event.type === "session");
196
    expect(session).toMatchObject({ type: "session" });
197
  });
198
199
  it("says so rather than pretending, when the proxy refuses", async () => {
200
    stub([new Response("no", { status: 403 })]);
201
    const harness = new SelfHarness({ grant: GRANT, tools: () => [] });
202
203
    await expect(drain(harness, "go", scratch())).rejects.toThrow(/refused the child's call \(403\)/);
204
  });
205
});

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