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/types/tool.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface ToolExecutionContext {
|
|
|
20
20
|
signal?: AbortSignal;
|
|
21
21
|
/** Session identifier associated with this invocation. */
|
|
22
22
|
sessionId?: string;
|
|
23
|
-
/** Forwards live sub-agent deltas (used by `
|
|
23
|
+
/** Forwards live sub-agent deltas (used by the `subagents` tool for realtime tracking). */
|
|
24
24
|
onSubagentEvent?: (event: { trackingId: string; delta?: string; thinkingDelta?: string; partialText?: string; partialThinking?: string }) => void;
|
|
25
25
|
// Q-55 Tool API v2 context additions (all optional, additive).
|
|
26
26
|
/** Stable id for this run (`newRunId`). Mirrors SubAgentRunIds.runId. */
|
|
@@ -29,6 +29,20 @@ export interface ToolExecutionContext {
|
|
|
29
29
|
emit?: ToolEmitFn;
|
|
30
30
|
/** Structured logger for tool implementations (defaults to no-op). */
|
|
31
31
|
logger?: ToolLogger;
|
|
32
|
+
/**
|
|
33
|
+
* Writes one line to this call's log file (`tool-logs/<n>-<tool>.ndjson` in the session folder), flushed at once, so
|
|
34
|
+
* after a crash it shows how far the call got: `ctx.log("uploaded 3 of 5 files", { done: 3 })`. Only written when the
|
|
35
|
+
* Agent persists to a folder (`persist: { dir }`); otherwise a no-op. For developers, never sent to the model.
|
|
36
|
+
*/
|
|
37
|
+
log: (message: string, data?: unknown) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Ends the whole run from inside a tool, for example when your own permission check says no:
|
|
40
|
+
* `if (!(await askUser("Pay $50?"))) ctx.stop("user declined the payment")`. It throws, so the code after it never runs
|
|
41
|
+
* (even if you forget `return`). The reason is saved as this call's result; the other tool calls of the same batch
|
|
42
|
+
* still run to completion; then the run ends with `finishReason: "stopped"` and NO further model turn, and the agent
|
|
43
|
+
* waits for the next user message. `timeoutMs` counts the time spent waiting for your check.
|
|
44
|
+
*/
|
|
45
|
+
stop: (reason: string) => never;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export type ToolExecuteFn<TInput = any, TOutput = any> = (
|
|
@@ -92,12 +106,6 @@ export interface ToolDefinition<TInput = any, TOutput = any> {
|
|
|
92
106
|
redact?: (err: unknown) => string;
|
|
93
107
|
/** Free-form tags for grouping/filtering tools (e.g. `["web", "readonly"]`). */
|
|
94
108
|
tags?: string[];
|
|
95
|
-
/**
|
|
96
|
-
* When true, the host must approve this call before execution (human-in-the-
|
|
97
|
-
* loop). The loop reads via `(toolDef as any).needsApproval`; enforcement
|
|
98
|
-
* is wired by the host, default open (undefined = no gate).
|
|
99
|
-
*/
|
|
100
|
-
needsApproval?: boolean;
|
|
101
109
|
/**
|
|
102
110
|
* Optional output schema (Zod or JSON Schema) used to validate/coerce the
|
|
103
111
|
* tool's return value before it reaches model context. Stored as `unknown`
|
|
@@ -135,6 +143,10 @@ export interface ToolResultRecord {
|
|
|
135
143
|
result: unknown;
|
|
136
144
|
isError?: boolean;
|
|
137
145
|
durationMs?: number;
|
|
146
|
+
/** This call's log file, relative to the session folder (only when the Agent persists to a folder). */
|
|
147
|
+
logFile?: string;
|
|
148
|
+
/** The tool ended the run with `ctx.stop(reason)`; `result` is the reason. */
|
|
149
|
+
stopped?: boolean;
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
/**
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the SDK writes state to disk safely. One writer for sessions, scheduled
|
|
3
|
+
* actions and the catalog cache: a crash or a power cut leaves either the old
|
|
4
|
+
* file or the new one, never a torn mix, and the last good version is kept as
|
|
5
|
+
* `<file>.bak`. Also the one-process-per-folder lock and the stale temp-file sweep.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { ConfigError } from "../types/errors.ts";
|
|
11
|
+
|
|
12
|
+
/** Flushes a folder's entries (a new or renamed file) to disk; best effort because some platforms cannot open a folder. */
|
|
13
|
+
export function fsyncFolder(dir: string): void {
|
|
14
|
+
let fd: number | undefined;
|
|
15
|
+
try {
|
|
16
|
+
fd = fs.openSync(dir, "r");
|
|
17
|
+
fs.fsyncSync(fd);
|
|
18
|
+
} catch {
|
|
19
|
+
// why: Windows (and some filesystems) refuse to open or fsync a folder; the file itself is already flushed.
|
|
20
|
+
} finally {
|
|
21
|
+
if (fd !== undefined) {
|
|
22
|
+
try {
|
|
23
|
+
fs.closeSync(fd);
|
|
24
|
+
} catch {
|
|
25
|
+
// why: closing a read-only folder handle cannot lose data.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Durable atomic write: temp file (0600) -> fsync -> previous version kept as `<file>.bak` (hard link, no copy)
|
|
33
|
+
* -> rename -> fsync of the folder. Throws on failure; the temp file is removed. `backup: false` skips the `.bak`.
|
|
34
|
+
*/
|
|
35
|
+
export function writeFileDurable(filePath: string, content: string | Uint8Array, opts: { backup?: boolean } = {}): void {
|
|
36
|
+
const dir = path.dirname(filePath);
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
} catch {
|
|
40
|
+
// why: a missing folder surfaces as the open() error below, with a clearer path.
|
|
41
|
+
}
|
|
42
|
+
const rand = globalThis.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
|
|
43
|
+
const tmp = `${filePath}.tmp-${process.pid}-${rand}`;
|
|
44
|
+
try {
|
|
45
|
+
const fd = fs.openSync(tmp, "wx", 0o600);
|
|
46
|
+
try {
|
|
47
|
+
fs.writeFileSync(fd, content);
|
|
48
|
+
fs.fsyncSync(fd);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
if (opts.backup !== false) {
|
|
53
|
+
try {
|
|
54
|
+
fs.rmSync(`${filePath}.bak`, { force: true });
|
|
55
|
+
fs.linkSync(filePath, `${filePath}.bak`); // the old inode survives the rename below
|
|
56
|
+
} catch {
|
|
57
|
+
// why: no previous version (first write) or no hard links on this filesystem: the write still goes ahead.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
fs.renameSync(tmp, filePath);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
try {
|
|
63
|
+
fs.rmSync(tmp, { force: true });
|
|
64
|
+
} catch {
|
|
65
|
+
// why: the original error is the one worth reporting.
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
fsyncFolder(dir);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads `file`; when `isValid` rejects it, falls back to `<file>.bak`. `undefined` when the file is missing (a deleted
|
|
74
|
+
* file is never resurrected from its backup). When both are invalid the primary text comes back, `fromBackup: false`.
|
|
75
|
+
*/
|
|
76
|
+
export function readWithBackup(file: string, isValid: (text: string) => boolean): { text: string; fromBackup: boolean } | undefined {
|
|
77
|
+
let text: string;
|
|
78
|
+
try {
|
|
79
|
+
text = fs.readFileSync(file, "utf8");
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (isValid(text)) return { text, fromBackup: false };
|
|
84
|
+
try {
|
|
85
|
+
const backup = fs.readFileSync(`${file}.bak`, "utf8");
|
|
86
|
+
if (isValid(backup)) return { text: backup, fromBackup: true };
|
|
87
|
+
} catch {
|
|
88
|
+
// why: no backup to fall back to.
|
|
89
|
+
}
|
|
90
|
+
return { text, fromBackup: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// One process per folder
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** Folders this process already holds (the lock is re-entrant: many Agents share one home). */
|
|
98
|
+
const heldFolders = new Set<string>();
|
|
99
|
+
let exitHookInstalled = false;
|
|
100
|
+
|
|
101
|
+
// Blocks the thread for `ms` (a lock file being written by another process is the only reason to wait); in: a short delay, out: nothing.
|
|
102
|
+
function pause(ms: number): void {
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// True while process `pid` exists (EPERM means it exists but belongs to someone else); in: a pid from a lock file, out: lockFolder's stale check.
|
|
107
|
+
function processAlive(pid: number): boolean {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return (err as { code?: string }).code === "EPERM";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Removes the locks this process still holds when it exits normally; a killed process leaves its lock for the next start to detect as stale.
|
|
117
|
+
function installExitHook(): void {
|
|
118
|
+
if (exitHookInstalled) return;
|
|
119
|
+
exitHookInstalled = true;
|
|
120
|
+
process.once("exit", () => {
|
|
121
|
+
for (const dir of heldFolders) {
|
|
122
|
+
try {
|
|
123
|
+
fs.rmSync(path.join(dir, ".lock"), { force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
// why: nothing can be reported while exiting; a leftover lock is detected as stale.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Deletes leftover `<file>.tmp-<pid>-<rand>` files of writes that never finished; only safe while holding the folder lock; in: a locked folder, out: nothing.
|
|
132
|
+
function sweepTempFiles(dir: string): void {
|
|
133
|
+
let entries: fs.Dirent[];
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
136
|
+
} catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
const full = path.join(dir, entry.name);
|
|
141
|
+
if (entry.isDirectory()) sweepTempFiles(full);
|
|
142
|
+
else if (/\.tmp-\d+-[0-9a-z]+$/.test(entry.name)) {
|
|
143
|
+
try {
|
|
144
|
+
fs.rmSync(full, { force: true });
|
|
145
|
+
} catch {
|
|
146
|
+
// why: an undeletable leftover is harmless.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Takes the one-process lock of a folder (`<dir>/.lock` = pid, host, start time) and sweeps the temp files of writes
|
|
154
|
+
* that never finished. Re-entrant inside one process. Throws `ConfigError` at once when another live process holds it;
|
|
155
|
+
* a lock left by a dead process (same host, pid gone) or an unreadable one is taken over. Local disks only.
|
|
156
|
+
*/
|
|
157
|
+
export function lockFolder(folder: string): void {
|
|
158
|
+
const dir = path.resolve(folder);
|
|
159
|
+
if (heldFolders.has(dir)) return;
|
|
160
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
161
|
+
const lockFile = path.join(dir, ".lock");
|
|
162
|
+
const mine = JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: new Date().toISOString() });
|
|
163
|
+
let unreadable = 0;
|
|
164
|
+
for (;;) {
|
|
165
|
+
try {
|
|
166
|
+
const fd = fs.openSync(lockFile, "wx", 0o600);
|
|
167
|
+
try {
|
|
168
|
+
fs.writeFileSync(fd, mine);
|
|
169
|
+
} finally {
|
|
170
|
+
fs.closeSync(fd);
|
|
171
|
+
}
|
|
172
|
+
heldFolders.add(dir);
|
|
173
|
+
installExitHook();
|
|
174
|
+
sweepTempFiles(dir);
|
|
175
|
+
return;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if ((err as { code?: string }).code !== "EEXIST") throw err;
|
|
178
|
+
}
|
|
179
|
+
let text = "";
|
|
180
|
+
try {
|
|
181
|
+
text = fs.readFileSync(lockFile, "utf8");
|
|
182
|
+
} catch {
|
|
183
|
+
continue; // released between the open and the read: try again
|
|
184
|
+
}
|
|
185
|
+
let owner: { pid?: unknown; host?: unknown; startedAt?: unknown } | undefined;
|
|
186
|
+
try {
|
|
187
|
+
owner = JSON.parse(text);
|
|
188
|
+
} catch {
|
|
189
|
+
owner = undefined;
|
|
190
|
+
}
|
|
191
|
+
if (typeof owner?.pid !== "number") {
|
|
192
|
+
// Empty or garbage: another process may be mid-write. Give it a moment, then treat it as stale.
|
|
193
|
+
if (++unreadable <= 5) {
|
|
194
|
+
pause(40);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
} else if (owner.pid !== process.pid) {
|
|
198
|
+
if (owner.host !== os.hostname()) {
|
|
199
|
+
throw new ConfigError(
|
|
200
|
+
`[Agent Accelerator] Folder "${dir}" is locked by host "${String(owner.host)}" (process ${owner.pid}). Only local disks are supported: use a folder on this machine.`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (processAlive(owner.pid)) {
|
|
204
|
+
throw new ConfigError(
|
|
205
|
+
`[Agent Accelerator] Folder "${dir}" is already in use by process ${owner.pid} (started ${String(owner.startedAt)}). ` +
|
|
206
|
+
`Only one process may use it at a time: stop that process or use another folder (Agent \`home\`, setHome(), AGENT_ACCEL_HOME). ` +
|
|
207
|
+
`If that process is gone, delete "${lockFile}".`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Stale (dead owner, our own pid from an earlier life, or unreadable): take it over, but only if nobody replaced it meanwhile.
|
|
212
|
+
try {
|
|
213
|
+
if (fs.readFileSync(lockFile, "utf8") === text) fs.rmSync(lockFile, { force: true });
|
|
214
|
+
} catch {
|
|
215
|
+
// why: it vanished: the next loop turn creates it.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/utils/headers.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function getPackageVersion(): string {
|
|
|
23
23
|
const v = (globalThis as any).process?.env?.npm_package_version;
|
|
24
24
|
if (typeof v === "string" && v) return v;
|
|
25
25
|
} catch {}
|
|
26
|
-
return "0.5.
|
|
26
|
+
return "0.5.4";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function getAgentAccelUserAgent(): string {
|
package/src/utils/retry.ts
CHANGED
|
@@ -29,7 +29,21 @@ export function isTransientError(error: unknown): boolean {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Whether a FAILED RUN (the provider layer already retried the call) is worth trying again: the failure is transient
|
|
34
|
+
* (HTTP 408/425/429/5xx, network codes, `transient`/`retryable` flags, `incomplete_stream`, a request/stream timeout).
|
|
35
|
+
* Never for aborts, bad configuration or input (401/403/400/404, `ConfigError`, `ValidationError`, `SecurityPolicyError`,
|
|
36
|
+
* `ThinkingLevelError`) and never for budgets or limits, which a retry would only reset or hit again.
|
|
37
|
+
*/
|
|
38
|
+
export function isRetryableRun(error: unknown): boolean {
|
|
39
|
+
if (!error || isAbortError(error)) return false;
|
|
40
|
+
const name = (error as { name?: unknown }).name;
|
|
41
|
+
if (name === "TimeoutError") return true; // the loop's own request / first-byte / idle timeouts
|
|
42
|
+
if (["BudgetExceededError", "ConfigError", "ValidationError", "SecurityPolicyError", "SessionLoadError", "ThinkingLevelError", "ToolError"].includes(String(name))) return false;
|
|
43
|
+
return isTransientError(error);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readRetryAfterMs(error: unknown): number | undefined {
|
|
33
47
|
const err = error as Record<string, unknown>;
|
|
34
48
|
// Preferred: milliseconds attached by the adapter from the Retry-After header.
|
|
35
49
|
const direct = err["retryAfterMs"];
|