agent-accelerator 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent/loop.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  shouldBlockRepeat,
19
19
  toolCallFingerprint,
20
20
  traceToolEvent,
21
+ type ExecuteToolsOptions,
21
22
  } from "../tools/executor.ts";
22
23
  import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } from "../models/catalog.ts";
23
24
  import { enforceFileAccess } from "../utils/safe-io.ts";
@@ -70,6 +71,17 @@ export interface AgentLoopConfig {
70
71
  enforceBudget?: (usage?: TokenUsage) => void;
71
72
  /** Called after every turn for durable persistence checkpoints (never fails a turn). */
72
73
  onProgress?: () => void;
74
+ /** Called once per model call this agent made itself (never for its workers), with the provider's usage and the catalog spec: the source of `usageReport()`. */
75
+ onTurnUsage?: (usage: TokenUsage, spec: ModelSpec | undefined) => void;
76
+ /** Called the moment each tool call finishes, before the rest of its batch (the crash-proof record of finished work). */
77
+ onToolResult?: (result: ToolResultRecord) => void;
78
+ /** Opens the log file of one tool call (see `ToolLogs`); unset when the Agent does not persist to a folder. */
79
+ openToolLog?: ExecuteToolsOptions["openToolLog"];
80
+ /**
81
+ * Continue a run that a crash cut off instead of starting from a new prompt: tool calls the last assistant turn made
82
+ * without a stored result are finished first (a result in `stored` is reused, anything else runs now), then the loop goes on.
83
+ */
84
+ resume?: { stored: Map<string, ToolResultRecord> };
73
85
  /**
74
86
  * Shared steer inbox for mid-session redirection. Entries are drained
75
87
  * between turns (after the current provider/tool work finishes) and
@@ -401,16 +413,14 @@ async function generateWithTimeout<T>(task: () => Promise<T>, timeoutMs: number)
401
413
  }
402
414
  }
403
415
 
404
- /** Compat wrapper around the one cost model (`priceUsage` in session/store.ts, Q-36).
405
- * Prefers provider-reported totals, else catalog pricing with integer
406
- * micro-USD accumulation. Returns undefined when unknown (never a 0 object). */
407
- export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): TokenUsage["cost"] {
416
+ /** Prices one usage snapshot with the one cost model (`priceUsage`): provider-reported total first, else catalog pricing scaled by `serviceTier`; undefined when unknown. In: loop turns and `accumulateUsage`, out: `usage.cost`. */
417
+ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec, serviceTier?: string): TokenUsage["cost"] {
408
418
  try {
409
419
  if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
410
420
  return usage.cost;
411
421
  }
412
422
  if (!spec) return usage.cost;
413
- const priced = priceUsage(usage, spec);
423
+ const priced = priceUsage(usage, spec, serviceTier ? { serviceTier } : undefined);
414
424
  return priced
415
425
  ? {
416
426
  inputCost: priced.inputCost,
@@ -438,7 +448,7 @@ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): Tok
438
448
  // This function never mutates `source` (Q-35): the priced cost is cloned
439
449
  // into a local before folding into the target.
440
450
  // ---------------------------------------------------------------------------
441
- function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
451
+ export function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec, serviceTier?: string): void {
442
452
  target.inputTokens += source.inputTokens || 0;
443
453
  target.outputTokens += source.outputTokens || 0;
444
454
  target.totalTokens += source.totalTokens || 0;
@@ -465,7 +475,7 @@ function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpe
465
475
  });
466
476
  }
467
477
 
468
- const priced = computeCostFromPricing(source, spec);
478
+ const priced = computeCostFromPricing(source, spec, serviceTier);
469
479
  // Q-35: pure — read `source` only; never assign back to `source.cost`.
470
480
  // Clone the priced cost so later target folds cannot alias source state.
471
481
  const sourceCost = priced ? { ...priced } : undefined;
