Let the model delegate on the session's own grant

ce995566e1d5 · Devin AI · · parent 58640ee239b1

Let the model delegate on the session's own grant

Delegation was a slash command that also demanded a child model and a
provider credential, and reported a failed child as "exited with code 1".
All three are fixed:

- `delegate` is now a tool the session declares to the model, so a
  fan-out happens mid-conversation. `/delegate` stays for starting
  children without spending a turn. The tool awaits its children and
  returns their answers, and tells each child which of N it is so a
  prompt can say "your own file".
- A child runs on the thread's grant. The parent keeps the grant, opens a
  loopback gateway on 127.0.0.1, and writes a private harness config
  pointing at it, so the child never sees the token and spends under the
  server's budget. `--child-model`/`--child-config` become overrides.
- Failures report what the child reported: the harness error nested under
  `error.data` with its ref, a proxy refusal with its HTTP status and
  body, a model the harness does not have, or a missing executable, with
  the stderr or stdout tail as the fallback.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.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 packages/openagents-cli/README.md
  • modified packages/openagents-cli/package.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-child-config.ts
  • added packages/openagents-cli/src/coder-child-gateway.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-thread.ts
  • added packages/openagents-cli/src/coder-tools.ts
  • modified packages/openagents-cli/test/coder-thread.test.ts
  • added packages/openagents-cli/test/coder-tools.test.ts

Diff

11 files changed, +1292 -105

packages/openagents-cli/README.md modified +37 -26

@@ -251,46 +251,57 @@ server import continues.

251 251
252 252
One prompt, many child coding agents, each in its own process. A child runs
253 253
under a harness — `opencode` today — and the CLI reports what every one of them
254
did:
254
did.
255 255
256
```sh
257
openagents delegate "Add a regression test for the retry path, then say done" \
258
  --agents 3 --concurrency 2 \
259
  --child-model vertex-express/gemini-3.7-flash \
260
  --child-config ~/.config/openagents/delegate.json \
261
  --child-approve
256
Inside `openagents coder`, delegation is a tool the model calls, so you ask for
257
it in the conversation and nothing else is required:
258
259
```text
260
> split this three ways: each agent surveys one package for dead exports
262 261
```
263 262
264
`--agents` is how many children run the prompt and `--concurrency` is how many
265
run at once; the rest queue, so a fan-out of thirty does not become thirty
266
processes. `--child-approve` lets a child use its tools without asking, which a
267
child needs because there is nobody to ask. Add `--json` for the task records
268
and outcomes as data. The exit code is non-zero when any child did not finish.
263
The model calls `delegate`, the interface lists each child with the tool it is
264
running, its tool and token counts, and its result, and `ctrl+x` stops every
265
running child. `/delegate [<n>x] <prompt>` still works when you want to start
266
children yourself without spending a turn.
267
268
Children run on the session's own thread grant, so there is no child model to
269
choose and no provider credential to install. The CLI holds the grant in the
270
parent process, opens a loopback gateway on `127.0.0.1`, and points the child
271
harness at it; the child never receives the grant token, and its spending is
272
the thread's spending, under the same server-side budget.
269 273
270
The same fleet is available inside the terminal session. Start `openagents
271
coder` with `--child-model` and type `/delegate [<n>x] <prompt>`:
274
The children need the harness itself on `PATH`:
272 275
273 276
```sh
274
openagents coder --child-model vertex-express/gemini-3.7-flash \
275
  --child-config ~/.config/openagents/delegate.json --child-approve
277
npm i -g opencode-ai
276 278
```
277 279
278
```text
279
/delegate 4x survey the package for dead exports
280
Headless, the same fleet runs without a session:
281
282
```sh
283
openagents delegate "Add a regression test for the retry path, then say done" \
284
  --agents 3 --concurrency 2
280 285
```
281 286
282
Delegating does not spend a chat turn and does not block the next prompt. The
283
interface lists each child, what tool it is running, its tool and token counts,
284
and its result, and `ctrl+x` stops every running child. Each child's raw
285
harness transcript is kept as JSONL under
286
`$TMPDIR/openagents-coder-delegations`.
287
`--agents` is how many children run the prompt and `--concurrency` is how many
288
run at once; the rest queue, so a fan-out of thirty does not become thirty
289
processes. Add `--json` for the task records and outcomes as data. The exit code
290
is non-zero when any child did not finish. Each child's raw harness transcript
291
is kept as JSONL under `$TMPDIR/openagents-coder-delegations`.
292
293
When a child fails, the CLI reports what the child reported — the harness error
294
and its reference, the provider or proxy refusal with its status, a model the
295
harness does not have, or a missing executable — rather than an exit code.
287 296
288
The child model has no default, because a child spends money under a provider
289
account. `--child-model`, `--child-command`, and `--child-config` fall back to
297
To run children on a provider of your own instead of the thread grant, name it:
298
`--child-model`, `--child-command`, and `--child-config` fall back to
290 299
`OPENAGENTS_DELEGATE_MODEL`, `OPENAGENTS_DELEGATE_COMMAND`, and
291 300
`OPENAGENTS_DELEGATE_CONFIG`. The CLI never reads or stores a provider
292 301
credential: `--child-config` names a harness configuration file, which the CLI
293
passes to the child as `OPENCODE_CONFIG` and nothing else.
302
passes to the child as `OPENCODE_CONFIG` and nothing else. A child approves its
303
own tool use, because a delegated child has nobody to ask; `--child-ask` stops
304
it at its first edit for a dry run.
294 305
295 306
## Manage issues
296 307
packages/openagents-cli/package.json modified +1 -1

@@ -1,6 +1,6 @@

