char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somsubagent: tighten default prompt prose056cf36

main
28.5 KiB705 linesraw
1import type { Context, JsonValue } from "@earendil-works/chord";
2import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
3import { type ModelThinkingLevel, Type } from "@earendil-works/pi-ai";
4import {
5  AgentDoc,
6  AssistantEntry,
7  type ConversationId,
8  type Cursor,
9  defineDoc,
10  defineExtension as defineDurableExtension,
11  defineTask,
12  defineTool,
13  type Harness,
14  type ModelRef,
15  section,
16  type TaskId,
17  type TaskOutcome,
18  type ToolExecutionApi,
19  type Tx,
20} from "@earendil-works/pi-durable";
21import { defineExtension } from "@berry/api";
22import { EventEntry } from "./entries.ts";
23import { startTool } from "./tool-timing.ts";
24
25export const SUBAGENT = "subagent";
26export const SUBAGENT_DESCRIPTION = "delegate tasks to blocking subagents";
27
28const PROSE_DEFAULT = "subagents:\n" + [
29  "work locally by default; delegate only for parallelizable work, independent review, " +
30  "cheaper-model tasks, or noisy exploration you want kept out of your context",
31  "subagents share the working tree: don't run parallel workers that edit overlapping files " +
32  "or mutate VCS state",
33  "when continuing a `conversation`, omit `model` and `thinkingLevel` so its prompt cache " +
34  "stays warm",
35  "background subagents report back on their own—don't poll `subagent_wait` unless you are " +
36  "blocked on the result, and `subagent_stop` work you no longer need",
37].map((g) => `- ${g}`).join("\n");
38
39const Call = defineDoc<{ delegation?: { id: TaskId<string>; child: ConversationId } }>({
40  kind: "berry.delegation-call",
41  version: 1,
42  scope: "task",
43  initial: () => ({}),
44  checkpointWhen: () => true,
45});
46
47/** How a subagent's turn ended. */
48type Answered = { status: "completed"; answer: string } | { status: "failed"; error: string };
49type Settled = Answered | { status: "stopped" };
50
51/** The input a delegation sends its subagent, which a retry finds again by its request ID. */
52const turn = (task: { id: TaskId<string>; input: { task: string } }) => ({
53  type: "input" as const,
54  content: task.input.task,
55  requestId: `delegation:${task.id}`,
56});
57
58/** A background delegation's report to its parent: the entry the web shows, and what the model reads. */
59const reportOf = (tid: string, task: string, outcome: Settled) => ({
60  kind: "berry.subagent-report",
61  data: { tid, task, ...outcome },
62  content: outcome.status === "completed"
63    ? `Background subagent ${tid} finished its task.\nTask: ${task}\n\n${outcome.answer}\n\n(conversation ${tid}; pass it as conversation to continue it)`
64    : outcome.status === "failed"
65    ? `Background subagent ${tid} failed: ${outcome.error}\nTask: ${task}\n\n(conversation ${tid})`
66    : `Background subagent ${tid} stopped.\nTask: ${task}\n\n(conversation ${tid})`,
67});
68
69const terminal = (outcome: Answered) => ({
70  status: "terminal" as const,
71  outcome: outcome.status === "completed"
72    ? { status: "completed" as const, result: outcome.answer }
73    : { status: "failed" as const, error: { message: outcome.error } },
74});
75
76type SubagentArgs = {
77  task: string;
78  /** The subagent conversation to continue, by its TID. */
79  conversation?: string;
80  model?: ModelRef;
81  thinkingLevel?: ModelThinkingLevel;
82  /** Run without waiting, reporting back when finished. */
83  background?: boolean;
84};
85
86type Seed = {
87  model: ModelRef;
88  thinkingLevel: ModelThinkingLevel;
89  extensions: string[];
90  events: { ext: string; event: JsonValue }[];
91};
92
93/** What a conversation delegates to: its worker models (none means those enabled in settings), its
94 * background limit, and the background delegations it started. */
95export type Subagent = {
96  models: ModelRef[];
97  /** How many background delegations may run at once. */
98  limit: number;
99  /** Background delegations this conversation started, by their child conversation id. */
100  children: Record<string, Child>;
101};
102type Child = {
103  task: string;
104  /** The delegation task driving it, by id; absent before ids were tracked. */
105  delegation?: string;
106  model: ModelRef;
107  startedAt: number;
108  settled?: { status: Settled["status"]; answer?: string; error?: string };
109};
110type SubagentEvent =
111  | { type: "models"; models: ModelRef[] }
112  | { type: "limit"; limit: number }
113  | {
114    type: "spawned";
115    conversationId: number;
116    task: string;
117    delegation?: string;
118    model: ModelRef;
119    startedAt: number;
120  }
121  | {
122    type: "settled";
123    conversationId: number;
124    status: Settled["status"];
125    answer?: string;
126    error?: string;
127  };
128
129const keyOf = (model: ModelRef) => `${model.provider}/${model.modelId}`;
130
131type CallOptions = {
132  signal?: AbortSignal;
133  progress?(details: JsonValue): void;
134};
135
136/** Delegation, blocking or in the background, with one owned task and transcript per invocation. */
137export function subagentExtension(host: {
138  harness: Harness;
139  /** The models enabled in settings. */
140  enabled(): Promise<readonly ModelRef[]>;
141  workers(id: ConversationId): Promise<Subagent>;
142  title(tx: Tx, id: ConversationId, task: string): Promise<void>;
143  /** Editable delegation guidance, with `$USER` replaced and blank prose omitted. */
144  prose(): Promise<string | undefined>;
145  /** A new subagent's conversation, on `model`. */
146  prepare(
147    api: ToolExecutionApi,
148    model: ModelRef,
149    thinkingLevel: ModelThinkingLevel | undefined,
150    context: Context,
151  ): Promise<Seed>;
152  /** The level `model` runs `requested` at. */
153  supported(model: ModelRef, requested: ModelThinkingLevel): ModelThinkingLevel;
154  /** A conversation by its TID, with the TID of the conversation that owns it. */
155  resolve(tid: string): Promise<{ id: ConversationId; owner?: string } | undefined>;
156  tidOf(id: ConversationId): Promise<string>;
157}) {
158  async function offered(id: ConversationId) {
159    const { models } = await host.workers(id);
160    return models.length > 0 ? models : await host.enabled();
161  }
162
163  /** A subagent's turn: the first in a conversation it owns, or one more in a conversation it continues. */
164  const Delegation = defineTask<
165    { task: string; child?: ConversationId },
166    { phase: "run" } | { phase: "report"; outcome: Answered; child: ConversationId },
167    string
168  >({
169    name: "berry.delegation",
170    version: 1,
171    initial: () => ({ phase: "run" }),
172    phases: {
173      async run(task, runtime, context) {
174        let child = task.input.child;
175        if (child === undefined) {
176          await runtime.commit(async (tx) => {
177            const record = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0];
178            if (!record) throw new Error("Delegation has no child conversation");
179            child = record.id;
180          }, context);
181        }
182        const conversation = (await runtime.conversation(child!, context))!;
183        const settled = await (await conversation.submit(turn(task), context)).wait(context);
184        // the child's answer lives in its own conversation, so read it where any entry is visible;
185        // a background settle lands in the same commit, before the report it will send
186        await runtime.commit(async (tx) => {
187          let outcome: Answered;
188          if (settled.status !== "done" || settled.type !== "input") {
189            outcome = {
190              status: "failed",
191              error: settled.status === "unanswered" ? settled.reason : settled.status,
192            };
193          } else {
194            const message = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0];
195            if (message?.role === "assistant") {
196              const answer = message.content.flatMap((part) =>
197                part.type === "text" ? [part.text] : []
198              );
199              outcome = { status: "completed", answer: answer.join("\n") };
200            } else if (task.background) {
201              outcome = { status: "failed", error: "Subagent has no answer" };
202            } else throw new Error("Subagent has no answer");
203          }
204          if (!task.background) return terminal(outcome);
205          await tx.appendEntry(EventEntry, runtime.conversationId, {
206            data: { ext: SUBAGENT, event: { type: "settled", conversationId: child!, ...outcome } },
207          });
208          return { status: "running", checkpoint: { phase: "report", outcome, child: child! } };
209        }, context);
210      },
211      async report(task, runtime, context) {
212        const { outcome, child } = task.state.checkpoint;
213        const { content, ...entry } = reportOf(await host.tidOf(child), task.input.task, outcome);
214        const parent = (await runtime.conversation(runtime.conversationId, context))!;
215        await parent.submit({
216          type: "input",
217          content,
218          entry,
219          whenBusy: "steer",
220          requestId: `subagent-report:${task.id}`,
221        }, context);
222        await runtime.commit(() => terminal(outcome), context);
223      },
224    },
225    async abort(task, runtime, context) {
226      // a continued conversation is owned by its first delegation: nothing else stops its turn when
227      // only this call is cancelled, as when a script ends without awaiting it
228      const { child } = task.input;
229      if (child !== undefined) {
230        try {
231          let pending = false;
232          await runtime.commit(async (tx) => {
233            const status = (await tx.submissionByRequest(child, turn(task).requestId))?.status;
234            pending = status === "queued" || status === "placed";
235          }, context);
236          if (pending) {
237            const conversation = (await runtime.conversation(child, context))!;
238            const submission = await conversation.submit(turn(task), context);
239            if ((await submission.abort(context)) === "already_placed") {
240              await conversation.abort(context);
241            }
242          }
243        } catch (error) {
244          // a stop still leaves its note and settles
245          if (!task.background) throw error;
246        }
247      }
248      if (!task.background) {
249        await runtime.commit(
250          () => ({ status: "terminal", outcome: { status: "aborted" } }),
251          context,
252        );
253        return;
254      }
255      // a child the delegation created was stopped with it, as work it owns
256      let stopped = child;
257      if (stopped === undefined) {
258        await runtime.commit(async (tx) => {
259          stopped = (await tx.scanConversations({ ownerTaskId: task.id }, 1)).items[0]?.id;
260        }, context).catch(() => {});
261      }
262      const tid = stopped === undefined
263        ? String(task.id)
264        : await host.tidOf(stopped).catch(() => String(stopped));
265      const { content, ...entry } = reportOf(tid, task.input.task, { status: "stopped" });
266      await runtime.commit(async (tx) => {
267        await tx.appendEntry(runtime.conversationId, {
268          ...entry,
269          model: [{ role: "user", content, timestamp: Date.now() }],
270        });
271        if (stopped !== undefined) {
272          await tx.appendEntry(EventEntry, runtime.conversationId, {
273            data: {
274              ext: SUBAGENT,
275              event: { type: "settled", conversationId: stopped, status: "stopped" },
276            },
277          });
278        }
279        return { status: "terminal", outcome: { status: "aborted" } };
280      }, context);
281    },
282  });
283
284  /** The configuration of which models a conversation delegates to, which the harness folds as an extension's. */
285  const workers = defineExtension<Subagent, SubagentEvent>({
286    name: SUBAGENT,
287    description: SUBAGENT_DESCRIPTION,
288    sortOrder: 30,
289    initial: () => ({ models: [], limit: 4, children: {} }),
290    apply: (state, event) => {
291      switch (event.type) {
292        case "models":
293          return { ...state, models: event.models };
294        case "limit":
295          return { ...state, limit: event.limit };
296        case "spawned": {
297          const { type: _, conversationId, ...child } = event;
298          return { ...state, children: { ...state.children, [conversationId]: child } };
299        }
300        case "settled": {
301          const { type: _, conversationId, ...settled } = event;
302          const child = state.children[conversationId];
303          if (!child) return state;
304          return {
305            ...state,
306            children: { ...state.children, [conversationId]: { ...child, settled } },
307          };
308        }
309      }
310    },
311    setup(ext) {
312      ext.promptProse({ default: PROSE_DEFAULT });
313      ext.configuration({
314        parameters: Type.Object({
315          models: Type.Array(Type.String(), {
316            title: "worker models",
317            default: [],
318            description: "permitted subagent models (empty = all globally-enabled models)",
319          }),
320          limit: Type.Integer({
321            title: "background subagent limit",
322            default: 4,
323            minimum: 1,
324            description: "how many background delegations may run at once",
325          }),
326        }),
327        values: ({ models, limit }) => ({ models: models.map(keyOf), limit }),
328        async events({ models: keys, limit }, state) {
329          const known = new Map(
330            [...state.models, ...await host.enabled()].map((model) => [keyOf(model), model]),
331          );
332          const models = [...new Set(keys)].map((key) => {
333            const model = known.get(key);
334            if (!model) throw new Error(`${key} is not enabled in settings`);
335            return { provider: model.provider, modelId: model.modelId };
336          });
337          return [{ type: "models", models }, { type: "limit", limit }];
338        },
339      });
340    },
341  });
342
343  async function invoke(
344    args: SubagentArgs,
345    api: ToolExecutionApi,
346    context: Context,
347    options: CallOptions,
348    reuse: boolean,
349  ): Promise<
350    | { conversation: string; status: "running" }
351    | { conversation: string; status: "done"; answer: string }
352  > {
353    const signal = options.signal ?? context.abortSignal;
354    const { task, background = false } = args;
355    const during = options.signal ? withAbortSignal(options.signal, context) : context;
356    let delegation: { id: TaskId<string>; child: ConversationId } | undefined;
357    try {
358      signal?.throwIfAborted();
359      await api.commit(async (tx) => {
360        if ((await tx.conversation(api.conversationId))?.owner) {
361          throw new Error("Only top-level agents can delegate");
362        }
363        if (reuse) {
364          const stored = (await tx.doc(Call, api.taskId)).delegation;
365          if (stored) delegation = { id: stored.id, child: stored.child };
366        }
367      }, during);
368      if (!delegation) {
369        const offers = await offered(api.conversationId);
370        const require = (model: ModelRef) => {
371          if (!offers.some((m) => keyOf(m) === keyOf(model))) {
372            throw new Error(`${keyOf(model)} is not enabled for delegation`);
373          }
374        };
375        const { conversation, model, thinkingLevel } = args;
376        const limit = background ? (await host.workers(api.conversationId)).limit : undefined;
377        let target: { seed: Seed } | { child: ConversationId };
378        if (conversation === undefined) {
379          if (!model) throw new Error("model is required to start a subagent");
380          require(model);
381          target = { seed: await host.prepare(api, model, thinkingLevel, during) };
382        } else {
383          const found = await host.resolve(conversation);
384          if (found?.owner !== await host.tidOf(api.conversationId)) {
385            throw new Error(`${conversation} is not one of this conversation's subagents`);
386          }
387          target = { child: found.id };
388        }
389        await api.commit(async (tx) => {
390          // the cap holds within the commit that would exceed it
391          if (limit !== undefined) {
392            let live = 0;
393            let cursor: Cursor | undefined;
394            do {
395              const page = await tx.scanTasks(
396                { conversationId: api.conversationId, kind: "berry.delegation", background: true },
397                100,
398                cursor,
399              );
400              live += page.items.filter((record) => record.state.status !== "terminal").length;
401              cursor = page.next;
402            } while (cursor !== undefined);
403            if (live >= limit) {
404              throw new Error(
405                `Subagent limit reached (${limit} running); use subagent_wait or subagent_stop first.`,
406              );
407            }
408          }
409          // a background delegation belongs to the conversation, not this call, so it outlives
410          // the call, the script, and the parent's abort without keeping the parent busy
411          const id = await tx.createTask(
412            Delegation,
413            "child" in target ? { task, child: target.child } : { task },
414            background
415              ? {
416                ownership: { kind: "conversation" },
417                conversationId: api.conversationId,
418                background: true,
419              }
420              : { ownership: { kind: "task", taskId: api.taskId } },
421          );
422          const child = "child" in target
423            ? target.child
424            : (await tx.createConversation({ ownership: { kind: "task", taskId: id } })).id;
425          const agent = await tx.doc(AgentDoc, child);
426          const worker = "seed" in target ? target.seed.model : model ?? agent.model;
427          if (!worker) throw new Error(`${conversation} has no model`);
428          require(worker);
429          if ("seed" in target) {
430            const { seed } = target;
431            agent.model = seed.model;
432            agent.thinkingLevel = seed.thinkingLevel;
433            agent.extensions = {
434              add: seed.extensions.filter((name) => name !== SUBAGENT),
435              remove: [SUBAGENT],
436            };
437            for (const event of seed.events) {
438              await tx.appendEntry(EventEntry, child, { data: event });
439            }
440          } else if (model || thinkingLevel) {
441            agent.model = { provider: worker.provider, modelId: worker.modelId };
442            agent.thinkingLevel = host.supported(
443              worker,
444              thinkingLevel ?? agent.thinkingLevel ?? "off",
445            );
446          }
447          if (background) {
448            await tx.appendEntry(EventEntry, api.conversationId, {
449              data: {
450                ext: SUBAGENT,
451                event: {
452                  type: "spawned",
453                  conversationId: child,
454                  task,
455                  delegation: String(id),
456                  model: { provider: worker.provider, modelId: worker.modelId },
457                  startedAt: Date.now(),
458                },
459              },
460            });
461          }
462          await host.title(tx, child, task);
463          delegation = { id, child };
464          if (reuse) (await tx.doc(Call, api.taskId)).delegation = delegation;
465        }, during);
466      }
467      const { id, child } = delegation!;
468      const tid = await host.tidOf(child);
469      const details = { tid, conversationId: child, ...(background ? { status: "running" } : {}) };
470      if (options.progress) options.progress(details);
471      else await api.details(details, during);
472      if (background) return { conversation: tid, status: "running" };
473      const { state: { outcome } } = await api.waitForTask(id, during);
474      if (outcome.status !== "completed") {
475        throw new Error(
476          `Subagent failed: ${"error" in outcome ? outcome.error?.message : outcome.status}\n\n` +
477            `(subagent conversation ${tid}; pass it as conversation to continue)`,
478        );
479      }
480      return { conversation: tid, status: "done", answer: outcome.result };
481    } finally {
482      // a nested call's cancellation stops its work, not merely its wait; a background
483      // spawn returning early is the whole point, so it never aborts its child
484      if (!background && delegation && signal?.aborted && !context.abortSignal?.aborted) {
485        await host.harness.abortTask(delegation.id, BACKGROUND_CONTEXT);
486        await host.harness.waitForTask(delegation.id, BACKGROUND_CONTEXT);
487      }
488    }
489  }
490
491  /** This conversation's background subagents by TID: those named, or every live one. */
492  async function targets(api: ToolExecutionApi, conversations: string[] | undefined) {
493    const { children } = await host.workers(api.conversationId);
494    const found: [string, Child][] = [];
495    if (conversations === undefined) {
496      for (const [id, child] of Object.entries(children)) {
497        if (!child.settled) found.push([await host.tidOf(Number(id) as ConversationId), child]);
498      }
499      return found;
500    }
501    const owner = await host.tidOf(api.conversationId);
502    for (const tid of conversations) {
503      const resolved = await host.resolve(tid);
504      const child = resolved?.owner === owner ? children[resolved.id] : undefined;
505      if (!child) throw new Error(`${tid} is not one of this conversation's background subagents`);
506      found.push([tid, child]);
507    }
508    return found;
509  }
510
511  const waited = (conversation: string, settled: NonNullable<Child["settled"]>) =>
512    settled.status === "completed"
513      ? { conversation, status: "done", answer: settled.answer ?? "" }
514      : settled.status === "failed"
515      ? { conversation, status: "failed", error: settled.error ?? "failed" }
516      : { conversation, status: "stopped" };
517
518  const settledOf = (outcome: TaskOutcome<string>): Settled =>
519    outcome.status === "completed"
520      ? { status: "completed", answer: outcome.result }
521      : outcome.status === "aborted"
522      ? { status: "stopped" }
523      : { status: "failed", error: outcome.error?.message ?? outcome.reason ?? outcome.status };
524
525  const tool = defineTool({
526    name: SUBAGENT,
527    description:
528      "Do the task yourself by default; delegate only when the benefit outweighs handoff overhead. " +
529      "Delegate a self-contained task to a worker model, or follow up in a subagent's conversation. " +
530      "No parent transcript or further delegation; shared files. Returns the final answer and the " +
531      "subagent's conversation.",
532    parameters: Type.Object({
533      task: Type.String({
534        minLength: 1,
535        description: "Task and all needed context, or a follow-up to a continued conversation.",
536      }),
537      conversation: Type.Optional(Type.String({
538        minLength: 1,
539        description: "Subagent conversation to continue, from an earlier result.",
540      })),
541      model: Type.Optional(Type.Object({
542        provider: Type.String({ minLength: 1 }),
543        modelId: Type.String({ minLength: 1 }),
544      }, {
545        description:
546          "Enabled worker model; required unless continuing, which keeps the subagent's.",
547      })),
548      thinkingLevel: Type.Optional(Type.Union([
549        Type.Literal("off"),
550        Type.Literal("minimal"),
551        Type.Literal("low"),
552        Type.Literal("medium"),
553        Type.Literal("high"),
554        Type.Literal("xhigh"),
555        Type.Literal("max"),
556      ], {
557        description: "Inherits the parent's, or a continued subagent's, when omitted; clamped to " +
558          "worker support.",
559      })),
560      background: Type.Optional(Type.Boolean({
561        description: "Start in the background and return at once; the report arrives as a message.",
562      })),
563    }),
564    replay: "safe",
565    executionMode: "parallel",
566    execute: async (args, api, context) => {
567      await startTool(api, context);
568      const result = await invoke(args, api, context, {}, true);
569      const text = result.status === "running"
570        ? `spawned subagent ${result.conversation} in the background; ` +
571          "its report arrives as a message when it finishes"
572        : `${result.answer}\n\n` +
573          `(subagent conversation ${result.conversation}; pass it as conversation to continue)`;
574      return { output: [{ type: "text", text }] };
575    },
576  });
577  return {
578    extension: defineDurableExtension({
579      name: SUBAGENT,
580      tasks: [Delegation],
581      tools: [tool],
582      sections: [
583        section(SUBAGENT, async ({ conversationId }) => {
584          const prose = await host.prose();
585          const lines = [
586            "Enabled models:",
587            ...(await offered(conversationId)).map((model) => JSON.stringify(model)),
588          ];
589          const { children, limit } = await host.workers(conversationId);
590          const live = Object.entries(children).filter(([, child]) => !child.settled)
591            .sort(([, a], [, b]) => a.startedAt - b.startedAt);
592          if (live.length > 0) lines.push(`Background subagents (${live.length}/${limit}):`);
593          // no elapsed time: this is the system prompt, whose every change costs a cold prompt cache
594          for (const [id, child] of live) {
595            const [first] = child.task.split("\n");
596            lines.push(
597              `- ${await host.tidOf(Number(id) as ConversationId)}: ` +
598                `${first.length > 80 ? `${first.slice(0, 80)}…` : first} ` +
599                `(${keyOf(child.model)}, running)`,
600            );
601          }
602          const dynamic = lines.join("\n");
603          return prose ? `${prose}\n${dynamic}` : dynamic;
604        }, { tag: false }),
605      ],
606    }),
607    workers,
608    /** What scripts may call: delegation, and the waiting and stopping the model is never offered directly. */
609    scripted: [{
610      declaration: {
611        name: tool.name,
612        extension: SUBAGENT,
613        description: tool.description,
614        parameters: tool.parameters,
615        returns: Type.Object({
616          conversation: Type.String(),
617          status: Type.Union([Type.Literal("done"), Type.Literal("running")]),
618          answer: Type.Optional(Type.String()),
619        }),
620        direct: true,
621      },
622      call: (args: SubagentArgs, api: ToolExecutionApi, context: Context, options: CallOptions) =>
623        invoke(args, api, context, options, false),
624    }, {
625      declaration: {
626        name: "subagent_wait",
627        extension: SUBAGENT,
628        description: "Wait for background subagents to finish and collect their answers; " +
629          "a timeout only ends the wait, never the work.",
630        parameters: Type.Object({
631          conversations: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
632            description: "Background subagents to wait for; default every live one.",
633          })),
634          timeoutMs: Type.Optional(Type.Number({
635            minimum: 1,
636            description: "How long to wait before returning running ones; default 600000.",
637          })),
638        }),
639        returns: Type.Array(Type.Object({
640          conversation: Type.String(),
641          status: Type.Union([
642            Type.Literal("done"),
643            Type.Literal("running"),
644            Type.Literal("failed"),
645            Type.Literal("stopped"),
646          ]),
647          answer: Type.Optional(Type.String()),
648          error: Type.Optional(Type.String()),
649        })),
650        direct: false,
651      },
652      call: async (
653        args: { conversations?: string[]; timeoutMs?: number },
654        api: ToolExecutionApi,
655        context: Context,
656      ) => {
657        const wanted = await targets(api, args.conversations);
658        const timed = withAbortSignal(AbortSignal.timeout(args.timeoutMs ?? 600_000), context);
659        return await Promise.all(wanted.map(async ([tid, child]) => {
660          if (child.settled) return waited(tid, child.settled);
661          // a spawn from before ids were tracked has nothing to wait on
662          if (child.delegation === undefined) return { conversation: tid, status: "running" };
663          const id = Number(child.delegation) as TaskId<string>;
664          try {
665            return waited(tid, settledOf((await api.waitForTask(id, timed)).state.outcome));
666          } catch (error) {
667            if (context.abortSignal?.aborted) throw error;
668            // the timeout may have raced the end of the work
669            const known = await api.getTask(id, context).catch(() => undefined);
670            return known?.state.status === "terminal"
671              ? waited(tid, settledOf(known.state.outcome))
672              : { conversation: tid, status: "running" };
673          }
674        }));
675      },
676    }, {
677      declaration: {
678        name: "subagent_stop",
679        extension: SUBAGENT,
680        description: "Stop background subagents; their note arrives without starting a run.",
681        parameters: Type.Object({
682          conversations: Type.Optional(Type.Array(Type.String({ minLength: 1 }), {
683            description: "Background subagents to stop; default every live one.",
684          })),
685        }),
686        returns: Type.Array(Type.Object({
687          conversation: Type.String(),
688          status: Type.Literal("stopped"),
689        })),
690        direct: false,
691      },
692      call: async (args: { conversations?: string[] }, api: ToolExecutionApi, context: Context) => {
693        const wanted = await targets(api, args.conversations);
694        const ids = wanted.flatMap(([, child]) =>
695          child.settled || child.delegation === undefined
696            ? []
697            : [Number(child.delegation) as TaskId<string>]
698        );
699        for (const id of ids) await host.harness.abortTask(id, context);
700        await Promise.all(ids.map((id) => host.harness.waitForTask(id, context)));
701        return wanted.map(([conversation]) => ({ conversation, status: "stopped" }));
702      },
703    }],
704  };
705}