Run delegated children on a thread of their own

c5e784ead893 · Devin AI · · parent 59194b586079

Run delegated children on a thread of their own

Children were handed the conversation's grant, so a reader who asked for
a fan-out on Ox Alpha got a fleet of Luna: the grant pins the model every
call through the proxy reaches, and that grant was pinned to whatever the
conversation opened with. The interface said one thing and the provider
did another.

A coder session now opens a second thread naming `ox-alpha` and lends
children that. The conversation keeps the thread it opened with, and a
fan-out spends a budget the reader's next question does not share.
`openThread` takes the model for this reason and no other — the proxy
refuses a model named in a request body, so opening a thread is the only
place one is chosen.

When that second thread is refused, delegation is off and the refusal is
what the session prints, code and sentence both. Falling back to the
conversation's grant is what produced the original complaint, so there is
no fallback: `ChildThread` is either `opened` or `refused`, and a refused
one is never a grant.

Also drops `--scope chat:account` from the copy that taught it. A plain
`openagents auth login` now mints that scope, so the instruction sends a
reader to fix something that is no longer broken.

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

Diff

4 files changed, +128 -10

packages/openagents-cli/src/cli.ts modified +85 -8

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

27 27
import { CoderTaskRegistry } from "./coder-tasks.js";
28 28
import { runCoderUi } from "./coder-ui.js";
29 29
import { backendIds } from "./coder-backends.js";
30
import { openThread, ThreadUnavailable } from "./coder-thread.js";
30
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
31 31
import { delegateTool } from "./coder-tools.js";
32 32
import { describeWorkspace } from "./coder-workspace.js";
33 33
import { ComputerClient } from "./computer-client.js";