1 1
{
2 2
  "name": "@openagentsinc/cli",
3
  "version": "0.3.2",
3
  "version": "0.3.3",
4 4
  "private": false,
5 5
  "description": "Effect TypeScript command-line client for OpenAgents repositories",
6 6
  "keywords": [
packages/openagents-cli/src/cli.ts modified +148 -47

@@ -15,6 +15,9 @@ import {

15 15
} from "./api-passthrough.js";
16 16
import { ApiTransport } from "./api-transport.js";
17 17
import { BrowserLauncher } from "./browser-launcher.js";
18
import type { ChildGrant } from "./coder-child-gateway.js";
19
import { startChildGateway } from "./coder-child-gateway.js";
20
import { writeChildHarnessConfig } from "./coder-child-config.js";
18 21
import type { DelegationOutcome } from "./coder-delegate.js";
19 22
import { DelegateFleet, describePrompt, OpencodeHarness } from "./coder-delegate.js";
20 23
import { fleetPlainLines } from "./coder-fleet.js";

@@ -25,6 +28,7 @@ import { CoderTaskRegistry } from "./coder-tasks.js";

25 28
import { runCoderUi } from "./coder-ui.js";
26 29
import { backendIds } from "./coder-backends.js";
27 30
import { openThread, ThreadUnavailable } from "./coder-thread.js";
31
import { delegateTool } from "./coder-tools.js";
28 32
import { describeWorkspace } from "./coder-workspace.js";
29 33
import { ComputerClient } from "./computer-client.js";
30 34
import { ComputerUp } from "./computer-up.js";

@@ -1445,17 +1449,19 @@ const coderRefusal = (origin: string, cause: unknown) => {

1445 1449
/**
1446 1450
 * Delegation flags, shared by `coder` and `delegate`.
1447 1451
 *
1448
 * The child model is what turns delegation on. There is no default: a child
1449
 * spends money under a provider account, and a console that silently picked a
1450
 * model would spend it fifteen times over on a prompt the reader thought was
1451
 * going nowhere. Each flag falls back to an environment variable so a fleet can
1452
 * be configured once for a shell rather than typed on every launch.
1452
 * None of them is required. Children run on the session's own thread grant
1453
 * through a loopback gateway, which is what makes delegation work in a fresh
1454
 * install with nothing configured: the earlier design demanded a child model
1455
 * and a provider credential of the reader's own, so the fleet was off by
1456
 * default and asking for one got a refusal. These flags are for the case where
1457
 * somebody wants children on a different provider than the session.
1453 1458
 */
1454 1459
const childModelFlag = Flag.string("child-model").pipe(
1455 1460
  Flag.optional,
1456 1461
  Flag.withDescription(
1457
    "The model delegated children run, as `provider/model`. Defaults to " +
1458
      "OPENAGENTS_DELEGATE_MODEL. Delegation is unavailable without one",
1462
    "Run children on this model instead of the session's own, as `provider/model`. " +
1463
      "Defaults to OPENAGENTS_DELEGATE_MODEL, and to the session's thread grant when " +
1464
      "neither is set",
1459 1465
  ),
1460 1466
);
1461 1467
const childCommandFlag = Flag.string("child-command").pipe(

@@ -1471,10 +1477,11 @@ const childConfigFlag = Flag.string("child-config").pipe(

1471 1477
      "provider credential reaches a child without being stored by the CLI",
1472 1478
  ),
1473 1479
);
1474
const childApproveFlag = Flag.boolean("child-approve").pipe(
1480
const childAskFlag = Flag.boolean("child-ask").pipe(
1475 1481
  Flag.withDescription(
1476
    "Let children use their tools without asking. A delegated child has nobody to " +
1477
      "ask, so a child that must edit files needs this",
1482
    "Make children ask before using a tool. A delegated child has nobody to ask, so " +
1483
      "this stops it at its first edit; it exists for a dry run over a directory you " +
1484
      "do not want touched",
1478 1485
  ),
1479 1486
);
1480 1487
const concurrencyFlag = Flag.integer("concurrency").pipe(

@@ -1482,28 +1489,64 @@ const concurrencyFlag = Flag.integer("concurrency").pipe(

1482 1489
  Flag.withDescription("How many children may run at once. The rest queue"),
1483 1490
);
1484 1491
1492
/** Delegation and whatever has to be torn down with it. */
1493
interface DelegationSetup {
1494
  readonly delegation: CoderDelegation;
1495
  /** Stops the child gateway and removes the generated harness config. */
1496
  close(): Promise<void>;
1497
}
1498
1485 1499
/**
1486
 * Assemble delegation, or nothing when no child model was named.
1500
 * Assemble delegation.
1487 1501
 *
1488
 * Returning undefined rather than throwing is what lets `coder` open without a
1489
 * child model and say so when `/delegate` is typed, instead of refusing to
1490
 * start over a feature the reader may not use.
1502
 * Three ways a child gets a model, in order: a model named on the command line
1503
 * or in the environment, with the reader's own harness config; the session's
1504
 * thread grant, lent to children through a loopback gateway; or nothing, which
1505
 * is only reached with no credential and no flag, and is what makes `/delegate`
1506
 * say so instead of failing later.
1491 1507
 */
1492
function buildDelegation(options: {
1508
async function buildDelegation(options: {
1493 1509
  readonly model: string | undefined;
1494 1510
  readonly command: string | undefined;
1495 1511
  readonly configPath: string | undefined;
1496 1512
  readonly autoApprove: boolean;
1497 1513
  readonly concurrency: number;
1498 1514
  readonly cwd: string;
1499
}): CoderDelegation | undefined {
1500
  const model = options.model ?? process.env["OPENAGENTS_DELEGATE_MODEL"];
1501
  if (model === undefined || model.trim().length === 0) return undefined;
1515
  /** The session's grant, when it has one. Children spend it by default. */
1516
  readonly grant: ChildGrant | undefined;
1517
}): Promise<DelegationSetup | undefined> {
1518
  const named = options.model ?? process.env["OPENAGENTS_DELEGATE_MODEL"];
1519
  const command = options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"];
1520
  const namedConfig = options.configPath ?? process.env["OPENAGENTS_DELEGATE_CONFIG"];
1521
1522
  let model: string;
1523
  let configPath: string | undefined;
1524
  let close: () => Promise<void>;
1525
1526
  if (named !== undefined && named.trim().length > 0) {
1527
    model = named;
1528
    configPath = namedConfig;
1529
    close = () => Promise.resolve();
1530
  } else if (options.grant !== undefined) {
1531
    const gateway = await startChildGateway(options.grant);
1532
    const harnessConfig = writeChildHarnessConfig({
1533
      baseUrl: gateway.baseUrl,
1534
      model: options.grant.model,
1535
    });
1536
    model = gateway.modelId;
1537
    configPath = harnessConfig.path;
1538
    close = async () => {
1539
      await gateway.close();
1540
      harnessConfig.remove();
1541
    };
1542
  } else {
1543
    return undefined;
1544
  }
1502 1545
1503 1546
  const harness = new OpencodeHarness({
1504 1547
    model,
1505
    command: options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"],
1506
    configPath: options.configPath ?? process.env["OPENAGENTS_DELEGATE_CONFIG"],
1548
    command,
1549
    configPath,
1507 1550
    autoApprove: options.autoApprove,
1508 1551
  });
1509 1552
  const registry = new CoderTaskRegistry();

@@ -1511,7 +1554,10 @@ function buildDelegation(options: {

1511 1554
    maxConcurrent: Math.max(1, options.concurrency),
1512 1555
    cwd: options.cwd,
1513 1556
  });
1514
  return { registry, fleet, label: `${harness.agent} (${model})` };
1557
  return {
1558
    delegation: { registry, fleet, label: `${harness.agent} (${model})` },
1559
    close,
1560
  };
1515 1561
}
1516 1562
1517 1563
const coderCommand = Command.make(

@@ -1525,7 +1571,7 @@ const coderCommand = Command.make(

1525 1571
    childModel: childModelFlag,
1526 1572
    childCommand: childCommandFlag,
1527 1573
    childConfig: childConfigFlag,
1528
    childApprove: childApproveFlag,
1574
    childAsk: childAskFlag,
1529 1575
    concurrency: concurrencyFlag,
1530 1576
  },
1531 1577
  ({

@@ -1537,7 +1583,7 @@ const coderCommand = Command.make(

1537 1583
    childModel,
1538 1584
    childCommand,
1539 1585
    childConfig,
1540
    childApprove,
1586
    childAsk,
1541 1587
    concurrency,
1542 1588
  }) =>
1543 1589
    Effect.gen(function* () {

@@ -1575,16 +1621,32 @@ const coderCommand = Command.make(

1575 1621
        : undefined;
1576 1622
1577 1623
      const source = thread ?? new DummyReplySource();
1578
      const delegation = buildDelegation({
1579
        model: Option.getOrUndefined(childModel),
1580
        command: Option.getOrUndefined(childCommand),
1581
        configPath: Option.getOrUndefined(childConfig),
1582
        autoApprove: childApprove,
1583
        concurrency,
1584
        cwd: process.cwd(),
1585
      });
1624
      const setup = yield* Effect.promise(() =>
1625
        buildDelegation({
1626
          model: Option.getOrUndefined(childModel),
1627
          command: Option.getOrUndefined(childCommand),
1628
          configPath: Option.getOrUndefined(childConfig),
1629
          autoApprove: !childAsk,
1630
          concurrency,
1631
          cwd: process.cwd(),
1632
          grant: thread?.childGrant,
1633
        }),
1634
      );
1635
1636
      const session = new CoderSession(
1637
        source,
1638
        workspace.repository,
1639
        workspace.branch,
1640
        setup?.delegation,
1641
      );
1586 1642
1587
      const session = new CoderSession(source, workspace.repository, workspace.branch, delegation);
1643
      // The model is told what it can do rather than the reader being asked to
1644
      // remember a slash command. A turn that needs three agents asks for them
1645
      // mid-sentence, and `/delegate` stays as the way to launch a fan-out
1646
      // without spending a turn to ask for one.
1647
      if (thread !== undefined && setup !== undefined) {
1648
        thread.useTools([delegateTool(setup.delegation)]);
1649
      }
1588 1650
1589 1651
      if (Option.isNone(stored) && !offline) {
1590 1652
        session.notice(

@@ -1622,6 +1684,7 @@ const coderCommand = Command.make(

1622 1684
          // session nobody is in. A process killed outright still leaves it to
1623 1685
          // the server's expiry reap.
1624 1686
          if (thread !== undefined) await thread.revoke();
1687
          if (setup !== undefined) await setup.close();
1625 1688
        }
1626 1689
      });
1627 1690

@@ -1631,7 +1694,7 @@ const coderCommand = Command.make(

1631 1694
    }),
1632 1695
).pipe(
1633 1696
  Command.withDescription(
1634
    "Open a terminal coding session on a thread of its own. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead. With --child-model, `/delegate [<n>x] <prompt>` runs child coding agents and the interface shows the fleet",
1697
    "Open a terminal coding session on a thread of its own. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead. The session can delegate: ask it to split work and it runs child coding agents on the same grant, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
1635 1698
  ),
1636 1699
);
1637 1700

@@ -1670,7 +1733,7 @@ const delegateCommand = Command.make(

1670 1733
    childModel: childModelFlag,
1671 1734
    childCommand: childCommandFlag,
1672 1735
    childConfig: childConfigFlag,
1673
    childApprove: childApproveFlag,
1736
    childAsk: childAskFlag,
1674 1737
    concurrency: concurrencyFlag,
1675 1738
  },
1676 1739
  ({

@@ -1681,28 +1744,61 @@ const delegateCommand = Command.make(

1681 1744
    childModel,
1682 1745
    childCommand,
1683 1746
    childConfig,
1684
    childApprove,
1747
    childAsk,
1685 1748
    concurrency,
1686 1749
  }) =>
1687 1750
    Effect.gen(function* () {
1688 1751
      const flags = yield* rootCommand;
1689 1752
      const cwd = Option.getOrUndefined(dir) ?? process.cwd();
1690
      const delegation = buildDelegation({
1691
        model: Option.getOrUndefined(childModel),
1692
        command: Option.getOrUndefined(childCommand),
1693
        configPath: Option.getOrUndefined(childConfig),
1694
        autoApprove: childApprove,
1695
        concurrency,
1696
        cwd,
1697
      });
1753
      const endpoint = yield* resolveApiEndpoint(endpointOverrides(flags));
1754
      const named = Option.getOrUndefined(childModel) ?? process.env["OPENAGENTS_DELEGATE_MODEL"];
1755
1756
      // Children spend a thread the same way the interactive session does, so
1757
      // this command opens one rather than demanding a provider of its own. It
1758
      // is skipped when a child model was named, because then the reader is
1759
      // paying somebody else and a thread would be opened and never spent.
1760
      const stored =
1761
        named === undefined
1762
          ? yield* findToken(endpoint.origin).pipe(
1763
              Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1764
                Effect.succeed(Option.none()),
1765
              ),
1766
            )
1767
          : Option.none();
1768
1769
      const thread = Option.isSome(stored)
1770
        ? yield* Effect.tryPromise({
1771
            try: () =>
1772
              openThread({
1773
                origin: endpoint.origin,
1774
                token: Redacted.value(stored.value.token),
1775
                objective: `openagents delegate: ${describePrompt(prompt)}`,
1776
              }),
1777
            catch: (cause) => coderRefusal(endpoint.origin, cause),
1778
          })
1779
        : undefined;
1698 1780
1699
      if (delegation === undefined) {
1781
      const setup = yield* Effect.promise(() =>
1782
        buildDelegation({
1783
          model: Option.getOrUndefined(childModel),
1784
          command: Option.getOrUndefined(childCommand),
1785
          configPath: Option.getOrUndefined(childConfig),
1786
          autoApprove: !childAsk,
1787
          concurrency,
1788
          cwd,
1789
          grant: thread?.childGrant,
1790
        }),
1791
      );
1792
1793
      if (setup === undefined) {
1700 1794
        return yield* new InputError({
1701 1795
          message:
1702
            "No child model. Pass --child-model provider/model or set " +
1703
            "OPENAGENTS_DELEGATE_MODEL.",
1796
            "Nothing to run children on. Sign in with `openagents auth login --scope " +
1797
            "chat:account` so children can spend a thread, or pass --child-model " +
1798
            "provider/model to run them on a provider of your own.",
1704 1799
        });
1705 1800
      }
1801
      const delegation = setup.delegation;
1706 1802
1707 1803
      const count = Math.max(1, agents);
1708 1804
      const label = Option.getOrUndefined(description) ?? describePrompt(prompt);

@@ -1747,6 +1843,11 @@ const delegateCommand = Command.make(

1747 1843
          process.off("SIGTERM", onSignal);
1748 1844
          process.off("exit", onSignal);
1749 1845
          unsubscribe();
1846
          await setup.close();
1847
          // The thread's slot goes back even on the failure path: an account
1848
          // holds eight, and a script that delegates in a loop would otherwise
1849
          // be refused on its ninth run.
1850
          if (thread !== undefined) await thread.revoke();
1750 1851
        }
1751 1852
      });
1752 1853

@@ -1780,7 +1881,7 @@ const delegateCommand = Command.make(

1780 1881
    }),
1781 1882
).pipe(
1782 1883
  Command.withDescription(
1783
    "Run one prompt on many child coding agents at once and report each result",
1884
    "Run one prompt on many child coding agents at once and report each result. Children run on a thread of their own by default, so this needs no provider credential",
1784 1885
  ),
1785 1886
);
1786 1887
packages/openagents-cli/src/coder-child-config.ts added +69

@@ -0,0 +1,69 @@

1
/**
2
 * The harness config that points children at this session's gateway.
3
 *
4
 * opencode reads its provider list from a config file named by
5
 * `OPENCODE_CONFIG`, so lending a session's grant to children means writing one
6
 * provider entry whose base URL is the loopback gateway. Written per session
7
 * into a private directory rather than into the reader's own
8
 * `~/.config/opencode`: the port changes every launch, two sessions must not
9
 * fight over one file, and nothing here should survive the process that needs
10
 * it.
11
 *
12
 * The file carries no credential. The gateway holds the grant, and the key
13
 * below exists only because an OpenAI-compatible client refuses to send a
14
 * request without one.
15
 */
16
17
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
import { tmpdir } from "node:os";
19
import { join } from "node:path";
20
21
import { CHILD_PROVIDER } from "./coder-child-gateway.js";
22
23
/** The config body, as a value, so a test can read it without a file. */
24
export function childHarnessConfig(options: {
25
  readonly baseUrl: string;
26
  readonly model: string;
27
}): Record<string, unknown> {
28
  return {
29
    $schema: "https://opencode.ai/config.json",
30
    provider: {
31
      [CHILD_PROVIDER]: {
32
        npm: "@ai-sdk/openai-compatible",
33
        name: "OpenAgents",
34
        options: {
35
          baseURL: options.baseUrl,
36
          // The gateway authenticates with the thread's grant and ignores
37
          // this, but a client that has no key at all will not send a request.
38
          apiKey: "openagents-thread-grant",
39
        },
40
        models: {
41
          [options.model]: { name: options.model, tool_call: true },
42
        },
43
      },
44
    },
45
  };
46
}
47
48
export interface ChildHarnessFile {
49
  readonly path: string;
50
  remove(): void;
51
}
52
53
/** Write the config to a private directory and hand back its path. */
54
export function writeChildHarnessConfig(options: {
55
  readonly baseUrl: string;
56
  readonly model: string;
57
}): ChildHarnessFile {
58
  const directory = mkdtempSync(join(tmpdir(), "openagents-child-"));
59
  const path = join(directory, "opencode.json");
60
  writeFileSync(path, `${JSON.stringify(childHarnessConfig(options), undefined, 2)}\n`, {
61
    mode: 0o600,
62
  });
63
  return {
64
    path,
65
    remove: () => {
66
      rmSync(directory, { recursive: true, force: true });
67
    },
68
  };
69
}
packages/openagents-cli/src/coder-child-gateway.ts added +267

@@ -0,0 +1,267 @@

1
/**
2
 * A local OpenAI-compatible endpoint that lends the session's thread grant to
3
 * child coding agents.
4
 *
5
 * Delegation used to require the reader to name a child model and to hold a
6
 * provider credential of their own, which meant the fleet was off by default
7
 * and `/delegate` in a fresh session did nothing. That was backwards: the
8
 * session already holds a grant the server minted for it, the grant is already
9
 * metered against the thread's own budget, and a child is the same kind of
10
 * spend as a reply. So children run on the session's grant, and delegation
11
 * needs no flags, no key, and no second account.
12
 *
13
 * A harness cannot talk to `POST /api/inference/proxy` directly, for two
14
 * reasons:
15
 *
16
 * - **Path.** An OpenAI-compatible client appends `/chat/completions` to its
17
 *   base URL, and the proxy is one fixed path that answers nothing else.
18
 * - **Tool history.** The proxy maps a `tool` message to a
19
 *   `function_call_output` item and sends it *alone*, which the provider
20
 *   refuses without the `function_call` that preceded it — and the response id
21
 *   that would link them is never given to a client. A coding agent calls a
22
 *   tool on nearly every step, so the second step of every child would fail.
23
 *   This gateway therefore flattens a tool exchange into plain turns: the
24
 *   assistant's call becomes assistant text naming the call, and the result
25
 *   becomes a user turn carrying the output. The harness keeps its own state
26
 *   machine and only needs the next call from the model, so a flattened history
27
 *   costs prompt-shape fidelity and nothing else.
28
 *
29
 * The grant never reaches the harness: it is held here, the child is told only
30
 * a loopback URL, and the placeholder key it sends back is ignored. That is why
31
 * the gateway binds to `127.0.0.1` and to a port the operating system picks —
32
 * anything on the box could otherwise spend the thread's budget, and a fixed
33
 * port would collide between two sessions.
34
 */
35
36
import { createServer } from "node:http";
37
import type { AddressInfo } from "node:net";
38
import { Redacted } from "effect";
39
40
/** What the child gateway needs in order to spend a thread's grant. */
41
export interface ChildGrant {
42
  /** The proxy URL the grant was minted for. */
43
  readonly proxyUrl: string;
44
  readonly token: Redacted.Redacted<string>;
45
  /** The model the grant pins. A request body cannot select another. */
46
  readonly model: string;
47
}
48
49
/** One chat-completions message as a harness sends it. */
50
interface ClientMessage {
51
  readonly role?: unknown;
52
  readonly content?: unknown;
53
  readonly tool_calls?: unknown;
54
  readonly tool_call_id?: unknown;
55
}
56
57
/** A turn the proxy accepts: a role and text, and nothing else. */
58
interface FlatMessage {
59
  readonly role: "system" | "user" | "assistant";
60
  readonly content: string;
61
}
62
63
/**
64
 * Flatten a harness's message list into turns the proxy accepts.
65
 *
66
 * Exported for the tests, which is the only way to check the mapping without
67
 * standing up a server and a child.
68
 */
69
export function flattenForProxy(messages: ReadonlyArray<unknown>): ReadonlyArray<FlatMessage> {
70
  const flat: FlatMessage[] = [];
71
72
  for (const raw of messages) {
73
    if (typeof raw !== "object" || raw === null) continue;
74
    const message = raw as ClientMessage;
75
    const text = textOf(message.content);
76
    const role = typeof message.role === "string" ? message.role : "user";
77
78
    if (role === "tool") {
79
      const id = typeof message.tool_call_id === "string" ? message.tool_call_id : "";
80
      // A result is put on a user turn because that is the only role the proxy
81
      // will carry text on that the model has not already spoken.
82
      flat.push({ role: "user", content: `[tool result${id === "" ? "" : ` ${id}`}]\n${text}` });
83
      continue;
84
    }
85
86
    const calls = describeCalls(message.tool_calls);
87
    if (role === "assistant" && calls !== undefined) {
88
      flat.push({ role: "assistant", content: `${text}\n[tool call]\n${calls}`.trim() });
89
      continue;
90
    }
91
92
    // An empty turn is dropped rather than sent: the proxy refuses a request
93
    // whose input is empty, and a blank assistant turn is what a harness emits
94
    // around a call it has already described.
95
    if (text.trim().length === 0) continue;
96
    flat.push({
97
      role: role === "system" ? "system" : role === "assistant" ? "assistant" : "user",
98
      content: text,
99
    });
100
  }
101
102
  return flat;
103
}
104
105
/** The text of a message whose content may be a string or a part array. */
106
function textOf(content: unknown): string {
107
  if (typeof content === "string") return content;
108
  if (!Array.isArray(content)) return "";
109
  return content
110
    .map((part) => {
111
      if (typeof part === "string") return part;
112
      if (typeof part === "object" && part !== null) {
113
        const text = (part as { text?: unknown }).text;
114
        return typeof text === "string" ? text : "";
115
      }
116
      return "";
117
    })
118
    .join("");
119
}
120
121
/** The calls on an assistant message, as one line each, or nothing. */
122
function describeCalls(toolCalls: unknown): string | undefined {
123
  if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;
124
  const lines: string[] = [];
125
  for (const raw of toolCalls) {
126
    if (typeof raw !== "object" || raw === null) continue;
127
    const fn = (raw as { function?: { name?: unknown; arguments?: unknown } }).function ?? {};
128
    const id = (raw as { id?: unknown }).id;
129
    const name = typeof fn.name === "string" ? fn.name : "tool";
130
    const args = typeof fn.arguments === "string" ? fn.arguments : "";
131
    const suffix = typeof id === "string" && id.length > 0 ? ` id=${id}` : "";
132
    lines.push(`${name}(${args})${suffix}`);
133
  }
134
  return lines.length === 0 ? undefined : lines.join("\n");
135
}
136
137
/** A running gateway: where children should send their calls, and how to stop. */
138
export interface ChildGateway {
139
  /** The base URL a harness config points at, without a trailing slash. */
140
  readonly baseUrl: string;
141
  /** The model id, as the harness must name it: `provider/model`. */
142
  readonly modelId: string;
143
  close(): Promise<void>;
144
}
145
146
/** The provider name children see. Part of the model id they are given. */
147
export const CHILD_PROVIDER = "openagents";
148
149
/**
150
 * Start the gateway on a loopback port of the operating system's choosing.
151
 *
152
 * Resolves once it is listening, because a child launched against a port that
153
 * is not up yet fails on its first call and reports it as a provider error.
154
 */
155
export async function startChildGateway(grant: ChildGrant): Promise<ChildGateway> {
156
  const server = createServer((request, response) => {
157
    const chunks: Buffer[] = [];
158
    request.on("data", (chunk: Buffer) => chunks.push(chunk));
159
    request.on("end", () => {
160
      void forward(Buffer.concat(chunks).toString("utf8"), grant, response);
161
    });
162
  });
163
164
  // A child that hangs must not hold the console open on the way out.
165
  server.unref();
166
167
  await new Promise<void>((resolve, reject) => {
168
    server.once("error", reject);
169
    server.listen(0, "127.0.0.1", resolve);
170
  });
171
172
  const address = server.address() as AddressInfo;
173
  return {
174
    baseUrl: `http://127.0.0.1:${String(address.port)}/v1`,
175
    modelId: `${CHILD_PROVIDER}/${grant.model}`,
176
    close: () =>
177
      new Promise<void>((resolve) => {
178
        server.close(() => resolve());
179
      }),
180
  };
181
}
182
183
/** Spend the grant on one child call and stream the answer back verbatim. */
184
async function forward(
185
  body: string,
186
  grant: ChildGrant,
187
  response: import("node:http").ServerResponse,
188
): Promise<void> {
189
  let payload: Record<string, unknown> = {};
190
  try {
191
    const parsed: unknown = JSON.parse(body === "" ? "{}" : body);
192
    if (typeof parsed === "object" && parsed !== null) payload = parsed as Record<string, unknown>;
193
  } catch {
194
    // An unparseable body is treated as an empty one, and the proxy's own
195
    // refusal is what the child then reports.
196
  }
197
198
  const messages = Array.isArray(payload["messages"]) ? payload["messages"] : [];
199
  const tools = payload["tools"];
200
201
  const upstream = await fetch(grant.proxyUrl, {
202
    method: "POST",
203
    headers: {
204
      authorization: `Bearer ${Redacted.value(grant.token)}`,
205
      "content-type": "application/json",
206
      accept: "text/event-stream, application/json",
207
    },
208
    body: JSON.stringify({
209
      // The grant pins the model, so whatever the child named is ignored here
210
      // rather than passed through and refused.
211
      model: grant.model,
212
      stream: true,
213
      messages: flattenForProxy(messages),
214
      ...(Array.isArray(tools) && tools.length > 0 ? { tools } : {}),
215
    }),
216
  }).catch((cause: unknown) => {
217
    response.writeHead(502, { "content-type": "application/json" });
218
    response.end(
219
      JSON.stringify({
220
        error: { message: `The inference proxy could not be reached: ${String(cause)}` },
221
      }),
222
    );
223
    return undefined;
224
  });
225
226
  if (upstream === undefined) return;
227
228
  if (!upstream.ok) {
229
    // The proxy answers a refusal as `{"error":{"code":"…"}}`, which an
230
    // OpenAI-compatible client reports as an unexplained server error because
231
    // it looks for `error.message`. Putting the status and the body in a
232
    // message is the difference between a child that says
233
    // `budget_exhausted` and three children that say `exited with code 1`.
234
    const detail = (await upstream.text().catch(() => "")).slice(0, 400);
235
    response.writeHead(upstream.status, { "content-type": "application/json" });
236
    response.end(
237
      JSON.stringify({
238
        error: {
239
          type: "openagents_proxy",
240
          message:
241
            `The OpenAgents inference proxy refused this call with HTTP ${String(upstream.status)}` +
242
            `${detail === "" ? "." : `: ${detail}`}`,
243
        },
244
      }),
245
    );
246
    return;
247
  }
248
249
  response.writeHead(upstream.status, {
250
    "content-type": upstream.headers.get("content-type") ?? "text/event-stream",
251
  });
252
253
  if (upstream.body === null) {
254
    response.end();
255
    return;
256
  }
257
258
  const reader = upstream.body.getReader();
259
  for (;;) {
260
    // The body is a stream and each read depends on the one before it.
261
    // eslint-disable-next-line no-await-in-loop
262
    const { done, value } = await reader.read();
263
    if (done) break;
264
    response.write(Buffer.from(value));
265
  }
266
  response.end();
267
}
packages/openagents-cli/src/coder-delegate.ts modified +133 -12

@@ -189,11 +189,7 @@ export function parseOpencodeEvent(line: string): DelegateEvent | undefined {

189 189
  }
190 190
191 191
  if (type === "error") {
192
    const message =
193
      stringField(event, "message") ??
194
      (isRecord(event["error"]) ? stringField(event["error"], "message") : undefined) ??
195
      "The child agent reported an error.";
196
    return { type: "error", message };
192
    return { type: "error", message: describeHarnessError(event) };
197 193
  }
198 194
199 195
  const sessionId = stringField(event, "sessionID");

@@ -204,6 +200,34 @@ export function parseOpencodeEvent(line: string): DelegateEvent | undefined {

204 200
  return undefined;
205 201
}
206 202
203
/**
204
 * The sentence behind a harness error event.
205
 *
206
 * opencode nests the sentence: the event carries an `error` with a `name` and a
207
 * `data` holding the `message` and a support `ref`. Reading only `message` off
208
 * the outer object — which is what this did — found nothing, so the fleet fell
209
 * back to the exit code and every failure on screen read `exited with code 1`,
210
 * which says nothing about the provider refusal, the missing credential, or the
211
 * unreachable endpoint that actually happened.
212
 */
213
function describeHarnessError(event: Record<string, unknown>): string {
214
  const error = isRecord(event["error"]) ? event["error"] : {};
215
  const data = isRecord(error["data"]) ? error["data"] : {};
216
  const sentence =
217
    stringField(event, "message") ??
218
    stringField(error, "message") ??
219
    stringField(data, "message") ??
220
    stringField(data, "error");
221
  const name = stringField(error, "name");
222
  const ref = stringField(data, "ref");
223
224
  const parts: string[] = [];
225
  if (name !== undefined && name !== "Error") parts.push(name);
226
  parts.push(sentence ?? "the child agent reported an error");
227
  const text = parts.join(": ");
228
  return ref === undefined ? text : `${text} (${ref})`;
229
}
230
207 231
/**
208 232
 * What the child was working on.
209 233
 *

@@ -335,11 +359,80 @@ export class OpencodeHarness implements DelegateHarness {

335 359
    this.model = options.model;
336 360
  }
337 361
362
  /** The preflight, run once and shared by every child of this fleet. */
363
  private preflight: Promise<string | undefined> | undefined;
364
365
  /**
366
   * Check that the harness exists and knows the model, once per fleet.
367
   *
368
   * Without this, a model the harness cannot resolve fails inside its provider
369
   * and is reported as `Unexpected server error`, once per child — fifteen
370
   * identical sentences naming nothing. `opencode models` costs one process at
371
   * the start of a fan-out and turns that into the model id and the fact that
372
   * it is not on the list.
373
   *
374
   * A preflight that cannot answer says nothing rather than guessing: a
375
   * harness that lists no models, or a build whose subcommand differs, must
376
   * not be able to block a fleet that would have worked.
377
   */
378
  private check(command: string): Promise<string | undefined> {
379
    this.preflight ??= new Promise<string | undefined>((resolve) => {
380
      const probe = spawn(command, ["models"], {
381
        env: {
382
          ...process.env,
383
          ...this.options.env,
384
          ...(this.options.configPath === undefined
385
            ? {}
386
            : { OPENCODE_CONFIG: this.options.configPath }),
387
        },
388
        stdio: ["ignore", "pipe", "ignore"],
389
      });
390
391
      let listing = "";
392
      probe.stdout.setEncoding("utf8");
393
      probe.stdout.on("data", (chunk: string) => {
394
        listing += chunk;
395
      });
396
397
      probe.on("error", (cause: Error) => {
398
        resolve(
399
          (cause as NodeJS.ErrnoException).code === "ENOENT"
400
            ? `The \`${command}\` harness is not installed. Install it with ` +
401
                "`npm i -g opencode-ai`, or name another with --child-command."
402
            : `The \`${command}\` harness could not be started: ${cause.message}`,
403
        );
404
      });
405
406
      probe.on("close", (code) => {
407
        const models = listing
408
          .split("\n")
409
          .map((line) => stripAnsi(line).trim())
410
          .filter((line) => line.includes("/"));
411
        if (code !== 0 || models.length === 0 || models.includes(this.model)) {
412
          resolve(undefined);
413
          return;
414
        }
415
        resolve(
416
          `The \`${command}\` harness has no model \`${this.model}\`. ` +
417
            `It offers ${String(models.length)}, including ${models.slice(0, 3).join(", ")}.`,
418
        );
419
      });
420
    });
421
    return this.preflight;
422
  }
423
338 424
  async *run(
339 425
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
340 426
    signal: AbortSignal,
341 427
  ): AsyncIterable<DelegateEvent> {
342 428
    const command = this.options.command ?? "opencode";
429
430
    const problem = await this.check(command);
431
    if (problem !== undefined) {
432
      yield { type: "error", message: problem };
433
      throw new Error(problem);
434
    }
435
343 436
    const args = ["run", "--format", "json", "--model", this.model, "--dir", input.cwd];
344 437
    if (this.options.autoApprove === true) args.push("--auto");
345 438
    args.push(input.prompt);

@@ -373,6 +466,9 @@ export class OpencodeHarness implements DelegateHarness {

373 466
    };
374 467
375 468
    let stderr = "";
469
    /** The tail of stdout, kept for a child that failed without an event. */
470
    let stdout = "";
471
    let reported: string | undefined;
376 472
    let exited = false;
377 473
    let failure: string | undefined;
378 474

@@ -390,13 +486,17 @@ export class OpencodeHarness implements DelegateHarness {

390 486
    child.stdout.setEncoding("utf8");
391 487
    child.stdout.on("data", (chunk: string) => {
392 488
      transcript.write(chunk);
489
      stdout = `${stdout}${chunk}`.slice(-4000);
393 490
      pending += chunk;
394 491
      let newline = pending.indexOf("\n");
395 492
      while (newline >= 0) {
396 493
        const line = pending.slice(0, newline);
397 494
        pending = pending.slice(newline + 1);
398 495
        const event = parseOpencodeEvent(line);
399
        if (event !== undefined) queue.push(event);
496
        if (event !== undefined) {
497
          if (event.type === "error") reported = event.message;
498
          queue.push(event);
499
        }
400 500
        newline = pending.indexOf("\n");
401 501
      }
402 502
      wake();

@@ -420,9 +520,17 @@ export class OpencodeHarness implements DelegateHarness {

420 520
421 521
    child.on("close", (code) => {
422 522
      const trailing = parseOpencodeEvent(pending);
423
      if (trailing !== undefined) queue.push(trailing);
523
      if (trailing !== undefined) {
524
        if (trailing.type === "error") reported = trailing.message;
525
        queue.push(trailing);
526
      }
424 527
      if (failure === undefined && code !== 0 && !signal.aborted) {
425
        failure = describeExit(code, stderr);
528
        // What the harness said beats what the shell said. A child that
529
        // reported `provider refused the key` and then exited 1 has already
530
        // explained itself, and replacing that with the exit code is how a
531
        // fleet ends up reporting three identical `code 1` lines that name
532
        // nothing a reader could fix.
533
        failure = reported ?? describeExit(code, stderr, stdout);
426 534
      }
427 535
      exited = true;
428 536
      wake();

@@ -448,15 +556,28 @@ export class OpencodeHarness implements DelegateHarness {

448 556
  }
449 557
}
450 558
451
function describeExit(code: number | null, stderr: string): string {
452
  const tail = stderr
559
/**
560
 * Why a child ended, in one sentence.
561
 *
562
 * Both streams are read, stderr first: a harness that dies before it starts
563
 * writes there, and one that dies mid-run may have written only structured
564
 * output that this side could not name. An exit code on its own is the last
565
 * resort, not the first answer.
566
 */
567
function describeExit(code: number | null, stderr: string, stdout = ""): string {
568
  const exit = code === null ? "was killed" : `exited with code ${code}`;
569
  const tail = lastLines(stderr) ?? lastLines(stdout);
570
  return tail === undefined ? `The child ${exit}.` : `The child ${exit}: ${tail}`;
571
}
572
573
function lastLines(text: string): string | undefined {
574
  const tail = text
453 575
    .split("\n")
454 576
    .map((line) => stripAnsi(line).trim())
455 577
    .filter((line) => line.length > 0)
456 578
    .slice(-2)
457 579
    .join(" ");
458
  const exit = code === null ? "was killed" : `exited with code ${code}`;
459
  return tail.length > 0 ? `The child ${exit}: ${tail}` : `The child ${exit}.`;
580
  return tail.length === 0 ? undefined : tail.slice(-400);
460 581
}
461 582
462 583
function stripAnsi(text: string): string {
packages/openagents-cli/src/coder-session.ts modified +4 -2

@@ -486,8 +486,10 @@ export class CoderSession {

486 486
    const delegation = this.delegation;
487 487
    if (delegation === undefined) {
488 488
      this.notice(
489
        "This session cannot delegate. Start it with a child model, for example " +
490
          "`openagents coder --child-model vertex-express/gemini-3.7-flash`.",
489
        "This session cannot delegate: children spend the session's thread, and this " +
490
          "session has none. Sign in with `openagents auth login --scope chat:account`, " +
491
          "or start the session with `--child-model provider/model` to run children on a " +
492
          "provider of your own.",
491 493
      );
492 494
      return;
493 495
    }
packages/openagents-cli/src/coder-thread.ts modified +155 -17

@@ -34,13 +34,19 @@

34 34
 *   ever produced here, and the interface's dim-italic reasoning entry, which
35 35
 *   the stand-in behind `--offline` still exercises, never appears against a
36 36
 *   live model.
37
 * - **No tool runs.** The chat lane ran tools on the server and reported each
37
 * - **Tools run here.** The chat lane ran tools on the server and reported each
38 38
 *   one. The proxy is a bare completions surface: it forwards the `tools` a
39 39
 *   caller declares and returns the calls the model asks for, and the caller
40
 *   executes them. This CLI declares none and has no tool runtime, so the
41
 *   `tool_calls` translation below is the honest mapping of a frame that does
42
 *   not arrive today. It is kept because the frame is part of the surface and
43
 *   the alternative is discovering the mapping is missing on the day tools land.
40
 *   executes them. So the loop below is the tool runtime — `useTools` gives it
41
 *   the tools a session declares, and a turn continues until the model stops
42
 *   asking for one.
43
 *
44
 *   A tool result is fed back as plain turns rather than as a `tool` message.
45
 *   The proxy maps a `tool` message to a `function_call_output` item and sends
46
 *   it on its own, which the provider refuses without the `function_call` that
47
 *   preceded it, and the response id that would link the two is never given to
48
 *   a client. Until the proxy carries a tool exchange, the honest thing is to
49
 *   say what was called and what came back in turns it does accept.
44 50
 *
45 51
 * Nothing here announces any of that on screen. `2c15c6ed20` removed the
46 52
 * `scopeNotice` seam with the reasoning that a session private to its own

@@ -56,10 +62,20 @@

56 62
57 63
import { Redacted } from "effect";
58 64
65
import type { ChildGrant } from "./coder-child-gateway.js";
59 66
import type { ReplyChunk, ReplySource } from "./coder-session.js";
67
import type { CoderTool } from "./coder-tools.js";
60 68
61 69
const THREADS_PATH = "/api/v3/threads";
62 70
71
/**
72
 * How many times one turn may call tools before it has to answer.
73
 *
74
 * A ceiling rather than a preference: a model that keeps delegating is a model
75
 * spending the thread's budget without ever reporting to the reader.
76
 */
77
const MAX_TOOL_STEPS = 6;
78
63 79
/** What the thread may still spend, as the server last reported it. */
64 80
export interface ThreadBudget {
65 81
  readonly calls: number;

@@ -178,6 +194,13 @@ interface WireMessage {

178 194
  readonly content: string;
179 195
}
180 196
197
/** A call the model asked for, assembled from its fragments. */
198
interface WireCall {
199
  readonly id: string;
200
  readonly name: string;
201
  readonly args: string;
202
}
203
181 204
export class ThreadReplySource implements ReplySource {
182 205
  readonly threadId: string;
183 206
  /**

@@ -188,6 +211,7 @@ export class ThreadReplySource implements ReplySource {

188 211
   */
189 212
  private readonly transcript: WireMessage[] = [];
190 213
  private remaining: ThreadBudget;
214
  private tools: ReadonlyArray<CoderTool> = [];
191 215
192 216
  constructor(private readonly state: SourceState) {
193 217
    this.threadId = state.threadId;

@@ -211,21 +235,65 @@ export class ThreadReplySource implements ReplySource {

211 235
    return formatBudget(this.remaining);
212 236
  }
213 237
238
  /**
239
   * Declare the tools the model may call.
240
   *
241
   * Set after construction because the tools need things the thread produces:
242
   * the delegate tool runs children on this grant, so it cannot exist until the
243
   * grant does.
244
   */
245
  useTools(tools: ReadonlyArray<CoderTool>): void {
246
    this.tools = tools;
247
  }
248
249
  /**
250
   * The grant, for lending to child agents.
251
   *
252
   * Handed out as a `Redacted` so a child harness's config or command line
253
   * cannot print it, and only to callers inside this process.
254
   */
255
  get childGrant(): ChildGrant {
256
    return {
257
      proxyUrl: this.state.proxyUrl,
258
      token: this.state.grantToken,
259
      model: this.state.model,
260
    };
261
  }
262
214 263
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
215 264
    this.transcript.push({ role: "user", content: prompt });
216 265
217
    let assistant = "";
218 266
    try {
219
      for await (const chunk of this.stream(signal)) {
220
        if (signal.aborted) break;
221
        if (chunk.type === "text") assistant += chunk.value;
222
        yield chunk;
267
      for (let step = 0; ; step += 1) {
268
        const calls: WireCall[] = [];
269
        let assistant = "";
270
271
        for await (const chunk of this.stream(signal, calls)) {
272
          if (signal.aborted) break;
273
          if (chunk.type === "text") assistant += chunk.value;
274
          yield chunk;
275
        }
276
277
        // Whatever the model said belongs to the thread even when the turn was
278
        // interrupted, or the next turn answers a question it cannot see it
279
        // half-answered.
280
        if (assistant.length > 0) this.transcript.push({ role: "assistant", content: assistant });
281
        if (signal.aborted || calls.length === 0) return;
282
283
        if (step >= MAX_TOOL_STEPS) {
284
          yield {
285
            type: "text",
286
            value: `\n\n[stopped after ${String(MAX_TOOL_STEPS)} tool steps in one turn]`,
287
          };
288
          return;
289
        }
290
291
        for (const call of calls) {
292
          if (signal.aborted) return;
293
          yield* this.invoke(call, signal);
294
        }
223 295
      }
224 296
    } finally {
225
      // Whatever the model said belongs to the thread even when the turn was
226
      // interrupted, or the next turn answers a question it cannot see it
227
      // half-answered.
228
      if (assistant.length > 0) this.transcript.push({ role: "assistant", content: assistant });
229 297
      // Read the budget on the way out of every turn, including an interrupted
230 298
      // one. Interrupting is a client-side abort: the proxy had already bought
231 299
      // the call and metered it, so a status line that kept the figure it

@@ -235,6 +303,47 @@ export class ThreadReplySource implements ReplySource {

235 303
    }
236 304
  }
237 305
306
  /**
307
   * Run one call, report it, and put the exchange on the thread.
308
   *
309
   * The transcript keeps the call and its result as an assistant turn and a
310
   * user turn for the reason given at the top of this file: a `tool` message is
311
   * not carried by the proxy today, and a model that cannot see what its own
312
   * call returned calls it again.
313
   */
314
  private async *invoke(call: WireCall, signal: AbortSignal): AsyncIterable<ReplyChunk> {
315
    yield { type: "tool_call", callId: call.id, name: call.name, arguments: call.args };
316
317
    const tool = this.tools.find((candidate) => candidate.name === call.name);
318
    let output: string;
319
    let failure: string | undefined;
320
321
    if (tool === undefined) {
322
      failure = `This session has no \`${call.name}\` tool.`;
323
      output = failure;
324
    } else {
325
      try {
326
        output = await tool.run(parseArguments(call.args), signal);
327
      } catch (cause) {
328
        failure = cause instanceof Error ? cause.message : String(cause);
329
        output = failure;
330
      }
331
    }
332
333
    yield {
334
      type: "tool_result",
335
      callId: call.id,
336
      output: failure === undefined ? output : undefined,
337
      error: failure,
338
    };
339
340
    this.transcript.push({
341
      role: "assistant",
342
      content: `[tool call]\n${call.name}(${call.args})`,
343
    });
344
    this.transcript.push({ role: "user", content: `[tool result ${call.name}]\n${output}` });
345
  }
346
238 347
  /**
239 348
   * Revoke the thread and its grant.
240 349
   *

@@ -254,8 +363,14 @@ export class ThreadReplySource implements ReplySource {

254 363
    }).catch(() => undefined);
255 364
  }
256 365
257
  /** Spend one call against the proxy and translate what comes back. */
258
  private async *stream(signal: AbortSignal): AsyncIterable<ReplyChunk> {
366
  /**
367
   * Spend one call against the proxy and translate what comes back.
368
   *
369
   * Calls the model asked for are appended to `collected` rather than yielded,
370
   * because the turn has to run them and report each result, and a caller that
371
   * only saw a chunk could not.
372
   */
373
  private async *stream(signal: AbortSignal, collected: WireCall[]): AsyncIterable<ReplyChunk> {
259 374
    const response = await fetch(this.state.proxyUrl, {
260 375
      method: "POST",
261 376
      signal,

@@ -271,6 +386,18 @@ export class ThreadReplySource implements ReplySource {

271 386
        model: this.state.model,
272 387
        stream: true,
273 388
        messages: this.transcript,
389
        ...(this.tools.length === 0
390
          ? {}
391
          : {
392
              tools: this.tools.map((tool) => ({
393
                type: "function",
394
                function: {
395
                  name: tool.name,
396
                  description: tool.description,
397
                  parameters: tool.parameters,
398
                },
399
              })),
400
            }),
274 401
      }),
275 402
    }).catch((cause: unknown) => {
276 403
      if (signal.aborted) return undefined;

@@ -316,7 +443,7 @@ export class ThreadReplySource implements ReplySource {

316 443
    }
317 444
318 445
    for (const call of calls.values()) {
319
      yield { type: "tool_call", callId: call.id, name: call.name, arguments: call.args };
446
      collected.push(call);
320 447
    }
321 448
  }
322 449

@@ -509,3 +636,14 @@ function string(value: unknown): string | undefined {

509 636
function number(value: unknown): number {
510 637
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
511 638
}
639
640
/**
641
 * A call's arguments as an object.
642
 *
643
 * A model that emits invalid JSON must reach the tool anyway: the tool's own
644
 * refusal ("prompt is required") is a sentence the model can act on, and a
645
 * parse error thrown here would end the turn instead.
646
 */
647
function parseArguments(args: string): Record<string, unknown> {
648
  return parse(args) ?? {};
649
}
packages/openagents-cli/src/coder-tools.ts added +168

@@ -0,0 +1,168 @@

1
/**
2
 * Tools the session declares to the model.
3
 *
4
 * Delegation started as a `/delegate` line the reader typed. That was the wrong
5
 * shape: it made the reader the planner, it had to be remembered, and the model
6
 * — asked to split work across agents — would answer that it could not, because
7
 * as far as it knew that was true. A tool fixes both halves. The model asks for
8
 * a fan-out mid-sentence when the work is parallel, and the reader can say
9
 * "check these three things at once" in prose.
10
 *
11
 * The tool runtime is the client's. The inference proxy forwards the tool
12
 * declarations and returns the calls the model asks for; nothing runs
13
 * server-side. So this module is the whole contract: a JSON schema the model
14
 * reads, and a function that runs on this machine.
15
 */
16
17
import type { CoderDelegation } from "./coder-session.js";
18
import { describePrompt, MAX_DELEGATE_COUNT } from "./coder-delegate.js";
19
import type { DelegationOutcome } from "./coder-delegate.js";
20
21
/** One tool the model may call. */
22
export interface CoderTool {
23
  readonly name: string;
24
  /** What the model reads to decide whether to call it. */
25
  readonly description: string;
26
  /** JSON Schema for the arguments. */
27
  readonly parameters: Record<string, unknown>;
28
  /**
29
   * Run the call and return what the model should see.
30
   *
31
   * A tool reports a refusal as text rather than by throwing: the model can
32
   * act on "that needs a prompt" and cannot act on a turn that died.
33
   */
34
  run(args: Record<string, unknown>, signal: AbortSignal): Promise<string>;
35
}
36
37
/** How much of a child's answer the model is shown, per child. */
38
const CHILD_RESULT_LIMIT = 2_000;
39
40
/**
41
 * The delegate tool: run one prompt on many child coding agents at once.
42
 *
43
 * Awaited rather than launched and forgotten. A model that is told "three
44
 * children are running" has nothing to say next and will either invent their
45
 * findings or ask the reader to wait, so the call returns when the children
46
 * have answered and the answers are the tool's output. The fleet block keeps
47
 * moving while that happens, because children report through the registry and
48
 * the renderer reads the registry, not this call.
49
 */
50
export function delegateTool(delegation: CoderDelegation): CoderTool {
51
  return {
52
    name: "delegate",
53
    description:
54
      "Run one prompt on independent child coding agents in parallel, in this repository, and " +
55
      "return what each one found or did. Use it whenever work splits into parts that do not " +
56
      "depend on each other: several files to change the same way, several hypotheses to check, " +
57
      "several tests to run down. Each child is a full coding agent with its own file and shell " +
58
      "tools, it starts with no context from this conversation, and it cannot ask questions, so " +
59
      "the prompt has to be self-contained. Children run on this session's budget. Each child is " +
60
      'told which number it is out of the count, so a prompt may say "work on your own numbered ' +
61
      'file". Prefer one call with a count over several calls. At most ' +
62
      `${String(MAX_DELEGATE_COUNT)} children.`,
63
    parameters: {
64
      type: "object",
65
      properties: {
66
        prompt: {
67
          type: "string",
68
          description:
69
            "The complete, self-contained instruction every child performs. Name the files, the " +
70
            "command, and what to report back.",
71
        },
72
        count: {
73
          type: "integer",
74
          minimum: 1,
75
          maximum: MAX_DELEGATE_COUNT,
76
          description: "How many children run this prompt. Defaults to 1.",
77
        },
78
        description: {
79
          type: "string",
80
          description: "Three to five words naming the task, shown in the fleet.",
81
        },
82
      },
83
      required: ["prompt"],
84
      additionalProperties: false,
85
    },
86
    run: async (args, signal) => {
87
      const prompt = typeof args["prompt"] === "string" ? args["prompt"].trim() : "";
88
      if (prompt.length === 0) {
89
        return "No children were started: `prompt` is required and must say what the child does.";
90
      }
91
92
      const requested = typeof args["count"] === "number" ? Math.trunc(args["count"]) : 1;
93
      const count = Math.min(MAX_DELEGATE_COUNT, Math.max(1, requested));
94
      const described = typeof args["description"] === "string" ? args["description"].trim() : "";
95
      const description = described.length > 0 ? described : describePrompt(prompt);
96
97
      // An interrupted turn must not leave children spending. The reader's
98
      // escape key is the only stop signal a running fan-out has.
99
      const onAbort = () => delegation.registry.stopAll();
100
      signal.addEventListener("abort", onAbort, { once: true });
101
102
      try {
103
        const outcomes = await Promise.all(
104
          Array.from({ length: count }, (_unused, index) =>
105
            delegation.fleet.submit({
106
              description,
107
              prompt: identify(prompt, index + 1, count),
108
              background: true,
109
            }),
110
          ),
111
        );
112
        return report(outcomes, delegation);
113
      } finally {
114
        signal.removeEventListener("abort", onAbort);
115
      }
116
    },
117
  };
118
}
119
120
/**
121
 * Tell a child which of the fan-out it is.
122
 *
123
 * Every child gets the same prompt, so a prompt that says "your own file"
124
 * otherwise has no way to mean anything and the whole fleet writes the same
125
 * one. A single child is told nothing, because there is nothing to
126
 * distinguish.
127
 */
128
function identify(prompt: string, index: number, count: number): string {
129
  if (count === 1) return prompt;
130
  return `You are child ${String(index)} of ${String(count)}.\n\n${prompt}`;
131
}
132
133
/** Every child's outcome, in the order they were launched. */
134
function report(outcomes: ReadonlyArray<DelegationOutcome>, delegation: CoderDelegation): string {
135
  const lines: string[] = [];
136
  let completed = 0;
137
138
  for (const outcome of outcomes) {
139
    if (outcome.status === "refused") {
140
      lines.push(`refused (${outcome.code}): ${outcome.reason}`);
141
      continue;
142
    }
143
144
    const task = delegation.registry.get(outcome.taskId);
145
    const label = `${outcome.taskId}${task === undefined ? "" : ` ${task.description}`}`;
146
    if (outcome.status === "completed") {
147
      completed += 1;
148
      const result = outcome.result.trim();
149
      lines.push(
150
        `${label} completed:\n${result.length === 0 ? "(no output)" : clip(result, CHILD_RESULT_LIMIT)}`,
151
      );
152
    } else if (outcome.status === "failed") {
153
      lines.push(`${label} failed: ${outcome.error}`);
154
    } else {
155
      lines.push(`${label} stopped before finishing.`);
156
    }
157
    delegation.registry.markRead(outcome.taskId);
158
  }
159
160
  const header =
161
    `${String(completed)} of ${String(outcomes.length)} ` +
162
    `${outcomes.length === 1 ? "child" : "children"} completed on ${delegation.label}.`;
163
  return [header, "", ...lines].join("\n");
164
}
165
166
function clip(text: string, limit: number): string {
167
  return text.length <= limit ? text : `${text.slice(0, limit)}\n…[truncated]`;
168
}
packages/openagents-cli/test/coder-thread.test.ts modified +86

@@ -232,6 +232,9 @@ describe("ThreadReplySource", () => {

232 232
      ],
233 233
    });
234 234
235
    // A call the session cannot run is still reported, and the turn continues
236
    // with the refusal on the thread, because the alternative is a turn that
237
    // ends on a tool row and never answers.
235 238
    expect(await chunks(await open())).toEqual([
236 239
      {
237 240
        type: "tool_call",

@@ -239,7 +242,90 @@ describe("ThreadReplySource", () => {

239 242
        name: "repo_grep",
240 243
        arguments: `{"pattern":"thread"}`,
241 244
      },
245
      {
246
        type: "tool_result",
247
        callId: "call-1",
248
        output: undefined,
249
        error: "This session has no `repo_grep` tool.",
250
      },
251
      { type: "text", value: "Hello" },
252
      { type: "text", value: "!" },
253
      { type: "text", value: " Nice" },
254
    ]);
255
  });
256
257
  it("runs a declared tool and answers from its result", async () => {
258
    const calls = stub({
259
      proxy: [
260
        sse([
261
          [
262
            `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"delegate","arguments":"{\\"prompt\\":\\"add tests\\"}"}}]}}]}`,
263
            `data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`,
264
            `data: [DONE]`,
265
            "",
266
          ].join("\n\n"),
267
        ]),
268
        sse([
269
          [
270
            `data: {"choices":[{"delta":{"content":"Two children finished."},"index":0}]}`,
271
            `data: [DONE]`,
272
            "",
273
          ].join("\n\n"),
274
        ]),
275
      ],
276
    });
277
278
    const source = await open();
279
    const seen: Array<Record<string, unknown>> = [];
280
    source.useTools([
281
      {
282
        name: "delegate",
283
        description: "run children",
284
        parameters: { type: "object" },
285
        run: async (args) => {
286
          seen.push(args);
287
          return "2 of 2 children completed.";
288
        },
289
      },
242 290
    ]);
291
292
    const out = await chunks(source);
293
294
    expect(seen).toEqual([{ prompt: "add tests" }]);
295
    expect(out).toContainEqual({
296
      type: "tool_result",
297
      callId: "call-1",
298
      output: "2 of 2 children completed.",
299
      error: undefined,
300
    });
301
    expect(textOf(out)).toBe("Two children finished.");
302
303
    // The tool declaration reaches the model, and the exchange is on the
304
    // thread, or the next turn cannot see what its own call returned.
305
    const proxied = calls.filter((call) => call.url.includes("/inference/proxy"));
306
    const first = proxied[0];
307
    expect(first?.body["tools"]).toEqual([
308
      {
309
        type: "function",
310
        function: { name: "delegate", description: "run children", parameters: { type: "object" } },
311
      },
312
    ]);
313
    const second = proxied[1];
314
    expect(second?.body["messages"]).toEqual([
315
      { role: "user", content: "hello" },
316
      { role: "assistant", content: `[tool call]\ndelegate({"prompt":"add tests"})` },
317
      { role: "user", content: "[tool result delegate]\n2 of 2 children completed." },
318
    ]);
319
  });
320
321
  it("lends the grant to children without handing over the token", async () => {
322
    stub({});
323
    const grant = (await open()).childGrant;
324
    expect(grant.model).toBe("gpt-5.6-luna");
325
    expect(grant.proxyUrl).toBe(`${ORIGIN}/api/inference/proxy`);
326
    // Redacted, so an interpolation into a config or a command line cannot
327
    // print it.
328
    expect(String(grant.token)).not.toContain(GRANT_TOKEN);
243 329
  });
244 330
245 331
  it("reports a revoked grant with a sentence rather than a status", async () => {
packages/openagents-cli/test/coder-tools.test.ts added +224

@@ -0,0 +1,224 @@

1
import { mkdtempSync, readFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
5
import { describe, expect, it } from "vitest";
6
7
import { childHarnessConfig, writeChildHarnessConfig } from "../src/coder-child-config.js";
8
import { flattenForProxy } from "../src/coder-child-gateway.js";
9
import { DelegateFleet, MAX_DELEGATE_COUNT, parseOpencodeEvent } from "../src/coder-delegate.js";
10
import type { DelegateEvent, DelegateHarness } from "../src/coder-delegate.js";
11
import type { CoderDelegation } from "../src/coder-session.js";
12
import { CoderTaskRegistry } from "../src/coder-tasks.js";
13
import { delegateTool } from "../src/coder-tools.js";
14
15
const harness = (answer: string, options: { readonly fail?: string } = {}): DelegateHarness => ({
16
  agent: "fake",
17
  model: "fake/model",
18
  async *run(_input, _signal) {
19
    const events: ReadonlyArray<DelegateEvent> = [{ type: "text", value: answer }];
20
    for (const event of events) yield event;
21
    if (options.fail !== undefined) throw new Error(options.fail);
22
  },
23
});
24
25
const delegationOf = (agent: DelegateHarness): CoderDelegation => {
26
  const registry = new CoderTaskRegistry();
27
  const transcriptDirectory = mkdtempSync(join(tmpdir(), "tools-test-"));
28
  return {
29
    registry,
30
    fleet: new DelegateFleet(registry, agent, { maxConcurrent: 4, transcriptDirectory }),
31
    label: "fake (fake/model)",
32
  };
33
};
34
35
describe("delegateTool", () => {
36
  it("declares a schema whose only required field is the prompt", () => {
37
    const tool = delegateTool(delegationOf(harness("done")));
38
    expect(tool.name).toBe("delegate");
39
    expect(tool.parameters["required"]).toEqual(["prompt"]);
40
    // The description is what makes the model reach for it unprompted, so it
41
    // has to say that children are parallel and start with no context.
42
    expect(tool.description).toMatch(/parallel/);
43
    expect(tool.description).toMatch(/no context/);
44
  });
45
46
  it("refuses without a prompt in a sentence the model can act on", async () => {
47
    const tool = delegateTool(delegationOf(harness("done")));
48
    const output = await tool.run({}, new AbortController().signal);
49
    expect(output).toMatch(/`prompt` is required/);
50
  });
51
52
  it("runs the requested number of children and reports each one", async () => {
53
    const delegation = delegationOf(harness("wrote the file"));
54
    const tool = delegateTool(delegation);
55
56
    const output = await tool.run(
57
      { prompt: "write a file", count: 3, description: "write files" },
58
      new AbortController().signal,
59
    );
60
61
    expect(output).toMatch(/^3 of 3 children completed on fake \(fake\/model\)\./);
62
    expect(output.match(/completed:/g)).toHaveLength(3);
63
    expect(output).toMatch(/wrote the file/);
64
    expect(delegation.registry.list()).toHaveLength(3);
65
  });
66
67
  it("tells each child of a fan-out which one it is", async () => {
68
    const prompts: string[] = [];
69
    const delegation = delegationOf({
70
      agent: "fake",
71
      model: "fake/model",
72
      async *run(input, _signal) {
73
        prompts.push(input.prompt);
74
        yield { type: "text", value: "ok" };
75
      },
76
    });
77
78
    await delegateTool(delegation).run(
79
      { prompt: "write your own numbered file", count: 2 },
80
      new AbortController().signal,
81
    );
82
83
    expect(prompts).toEqual([
84
      "You are child 1 of 2.\n\nwrite your own numbered file",
85
      "You are child 2 of 2.\n\nwrite your own numbered file",
86
    ]);
87
  });
88
89
  it("leaves a lone child's prompt exactly as the model wrote it", async () => {
90
    const prompts: string[] = [];
91
    const delegation = delegationOf({
92
      agent: "fake",
93
      model: "fake/model",
94
      async *run(input, _signal) {
95
        prompts.push(input.prompt);
96
        yield { type: "text", value: "ok" };
97
      },
98
    });
99
100
    await delegateTool(delegation).run({ prompt: "run the tests" }, new AbortController().signal);
101
    expect(prompts).toEqual(["run the tests"]);
102
  });
103
104
  it("gives the model the child's own failure sentence", async () => {
105
    const tool = delegateTool(delegationOf(harness("", { fail: "provider refused the key" })));
106
    const output = await tool.run({ prompt: "write a file" }, new AbortController().signal);
107
    expect(output).toMatch(/0 of 1 child completed/);
108
    expect(output).toMatch(/failed: provider refused the key/);
109
  });
110
111
  it("clamps a count the model overshot rather than launching it", async () => {
112
    const delegation = delegationOf(harness("ok"));
113
    await delegateTool(delegation).run(
114
      { prompt: "write a file", count: 999 },
115
      new AbortController().signal,
116
    );
117
    expect(delegation.registry.list().length).toBe(MAX_DELEGATE_COUNT);
118
  });
119
120
  it("stops the fleet when the turn is interrupted", async () => {
121
    const delegation = delegationOf({
122
      agent: "fake",
123
      model: "fake/model",
124
      async *run(_input, signal) {
125
        yield { type: "text", value: "starting" };
126
        await new Promise<void>((resolve) => {
127
          signal.addEventListener("abort", () => resolve(), { once: true });
128
        });
129
      },
130
    });
131
    const controller = new AbortController();
132
    const running = delegateTool(delegation).run({ prompt: "wait" }, controller.signal);
133
    // Give the child a turn of the loop to start before interrupting it.
134
    await new Promise((resolve) => setTimeout(resolve, 10));
135
    controller.abort();
136
    const output = await running;
137
    expect(output).toMatch(/stopped before finishing/);
138
  });
139
});
140
141
describe("harness error reporting", () => {
142
  it("reads the sentence opencode nests under error.data", () => {
143
    const event = parseOpencodeEvent(
144
      JSON.stringify({
145
        type: "error",
146
        error: {
147
          name: "UnknownError",
148
          data: { message: "Unexpected server error.", ref: "err_089" },
149
        },
150
      }),
151
    );
152
    expect(event).toEqual({
153
      type: "error",
154
      message: "UnknownError: Unexpected server error. (err_089)",
155
    });
156
  });
157
158
  it("falls back to a sentence rather than reporting an empty error", () => {
159
    expect(parseOpencodeEvent(JSON.stringify({ type: "error" }))).toEqual({
160
      type: "error",
161
      message: "the child agent reported an error",
162
    });
163
  });
164
});
165
166
describe("child harness config", () => {
167
  it("points the child at the gateway and carries no credential", () => {
168
    const config = childHarnessConfig({ baseUrl: "http://127.0.0.1:1/v1", model: "luna" });
169
    const provider = (config["provider"] as Record<string, Record<string, unknown>>)["openagents"];
170
    expect((provider?.["options"] as Record<string, unknown>)["baseURL"]).toBe(
171
      "http://127.0.0.1:1/v1",
172
    );
173
    expect(JSON.stringify(config)).not.toMatch(/Bearer|oa_/);
174
    expect(Object.keys(provider?.["models"] as Record<string, unknown>)).toEqual(["luna"]);
175
  });
176
177
  it("writes a private file and removes it again", () => {
178
    const file = writeChildHarnessConfig({ baseUrl: "http://127.0.0.1:1/v1", model: "luna" });
179
    expect(JSON.parse(readFileSync(file.path, "utf8"))).toEqual(
180
      childHarnessConfig({ baseUrl: "http://127.0.0.1:1/v1", model: "luna" }),
181
    );
182
    file.remove();
183
    expect(() => readFileSync(file.path, "utf8")).toThrow();
184
  });
185
});
186
187
describe("flattenForProxy", () => {
188
  it("turns a tool exchange into turns the proxy accepts", () => {
189
    expect(
190
      flattenForProxy([
191
        { role: "system", content: "be brief" },
192
        { role: "user", content: "weather in Tokyo" },
193
        {
194
          role: "assistant",
195
          content: "",
196
          tool_calls: [
197
            { id: "call_1", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
198
          ],
199
        },
200
        { role: "tool", tool_call_id: "call_1", content: "18C" },
201
      ]),
202
    ).toEqual([
203
      { role: "system", content: "be brief" },
204
      { role: "user", content: "weather in Tokyo" },
205
      { role: "assistant", content: '[tool call]\nget_weather({"city":"Tokyo"}) id=call_1' },
206
      { role: "user", content: "[tool result call_1]\n18C" },
207
    ]);
208
  });
209
210
  it("reads part arrays and drops the empty turns a harness emits", () => {
211
    expect(
212
      flattenForProxy([
213
        {
214
          role: "user",
215
          content: [
216
            { type: "text", text: "one" },
217
            { type: "text", text: " two" },
218
          ],
219
        },
220
        { role: "assistant", content: "   " },
221
      ]),
222
    ).toEqual([{ role: "user", content: "one two" }]);
223
  });
224
});

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