@@ -506,6 +516,8 @@ function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord;
506
516
  async function executeToolCallsWithRepeatGuard(options: {
507
517
  tools: Record<string, ToolDefinition>;
508
518
  toolCalls: ToolCallRecord[];
519
+ onResult?: (result: ToolResultRecord) => void;
520
+ openToolLog?: ExecuteToolsOptions["openToolLog"];
509
521
  previousFingerprints: Set<string>;
510
522
  repeatCounts: Map<string, number>;
511
523
  agentName?: string;
@@ -546,6 +558,8 @@ async function executeToolCallsWithRepeatGuard(options: {
546
558
  signal: options.signal,
547
559
  sessionId: options.sessionId,
548
560
  onSubagentEvent: options.onSubagentEvent,
561
+ onResult: options.onResult,
562
+ openToolLog: options.openToolLog,
549
563
  })
550
564
  : [];
551
565
  const byId = new Map<string, ToolResultRecord[]>();
@@ -572,6 +586,73 @@ async function executeToolCallsWithRepeatGuard(options: {
572
586
  };
573
587
  }
574
588
 
589
+ /** Tool calls of the LAST assistant message that have no result in the conversation yet (a crash or interrupt cut them off). */
590
+ export function unfinishedToolCalls(messages: AgentContext["messages"]): ToolCallRecord[] {
591
+ let lastAssistant = -1;
592
+ for (let i = messages.length - 1; i >= 0; i--) {
593
+ if (messages[i]!.role === "assistant") {
594
+ lastAssistant = i;
595
+ break;
596
+ }
597
+ }
598
+ const calls = lastAssistant < 0 ? [] : messages[lastAssistant]!.content;
599
+ if (!Array.isArray(calls)) return [];
600
+ const answered = new Set<string>();
601
+ for (const m of messages.slice(lastAssistant + 1)) {
602
+ if (m.role !== "tool" || !Array.isArray(m.content)) continue;
603
+ for (const p of m.content) if (p.type === "tool_result") answered.add(p.id);
604
+ }
605
+ const open: ToolCallRecord[] = [];
606
+ for (const p of calls) {
607
+ if (p.type === "tool_call" && !answered.has(p.id)) {
608
+ open.push({ id: p.id, name: p.name, arguments: p.arguments, ...(p.rawArguments ? { rawArguments: p.rawArguments } : {}), ...(p.callId ? { callId: p.callId } : {}) });
609
+ }
610
+ }
611
+ return open;
612
+ }
613
+
614
+ /** True when the conversation ends with tool results of a batch in which a tool called `ctx.stop` (the run was meant to end there). */
615
+ export function endsWithStop(messages: AgentContext["messages"]): boolean {
616
+ for (let i = messages.length - 1; i >= 0 && messages[i]!.role === "tool"; i--) {
617
+ const content = messages[i]!.content;
618
+ if (Array.isArray(content) && content.some((p) => p.type === "tool_result" && p.stopped === true)) return true;
619
+ }
620
+ return false;
621
+ }
622
+
623
+ // Finishes the tool calls a crash left open (stored result reused, anything else executed now) and records the results in the conversation; in: a resumed run's config, out: tool results in context + what the response should report.
624
+ async function finishInterruptedTools(
625
+ config: AgentLoopConfig
626
+ ): Promise<{ calls: ToolCallRecord[]; results: ToolResultRecord[]; subagents: SubAgentExecutionMetadata[] }> {
627
+ const open = unfinishedToolCalls(config.context.messages);
628
+ const none = { calls: [], results: [], subagents: [] };
629
+ if (open.length === 0 || !config.resume) return none;
630
+ const stored = config.resume.stored;
631
+ const toRun = open.filter((c) => !stored.has(c.id));
632
+ const executed = toRun.length
633
+ ? await executeToolCalls({
634
+ tools: config.tools,
635
+ toolCalls: toRun,
636
+ agentName: config.agentName,
637
+ parallel: true,
638
+ signal: config.runOptions?.signal,
639
+ sessionId: config.runOptions?.sessionId || config.options?.sessionId,
640
+ onResult: config.onToolResult,
641
+ openToolLog: config.openToolLog,
642
+ })
643
+ : [];
644
+ const ran = new Map(executed.map((r) => [r.id, r]));
645
+ const subagents: SubAgentExecutionMetadata[] = [];
646
+ const results = open.map((call) => {
647
+ const { sanitized, metas } = sanitizeToolResult(ran.get(call.id) ?? stored.get(call.id)!);
648
+ subagents.push(...metas);
649
+ return sanitized;
650
+ });
651
+ config.context.addToolResults(results);
652
+ emitProgress(config);
653
+ return { calls: open, results, subagents };
654
+ }
655
+
575
656
  /**
576
657
  * Runs a single non-streaming agent turn or multi-turn loop
577
658
  */
@@ -614,13 +695,28 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
614
695
  let finalResult: any = null;
615
696
  let turns = 0;
616
697
  let toolsExecuted = false;
698
+ let stoppedByTool = false;
617
699
  let previousToolCallFingerprints = new Set<string>();
618
700
  const repeatCounts = new Map<string, number>();
619
701
 
620
702
  try {
621
703
  const standardTools = toStandardToolDeclarations(tools);
622
704
 
623
- while (turns < maxTurns) {
705
+ if (config.resume) {
706
+ const finished = await finishInterruptedTools(config);
707
+ stoppedByTool = finished.results.some((r) => r.stopped) || endsWithStop(context.messages);
708
+ if (finished.calls.length > 0) {
709
+ toolsExecuted = true;
710
+ allToolCalls.push(...finished.calls);
711
+ allToolResults.push(...finished.results);
712
+ for (const meta of finished.subagents) {
713
+ allSubagents.push(meta);
714
+ accumulateUsage(accumulatedUsage, meta.usage, getModelFromCatalog(meta.provider, meta.model));
715
+ }
716
+ }
717
+ }
718
+
719
+ while (!stoppedByTool && turns < maxTurns) {
624
720
  // Q-21: fail fast on abort (throws AbortError with partial).
625
721
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
626
722
  checkDeadline(startTime, limits.deadlineMs);
@@ -703,8 +799,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
703
799
  }
704
800
  }
705
801
 
706
- accumulateUsage(accumulatedUsage, genResult.usage, spec);
707
- config.enforceBudget?.({ ...genResult.usage, cost: computeCostFromPricing(genResult.usage, spec) });
802
+ accumulateUsage(accumulatedUsage, genResult.usage, spec, options?.serviceTier);
803
+ try { config.onTurnUsage?.(genResult.usage, spec); } catch {}
804
+ config.enforceBudget?.({ ...genResult.usage, cost: computeCostFromPricing(genResult.usage, spec, options?.serviceTier) });
708
805
  checkTokenLimit(accumulatedUsage, limits.maxTotalTokens);
709
806
 
710
807
  // Record assistant turn in context
@@ -758,6 +855,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
758
855
  // Execute tool calls — always parallel (model-driven, bloatfree DX7)
759
856
  // Q-21: check abort before tools (throws AbortError with partial).
760
857
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
858
+ emitProgress(config); // the calls are on record BEFORE they run, so a crash can finish them instead of forgetting them
761
859
  allToolCalls.push(...genResult.toolCalls);
762
860
  // Q-22: per-run tool-call budget.
763
861
  if (allToolCalls.length > limits.maxToolCallsPerRun) {
@@ -774,6 +872,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
774
872
  agentName,
775
873
  signal: runOptions?.signal,
776
874
  sessionId: runOptions?.sessionId || options?.sessionId,
875
+ onResult: config.onToolResult,
876
+ openToolLog: config.openToolLog,
777
877
  });
778
878
  const results = guarded.results;
779
879
  previousToolCallFingerprints = guarded.fingerprints;
@@ -811,6 +911,12 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
811
911
  toolCalls: genResult.toolCalls,
812
912
  toolResults: sanitizedResults,
813
913
  });