@@ -649,7 +649,7 @@ const loginHeadlessFlag = Flag.boolean("headless").pipe(

649 649
const loginScopeFlag = Flag.string("scope").pipe(
650 650
  Flag.atLeast(0),
651 651
  Flag.withDescription(
652
    "Request a scope for the new token; repeatable. Omit to take the server's default. Use chat:account to reach the chat API from `openagents coder`.",
652
    "Request a scope for the new token; repeatable. Omit to take the server's default, which already reaches the chat API from `openagents coder`.",
653 653
  ),
654 654
);
655 655
const loginResumeFlag = Flag.boolean("resume").pipe(

@@ -1489,6 +1489,54 @@ const concurrencyFlag = Flag.integer("concurrency").pipe(

1489 1489
  Flag.withDescription("How many children may run at once. The rest queue"),
1490 1490
);
1491 1491
1492
/**
1493
 * The model delegated children run on.
1494
 *
1495
 * A child is a coding agent, and the model a session's own turns run on is
1496
 * chosen for conversation, so children are pinned to Ox Alpha rather than
1497
 * inheriting the parent's. It is a separate thread with its own budget, so a
1498
 * fan-out cannot spend the authority the conversation is holding, and the
1499
 * server issues it — no provider key reaches this process either way.
1500
 */
1501
const CHILD_THREAD_MODEL = "ox-alpha";
1502
1503
/** The children's thread, or the server's own words for why there is none. */
1504
type ChildThread =
1505
  | { readonly kind: "opened"; readonly thread: ThreadReplySource }
1506
  | { readonly kind: "refused"; readonly reason: string };
1507
1508
/**
1509
 * Open the thread children spend.
1510
 *
1511
 * A refusal is reported, never absorbed. Lending children the conversation's
1512
 * own grant instead would run them on the conversation's model while the
1513
 * interface said Ox Alpha, so a session that cannot open this thread delegates
1514
 * to nothing and says which refusal stopped it.
1515
 */
1516
async function openChildThread(options: {
1517
  readonly origin: string;
1518
  readonly token: string;
1519
  readonly objective: string;
1520
}): Promise<ChildThread> {
1521
  try {
1522
    return {
1523
      kind: "opened",
1524
      thread: await openThread({
1525
        origin: options.origin,
1526
        token: options.token,
1527
        objective: options.objective,
1528
        model: process.env["OPENAGENTS_DELEGATE_THREAD_MODEL"] ?? CHILD_THREAD_MODEL,
1529
      }),
1530
    };
1531
  } catch (cause) {
1532
    const reason =
1533
      cause instanceof ThreadUnavailable
1534
        ? `${cause.message} (${cause.code})`
1535
        : `The thread could not be opened: ${String(cause)}`;
1536
    return { kind: "refused", reason };
1537
  }
1538
}
1539
1492 1540
/** Delegation and whatever has to be torn down with it. */
1493 1541
interface DelegationSetup {
1494 1542
  readonly delegation: CoderDelegation;

@@ -1621,6 +1669,23 @@ const coderCommand = Command.make(

1621 1669
        : undefined;
1622 1670
1623 1671
      const source = thread ?? new DummyReplySource();
1672
1673
      // Children get their own thread on their own model. The conversation
1674
      // stays on the model it opened with, and a fan-out spends a budget the
1675
      // reader's next question does not share.
1676
      const childThread =
1677
        thread !== undefined && Option.isSome(stored)
1678
          ? yield* Effect.promise(() =>
1679
              openChildThread({
1680
                origin: endpoint.origin,
1681
                token: Redacted.value(stored.value.token),
1682
                objective: `delegated children of openagents coder in ${workspace.repository}`,
1683
              }),
1684
            )
1685
          : undefined;
1686
1687
      const childGrant = childThread?.kind === "opened" ? childThread.thread.childGrant : undefined;
1688
1624 1689
      const setup = yield* Effect.promise(() =>
1625 1690
        buildDelegation({
1626 1691
          model: Option.getOrUndefined(childModel),

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

1629 1694
          autoApprove: !childAsk,
1630 1695
          concurrency,
1631 1696
          cwd: process.cwd(),
1632
          grant: thread?.childGrant,
1697
          grant: childGrant,
1633 1698
        }),
1634 1699
      );
1635 1700

@@ -1648,10 +1713,17 @@ const coderCommand = Command.make(

1648 1713
        thread.useTools([delegateTool(setup.delegation)]);
1649 1714
      }
1650 1715
1716
      // Delegation is off rather than quietly running children on the
1717
      // conversation's model, so the refusal that turned it off is what the
1718
      // reader sees.
1719
      if (childThread?.kind === "refused") {
1720
        session.notice(`This session cannot delegate: ${childThread.reason}`);
1721
      }
1722
1651 1723
      if (Option.isNone(stored) && !offline) {
1652 1724
        session.notice(
1653 1725
          "No stored credential, so replies come from the built-in stand-in. " +
1654
            "Run `openagents auth login --scope chat:account` to reach a real model.",
1726
            "Run `openagents auth login` to reach a real model.",
1655 1727
        );
1656 1728
      }
1657 1729

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

1684 1756
          // session nobody is in. A process killed outright still leaves it to
1685 1757
          // the server's expiry reap.
1686 1758
          if (thread !== undefined) await thread.revoke();
1759
          if (childThread?.kind === "opened") await childThread.thread.revoke();
1687 1760
          if (setup !== undefined) await setup.close();
1688 1761
        }
1689 1762
      });

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

1694 1767
    }),
1695 1768
).pipe(
1696 1769
  Command.withDescription(
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",
1770
    "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 a thread of their own pinned to Ox Alpha, or launch a fan-out yourself with `/delegate [<n>x] <prompt>`, and the interface shows the fleet",
1698 1771
  ),
1699 1772
);
1700 1773

@@ -1766,6 +1839,9 @@ const delegateCommand = Command.make(

1766 1839
            )
1767 1840
          : Option.none();
1768 1841
1842
      // Every turn on this thread is a child's, so it is opened on the child
1843
      // model directly rather than opening one thread to hold and another to
1844
      // spend.
1769 1845
      const thread = Option.isSome(stored)
1770 1846
        ? yield* Effect.tryPromise({
1771 1847
            try: () =>

@@ -1773,6 +1849,7 @@ const delegateCommand = Command.make(

1773 1849
                origin: endpoint.origin,
1774 1850
                token: Redacted.value(stored.value.token),
1775 1851
                objective: `openagents delegate: ${describePrompt(prompt)}`,
1852
                model: process.env["OPENAGENTS_DELEGATE_THREAD_MODEL"] ?? CHILD_THREAD_MODEL,
1776 1853
              }),
1777 1854
            catch: (cause) => coderRefusal(endpoint.origin, cause),
1778 1855
          })

