agent-accelerator 0.5.1 → 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.
@@ -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
- traceToolEvent("call_blocked", "warn", call, scope, { reason: "truncated_arguments" });
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
- traceToolEvent("call_blocked", "warn", call, scope, { reason: "tool_not_found", suggestion: resolved.suggestion });
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
- traceToolEvent(event?.type === "progress" ? "progress" : "event", "info", call, scope, { event: capJson(event) });
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
- traceToolEvent("call_blocked", "warn", call, scope, { reason: "schema_validation", detail: parseRes.error.message });
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
- traceToolEvent("call_started", "info", call, scope, { maxAttempts, timeoutMs });
630
+ trace("call_started", "info", { maxAttempts, timeoutMs });
601
631
  while (true) {
602
632
  attempt++;
603
633
  attempts = attempt;
604
- traceToolEvent("attempt_started", "info", call, scope, { attempt, maxAttempts });
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
- traceToolEvent("attempt_failed", "warn", call, scope, { attempt, maxAttempts, willRetry, error: describeError(err), progress: capJson(progress) });
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
- traceToolEvent("retry_scheduled", "info", call, scope, { attempt, nextAttempt: attempt + 1, delayMs });
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
- traceToolEvent("call_succeeded", "info", call, scope, { attempts: attempt, durationMs: elapsedMs(executionStartTime), resultChars });
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
- traceToolEvent(aborted ? "call_aborted" : timedOut ? "call_timed_out" : "call_failed", "error", call, scope, {
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
- const results = await Promise.all(toolCalls.map((tc) => runSingle(tc)));
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 runSingle(tc));
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>;
@@ -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;
@@ -9,6 +9,9 @@ export interface TextPart {
9
9
  thoughtSignature?: string;
10
10
  }
11
11
 
12
+ /** Detail level a provider spends on one image or video (more detail = more input tokens). Google only; other providers ignore it with a warning. */
13
+ export type MediaResolution = "low" | "medium" | "high" | "ultra_high";
14
+
12
15
  /** Image content accepted as a URL, data, path, or binary value. */
13
16
  export interface ImagePart {
14
17
  type: "image";
@@ -17,6 +20,8 @@ export interface ImagePart {
17
20
  */
18
21
  image: string | Uint8Array | ArrayBuffer;
19
22
  mimeType?: string;
23
+ /** Detail level for this image (Google: `low` 280, `medium` 560, `high` 1120, `ultra_high` 2240 tokens). */
24
+ resolution?: MediaResolution;
20
25
  }
21
26
 
22
27
  /** Audio content accepted as a URL, data, path, or binary value. */
@@ -48,6 +53,8 @@ export interface VideoPart {
48
53
  */
49
54
  video: string | Uint8Array | ArrayBuffer;
50
55
  mimeType?: string;
56
+ /** Detail level per frame (Google: `low`/`medium` 70, `high` 280 tokens). */
57
+ resolution?: MediaResolution;
51
58
  }
52
59
 
53
60
  /** Assistant-generated structured tool invocation. */
@@ -78,6 +85,10 @@ export interface ToolResultPart {
78
85
  name: string;
79
86
  result: unknown;
80
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;
81
92
  }
82
93
 
83
94
  /** Provider reasoning/thinking content. */
@@ -100,12 +111,20 @@ export type ContentPart =
100
111
 
101
112
  export type MessageRole = "system" | "user" | "assistant" | "tool";
102
113
 
114
+ /** A provider's raw output steps for one assistant turn, kept to replay history byte-exact (only the tagged provider reads them). */
115
+ export interface ProviderSteps {
116
+ provider: string;
117
+ steps: unknown[];
118
+ }
119
+
103
120
  /** Normalized conversation message. */
104
121
  export interface Message {
105
122
  role: MessageRole;
106
123
  content: string | ContentPart[];
107
124
  name?: string;
108
125
  thoughtSignature?: string;
126
+ /** Raw provider output steps of an assistant turn (opaque; e.g. Google `steps`). */
127
+ providerSteps?: ProviderSteps;
109
128
  }
110
129
 
111
130
  /**
@@ -1,5 +1,5 @@
1
1
  import type { ThinkingConfig, CacheOption, ServiceTier, TokenUsage } from "./core.ts";
2
- import type { ProviderContext } from "./message.ts";
2
+ import type { ProviderContext, ProviderSteps } from "./message.ts";
3
3
  import type { StandardToolDeclaration, ToolCallRecord } from "./tool.ts";
4
4
  import type { AssistantMessageEventStream } from "../streaming/event-stream.ts";
5
5
 
@@ -226,6 +226,8 @@ export interface ProviderGenerateResult {
226
226
  provider: ProviderId;
227
227
  raw: ProviderRawData;
228
228
  durationMs: number;
229
+ /** Raw output steps for exact history replay (stored on the assistant message). */
230
+ providerSteps?: ProviderSteps;
229
231
  }
230
232
 
231
233
  /** Provider contract implemented by native REST transports. */
@@ -244,4 +246,6 @@ export interface Provider {
244
246
  context: ProviderContext,
245
247
  options?: ProviderRequestOptions
246
248
  ): AssistantMessageEventStream;
249
+ /** True when this call may legitimately run far past the request timeouts (e.g. Google long decode); the loop then leaves timing to the adapter's own per-request timeouts. */
250
+ isLongRunning?(model: string | ModelSpec, options?: ProviderRequestOptions): boolean;
247
251
  }
@@ -1,6 +1,7 @@
1
1
  import type { TokenUsage } from "./core.ts";
2
2
  import type { ToolCallRecord, ToolResultRecord } from "./tool.ts";
3
3
  import type { ProviderRawData, ProviderId } from "./model.ts";
4
+ import type { ProviderSteps } from "./message.ts";
4
5
 
5
6
  /** One logged step inside a sub-agent worker run (surfaced via `agent.track(id)`). */
6
7
  export interface SubAgentStep {
@@ -59,6 +60,7 @@ export type FinishReason =
59
60
  | "content_filter"
60
61
  | "refusal"
61
62
  | "max_turns"
63
+ | "stopped"
62
64
  | "error"
63
65
  | (string & {});
64
66
 
@@ -87,6 +89,7 @@ export function normalizeFinishReason(raw?: string): {
87
89
  length: "length",
88
90
  max_tokens: "length",
89
91
  max_turns: "max_turns",
92
+ stopped: "stopped",
90
93
  content_filter: "content_filter",
91
94
  refusal: "refusal",
92
95
  refused: "refusal",
@@ -142,6 +145,8 @@ export class AgentResponse {
142
145
  readonly durationMs: number;
143
146
  readonly raw: ProviderRawData;
144
147
  readonly turns: number;
148
+ /** Raw provider output steps of a single streamed turn (history replay only; not in `toJSON`). */
149
+ readonly providerSteps?: ProviderSteps;
145
150
 
146
151
  /**
147
152
  * Constructs a normalized response. Agent runs create this automatically;
@@ -167,6 +172,7 @@ export class AgentResponse {
167
172
  durationMs: number;
168
173
  raw: ProviderRawData;
169
174
  turns?: number;
175
+ providerSteps?: ProviderSteps;
170
176
  }) {
171
177
  this.text = data.text;
172
178
  if (data.parsed !== undefined) (this as { parsed?: unknown }).parsed = data.parsed;
@@ -184,6 +190,7 @@ export class AgentResponse {
184
190
  this.durationMs = data.durationMs;
185
191
  this.raw = data.raw;
186
192
  this.turns = data.turns ?? 1;
193
+ if (data.providerSteps) this.providerSteps = data.providerSteps;
187
194
  }
188
195
 
189
196
  /** Returns the final assistant text. */
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
+ }
@@ -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.1";
26
+ return "0.5.3";
27
27
  }
28
28
 
29
29
  function getAgentAccelUserAgent(): string {