agent-accelerator 0.5.2 → 0.5.3
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 +54 -5
- package/package.json +1 -1
- package/src/agent/agent.ts +283 -15
- package/src/agent/context.ts +2 -0
- package/src/agent/loop.ts +127 -8
- package/src/index.ts +2 -1
- 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 +201 -0
- package/src/session/store.ts +181 -82
- package/src/session/tool-log.ts +62 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +26 -0
- package/src/types/message.ts +4 -0
- package/src/types/response.ts +2 -0
- package/src/types/tool.ts +18 -6
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +15 -1
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
|
}
|
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;
|
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",
|
package/src/types/tool.ts
CHANGED
|
@@ -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.3";
|
|
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"];
|