@@ -1793,9 +1870,9 @@ const delegateCommand = Command.make(

1793 1870
      if (setup === undefined) {
1794 1871
        return yield* new InputError({
1795 1872
          message:
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.",
1873
            "Nothing to run children on. Sign in with `openagents auth login` so " +
1874
            "children can spend a thread, or pass --child-model provider/model to run " +
1875
            "them on a provider of your own.",
1799 1876
        });
1800 1877
      }
1801 1878
      const delegation = setup.delegation;
packages/openagents-cli/src/coder-session.ts modified +1 -1

@@ -487,7 +487,7 @@ export class CoderSession {

487 487
    if (delegation === undefined) {
488 488
      this.notice(
489 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`, " +
490
          "session has none. Sign in with `openagents auth login`, " +
491 491
          "or start the session with `--child-model provider/model` to run children on a " +
492 492
          "provider of your own.",
493 493
      );
packages/openagents-cli/src/coder-thread.ts modified +12 -1

@@ -91,6 +91,16 @@ export interface ThreadOptions {

91 91
  readonly objective: string;
92 92
  /** Recorded on the thread as its admitted execution shape. */
93 93
  readonly reasoning?: string | undefined;
94
  /**
95
   * The model the thread's grant pins, and therefore the model every call
96
   * through the proxy reaches. Omitted, the server pins its default.
97
   *
98
   * This is the only place a model is chosen: the proxy refuses a model named
99
   * in a request body. So a session that wants its children on another model
100
   * opens a second thread naming it, with its own budget, rather than lending
101
   * them the authority its own turns spend.
102
   */
103
  readonly model?: string | undefined;
94 104
}
95 105
96 106
export class ThreadUnavailable extends Error {

@@ -125,6 +135,7 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

125 135
    body: JSON.stringify({
126 136
      objective: options.objective,
127 137
      ...(options.reasoning === undefined ? {} : { reasoning: options.reasoning }),
138
      ...(options.model === undefined ? {} : { model: options.model }),
128 139
    }),
129 140
  }).catch((cause: unknown) => {
130 141
    throw new ThreadUnavailable(

@@ -138,7 +149,7 @@ export async function openThread(options: ThreadOptions): Promise<ThreadReplySou

138 149
  if (response.status === 401 || response.status === 403) {
139 150
    throw new ThreadUnavailable(
140 151
      "scope_missing",
141
      "This token cannot open a thread. Sign in again with the chat:account scope.",
152
      "This token cannot open a thread. Run `openagents auth login` to sign in again.",
142 153
      response.status,
143 154
    );
144 155
  }
packages/openagents-cli/test/coder-thread.test.ts modified +30

@@ -129,6 +129,36 @@ describe("openThread", () => {

129 129
    expect(calls[0]?.body).toEqual({ objective: "coder in repo on main", reasoning: "high" });
130 130
  });
131 131
132
  it("names the model the thread's grant should pin, so children can run on another", async () => {
133
    const calls = stub({});
134
    await openThread({
135
      origin: ORIGIN,
136
      token: ACCOUNT_TOKEN,
137
      objective: "delegated children",
138
      model: "ox-alpha",
139
    });
140
141
    expect(calls[0]?.body).toEqual({ objective: "delegated children", model: "ox-alpha" });
142
  });
143
144
  it("lends children the model their own thread pinned, not the conversation's", async () => {
145
    stub({
146
      create: json(201, {
147
        ...CREATED,
148
        grant: { ...CREATED.grant, model: "ox-alpha" },
149
      }),
150
    });
151
152
    const source = await openThread({
153
      origin: ORIGIN,
154
      token: ACCOUNT_TOKEN,
155
      objective: "delegated children",
156
      model: "ox-alpha",
157
    });
158
159
    expect(source.childGrant.model).toBe("ox-alpha");
160
  });
161
132 162
  it("starts with the ceilings the grant was minted with", async () => {
133 163
    stub({});
134 164
    expect((await open()).budget).toBe("256 calls · 1.0M tok · $2.00");

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