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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One log file per tool call, inside the session folder: `tool-logs/<n>-<tool>.ndjson`. The executor writes its own
|
|
3
|
+
* steps into it (input, attempts, retries, result or error, duration) and a tool adds its own with `ctx.log(...)`.
|
|
4
|
+
* Every line is flushed to disk as it is written, so after a crash the file shows how far the call got. The file is for
|
|
5
|
+
* developers and harnesses: it is never sent to the model, and nothing in the SDK reads it back.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { fsyncFolder } from "../utils/durable.ts";
|
|
10
|
+
|
|
11
|
+
/** The log of one tool call, as the executor sees it. */
|
|
12
|
+
export interface ToolLogWriter {
|
|
13
|
+
/** Path relative to the session folder (`tool-logs/003-send_email.ndjson`); saved on the call's result. */
|
|
14
|
+
readonly file: string;
|
|
15
|
+
/** Appends one line (`timestamp` is added) and flushes it. Never throws. */
|
|
16
|
+
write(entry: Record<string, unknown>): void;
|
|
17
|
+
/** Closes the file; later writes (a tool still running after a timeout) are ignored. */
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Hands out the log files of one session folder, numbering them in the order the calls start. */
|
|
22
|
+
export class ToolLogs {
|
|
23
|
+
private next: number | undefined;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly sessionDir: string) {}
|
|
26
|
+
|
|
27
|
+
// Opens `tool-logs/<n>-<tool>.ndjson` for one call; the numbering continues after files from earlier runs and restarts; in: a tool call as the executor starts it, out: the writer the executor and `ctx.log` write through.
|
|
28
|
+
open(call: { name: string }): ToolLogWriter | undefined {
|
|
29
|
+
const dir = path.join(this.sessionDir, "tool-logs");
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
this.next ??= fs.readdirSync(dir).reduce((max, f) => Math.max(max, Number(/^(\d+)-/.exec(f)?.[1] ?? 0)), 0) + 1;
|
|
33
|
+
const name = `${String(this.next++).padStart(3, "0")}-${call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)}.ndjson`;
|
|
34
|
+
const fd = fs.openSync(path.join(dir, name), "a", 0o600);
|
|
35
|
+
fsyncFolder(dir);
|
|
36
|
+
let open = true;
|
|
37
|
+
return {
|
|
38
|
+
file: `tool-logs/${name}`,
|
|
39
|
+
write(entry) {
|
|
40
|
+
if (!open) return;
|
|
41
|
+
try {
|
|
42
|
+
fs.writeSync(fd, JSON.stringify({ timestamp: Date.now(), ...entry }) + "\n");
|
|
43
|
+
fs.fdatasyncSync(fd);
|
|
44
|
+
} catch {
|
|
45
|
+
// why: a full disk must never fail the tool call being logged.
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
close() {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
open = false;
|
|
51
|
+
try {
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
} catch {
|
|
54
|
+
// why: nothing left to flush.
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined; // logging is best effort: the call runs without a log
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one agent spent on ITS OWN model calls, kept exactly: tokens as the provider reported them, cost in integer
|
|
3
|
+
* micro-USD priced per turn (so cache hits, cache writes and price tiers are right), plus where each turn's price came
|
|
4
|
+
* from. Every agent records its own turns and saves them in its session; `usageReport()` then adds the agent and its
|
|
5
|
+
* workers up WITHOUT counting anything twice (a parent's `AgentResponse.usage` already contains its workers' usage, so
|
|
6
|
+
* responses are never summed). Pure functions, no Agent import.
|
|
7
|
+
*/
|
|
8
|
+
import type { TokenUsage } from "../types/core.ts";
|
|
9
|
+
import type { PricedCost } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
/** Raw totals of one agent's own model calls, as saved in its session (`usage`). */
|
|
12
|
+
export interface UsageTotals {
|
|
13
|
+
/** Model calls made. */
|
|
14
|
+
turns: number;
|
|
15
|
+
/** Input tokens; they INCLUDE the cache reads and writes. */
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
cacheWriteTokens: number;
|
|
19
|
+
/** Output tokens; they INCLUDE the thinking tokens. */
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
thinkingTokens: number;
|
|
22
|
+
/** Integer micro-USD, summed over the turns that had a price. */
|
|
23
|
+
costMicroUSD: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
|
24
|
+
/** Turns by where their price came from: the provider's reported cost, the catalog, or nowhere (unknown pricing). */
|
|
25
|
+
turnsBy: { provider: number; catalog: number; unpriced: number };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A zeroed {@link UsageTotals}. */
|
|
29
|
+
export function emptyUsage(): UsageTotals {
|
|
30
|
+
return {
|
|
31
|
+
turns: 0,
|
|
32
|
+
inputTokens: 0,
|
|
33
|
+
cacheReadTokens: 0,
|
|
34
|
+
cacheWriteTokens: 0,
|
|
35
|
+
outputTokens: 0,
|
|
36
|
+
thinkingTokens: 0,
|
|
37
|
+
costMicroUSD: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
38
|
+
turnsBy: { provider: 0, catalog: 0, unpriced: 0 },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const count = (n: unknown): number => (typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.floor(n) : 0);
|
|
43
|
+
|
|
44
|
+
/** A model reference (string or catalog spec) as a plain string for a report line. */
|
|
45
|
+
export function modelName(model: unknown): string {
|
|
46
|
+
return typeof model === "string" ? model : String((model as { id?: unknown } | null | undefined)?.id ?? "unknown");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Adds one model call (`priced` from `priceUsage`; undefined = unknown pricing, which is counted but never turned into $0). */
|
|
50
|
+
export function addTurn(totals: UsageTotals, usage: TokenUsage, priced: PricedCost | undefined): void {
|
|
51
|
+
const input = count(usage.inputTokens);
|
|
52
|
+
const output = count(usage.outputTokens);
|
|
53
|
+
totals.turns += 1;
|
|
54
|
+
totals.inputTokens += input;
|
|
55
|
+
totals.outputTokens += output;
|
|
56
|
+
totals.cacheReadTokens += Math.min(count(usage.cachedTokens ?? usage.cacheReadTokens), input);
|
|
57
|
+
totals.cacheWriteTokens += Math.min(count(usage.cacheWriteTokens), input);
|
|
58
|
+
totals.thinkingTokens += Math.min(count(usage.thinkingTokens), output);
|
|
59
|
+
if (!priced) {
|
|
60
|
+
totals.turnsBy.unpriced += 1;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
totals.turnsBy[priced.source === "provider" ? "provider" : "catalog"] += 1;
|
|
64
|
+
const b = priced.breakdown;
|
|
65
|
+
totals.costMicroUSD.input += b.inputMicroUSD;
|
|
66
|
+
totals.costMicroUSD.output += b.outputMicroUSD + b.audioMicroUSD;
|
|
67
|
+
totals.costMicroUSD.cacheRead += b.cacheReadMicroUSD;
|
|
68
|
+
totals.costMicroUSD.cacheWrite += b.cacheWriteMicroUSD;
|
|
69
|
+
totals.costMicroUSD.total += b.totalMicroUSD;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Adds `other` into `into` (workers into a total). */
|
|
73
|
+
export function mergeUsage(into: UsageTotals, other: UsageTotals): void {
|
|
74
|
+
for (const k of ["turns", "inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens", "thinkingTokens"] as const) into[k] += other[k];
|
|
75
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) into.costMicroUSD[k] += other.costMicroUSD[k];
|
|
76
|
+
for (const k of ["provider", "catalog", "unpriced"] as const) into.turnsBy[k] += other.turnsBy[k];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Reads saved totals back defensively (a hand-edited or older file never breaks a load); unusable input gives zeros. */
|
|
80
|
+
export function readUsage(saved: unknown): UsageTotals {
|
|
81
|
+
const out = emptyUsage();
|
|
82
|
+
const s = saved as Partial<UsageTotals> | null | undefined;
|
|
83
|
+
if (!s || typeof s !== "object") return out;
|
|
84
|
+
for (const k of ["turns", "inputTokens", "cacheReadTokens", "cacheWriteTokens", "outputTokens", "thinkingTokens"] as const) out[k] = count(s[k]);
|
|
85
|
+
for (const k of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) out.costMicroUSD[k] = count(s.costMicroUSD?.[k]);
|
|
86
|
+
for (const k of ["provider", "catalog", "unpriced"] as const) out.turnsBy[k] = count(s.turnsBy?.[k]);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Where a total's price came from: `provider` (the provider's reported cost), `catalog`, `mixed` (both), or `unknown` (no turn had a price). */
|
|
91
|
+
export type CostSource = "provider" | "catalog" | "mixed" | "unknown";
|
|
92
|
+
|
|
93
|
+
/** One agent's (or the whole session's) spending in a form made to be read: tokens, cache hit rate, cost. */
|
|
94
|
+
export interface UsageLine {
|
|
95
|
+
/** Model calls made. */
|
|
96
|
+
turns: number;
|
|
97
|
+
tokens: {
|
|
98
|
+
/** Everything sent: `freshInput + cacheRead + cacheWrite`. */
|
|
99
|
+
input: number;
|
|
100
|
+
/** Input that was neither read from nor written to the cache. */
|
|
101
|
+
freshInput: number;
|
|
102
|
+
cacheRead: number;
|
|
103
|
+
cacheWrite: number;
|
|
104
|
+
/** Everything generated, thinking included. */
|
|
105
|
+
output: number;
|
|
106
|
+
/** The part of `output` that was thinking. */
|
|
107
|
+
thinking: number;
|
|
108
|
+
total: number;
|
|
109
|
+
};
|
|
110
|
+
/** `cacheRead / input` from 0 to 1; `null` when nothing was sent. */
|
|
111
|
+
cacheHitRate: number | null;
|
|
112
|
+
cost: {
|
|
113
|
+
source: CostSource;
|
|
114
|
+
/** Turns with no known price: when above 0 the totals are a lower bound. */
|
|
115
|
+
unpricedTurns: number;
|
|
116
|
+
/** USD; `null` when no turn had a price (unknown is never shown as 0). */
|
|
117
|
+
totalUsd: number | null;
|
|
118
|
+
/** The parts, in USD. For turns whose cost the provider reported, only the total is exact: the parts then add up to less than `totalUsd`. Absent when unknown. */
|
|
119
|
+
parts?: { inputUsd: number; outputUsd: number; cacheReadUsd: number; cacheWriteUsd: number };
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Turns raw totals into a readable {@link UsageLine}. */
|
|
124
|
+
export function usageLine(t: UsageTotals): UsageLine {
|
|
125
|
+
const priced = t.turnsBy.provider + t.turnsBy.catalog;
|
|
126
|
+
const source: CostSource = priced === 0 ? "unknown" : t.turnsBy.provider > 0 && t.turnsBy.catalog > 0 ? "mixed" : t.turnsBy.provider > 0 ? "provider" : "catalog";
|
|
127
|
+
const usd = (micro: number): number => micro / 1_000_000;
|
|
128
|
+
const c = t.costMicroUSD;
|
|
129
|
+
return {
|
|
130
|
+
turns: t.turns,
|
|
131
|
+
tokens: {
|
|
132
|
+
input: t.inputTokens,
|
|
133
|
+
freshInput: Math.max(0, t.inputTokens - t.cacheReadTokens - t.cacheWriteTokens),
|
|
134
|
+
cacheRead: t.cacheReadTokens,
|
|
135
|
+
cacheWrite: t.cacheWriteTokens,
|
|
136
|
+
output: t.outputTokens,
|
|
137
|
+
thinking: t.thinkingTokens,
|
|
138
|
+
total: t.inputTokens + t.outputTokens,
|
|
139
|
+
},
|
|
140
|
+
cacheHitRate: t.inputTokens > 0 ? t.cacheReadTokens / t.inputTokens : null,
|
|
141
|
+
cost: {
|
|
142
|
+
source,
|
|
143
|
+
unpricedTurns: t.turnsBy.unpriced,
|
|
144
|
+
totalUsd: priced === 0 ? null : usd(c.total),
|
|
145
|
+
...(priced === 0 ? {} : { parts: { inputUsd: usd(c.input), outputUsd: usd(c.output), cacheReadUsd: usd(c.cacheRead), cacheWriteUsd: usd(c.cacheWrite) } }),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One worker in the usage tree, with raw totals (the report turns them into lines). */
|
|
151
|
+
export interface UsageNode {
|
|
152
|
+
id: string;
|
|
153
|
+
name: string;
|
|
154
|
+
role?: string;
|
|
155
|
+
model: string;
|
|
156
|
+
status: "open" | "closed" | "fixed";
|
|
157
|
+
totals: UsageTotals;
|
|
158
|
+
workers: UsageNode[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An agent or worker line of a {@link UsageReport}. */
|
|
162
|
+
export interface AgentUsage extends UsageLine {
|
|
163
|
+
id: string;
|
|
164
|
+
name: string;
|
|
165
|
+
model: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A worker line, with the workers it started (`open` and `closed` are `subagents`/`agent.workers` workers, `fixed` is a `subagents: [...]` worker). */
|
|
169
|
+
export interface WorkerUsage extends AgentUsage {
|
|
170
|
+
role?: string;
|
|
171
|
+
status: "open" | "closed" | "fixed";
|
|
172
|
+
workers: WorkerUsage[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** What `agent.usageReport()` returns. `agent` is the agent's own model calls only; `total` is the agent plus every worker below it. */
|
|
176
|
+
export interface UsageReport {
|
|
177
|
+
agent: AgentUsage;
|
|
178
|
+
workers: WorkerUsage[];
|
|
179
|
+
total: UsageLine;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Adds a node and everything below it into `into`; in: a usage tree, out: the whole-session total.
|
|
183
|
+
function sumTree(into: UsageTotals, nodes: UsageNode[]): void {
|
|
184
|
+
for (const n of nodes) {
|
|
185
|
+
mergeUsage(into, n.totals);
|
|
186
|
+
sumTree(into, n.workers);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// One node as a report line, children included; in: a usage node, out: UsageReport.workers.
|
|
191
|
+
function nodeLine(n: UsageNode): WorkerUsage {
|
|
192
|
+
return { id: n.id, name: n.name, ...(n.role ? { role: n.role } : {}), model: n.model, status: n.status, ...usageLine(n.totals), workers: n.workers.map(nodeLine) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Builds the report from the agent's own totals and its worker tree. */
|
|
196
|
+
export function buildUsageReport(own: { id: string; name: string; model: string; totals: UsageTotals }, workers: UsageNode[]): UsageReport {
|
|
197
|
+
const total = emptyUsage();
|
|
198
|
+
mergeUsage(total, own.totals);
|
|
199
|
+
sumTree(total, workers);
|
|
200
|
+
return { agent: { id: own.id, name: own.name, model: own.model, ...usageLine(own.totals) }, workers: workers.map(nodeLine), total: usageLine(total) };
|
|
201
|
+
}
|
package/src/telemetry-file.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Each event is a single synchronous append, so a process crash keeps every
|
|
5
5
|
* event already emitted: a `tool.call_started` with no ending event was in
|
|
6
6
|
* flight, and the last `tool.progress` shows how far it got. Power loss is not
|
|
7
|
-
* covered (no fsync). `turn.thinking`
|
|
7
|
+
* covered (no fsync, by design: this is observability, not state). `turn.thinking`
|
|
8
|
+
* (full reasoning text) is never written. Takes the home's one-process lock.
|
|
8
9
|
*/
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { telemetryBus } from "./telemetry.ts";
|
|
12
13
|
import { safeStringify } from "./utils/serialization.ts";
|
|
14
|
+
import { lockFolder } from "./utils/durable.ts";
|
|
13
15
|
|
|
14
16
|
const sinks = new Set<string>();
|
|
15
17
|
|
|
@@ -17,6 +19,7 @@ const sinks = new Set<string>();
|
|
|
17
19
|
export function persistTelemetry(home: string): void {
|
|
18
20
|
const dir = path.join(home, "telemetry");
|
|
19
21
|
if (sinks.has(dir)) return;
|
|
22
|
+
lockFolder(home);
|
|
20
23
|
sinks.add(dir);
|
|
21
24
|
telemetryBus.onEvent((event) => {
|
|
22
25
|
if (event.code === "turn.thinking") return;
|
package/src/tools/executor.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { ConfigError } from "../types/errors.ts";
|
|
|
9
9
|
import { isSubagentResult } from "./subagent-result.ts";
|
|
10
10
|
import { isTruncatedArguments } from "../types/tool.ts";
|
|
11
11
|
import { telemetryBus } from "../telemetry.ts";
|
|
12
|
+
import type { ToolLogWriter } from "../session/tool-log.ts";
|
|
12
13
|
|
|
13
14
|
export interface ExecuteToolsOptions {
|
|
14
15
|
tools: Record<string, ToolDefinition>;
|
|
@@ -17,6 +18,10 @@ export interface ExecuteToolsOptions {
|
|
|
17
18
|
parallel?: boolean;
|
|
18
19
|
signal?: AbortSignal;
|
|
19
20
|
sessionId?: string;
|
|
21
|
+
/** Called the moment EACH call finishes (before the whole batch does), so a caller can save a result before the next tool is done. Never throws into the run. */
|
|
22
|
+
onResult?: (result: ToolResultRecord) => void;
|
|
23
|
+
/** Opens the log file of one call (only when the Agent persists to a folder); the executor writes its steps into it and `ctx.log` writes through it. */
|
|
24
|
+
openToolLog?: (call: ToolCallRecord, sessionId: string | undefined) => ToolLogWriter | undefined;
|
|
20
25
|
/** Forwards live sub-agent deltas (realtime worker thinking/text) to the caller. */
|
|
21
26
|
onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
|
|
22
27
|
}
|
|
@@ -52,6 +57,14 @@ export function normalizeToolName(name: string): string {
|
|
|
52
57
|
.toLowerCase();
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
// Thrown by `ctx.stop(reason)` to unwind the tool's code at once; the executor turns it into a stopped result and never retries it.
|
|
61
|
+
class ToolStopSignal extends Error {
|
|
62
|
+
constructor() {
|
|
63
|
+
super("The tool ended the run with ctx.stop()");
|
|
64
|
+
this.name = "ToolStopSignal";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/** Default cap for string tool results kept for model context. */
|
|
56
69
|
export const DEFAULT_MAX_RESULT_CHARS = 20000;
|
|
57
70
|
|
|
@@ -467,7 +480,7 @@ async function executeAttempt(
|
|
|
467
480
|
export async function executeToolCalls(
|
|
468
481
|
options: ExecuteToolsOptions
|
|
469
482
|
): Promise<ToolResultRecord[]> {
|
|
470
|
-
const { tools, toolCalls, agentName, parallel = true, signal, sessionId, onSubagentEvent } = options;
|
|
483
|
+
const { tools, toolCalls, agentName, parallel = true, signal, sessionId, onSubagentEvent, onResult, openToolLog } = options;
|
|
471
484
|
assertNoToolCollisions(tools);
|
|
472
485
|
const globalSemaphore = new Semaphore(DEFAULT_TOOL_CONCURRENCY);
|
|
473
486
|
const perToolSemaphores = new Map<string, Semaphore>();
|
|
@@ -485,8 +498,14 @@ export async function executeToolCalls(
|
|
|
485
498
|
};
|
|
486
499
|
|
|
487
500
|
const scope = { agentName, sessionId };
|
|
488
|
-
const runSingle = async (call: ToolCallRecord): Promise<ToolResultRecord> => {
|
|
501
|
+
const runSingle = async (call: ToolCallRecord, log: ToolLogWriter | undefined): Promise<ToolResultRecord> => {
|
|
489
502
|
const startTime = nowMs();
|
|
503
|
+
// Every lifecycle step goes to the telemetry bus AND this call's log file.
|
|
504
|
+
const trace = (phase: string, level: "info" | "warn" | "error", data: Record<string, unknown> = {}): void => {
|
|
505
|
+
traceToolEvent(phase, level, call, scope, data);
|
|
506
|
+
log?.write({ phase, level, ...data });
|
|
507
|
+
};
|
|
508
|
+
log?.write({ phase: "input", name: call.name, input: toJsonSafe(call.arguments) });
|
|
490
509
|
// Latest `ctx.emit({ type: "progress", ... })` snapshot: what the tool had finished before any failure.
|
|
491
510
|
let progress: unknown;
|
|
492
511
|
let attempts = 0;
|
|
@@ -497,7 +516,7 @@ export async function executeToolCalls(
|
|
|
497
516
|
// finishReason here — return a synthetic error so the model retries with
|
|
498
517
|
// complete JSON.
|
|
499
518
|
if (isTruncatedToolCall(call)) {
|
|
500
|
-
|
|
519
|
+
trace("call_blocked", "warn", { reason: "truncated_arguments" });
|
|
501
520
|
return {
|
|
502
521
|
id: call.id,
|
|
503
522
|
name: call.name,
|
|
@@ -513,7 +532,7 @@ export async function executeToolCalls(
|
|
|
513
532
|
const toolDef = resolved.definition;
|
|
514
533
|
|
|
515
534
|
if (!toolDef) {
|
|
516
|
-
|
|
535
|
+
trace("call_blocked", "warn", { reason: "tool_not_found", suggestion: resolved.suggestion });
|
|
517
536
|
const suffix = resolved.suggestion ? ` Did you mean '${resolved.suggestion}'?` : "";
|
|
518
537
|
return {
|
|
519
538
|
id: call.id,
|
|
@@ -527,6 +546,12 @@ export async function executeToolCalls(
|
|
|
527
546
|
let releaseGlobal: (() => void) | undefined;
|
|
528
547
|
let releaseTool: (() => void) | undefined;
|
|
529
548
|
let executionStartTime = startTime;
|
|
549
|
+
// Set by `ctx.stop(reason)`: the call's result becomes the reason and the run ends after this batch.
|
|
550
|
+
let stopReason: string | undefined;
|
|
551
|
+
const stoppedResult = (): ToolResultRecord => {
|
|
552
|
+
trace("call_stopped", "warn", { reason: stopReason, attempts, durationMs: elapsedMs(executionStartTime) });
|
|
553
|
+
return { id: call.id, name: resolved.name, result: stopReason, isError: false, stopped: true, durationMs: elapsedMs(executionStartTime) };
|
|
554
|
+
};
|
|
530
555
|
|
|
531
556
|
try {
|
|
532
557
|
if (signal?.aborted) {
|
|
@@ -542,9 +567,14 @@ export async function executeToolCalls(
|
|
|
542
567
|
signal,
|
|
543
568
|
sessionId,
|
|
544
569
|
onSubagentEvent,
|
|
570
|
+
stop: (reason: string): never => {
|
|
571
|
+
stopReason ??= String(reason);
|
|
572
|
+
throw new ToolStopSignal();
|
|
573
|
+
},
|
|
574
|
+
log: (message, data) => log?.write({ phase: "log", message, ...(data !== undefined ? { data: toJsonSafe(data) } : {}) }),
|
|
545
575
|
emit: (event) => {
|
|
546
576
|
if (event?.type === "progress") progress = event;
|
|
547
|
-
|
|
577
|
+
trace(event?.type === "progress" ? "progress" : "event", "info", { event: capJson(event) });
|
|
548
578
|
},
|
|
549
579
|
};
|
|
550
580
|
|
|
@@ -565,7 +595,7 @@ export async function executeToolCalls(
|
|
|
565
595
|
const hint = expectedParams
|
|
566
596
|
? ` Expected parameters: ${expectedParams}.`
|
|
567
597
|
: "";
|
|
568
|
-
|
|
598
|
+
trace("call_blocked", "warn", { reason: "schema_validation", detail: parseRes.error.message });
|
|
569
599
|
return {
|
|
570
600
|
id: call.id,
|
|
571
601
|
name: call.name,
|
|
@@ -597,20 +627,21 @@ export async function executeToolCalls(
|
|
|
597
627
|
executionStartTime = nowMs();
|
|
598
628
|
let rawResult: unknown;
|
|
599
629
|
let attempt = 0;
|
|
600
|
-
|
|
630
|
+
trace("call_started", "info", { maxAttempts, timeoutMs });
|
|
601
631
|
while (true) {
|
|
602
632
|
attempt++;
|
|
603
633
|
attempts = attempt;
|
|
604
|
-
|
|
634
|
+
trace("attempt_started", "info", { attempt, maxAttempts });
|
|
605
635
|
try {
|
|
606
636
|
rawResult = await executeAttempt(toolDef, parsedInput, context, timeoutMs);
|
|
607
637
|
break;
|
|
608
638
|
} catch (err: any) {
|
|
609
639
|
if (signal?.aborted) throw new Error("Tool execution aborted");
|
|
640
|
+
if (stopReason !== undefined) throw err; // ctx.stop: not a failure, never retried
|
|
610
641
|
timedOut = err?.toolTimeout === true;
|
|
611
642
|
const willRetry =
|
|
612
643
|
isTransientError(err) && attempt < maxAttempts && !(err?.code === "ETIMEDOUT" && (!configuredTries || configuredTries <= 0));
|
|
613
|
-
|
|
644
|
+
trace("attempt_failed", "warn", { attempt, maxAttempts, willRetry, error: describeError(err), progress: capJson(progress) });
|
|
614
645
|
if (!isTransientError(err) || attempt >= maxAttempts) throw err;
|
|
615
646
|
// A timeout is a deliberate per-attempt deadline, not a parent
|
|
616
647
|
// cancellation (which rejects via the abort signal instead and is
|
|
@@ -620,15 +651,19 @@ export async function executeToolCalls(
|
|
|
620
651
|
// tool body, but a tool that ignores the signal still resolves.
|
|
621
652
|
if (err?.code === "ETIMEDOUT" && (!configuredTries || configuredTries <= 0)) throw err;
|
|
622
653
|
const delayMs = Math.min(30_000, 250 * 2 ** Math.min(attempt - 1, 7));
|
|
623
|
-
|
|
654
|
+
trace("retry_scheduled", "info", { attempt, nextAttempt: attempt + 1, delayMs });
|
|
624
655
|
await waitForRetry(delayMs, signal);
|
|
625
656
|
}
|
|
626
657
|
}
|
|
627
658
|
|
|
659
|
+
if (stopReason !== undefined) return stoppedResult(); // the tool caught the stop signal but the run still stops
|
|
660
|
+
|
|
628
661
|
// Undefined means "no value", serialized null-safe as "null" (changed
|
|
629
662
|
// from the legacy "Success" marker so model context stays valid JSON).
|
|
663
|
+
// The log keeps the full result (the model gets a capped copy below).
|
|
664
|
+
log?.write({ phase: "result", result: isSubagentResult(rawResult) ? rawResult.xml : toJsonSafe(rawResult) });
|
|
630
665
|
const finishOk = (resultChars: number): void =>
|
|
631
|
-
|
|
666
|
+
trace("call_succeeded", "info", { attempts: attempt, durationMs: elapsedMs(executionStartTime), resultChars });
|
|
632
667
|
if (rawResult === undefined) {
|
|
633
668
|
finishOk(4);
|
|
634
669
|
return {
|
|
@@ -676,8 +711,9 @@ export async function executeToolCalls(
|
|
|
676
711
|
// scrubbed message plus what the tool finished before failing, so it repairs
|
|
677
712
|
// only the remainder instead of repeating completed side effects.
|
|
678
713
|
const aborted = signal?.aborted === true;
|
|
714
|
+
if (stopReason !== undefined && !aborted) return stoppedResult();
|
|
679
715
|
const done = err?.context?.progress ?? progress;
|
|
680
|
-
|
|
716
|
+
trace(aborted ? "call_aborted" : timedOut ? "call_timed_out" : "call_failed", "error", {
|
|
681
717
|
attempts,
|
|
682
718
|
durationMs: elapsedMs(executionStartTime),
|
|
683
719
|
error: describeError(err),
|
|
@@ -726,14 +762,35 @@ export async function executeToolCalls(
|
|
|
726
762
|
}
|
|
727
763
|
};
|
|
728
764
|
|
|
765
|
+
// Runs one call and reports its result at once; a throwing observer must not fail the run.
|
|
766
|
+
const runAndReport = async (call: ToolCallRecord): Promise<ToolResultRecord> => {
|
|
767
|
+
let log: ToolLogWriter | undefined;
|
|
768
|
+
try {
|
|
769
|
+
log = openToolLog?.(call, sessionId);
|
|
770
|
+
} catch {
|
|
771
|
+
log = undefined; // logging is best effort: the call runs without a log
|
|
772
|
+
}
|
|
773
|
+
let result: ToolResultRecord;
|
|
774
|
+
try {
|
|
775
|
+
result = await runSingle(call, log);
|
|
776
|
+
} finally {
|
|
777
|
+
log?.close();
|
|
778
|
+
}
|
|
779
|
+
if (log) result.logFile = log.file;
|
|
780
|
+
try {
|
|
781
|
+
onResult?.(result);
|
|
782
|
+
} catch {
|
|
783
|
+
// why: observers (persistence) are best-effort.
|
|
784
|
+
}
|
|
785
|
+
return result;
|
|
786
|
+
};
|
|
729
787
|
if (parallel && toolCalls.length > 1) {
|
|
730
|
-
|
|
731
|
-
return results;
|
|
788
|
+
return Promise.all(toolCalls.map((tc) => runAndReport(tc)));
|
|
732
789
|
}
|
|
733
790
|
|
|
734
791
|
const results: ToolResultRecord[] = [];
|
|
735
792
|
for (const tc of toolCalls) {
|
|
736
|
-
results.push(await
|
|
793
|
+
results.push(await runAndReport(tc));
|
|
737
794
|
}
|
|
738
795
|
return results;
|
|
739
796
|
}
|
|
@@ -2,7 +2,7 @@ import type { SubAgentExecutionMetadata } from "../types/response.ts";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Module-private brand proving a tool result was produced by the SDK's own
|
|
5
|
-
* delegation tools (`agentToTool` / `
|
|
5
|
+
* delegation tools (`agentToTool` / the `subagents` tool). Symbols cannot be
|
|
6
6
|
* forged by JSON a tool fetched from the web, so only branded results get
|
|
7
7
|
* their `_subagentMetadata` trusted (usage/cost rolled up, XML shown to the
|
|
8
8
|
* model).
|
package/src/tools/tool.ts
CHANGED
|
@@ -143,11 +143,6 @@ export interface CreateToolOptions<TInput = any, TOutput = any> {
|
|
|
143
143
|
retry?: { maxRetries?: number; baseDelayMs?: number; maxDelayMs?: number };
|
|
144
144
|
/** Free-form tags for grouping/filtering tools (e.g. `["web", "readonly"]`). */
|
|
145
145
|
tags?: string[];
|
|
146
|
-
/**
|
|
147
|
-
* When true, the host must approve this call before execution. Stored only;
|
|
148
|
-
* enforcement is wired by the host, default open.
|
|
149
|
-
*/
|
|
150
|
-
needsApproval?: boolean;
|
|
151
146
|
/**
|
|
152
147
|
* Optional output schema (Zod or JSON Schema, stored as `unknown` so `zod`
|
|
153
148
|
* stays optional for type-only consumers).
|
|
@@ -208,7 +203,6 @@ export function tool<TInput = any, TOutput = any>(
|
|
|
208
203
|
...(options.redact !== undefined ? { redact: options.redact } : {}),
|
|
209
204
|
...(options.retry !== undefined ? { retry: options.retry } : {}),
|
|
210
205
|
...(options.tags !== undefined ? { tags: options.tags } : {}),
|
|
211
|
-
...(options.needsApproval !== undefined ? { needsApproval: options.needsApproval } : {}),
|
|
212
206
|
...(options.output !== undefined ? { output: options.output } : {}),
|
|
213
207
|
...(options.maxAttempts !== undefined ? { maxAttempts: options.maxAttempts } : {}),
|
|
214
208
|
} as ToolDefinition<TInput, TOutput>;
|
package/src/types/agent.ts
CHANGED
|
@@ -133,6 +133,21 @@ export interface AgentConfig {
|
|
|
133
133
|
* Writes are atomic and never fail a turn.
|
|
134
134
|
*/
|
|
135
135
|
persist?: { dir?: string; file?: string };
|
|
136
|
+
/**
|
|
137
|
+
* How many times a run that FAILED for a transient reason (provider down or overloaded, timeouts, a cut stream, after
|
|
138
|
+
* the provider layer's own `retry` gave up) is tried again from where it stopped, with a growing wait (5 s, 15 s, 45 s,
|
|
139
|
+
* then 60 s). Default 3; `0` turns it off. Finished tool calls are not repeated. Never for aborts, bad config or input
|
|
140
|
+
* (401/400/...), budgets or limits. Applies to `run()` and queued runs, not to `stream()`. The final response also reports the
|
|
141
|
+
* tool calls, results, turns and token usage of the failed attempts (they happened and cost money).
|
|
142
|
+
*/
|
|
143
|
+
runRetries?: number;
|
|
144
|
+
/**
|
|
145
|
+
* What happens to a run a crash cut off (needs `persist: { dir }` and the SAME `sessionId` after the restart).
|
|
146
|
+
* `"automatic"` (default): the Agent continues it as soon as it is constructed (tool calls that already have a saved
|
|
147
|
+
* result are not run again), then runs any prompts that were waiting. `"explicit"`: nothing runs until you call
|
|
148
|
+
* `agent.resume()`; `agent.interrupted()` lists what was cut off.
|
|
149
|
+
*/
|
|
150
|
+
resume?: "automatic" | "explicit";
|
|
136
151
|
/**
|
|
137
152
|
* Folder for everything Agent Accelerator writes to disk (`sessions/`,
|
|
138
153
|
* `scheduled_actions/`, `telemetry/`). Defaults to `setHome()`, then the `AGENT_ACCEL_HOME` env var, then `.agent-accel/` in the working directory.
|
|
@@ -214,6 +229,17 @@ export interface AgentConfig {
|
|
|
214
229
|
limits?: AgentRunLimits;
|
|
215
230
|
}
|
|
216
231
|
|
|
232
|
+
/** A run that a crash cut off (see {@link Agent.interrupted}). */
|
|
233
|
+
export interface InterruptedRun {
|
|
234
|
+
runId: string;
|
|
235
|
+
/** Epoch ms when the run began. */
|
|
236
|
+
startedAt: number;
|
|
237
|
+
/** Preview of the prompt that started it. */
|
|
238
|
+
prompt: string;
|
|
239
|
+
/** Names of tool calls it had asked for that have no result yet. */
|
|
240
|
+
pendingToolCalls: string[];
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
/** Per-request + run limits (Q-22). */
|
|
218
244
|
export interface AgentRunLimits {
|
|
219
245
|
maxTurns?: number;
|
|
@@ -252,7 +278,7 @@ export interface MidSessionConfig {
|
|
|
252
278
|
|
|
253
279
|
/** Developer-configured policy for LLM-spawned dynamic sub-agents. */
|
|
254
280
|
export interface DynamicSubagentsConfig {
|
|
255
|
-
/**
|
|
281
|
+
/** Adds the `subagents` tool (spawn, message, close, list persistent workers). Defaults to true when the object is present; `agent.workers` works either way. */
|
|
256
282
|
enabled?: boolean;
|
|
257
283
|
/**
|
|
258
284
|
* Lets the Main Agent schedule dynamic sub-agents to run later (once or on
|
|
@@ -262,22 +288,35 @@ export interface DynamicSubagentsConfig {
|
|
|
262
288
|
allowScheduledActions?: boolean;
|
|
263
289
|
/** Developer-only worker model override. Never choosable by the Main Agent LLM. Falls back to top-level `subagentModel`, then `SUB_AGENT_MODEL` env, then the parent model. */
|
|
264
290
|
model?: string | ModelSpec | ModelProviderInstance;
|
|
265
|
-
/** Max workers
|
|
291
|
+
/** Max workers that may be open at once on this agent (the model must close one before spawning another). Defaults to 4. */
|
|
266
292
|
maxSpawn?: number;
|
|
267
293
|
/** Fixed reasoning level for all dynamic workers. The Main Agent cannot override it. Falls back to the parent level when omitted. */
|
|
268
294
|
thinkingLevel?: ThinkingLevel;
|
|
295
|
+
/**
|
|
296
|
+
* Spending cap of EACH worker (the `subagents` tool, `agent.workers` and scheduled actions), set as that worker's own
|
|
297
|
+
* per-run `budget`: a spawn, a message or a scheduled run is one run, and the usage of the worker's own workers counts
|
|
298
|
+
* toward it. Exceeding it fails that call with a budget error and leaves the worker open. Default: unlimited. Models
|
|
299
|
+
* with unknown pricing count $0 toward `maxCostUsd`. Passed down to the workers' own workers.
|
|
300
|
+
*/
|
|
301
|
+
workerBudget?: Pick<Budget, "maxCostUsd" | "maxTotalTokens">;
|
|
302
|
+
/**
|
|
303
|
+
* How many levels of workers may exist below this agent (default 3). `1` = workers cannot start workers of their own;
|
|
304
|
+
* `3` = a worker may start workers that start workers. A worker with levels left gets its own `subagents` tool, and
|
|
305
|
+
* its children can only be granted the tools the worker itself was granted. Whole number >= 1.
|
|
306
|
+
*/
|
|
307
|
+
maxDepth?: number;
|
|
269
308
|
/** Tool pool available to dynamic workers. Workers receive zero tools unless the Main Agent grants a per-task `tools` subset by name. */
|
|
270
309
|
tools?: Record<string, ToolDefinition> | ToolDefinition[];
|
|
271
|
-
/** Per-
|
|
310
|
+
/** Per-run timeout in ms of a SCHEDULED ACTION's worker (the `subagents` tool and `agent.workers` have no timeouts). `0` (default) = no limit. `-1` = the action sets its own `timeoutMs`. `>0` = fixed. */
|
|
272
311
|
timeout?: number;
|
|
273
312
|
/**
|
|
274
|
-
* Canonical
|
|
313
|
+
* Canonical timeout in ms for scheduled-action workers (Q-52, preferred over `timeout`).
|
|
275
314
|
* Maps to `timeout`: `workerTimeoutMs ?? timeout ?? 0`. See
|
|
276
315
|
* `normalizeWorkerTimeout` (re-exported from the root entry).
|
|
277
316
|
*/
|
|
278
317
|
workerTimeoutMs?: number;
|
|
279
318
|
/**
|
|
280
|
-
*
|
|
319
|
+
* Scheduled actions only: when true, each action sets its own `timeoutMs` (maps to
|
|
281
320
|
* `timeout: -1`). When both are set, an explicit `timeout`/`workerTimeoutMs`
|
|
282
321
|
* wins over `modelMaySetTimeout`.
|
|
283
322
|
*/
|
|
@@ -363,7 +402,7 @@ export interface Budget {
|
|
|
363
402
|
maxTotalTokens?: number;
|
|
364
403
|
/** Maximum wall/monotonic duration in ms since budget tracking started. */
|
|
365
404
|
maxDurationMs?: number;
|
|
366
|
-
/** Maximum total dynamic sub-agents spawned
|
|
405
|
+
/** Maximum total dynamic sub-agents spawned in one run (`subagents` tool or `agent.workers.spawn`). */
|
|
367
406
|
maxTotalSubagents?: number;
|
|
368
407
|
}
|
|
369
408
|
|
package/src/types/message.ts
CHANGED
|
@@ -85,6 +85,10 @@ export interface ToolResultPart {
|
|
|
85
85
|
name: string;
|
|
86
86
|
result: unknown;
|
|
87
87
|
isError?: boolean;
|
|
88
|
+
/** This call's log file, relative to the session folder (see `ToolExecutionContext.log`). */
|
|
89
|
+
logFile?: string;
|
|
90
|
+
/** The tool ended the run with `ctx.stop(reason)` (see `ToolExecutionContext.stop`). */
|
|
91
|
+
stopped?: boolean;
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** Provider reasoning/thinking content. */
|
package/src/types/response.ts
CHANGED
|
@@ -60,6 +60,7 @@ export type FinishReason =
|
|
|
60
60
|
| "content_filter"
|
|
61
61
|
| "refusal"
|
|
62
62
|
| "max_turns"
|
|
63
|
+
| "stopped"
|
|
63
64
|
| "error"
|
|
64
65
|
| (string & {});
|
|
65
66
|
|
|
@@ -88,6 +89,7 @@ export function normalizeFinishReason(raw?: string): {
|
|
|
88
89
|
length: "length",
|
|
89
90
|
max_tokens: "length",
|
|
90
91
|
max_turns: "max_turns",
|
|
92
|
+
stopped: "stopped",
|
|
91
93
|
content_filter: "content_filter",
|
|
92
94
|
refusal: "refusal",
|
|
93
95
|
refused: "refusal",
|