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/README.md +117 -32
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +14 -20
- package/package.json +1 -1
- package/src/agent/agent.ts +355 -38
- package/src/agent/context.ts +3 -1
- package/src/agent/delegation.ts +208 -524
- package/src/agent/loop.ts +139 -18
- package/src/agent/subagents-tool.ts +87 -0
- package/src/agent/subagents.md +31 -0
- package/src/agent/turns.ts +60 -0
- package/src/agent/worker.ts +38 -30
- package/src/agent/workers.ts +405 -0
- package/src/index.ts +5 -3
- package/src/models/catalog-cache.ts +3 -10
- package/src/scheduler/scheduler.ts +2 -0
- package/src/scheduler/store.ts +34 -21
- package/src/session/journal.ts +212 -0
- package/src/session/store.ts +203 -90
- package/src/session/tool-log.ts +62 -0
- package/src/session/usage.ts +201 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/subagent-result.ts +1 -1
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +45 -6
- package/src/types/message.ts +4 -0
- package/src/types/response.ts +2 -0
- package/src/types/tool.ts +19 -7
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +15 -1
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
|
-
/**
|
|
405
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
...
|
|
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.
|
|
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 =
|
|
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
|
+
}
|
package/src/agent/worker.ts
CHANGED
|
@@ -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
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* `
|
|
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: {
|
|
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
|
-
|
|
272
|
-
stateless:
|
|
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
|
-
*
|
|
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
|