914
+ // A tool ended the run (`ctx.stop`): the whole batch has finished, so stop here. Waiting steers become follow-ups.
915
+ if (sanitizedResults.some((r) => r.stopped)) {
916
+ stoppedByTool = true;
917
+ emitProgress(config);
918
+ break;
919
+ }
814
920
  // Steer drain AFTER tool results so call/result pairs stay adjacent.
815
921
  // The next loop iteration sends the injected user message to the model
816
922
  // within the same run (same usage/turn budget).
@@ -829,7 +935,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
829
935
  // results above were never sent back, so the run is truncated — surface
830
936
  // it via finishReason + warning instead of silently returning.
831
937
  const truncatedByMaxTurns =
832
- turns >= maxTurns && !!finalResult?.toolCalls && finalResult.toolCalls.length > 0;
938
+ !stoppedByTool && turns >= maxTurns && !!finalResult?.toolCalls && finalResult.toolCalls.length > 0;
833
939
  if (truncatedByMaxTurns) {
834
940
  warnMaxTurnsHit(
835
941
  agentName,
@@ -841,7 +947,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
841
947
  // Structured outputs: validate final text only (tool turns skip this).
842
948
  // Strict-fail throws ValidationError with partial attached via catch below.
843
949
  let parsedOutput: unknown | undefined;
844
- if (loopOutput.raw !== undefined && !truncatedByMaxTurns) {
950
+ if (loopOutput.raw !== undefined && !truncatedByMaxTurns && !stoppedByTool) {
845
951
  parsedOutput = parseFinalStructuredOutput(finalResult?.text || "", loopOutput.raw, loopOutput.spec);
846
952
  }
847
953
 
@@ -858,7 +964,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
858
964
  model: modelId,
859
965
  provider: provider.id,
860
966
  // Q-04: canonical finish reason + raw vendor passthrough.
861
- ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
967
+ ...(stoppedByTool
968
+ ? { finishReason: "stopped" as const, ...(finalResult?.finishReason ? { rawFinishReason: finalResult.finishReason } : {}) }
969
+ : canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns)),
862
970
  durationMs: Date.now() - startTime,
863
971
  raw: applyCaptureRaw(finalResult?.raw, config.captureRaw) as ProviderRawData,
864
972
  turns,
@@ -1016,6 +1124,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1016
1124
  turns = 0;
1017
1125
  let previousToolCallFingerprints = new Set<string>();
1018
1126
  const repeatCounts = new Map<string, number>();
1127
+ let stoppedByTool = false;
1019
1128
 
1020
1129
  while (turns < maxTurns) {
1021
1130
  throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
@@ -1144,8 +1253,9 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1144
1253
  }
1145
1254
  }
1146
1255
 
1147
- accumulateUsage(accumulatedUsage, turnResponse.usage, spec2);
1148
- config.enforceBudget?.({ ...turnResponse.usage, cost: computeCostFromPricing(turnResponse.usage, spec2) });
1256
+ accumulateUsage(accumulatedUsage, turnResponse.usage, spec2, options?.serviceTier);
1257
+ try { config.onTurnUsage?.(turnResponse.usage, spec2); } catch {}
1258
+ config.enforceBudget?.({ ...turnResponse.usage, cost: computeCostFromPricing(turnResponse.usage, spec2, options?.serviceTier) });
1149
1259
  checkTokenLimit(accumulatedUsage, streamLimits.maxTotalTokens);
1150
1260
 
1151
1261
  context.addAssistantMessage(
@@ -1198,6 +1308,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1198
1308
  }
1199
1309
 
1200
1310
  throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
1311
+ emitProgress(config); // the calls are on record BEFORE they run, so a crash can finish them instead of forgetting them
1201
1312
  allToolCalls.push(...turnResponse.toolCalls);
1202
1313
  if (allToolCalls.length > streamLimits.maxToolCallsPerRun) {
1203
1314
  throw new Error(
@@ -1219,6 +1330,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1219
1330
  agentName,
1220
1331
  signal: linked.signal,
1221
1332
  sessionId: runOptions?.sessionId || options?.sessionId,
1333
+ onResult: config.onToolResult,
1334
+ openToolLog: config.openToolLog,
1222
1335
  onSubagentEvent: (ev) => {
1223
1336
  try {
1224
1337
  outerStream.push({
@@ -1284,6 +1397,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1284
1397
  toolCalls: turnResponse.toolCalls,
1285
1398
  toolResults: sanitizedResults,
1286
1399
  });
1400
+ // A tool ended the run (`ctx.stop`): the whole batch has finished, so stop here. Waiting steers become follow-ups.
1401
+ if (sanitizedResults.some((r) => r.stopped)) {
1402
+ stoppedByTool = true;
1403
+ emitProgress(config);
1404
+ break;
1405
+ }
1287
1406
  const toolSteered = drainSteerInbox(context, config.steerInbox);
1288
1407
  if (toolSteered.length > 0) {
1289
1408
  for (const preview of toolSteered) {
@@ -1301,7 +1420,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1301
1420
  }
1302
1421
 
1303
1422
  const truncatedByMaxTurns =
1304
- turns >= maxTurns && !!lastResponse?.toolCalls && lastResponse.toolCalls.length > 0;
1423
+ !stoppedByTool && turns >= maxTurns && !!lastResponse?.toolCalls && lastResponse.toolCalls.length > 0;
1305
1424
  if (truncatedByMaxTurns) {
1306
1425
  warnMaxTurnsHit(
1307
1426
  agentName,
@@ -1309,10 +1428,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1309
1428
  lastResponse!.toolCalls.map((c) => c.name)
1310
1429
  );
1311
1430
  }
1312
- const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1431
+ const truncatedFinish = stoppedByTool
1432
+ ? { finishReason: "stopped" as const, ...(lastResponse?.finishReason ? { rawFinishReason: lastResponse.finishReason } : {}) }
1433
+ : canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1313
1434
 
1314
1435
  let streamParsed: unknown | undefined;
1315
- if (streamOutputRaw !== undefined && !truncatedByMaxTurns) {
1436
+ if (streamOutputRaw !== undefined && !truncatedByMaxTurns && !stoppedByTool) {
1316
1437
  try {
1317
1438
  streamParsed = parseFinalStructuredOutput(lastResponse?.text || "", streamOutputRaw, streamOutputSpec);
1318
1439
  } catch (err) {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The model-facing `subagents` tool: one flat tool with a `command` field (`spawn | message | close | list`), a thin
3
+ * wrapper over `agent.workers`. The tool manual lives in `subagents.md`, not in code. Results are plain text with one
4
+ * header line naming the worker; the branded envelope under them carries usage and lineage to the loop.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { z } from "zod";
8
+ import type { Agent } from "./agent.ts";
9
+ import { tool } from "../tools/tool.ts";
10
+ import { makeSubagentResult } from "../tools/subagent-result.ts";
11
+ import { ValidationError } from "../types/errors.ts";
12
+ import type { ToolDefinition } from "../types/tool.ts";
13
+ import type { WorkerReply } from "./workers.ts";
14
+
15
+ const COMMANDS = ["spawn", "message", "close", "list"] as const;
16
+
17
+ let manual: string | undefined;
18
+ /** Read on first use (not at import), so a program that never enables workers never touches the file. */
19
+ function loadManual(): string {
20
+ return (manual ??= readFileSync(new URL("./subagents.md", import.meta.url), "utf8"));
21
+ }
22
+
23
+ // One worker reply as the model sees it: a header line naming the worker, then its text; in: a WorkerReply, out: the branded result.
24
+ function asResult(reply: WorkerReply): ReturnType<typeof makeSubagentResult> {
25
+ const text = reply.error ? `[worker ${reply.id}] failed: ${reply.error}` : `[worker ${reply.id}] replied:\n${reply.text}`;
26
+ return makeSubagentResult(text, [reply.metadata]);
27
+ }
28
+
29
+ /** Builds the `subagents` tool for `agent`; its description is the manual with the worker pool and the open-worker limit filled in. */
30
+ export function createSubagentsTool(agent: Agent): ToolDefinition {
31
+ const poolNames = Object.keys(agent.dynamicSubagents?.tools ?? {});
32
+ const max = agent.dynamicSubagents?.maxSpawn ?? 4;
33
+ return tool({
34
+ name: "subagents",
35
+ description: loadManual()
36
+ .replace(/\{\{MAX\}\}/g, String(max))
37
+ .replace("{{TOOLS}}", poolNames.length > 0 ? poolNames.join(", ") : "none (omit tools)"),
38
+ input: z.object({
39
+ command: z.enum(COMMANDS),
40
+ id: z.string().optional().describe("The worker's full id (message, close)."),
41
+ name: z.string().optional().describe('Short label for a new worker, e.g. "researcher" (spawn).'),
42
+ role: z.string().optional().describe("Short persona for the worker (spawn)."),
43
+ instructions: z.string().optional().describe("The worker's own system prompt (spawn)."),
44
+ task: z.string().optional().describe("The assignment (spawn) or the next message to the worker (message)."),
45
+ tools: z.array(z.string()).optional().describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"} (spawn).`),
46
+ history: z.number().int().min(0).optional().describe("How many of the first finished turns of this conversation to copy into the worker; 0 or omitted = none (spawn)."),
47
+ }),
48
+ execute: async (a, ctx) => {
49
+ const opts = { signal: ctx.signal, callId: ctx.toolCallId, onSubagentEvent: ctx.onSubagentEvent };
50
+ const need = (field: "id" | "name" | "task"): string => {
51
+ const value = a[field];
52
+ if (!value?.trim()) {
53
+ const example = a.command === "spawn" ? { command: "spawn", name: "researcher", task: "Do the work." } : { command: a.command, id: "researcher-1a2b3c4d", task: "Next question." };
54
+ throw new ValidationError(`${a.command} needs "${field}". Example: ${JSON.stringify(example)}`);
55
+ }
56
+ return value;
57
+ };
58
+ try {
59
+ switch (a.command) {
60
+ case "spawn":
61
+ return asResult(
62
+ await agent.workers.spawn({ name: need("name"), role: a.role, instructions: a.instructions, task: need("task"), tools: a.tools, history: a.history }, opts)
63
+ );
64
+ case "message":
65
+ return asResult(await agent.workers.message(need("id"), need("task"), opts));
66
+ case "close":
67
+ return `Worker ${agent.workers.close(need("id")).id} closed.`;
68
+ case "list":
69
+ return describeWorkers(agent);
70
+ }
71
+ } catch (err) {
72
+ // A bad request is feedback for the model; anything else (budget, configuration) is a real failure.
73
+ if (err instanceof ValidationError) return `Error: ${err.message}`;
74
+ throw err;
75
+ }
76
+ },
77
+ });
78
+ }
79
+
80
+ // The `list` answer: the open workers and the conversation turns `history` can copy; in: the agent's registry, out: plain text for the model.
81
+ function describeWorkers(agent: Agent): string {
82
+ const workers = agent.workers.list();
83
+ const turns = agent.workers.turns();
84
+ const open = workers.length === 0 ? "none" : workers.map((w) => `- ${w.id} (${w.name}${w.role ? `, ${w.role}` : ""}) tools: ${w.tools.join(", ") || "none"}${w.busy ? " [busy]" : ""}`).join("\n");
85
+ const copyable = turns.length === 0 ? "none yet" : turns.map((t) => `${t.n}. ${t.prompt}`).join("\n");
86
+ return `Open workers:\n${open}\n\nFinished turns of this conversation (history: N copies turns 1 to N):\n${copyable}`;
87
+ }
@@ -0,0 +1,31 @@
1
+ Starts helpers ("workers") that work on a task for you and can be asked again later. A worker is a separate agent with its own instructions, its own tools and its own memory: it knows only what you give it (plus the earlier conversation you choose to copy). Use workers for parallel research, a second opinion, or a long side task you do not want in your own context.
2
+
3
+ COMMANDS (one tool, the `command` field)
4
+ - spawn: start a worker. Needs `name` and `task`. Optional: `instructions` (the worker's own system prompt), `role`, `tools`, `history`. It waits for the worker's first reply and gives you the worker's `id`.
5
+ - message: send `task` to the open worker `id`. The worker remembers everything said to it before. It waits for the reply.
6
+ - close: end the worker `id` when you no longer need it. This frees one of your {{MAX}} open slots. A closed worker cannot be messaged again.
7
+ - list: your open workers, plus the numbered turns of this conversation that `history` can copy.
8
+
9
+ FIELDS
10
+ - name: a short label such as "researcher". The SDK adds a suffix; use the full `id` it returns ("researcher-1a2b3c4d") in message and close.
11
+ - instructions: the worker's own system prompt: its role, rules and the format you want back.
12
+ - task: for spawn, the first assignment; for message, the next message to that worker.
13
+ - tools: names of worker tools to grant. Available: {{TOOLS}}. Grant only what the task needs.
14
+ - history: a number N (default 0 = none). Starts the worker with a copy of the first N finished turns of this conversation, in order. Call list to see the turns and their numbers. The turn you are in right now is never copied: restate what you need in `task`.
15
+
16
+ RESULTS
17
+ Every spawn and message answers with one header line and the worker's text: `[worker <id>] replied:` or `[worker <id>] failed: <reason>`. A failed worker stays open: fix the problem and message it again, or close it.
18
+
19
+ EXAMPLES
20
+ Start a researcher:
21
+ {"command":"spawn","name":"researcher","instructions":"You research databases. Answer in 5 bullets with sources.","task":"Compare SQLite and Postgres for a small SaaS."}
22
+ Ask it a follow-up:
23
+ {"command":"message","id":"researcher-1a2b3c4d","task":"Now focus on write-heavy workloads."}
24
+
25
+ RULES
26
+ - Several workers at once: make several spawn calls in the same turn. They run in parallel.
27
+ - A worker handles one call at a time. Wait for its reply before messaging it again.
28
+ - You can have at most {{MAX}} open workers. Close the ones you are done with.
29
+ - A worker starts blank: put everything it needs in `instructions` and `task`, or copy earlier turns with `history`.
30
+ - Worker answers are written by another model. Check them before you rely on them.
31
+ - Never put secrets in `instructions` or `task`.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Turn grouping: a "turn" is one finished run (the user prompt plus the whole answer to it: text, thinking, tool calls
3
+ * and results), found through the run marks the Agent keeps. A worker that starts from a copy of its parent's history
4
+ * gets whole finished turns only, so a prompt never reaches it without its answer. Pure functions, no Agent import.
5
+ */
6
+ import type { Message, ContentPart } from "../types/message.ts";
7
+ import type { SessionRunMark } from "../session/store.ts";
8
+ import { ValidationError } from "../types/errors.ts";
9
+ import { previewPrompt } from "../utils/session.ts";
10
+ import { deepCloneValue } from "./context.ts";
11
+
12
+ /** One finished turn: its 1-based number (counting finished turns only), a prompt preview, and its messages `[from, to)`. */
13
+ export interface Turn {
14
+ n: number;
15
+ prompt: string;
16
+ from: number;
17
+ to: number;
18
+ }
19
+
20
+ /** Longest prompt preview shown to a model in a turn list. */
21
+ const PREVIEW_CHARS = 200;
22
+
23
+ // A run counts as finished when it ended with its answer (`done`) or with a tool's `ctx.stop` (`stopped`); in: a run mark, out: finishedTurns.
24
+ function isFinished(mark: SessionRunMark): boolean {
25
+ return mark.end === "done" || mark.end === "stopped";
26
+ }
27
+
28
+ /** The finished turns of a conversation, oldest first. The running turn and failed, aborted or abandoned runs are not turns. */
29
+ export function finishedTurns(messages: Message[], marks: SessionRunMark[]): Turn[] {
30
+ const sorted = marks.filter((m) => m.i >= 0 && m.i < messages.length).sort((a, b) => a.i - b.i);
31
+ const turns: Turn[] = [];
32
+ sorted.forEach((mark, k) => {
33
+ if (!isFinished(mark)) return;
34
+ turns.push({
35
+ n: turns.length + 1,
36
+ prompt: previewPrompt(messages[mark.i]!.content as string | ContentPart[]).slice(0, PREVIEW_CHARS),
37
+ from: mark.i,
38
+ to: sorted[k + 1]?.i ?? messages.length,
39
+ });
40
+ });
41
+ return turns;
42
+ }
43
+
44
+ /**
45
+ * A deep copy of the first `n` finished turns (plus anything before the first run mark), in order. `0` gives an empty
46
+ * history. Throws a `ValidationError` naming how many finished turns exist when `n` is not a whole number in range.
47
+ */
48
+ export function historyUpTo(messages: Message[], marks: SessionRunMark[], n: number): Message[] {
49
+ const turns = finishedTurns(messages, marks);
50
+ if (!Number.isInteger(n) || n < 0 || n > turns.length) {
51
+ throw new ValidationError(
52
+ `history: ${JSON.stringify(n)} is not valid; use "none" or a whole number from 1 to ${turns.length} (the number of finished turns).`
53
+ );
54
+ }
55
+ if (n === 0) return [];
56
+ const first = marks.filter((m) => m.i >= 0 && m.i < messages.length).reduce((lo, m) => Math.min(lo, m.i), messages.length);
57
+ const kept = [...messages.slice(0, first)];
58
+ for (const turn of turns.slice(0, n)) kept.push(...messages.slice(turn.from, turn.to));
59
+ return deepCloneValue(kept);
60
+ }
@@ -42,11 +42,6 @@ export function sanitizeToolName(raw: string): string {
42
42
  return tag.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-{2,}/g, "-").slice(0, 64) || "sub-agent";
43
43
  }
44
44
 
45
- /** Stable worker ids: `{sanitizedName}-{32hex}` display id + raw tracking id. */
46
- export function makeWorkerIds(sanitizedName: string, trackingId: string): { trackingId: string; displayId: string } {
47
- return { trackingId, displayId: `${sanitizedName}-${trackingId}` };
48
- }
49
-
50
45
  // ---------------------------------------------------------------------------
51
46
  // Shared Agent / SubAgent config normalization (behavior-preserving)
52
47
  // ---------------------------------------------------------------------------
@@ -203,11 +198,6 @@ export function grantWorkerTools(
203
198
  return grantedTools;
204
199
  }
205
200
 
206
- /** Resolves a provider id for credential reuse / error metadata (shared). */
207
- export function providerOfWorkerModel(modelStr: string | unknown | undefined): string | undefined {
208
- return providerOf(modelStr);
209
- }
210
-
211
201
  function providerOf(modelStr: string | unknown | undefined): string | undefined {
212
202
  if (!modelStr) return undefined;
213
203
  try {
@@ -246,18 +236,34 @@ export function resolveWorkerCredentials(
246
236
  return { apiKeyToUse: undefined, baseUrlToUse: undefined, attemptedProvider };
247
237
  }
248
238
 
239
+ /** How many levels of workers may exist below an agent unless `dynamicSubagents.maxDepth` says otherwise. */
240
+ export const DEFAULT_MAX_DEPTH = 3;
241
+
249
242
  /**
250
- * Config of a stateless dynamic worker (`spawn_subagents` and scheduled
251
- * actions): the developer's fixed worker model, level and limits, never the
252
- * LLM's choice. Credentials carry over only when the providers match, and
253
- * `inheritableConfig()` keeps the worker no less constrained than its parent.
243
+ * Config of a dynamic worker (scheduled actions, the worker registry): the developer's fixed worker
244
+ * model, level and limits, never the LLM's choice. Credentials carry over only when the providers match, and
245
+ * `inheritableConfig()` keeps the worker no less constrained than its parent. Stateless one-shot by default;
246
+ * `persistent` makes a multi-turn worker that keeps its history (saved under `persistent.dir` when given, and
247
+ * continued by `resume()` after a crash, never automatically). A persistent worker with levels left below it
248
+ * (`maxDepth` of the parent, minus one for itself) gets its own `subagents` tool; its children can only be granted
249
+ * the tools it was granted. One-shot workers never spawn. `dynamicSubagents.workerBudget` becomes each worker's own
250
+ * per-run `budget` (and is passed to its children).
254
251
  */
255
252
  export function buildDynamicWorkerConfig(
256
253
  parent: Agent,
257
- worker: { name: string; role?: string; instructions: string; sessionId: string; tools: Record<string, ToolDefinition> }
254
+ worker: {
255
+ name: string;
256
+ role?: string;
257
+ instructions: string;
258
+ sessionId: string;
259
+ tools: Record<string, ToolDefinition>;
260
+ persistent?: { dir?: string };
261
+ }
258
262
  ): AgentConfig {
259
263
  const model = parent.subagentModel;
260
264
  const { apiKeyToUse, baseUrlToUse } = resolveWorkerCredentials(parent, model);
265
+ const dyn = parent.dynamicSubagents;
266
+ const levelsBelow = (dyn?.maxDepth ?? DEFAULT_MAX_DEPTH) - 1;
261
267
  return {
262
268
  name: worker.name,
263
269
  description: worker.role || `Sub-agent ${worker.name}`,
@@ -268,8 +274,22 @@ export function buildDynamicWorkerConfig(
268
274
  thinkingLevel: parent.dynamicSubagents?.thinkingLevel ?? parent.thinkingConfig?.level,
269
275
  sessionId: worker.sessionId,
270
276
  tools: worker.tools,
271
- // Stateless by design: one task in, one result out, then shut down. No history, no recursion.
272
- stateless: true,
277
+ ...(dyn?.workerBudget ? { budget: { ...dyn.workerBudget } } : {}),
278
+ stateless: !worker.persistent,
279
+ ...(worker.persistent?.dir ? { persist: { dir: worker.persistent.dir }, resume: "explicit" as const } : {}),
280
+ ...(worker.persistent && levelsBelow >= 1
281
+ ? {
282
+ dynamicSubagents: {
283
+ enabled: true,
284
+ model,
285
+ ...(dyn?.maxSpawn !== undefined ? { maxSpawn: dyn.maxSpawn } : {}),
286
+ ...(dyn?.thinkingLevel ? { thinkingLevel: dyn.thinkingLevel } : {}),
287
+ tools: worker.tools,
288
+ ...(dyn?.workerBudget ? { workerBudget: dyn.workerBudget } : {}),
289
+ maxDepth: levelsBelow,
290
+ },
291
+ }
292
+ : {}),
273
293
  serviceTier: parent.serviceTier,
274
294
  headers: parent.customHeaders,
275
295
  maxTurns: parent.maxTurns,
@@ -277,18 +297,6 @@ export function buildDynamicWorkerConfig(
277
297
  };
278
298
  }
279
299
 
280
- /** Builds one `<NAME>...text...</NAME>` block inside `<SUB-AGENTS-RESPONSE>`. */
281
- export function buildWorkerXmlBlock(name: string, text: string): string {
282
- const escaped = escapeXml(text);
283
- const indentedText = escaped.split("\n").map((line) => ` ${line}`).join("\n");
284
- return ` <${name}>\n${indentedText}\n </${name}>`;
285
- }
286
-
287
- /** Builds the `<SUB-AGENTS-RESPONSE>` envelope for dynamic workers. */
288
- export function buildSubagentsXml(results: Array<{ name: string; text: string }>): string {
289
- return `<SUB-AGENTS-RESPONSE>\n${results.map((r) => buildWorkerXmlBlock(r.name, r.text)).join("\n")}\n</SUB-AGENTS-RESPONSE>`;
290
- }
291
-
292
300
  // ---------------------------------------------------------------------------
293
301
  // Shared live worker state machine (single home for fixed + dynamic workers)
294
302
  // ---------------------------------------------------------------------------
@@ -322,7 +330,7 @@ export interface LiveWorkerHooks {
322
330
 
323
331
  /**
324
332
  * Single live worker state machine shared by fixed (`agentToTool`) and
325
- * dynamic (`spawn_subagents`) workers. Owns `liveTurn` / `partialText` /
333
+ * registry workers. Owns `liveTurn` / `partialText` /
326
334
  * `partialThinking`, finalizes partial streaming entries on `onTurn`, and
327
335
  * forwards deltas to the parent stream. The trace object is mutated in place;
328
336
  * trimming / touching / notifying / persisting are delegated to hooks so this