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.
@@ -1,16 +1,20 @@
1
- import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState } from "../types/agent.ts";
1
+ import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState, InterruptedRun } from "../types/agent.ts";
2
2
  import type { TokenUsage } from "../types/core.ts";
3
3
  import type { ProviderRequestOptions } from "../types/model.ts";
4
4
  import { normalizeThinkingV2, resolveWhenBusy, normalizeWorkerTimeout } from "../tools/tool.ts";
5
- import type { ToolDefinition } from "../types/tool.ts";
5
+ import type { ToolDefinition, ToolResultRecord } from "../types/tool.ts";
6
+ import { isSubagentResult } from "../tools/subagent-result.ts";
6
7
  import { CACHE_OPTIONS, type ThinkingLevel, type ThinkingConfig, type CacheOption, type ServiceTier } from "../types/core.ts";
7
8
  import type { ContentPart } from "../types/message.ts";
8
9
  import { AgentResponse, type StreamEvent } from "../types/response.ts";
9
10
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
10
11
  import { AgentContext } from "./context.ts";
11
12
  import { resolveModel } from "../providers/registry.ts";
12
- import { buildAgentTools, createSubagentSpawnTool } from "./delegation.ts";
13
+ import { buildAgentTools } from "./delegation.ts";
14
+ import { createSubagentsTool } from "./subagents-tool.ts";
15
+ import { WorkerRegistry } from "./workers.ts";
13
16
  import {
17
+ DEFAULT_MAX_DEPTH,
14
18
  normalizeToolMap,
15
19
  resolveDynamicModelInput,
16
20
  resolveMainModel,
@@ -19,10 +23,12 @@ import * as path from "node:path";
19
23
  import { registerAgent } from "../scheduler/scheduler.ts";
20
24
  import { resolveHome } from "../scheduler/store.ts";
21
25
  import { persistTelemetry } from "../telemetry-file.ts";
26
+ import { lockFolder } from "../utils/durable.ts";
22
27
  import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
23
28
  import { assertNoToolCollisions } from "../tools/executor.ts";
24
- import { runAgentLoop, streamAgentLoop, type SteerEntry } from "./loop.ts";
25
- import { createSessionId, nowMs } from "../utils/session.ts";
29
+ import { runAgentLoop, streamAgentLoop, unfinishedToolCalls, endsWithStop, accumulateUsage, type SteerEntry } from "./loop.ts";
30
+ import { isRetryableRun, readRetryAfterMs, sleep } from "../utils/retry.ts";
31
+ import { createSessionId, nowMs, newRunId } from "../utils/session.ts";
26
32
  import {
27
33
  chargeBudgetUsage,
28
34
  checkBudget,
@@ -31,7 +37,10 @@ import {
31
37
  listSubAgentTraceIdsForSessions,
32
38
  type SubAgentTrace,
33
39
  } from "./delegation.ts";
34
- import { saveSessionDir, saveSessionFile, findInvalidMessage, telemetryBus } from "../session/store.ts";
40
+ import { addTurn, buildUsageReport, emptyUsage, mergeUsage, modelName, readUsage, type UsageNode, type UsageReport, type UsageTotals } from "../session/usage.ts";
41
+ import { saveSessionFile, sessionDirFor, loadSessionDir, findInvalidMessage, priceUsage, telemetryBus, type SessionRunMark, type SessionRunEnd, type SessionPending, type SessionToolRecord } from "../session/store.ts";
42
+ import { SessionJournal } from "../session/journal.ts";
43
+ import { ToolLogs, type ToolLogWriter } from "../session/tool-log.ts";
35
44
  import { isLocalPathInput } from "../utils/safe-io.ts";
36
45
  import { clearSessionRouting, normalizeStructuredOutput, normalizeStop } from "../providers.ts";
37
46
  import type { StructuredOutputSpec } from "../types/model.ts";
@@ -53,7 +62,7 @@ export function resolveEffectiveThinking(
53
62
  return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
54
63
  }
55
64
 
56
- import { ConfigError, SessionLoadError } from "../types/errors.ts";
65
+ import { ConfigError, SessionLoadError, isAbortError } from "../types/errors.ts";
57
66
 
58
67
  /**
59
68
  * Disposers for the manual user-signal forward listeners (Q-21 fallback when
@@ -62,6 +71,9 @@ import { ConfigError, SessionLoadError } from "../types/errors.ts";
62
71
  */
63
72
  const mergeDisposers = new WeakMap<AbortController, () => void>();
64
73
 
74
+ /** Waits before run retry 1, 2, 3 ... (the last one repeats). Module-level so tests can shrink it; not public API. */
75
+ export const RUN_RETRY_DELAYS_MS: number[] = [5_000, 15_000, 45_000, 60_000];
76
+
65
77
  /** Max named sessions kept per Agent (Q-19a LRU). */
66
78
  const SESSION_LRU_MAX = 100;
67
79
  /** Idle TTL for named sessions (Q-19a). */
@@ -159,6 +171,10 @@ export class Agent {
159
171
  timeout: number;
160
172
  /** Scheduled actions (`scheduled_actions` + `get_datetime` tools) are on. */
161
173
  allowScheduledActions: boolean;
174
+ /** Levels of workers allowed below this agent. */
175
+ maxDepth: number;
176
+ /** Per-run spending cap of each worker (unset = unlimited). */
177
+ workerBudget?: { maxCostUsd?: number; maxTotalTokens?: number };
162
178
  };
163
179
  /** Home folder for everything written to disk (`sessions/`, `scheduled_actions/`, `telemetry/`). */
164
180
  readonly home: string;
@@ -246,6 +262,26 @@ export class Agent {
246
262
  private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
247
263
  /** Coalesced persistence single-flight flag (Q-28). */
248
264
  private persistScheduled = false;
265
+ /** Step journal of the persisted session folder (dir mode only). */
266
+ private journal?: SessionJournal;
267
+ /** Log files of tool calls in the persisted session folder (dir mode only). */
268
+ private toolLogs?: ToolLogs;
269
+ private toolLogsDir?: string;
270
+ /** Where each run of the default session began (saved with the session). @internal */
271
+ readonly runMarks: SessionRunMark[] = [];
272
+ /** Tool calls finished in the current run; saved the moment each finishes so a crash never repeats them. @internal */
273
+ readonly toolRecords: SessionToolRecord[] = [];
274
+ /** What this agent spent on its OWN model calls (workers keep their own); saved with the session. @internal */
275
+ readonly usageTotals: UsageTotals = emptyUsage();
276
+ /** The run in flight (its mark has no ending yet, but it is not "interrupted"). */
277
+ private activeMark?: SessionRunMark;
278
+ /** What to do with a cut-off run at construction (see `AgentConfig.resume`). */
279
+ private readonly resumeMode: "automatic" | "explicit";
280
+ /** How often a failed run is tried again (see `AgentConfig.runRetries`). */
281
+ private readonly runRetries: number;
282
+ /** The resume in flight, so a second `resume()` returns the same promise. */
283
+ private resuming?: Promise<AgentResponse | undefined>;
284
+ private workerRegistry?: WorkerRegistry;
249
285
 
250
286
  /**
251
287
  * Creates an agent and registers its model, tools, cache, and delegation settings.
@@ -282,6 +318,17 @@ export class Agent {
282
318
  const dynEnabled = dynRaw ? (dynRaw.enabled ?? true) : false;
283
319
  if (dynRaw) {
284
320
  const dynTools = normalizeToolMap((dynRaw as { tools?: Record<string, ToolDefinition> | ToolDefinition[] }).tools);
321
+ const rawDepth = (dynRaw as { maxDepth?: unknown }).maxDepth;
322
+ if (rawDepth !== undefined && !(Number.isInteger(rawDepth) && (rawDepth as number) >= 1)) {
323
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.maxDepth ${JSON.stringify(rawDepth)}: expected a whole number >= 1 (default ${DEFAULT_MAX_DEPTH}); set dynamicSubagents.enabled: false to turn workers off.`);
324
+ }
325
+ const rawBudget = (dynRaw as { workerBudget?: { maxCostUsd?: unknown; maxTotalTokens?: unknown } }).workerBudget;
326
+ for (const key of ["maxCostUsd", "maxTotalTokens"] as const) {
327
+ const v = rawBudget?.[key];
328
+ if (v !== undefined && !(typeof v === "number" && Number.isFinite(v) && v > 0)) {
329
+ throw new ConfigError(`[Agent Accelerator] Invalid dynamicSubagents.workerBudget.${key} ${JSON.stringify(v)}: expected a number greater than 0, or omit it for no limit.`);
330
+ }
331
+ }
285
332
  const rawMax = (dynRaw as any)?.maxSpawn;
286
333
  const maxSpawn = Number.isFinite(rawMax) ? Math.max(1, Math.floor(rawMax as number)) : 4;
287
334
  // `workerTimeoutMs ?? timeout`, or -1 when `modelMaySetTimeout` (see normalizeWorkerTimeout).
@@ -294,6 +341,8 @@ export class Agent {
294
341
  tools: dynTools,
295
342
  timeout,
296
343
  allowScheduledActions: dynRaw.allowScheduledActions === true,
344
+ maxDepth: (rawDepth as number | undefined) ?? DEFAULT_MAX_DEPTH,
345
+ ...(rawBudget ? { workerBudget: { ...(rawBudget as { maxCostUsd?: number; maxTotalTokens?: number }) } } : {}),
297
346
  };
298
347
  }
299
348
  this.apiKey = (rawModel as any)?.apiKey || config.apiKey;
@@ -313,6 +362,11 @@ export class Agent {
313
362
  config.persist && !config.persist.dir && !config.persist.file
314
363
  ? { ...config.persist, dir: path.join(this.home, "sessions") }
315
364
  : config.persist;
365
+ // One process per folder: fail fast here, before anything is written (the home for `persist: {}`, else the folder named).
366
+ if (config.persist) {
367
+ const { dir, file } = config.persist;
368
+ lockFolder(dir ?? (file ? path.dirname(path.resolve(file)) : this.home));
369
+ }
316
370
  // `whenBusy` (canonical) wins over legacy `mode`; anything else is "auto".
317
371
  const normalizedMode = resolveWhenBusy(config.midSession);
318
372
  const rawMaxQueued = (config.midSession as any)?.maxQueued;
@@ -347,6 +401,14 @@ export class Agent {
347
401
  );
348
402
  }
349
403
  this.cache = config.cache;
404
+ if (config.resume !== undefined && config.resume !== "automatic" && config.resume !== "explicit") {
405
+ throw new ConfigError(`[Agent Accelerator] Invalid resume ${JSON.stringify(config.resume)}: expected "automatic" (default) or "explicit".`);
406
+ }
407
+ this.resumeMode = config.resume ?? "automatic";
408
+ if (config.runRetries !== undefined && !(Number.isInteger(config.runRetries) && config.runRetries >= 0)) {
409
+ throw new ConfigError(`[Agent Accelerator] Invalid runRetries ${JSON.stringify(config.runRetries)}: expected a whole number >= 0 (default 3, 0 turns it off).`);
410
+ }
411
+ this.runRetries = config.runRetries ?? 3;
350
412
 
351
413
  this.serviceTier = config.serviceTier;
352
414
  this.onFailure =
@@ -400,8 +462,8 @@ export class Agent {
400
462
  );
401
463
  }
402
464
  if (dyn?.enabled === true) {
403
- const spawnTool = createSubagentSpawnTool(this);
404
- this.tools[spawnTool.name || "spawn_subagents"] = spawnTool;
465
+ const subagentsTool = createSubagentsTool(this);
466
+ this.tools[subagentsTool.name || "subagents"] = subagentsTool;
405
467
  }
406
468
  if (dyn?.allowScheduledActions) {
407
469
  // Fixed by config, so the tool set (and the prompt cache) stays stable.
@@ -415,6 +477,157 @@ export class Agent {
415
477
 
416
478
  this.context = new AgentContext(this.getFullInstructions());
417
479
  if (dyn?.allowScheduledActions) registerAgent(this);
480
+ if (this.recoverSession() && this.resumeMode === "automatic") {
481
+ // Synchronously, so a run() issued right after construction queues BEHIND the resumed run.
482
+ this.resume().catch((err) => this.reportResumeFailure(err));
483
+ }
484
+ }
485
+
486
+ /** The persistent sub-agent workers of this agent: `spawn`, `message`, `close`, `list`, `turns` (see `WorkerRegistry`). */
487
+ get workers(): WorkerRegistry {
488
+ return (this.workerRegistry ??= new WorkerRegistry(this));
489
+ }
490
+
491
+ // Loads a persisted session that a crash cut off (a run without an ending) or that still had waiting prompts, so it can be resumed; true when there is something to resume; in: the session folder of `persist.dir`, out: context, runMarks, toolRecords, pendingQueue.
492
+ private recoverSession(): boolean {
493
+ const root = this.persistConfig?.dir;
494
+ if (!root || this.stateless) return false;
495
+ let dir: string;
496
+ try {
497
+ dir = sessionDirFor(root, this.sessionId);
498
+ } catch {
499
+ return false; // not a usable session id for a folder name: nothing was ever saved under it
500
+ }
501
+ const session = loadSessionDir(dir)?.session; // a session over the load limits throws SessionLoadError: better than overwriting it
502
+ if (!session) return false;
503
+ const waiting = [...(session.pending?.steer ?? []), ...(session.pending?.queue ?? [])];
504
+ if (!(session.runs ?? []).some((r) => !r.end) && waiting.length === 0) return false;
505
+ this.context.messages = session.messages;
506
+ this.runMarks.splice(0, this.runMarks.length, ...(session.runs ?? []));
507
+ this.restoreUsage(session.usage);
508
+ const open = new Set(unfinishedToolCalls(this.context.messages).map((c) => c.id));
509
+ this.toolRecords.splice(0, this.toolRecords.length, ...(session.tools ?? []).filter((t) => open.has(t.id)));
510
+ for (const w of waiting) {
511
+ this.pendingQueue.push({
512
+ prompt: w.prompt,
513
+ options: w.additionalContext ? { additionalContext: w.additionalContext } : undefined,
514
+ isStream: false,
515
+ resolve: () => {},
516
+ reject: (err) => this.reportResumeFailure(err),
517
+ });
518
+ }
519
+ return true;
520
+ }
521
+
522
+ // Puts saved spending totals back after a restart or import (zeros when the file has none); in: a loaded session's `usage`, out: this.usageTotals.
523
+ private restoreUsage(saved: unknown): void {
524
+ Object.assign(this.usageTotals, readUsage(saved));
525
+ }
526
+
527
+ // Records one model call of THIS agent (priced per turn, with the service tier); in: the loop's onTurnUsage, out: this.usageTotals (saved by the next persist).
528
+ private recordTurnUsage(usage: TokenUsage, spec: Parameters<typeof priceUsage>[1]): void {
529
+ addTurn(this.usageTotals, usage, priceUsage(usage, spec, this.serviceTier ? { serviceTier: this.serviceTier } : undefined));
530
+ }
531
+
532
+ /** Adds another agent's spending to this one (a fixed worker's per-call copy into its template). @internal */
533
+ absorbUsage(other: UsageTotals): void {
534
+ mergeUsage(this.usageTotals, other);
535
+ }
536
+
537
+ /**
538
+ * What this agent and every worker below it spent: per agent and per worker (tokens, cache hits, thinking, exact
539
+ * cost per turn) plus a whole-session total, with nothing counted twice. `agent` is this agent's own model calls;
540
+ * workers (open, closed, nested, and rebuilt from disk after a restart) and fixed `subagents` workers are listed
541
+ * below it. Spending of one-shot scheduled-action workers is in their run files, not here.
542
+ */
543
+ usageReport(): UsageReport {
544
+ return buildUsageReport({ id: this.sessionId, name: this.name, model: modelName(this.modelStringOrSpec), totals: this.usageTotals }, [...this.workers.usageNodes(), ...this.fixedWorkerNodes()]);
545
+ }
546
+
547
+ // The `subagents: [...]` workers as usage nodes (their per-call copies' spending is merged into them); in: the constructor config, out: usageReport.
548
+ private fixedWorkerNodes(): UsageNode[] {
549
+ const nodes: UsageNode[] = [];
550
+ for (const entry of this.forkConfig.subagents ?? []) {
551
+ const worker = "agent" in entry ? entry.agent : entry;
552
+ const name = "agent" in entry ? entry.name : worker.name;
553
+ nodes.push({ id: name, name, model: modelName(worker.modelStringOrSpec), status: "fixed", totals: worker.usageTotals, workers: [] });
554
+ }
555
+ return nodes;
556
+ }
557
+
558
+ // Says on the telemetry bus that resuming (or a prompt that was waiting) failed; in: a rejected resume, out: telemetryBus.
559
+ private reportResumeFailure(err: unknown): void {
560
+ telemetryBus.emitWarning("agent.resume_failed", `[Agent Accelerator] Resuming session ${this.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`, { sessionId: this.sessionId });
561
+ }
562
+
563
+ /** Runs that a crash cut off (they began, never ended and are not running now), oldest first. Needs `persist: { dir }` and the same `sessionId` as before the restart. */
564
+ interrupted(): InterruptedRun[] {
565
+ const calls = unfinishedToolCalls(this.context.messages).map((c) => c.name);
566
+ return this.runMarks
567
+ .filter((m) => !m.end && m !== this.activeMark)
568
+ .map((m) => ({
569
+ runId: m.id,
570
+ startedAt: m.at,
571
+ prompt: this.previewPrompt((this.context.messages[m.i]?.content ?? "") as string | ContentPart[]),
572
+ pendingToolCalls: calls,
573
+ }));
574
+ }
575
+
576
+ /**
577
+ * Continues the run a crash cut off, without a new prompt: tool calls that already have a saved result are NOT run
578
+ * again (only calls with no result are), then the model carries on from the saved conversation, and prompts that
579
+ * were waiting run afterwards. Resolves to that run's response, or `undefined` when nothing was cut off. Returns the
580
+ * same promise while a resume is in flight. With `resume: "automatic"` (default) the constructor already did this.
581
+ */
582
+ resume(): Promise<AgentResponse | undefined> {
583
+ if (this.resuming) return this.resuming;
584
+ if (this.currentRun) throw new Error("[Agent Accelerator] resume() needs an idle agent: a run is already active.");
585
+ const mark = this.runMarks.filter((m) => !m.end && m !== this.activeMark).pop();
586
+ const last = this.context.messages[this.context.messages.length - 1];
587
+ const nothingOpen = unfinishedToolCalls(this.context.messages).length === 0;
588
+ if (!mark || (nothingOpen && (last?.role === "assistant" || endsWithStop(this.context.messages)))) {
589
+ // The answer was complete (or a tool had ended the run); only the note that it ended was lost.
590
+ if (mark) this.endRun(mark, endsWithStop(this.context.messages) ? "stopped" : "done");
591
+ this.pumpQueue();
592
+ return Promise.resolve(undefined);
593
+ }
594
+ telemetryBus.emit({
595
+ code: "agent.resumed",
596
+ message: `[Agent Accelerator] resuming run ${mark.id} of session ${this.sessionId}`,
597
+ data: { sessionId: this.sessionId, runId: mark.id, pendingToolCalls: unfinishedToolCalls(this.context.messages).map((c) => c.name) },
598
+ });
599
+ const run = this.startNonStreamingRun("", undefined, mark);
600
+ const clear = (): void => {
601
+ if (this.resuming === run) this.resuming = undefined;
602
+ };
603
+ this.resuming = run;
604
+ run.then(clear, clear);
605
+ return run;
606
+ }
607
+
608
+ // The finished tool calls of the current run as results the loop can reuse; in: toolRecords, out: loopConfig.resume.stored.
609
+ private storedToolResults(): Map<string, ToolResultRecord> {
610
+ return new Map(this.toolRecords.map((t) => [t.id, { id: t.id, name: t.name, result: t.result, ...(t.isError ? { isError: true } : {}), ...(t.stopped ? { stopped: true } : {}) } as ToolResultRecord]));
611
+ }
612
+
613
+ // Closes a run's mark with how it ended and saves that at once (not on the next tick), so the disk is up to date when `await run()` returns; in: the run's promise handlers, out: runMarks + journal.
614
+ private endRun(mark: SessionRunMark | undefined, status: SessionRunEnd): void {
615
+ if (!mark) return;
616
+ if (!mark.end) mark.end = status;
617
+ if (this.activeMark === mark) this.activeMark = undefined;
618
+ this.doPersist();
619
+ }
620
+
621
+ // Remembers a finished tool call and saves it at once (synchronously): the work is on disk before anything else can happen; in: the executor's per-call callback, out: toolRecords + journal.
622
+ private recordTool(result: ToolResultRecord): void {
623
+ this.toolRecords.push({
624
+ id: result.id,
625
+ name: result.name,
626
+ result: isSubagentResult(result.result) ? result.result.xml : result.result,
627
+ ...(result.isError ? { isError: true } : {}),
628
+ ...(result.stopped ? { stopped: true } : {}),
629
+ });
630
+ this.doPersist();
418
631
  }
419
632
 
420
633
  private getFullInstructions(): string | undefined {
@@ -432,26 +645,6 @@ export class Agent {
432
645
  "3. Once your reasoning is complete, output your final response or function calls directly in the standard response output.";
433
646
  out = out ? `${out}\n\n${guidance}` : guidance;
434
647
  }
435
- if (this.dynamicSubagents?.enabled === true && !out.includes("[Dynamic Sub-Agents]")) {
436
- const dyn = this.dynamicSubagents;
437
- const toolNames = Object.keys(dyn.tools);
438
- const timeoutNote =
439
- dyn.timeout === -1
440
- ? "Timeout policy: set a per-sub-agent timeoutMs (ms) for each task; omit it for no limit."
441
- : dyn.timeout === 0
442
- ? "Timeout policy: workers run with no time limit."
443
- : `Timeout policy: every worker is limited to ${dyn.timeout}ms; per-task timeouts are ignored.`;
444
- const policy =
445
- "[Dynamic Sub-Agents]\n" +
446
- `1. You may spawn at most ${dyn.maxSpawn} sub-agent(s) per spawn_subagents call. Extra tasks beyond ${dyn.maxSpawn} are ignored.\n` +
447
- "2. Workers are stateless: each receives one task, returns its result, then shuts down. No conversation history is kept.\n" +
448
- "3. You cannot choose worker models or reasoning levels — they are fixed by the developer.\n" +
449
- (toolNames.length > 0
450
- ? `4. Worker-available tools: ${toolNames.join(", ")}. Grant each worker ONLY the tools its task needs via the per-task tools list; omit it for no tools.\n`
451
- : "4. No worker tools are available; omit the per-task tools list.\n") +
452
- `5. ${timeoutNote}`;
453
- out = out ? `${out}\n\n${policy}` : policy;
454
- }
455
648
  return out || undefined;
456
649
  }
457
650
 
@@ -461,6 +654,7 @@ export class Agent {
461
654
  this.context.messages = [];
462
655
  this.context.thoughtSignatures = [];
463
656
  this.context.systemPrompt = this.getFullInstructions();
657
+ this.runMarks.length = 0;
464
658
  // Q-25: reset also drops session-scoped provider chaining/routing.
465
659
  try { clearInteractionChains(sid); } catch {}
466
660
  try { clearSessionRouting(sid); } catch {}
@@ -546,6 +740,7 @@ export class Agent {
546
740
  try { entry.reject(err); } catch {}
547
741
  try { entry.deferredStream?.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
548
742
  }
743
+ this.persistNow();
549
744
  return n;
550
745
  }
551
746
 
@@ -716,6 +911,7 @@ export class Agent {
716
911
  }
717
912
  return new Promise<AgentResponse>((resolve, reject) => {
718
913
  this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
914
+ this.persistNow();
719
915
  try {
720
916
  this.currentRun?.outerStream?.push({
721
917
  type: "queued",
@@ -730,6 +926,7 @@ export class Agent {
730
926
  if (this.currentRun !== null) return;
731
927
  const next = this.pendingQueue.shift();
732
928
  if (!next) return;
929
+ this.persistNow();
733
930
  // Q-20: reject queued items whose signal aborted before start.
734
931
  try {
735
932
  if ((next.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
@@ -788,6 +985,8 @@ export class Agent {
788
985
  if (leftover.length > 0) {
789
986
  try {
790
987
  const max = this.midSessionConfig.maxQueued ?? 20;
988
+ // Late steers were typed BEFORE anything already queued: they go to the front, in the order they arrived.
989
+ const lateSteers: Agent["pendingQueue"] = [];
791
990
  for (const entry of leftover) {
792
991
  try {
793
992
  if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
@@ -795,14 +994,14 @@ export class Agent {
795
994
  continue;
796
995
  }
797
996
  } catch { continue; }
798
- if (this.pendingQueue.length >= max) {
997
+ if (this.pendingQueue.length + lateSteers.length >= max) {
799
998
  entry.settle?.reject(new Error("[Agent Accelerator] Steer dropped: the follow-up queue is full."));
800
999
  continue;
801
1000
  }
802
1001
  // The steer was never injected: it now runs as a follow-up, and the
803
1002
  // caller's promise resolves with that run's response.
804
1003
  if (entry.settle) entry.settle.deferredToQueue = true;
805
- this.pendingQueue.push({
1004
+ lateSteers.push({
806
1005
  prompt: entry.prompt,
807
1006
  options: entry.options,
808
1007
  isStream: false,
@@ -810,8 +1009,10 @@ export class Agent {
810
1009
  reject: entry.settle?.reject ?? (() => {}),
811
1010
  });
812
1011
  }
1012
+ this.pendingQueue.unshift(...lateSteers);
813
1013
  } catch {}
814
1014
  }
1015
+ this.persistNow();
815
1016
  // Defer pump so the completing run's .then handlers settle first.
816
1017
  queueMicrotask(() => this.pumpQueue());
817
1018
  }
@@ -962,9 +1163,11 @@ export class Agent {
962
1163
 
963
1164
  private startNonStreamingRun(
964
1165
  prompt: string | ContentPart[],
965
- options?: AgentRunOptions
1166
+ options?: AgentRunOptions,
1167
+ resumeMark?: SessionRunMark
966
1168
  ): Promise<AgentResponse> {
967
1169
  this.resetBudgetState();
1170
+ let mark: SessionRunMark | undefined;
968
1171
  const runController = new AbortController();
969
1172
  const steerInbox: SteerEntry[] = [];
970
1173
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
@@ -978,7 +1181,14 @@ export class Agent {
978
1181
  if (effectiveLevel) {
979
1182
  validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
980
1183
  }
981
- this.prepareTurn(prompt, runOptions, targetContext);
1184
+ if (resumeMark) {
1185
+ // Continuing a cut-off run: no new prompt, the saved conversation is the input.
1186
+ mark = resumeMark;
1187
+ this.activeMark = mark;
1188
+ targetContext.systemPrompt = this.getFullInstructions();
1189
+ } else {
1190
+ mark = this.prepareTurn(prompt, runOptions, targetContext);
1191
+ }
982
1192
  this.persistNow();
983
1193
 
984
1194
  const providerOptions = this.buildProviderOptions(runOptions);
@@ -997,6 +1207,10 @@ export class Agent {
997
1207
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
998
1208
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
999
1209
  onProgress: () => this.persistNow(),
1210
+ onTurnUsage: (u: TokenUsage, spec: Parameters<typeof priceUsage>[1]) => this.recordTurnUsage(u, spec),
1211
+ onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1212
+ openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1213
+ ...(resumeMark ? { resume: { stored: this.storedToolResults() } } : {}),
1000
1214
  steerInbox,
1001
1215
  onSteerInjected: () => this.persistNow(),
1002
1216
  // Q-22: loop enforces these (plus local timeout races).
@@ -1004,7 +1218,28 @@ export class Agent {
1004
1218
  ...(this.output !== undefined ? { output: this.output } : {}),
1005
1219
  };
1006
1220
 
1007
- const res = await runAgentLoop(loopConfig);
1221
+ // A run that failed for a transient reason is tried again from where it stopped (the prompt is already in history,
1222
+ // finished tool calls are kept), holding the agent busy meanwhile; anything else fails at once.
1223
+ let res: AgentResponse;
1224
+ const failedAttempts: FailedAttempt[] = [];
1225
+ for (let attempt = 0; ; attempt++) {
1226
+ try {
1227
+ res = await runAgentLoop(attempt === 0 ? loopConfig : { ...loopConfig, resume: { stored: this.storedToolResults() } });
1228
+ break;
1229
+ } catch (err) {
1230
+ if (attempt >= this.runRetries || runOptions?.signal?.aborted || !isRetryableRun(err)) throw err;
1231
+ const partial = (err as { partial?: FailedAttempt }).partial;
1232
+ if (partial?.usage) failedAttempts.push(partial);
1233
+ const delayMs = Math.max(RUN_RETRY_DELAYS_MS[Math.min(attempt, RUN_RETRY_DELAYS_MS.length - 1)]!, readRetryAfterMs(err) ?? 0);
1234
+ telemetryBus.emitWarning(
1235
+ "agent.run_retry",
1236
+ `[Agent Accelerator] Run failed (${err instanceof Error ? err.message : String(err)}); retry ${attempt + 1} of ${this.runRetries} in ${delayMs} ms.`,
1237
+ { sessionId: this.sessionId, attempt: attempt + 1, of: this.runRetries, delayMs }
1238
+ );
1239
+ await sleep(delayMs, runOptions?.signal);
1240
+ }
1241
+ }
1242
+ if (failedAttempts.length > 0) res = withFailedAttempts(res, failedAttempts);
1008
1243
  this.mergeSubagentTraces(res.subagents as any);
1009
1244
  this.persistNow();
1010
1245
  if (this.stateless) {
@@ -1015,6 +1250,10 @@ export class Agent {
1015
1250
  })();
1016
1251
 
1017
1252
  this.currentRun = { steerInbox, abortController: runController, runPromise: task };
1253
+ task.then(
1254
+ (res) => this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done"),
1255
+ (err) => this.endRun(mark, isAbortError(err) ? "aborted" : "failed")
1256
+ );
1018
1257
  const cleanup = () => {
1019
1258
  try { this.cleanupMerge(runController); } catch {}
1020
1259
  if (this.currentRun?.runPromise === task) this.finishCurrentRun();
@@ -1034,7 +1273,7 @@ export class Agent {
1034
1273
  }
1035
1274
  // Q-19a: named session routing (default session for compat).
1036
1275
  const targetContext = this.getSessionContext(options?.sessionId || this.sessionId);
1037
- this.prepareTurn(prompt, options, targetContext);
1276
+ const mark = this.prepareTurn(prompt, options, targetContext);
1038
1277
  this.persistNow();
1039
1278
 
1040
1279
  this.resetBudgetState();
@@ -1059,6 +1298,9 @@ export class Agent {
1059
1298
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1060
1299
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
1061
1300
  onProgress: () => this.persistNow(),
1301
+ onTurnUsage: (u: TokenUsage, spec: Parameters<typeof priceUsage>[1]) => this.recordTurnUsage(u, spec),
1302
+ onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1303
+ openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1062
1304
  steerInbox,
1063
1305
  onSteerInjected: () => this.persistNow(),
1064
1306
  // Q-22: loop enforces these (plus local timeout races).
@@ -1070,6 +1312,7 @@ export class Agent {
1070
1312
  this.currentRun = { steerInbox, outerStream: s, abortController: runController };
1071
1313
  s.result().then(
1072
1314
  (res) => {
1315
+ this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done");
1073
1316
  try {
1074
1317
  this.mergeSubagentTraces(res.subagents as any);
1075
1318
  this.persistNow();
@@ -1081,7 +1324,8 @@ export class Agent {
1081
1324
  }
1082
1325
  if (this.currentRun?.outerStream === s) this.finishCurrentRun();
1083
1326
  },
1084
- () => {
1327
+ (err) => {
1328
+ this.endRun(mark, isAbortError(err) ? "aborted" : "failed");
1085
1329
  try { this.cleanupMerge(runController); } catch {}
1086
1330
  if (this.currentRun?.outerStream === s) this.finishCurrentRun();
1087
1331
  }
@@ -1168,6 +1412,25 @@ export class Agent {
1168
1412
  });
1169
1413
  }
1170
1414
 
1415
+ // Opens the log file of one tool call when this Agent persists its default session to a folder, else nothing; in: the executor, once per call, out: a writer for `tool-logs/<n>-<tool>.ndjson`.
1416
+ private openToolLog(call: { name: string }, sessionId: string | undefined): ToolLogWriter | undefined {
1417
+ const root = this.persistConfig?.dir;
1418
+ if (!root || this.stateless || sessionId !== this.sessionId) return undefined;
1419
+ const dir = sessionDirFor(root, this.sessionId);
1420
+ if (this.toolLogsDir !== dir) {
1421
+ this.toolLogs = new ToolLogs(dir);
1422
+ this.toolLogsDir = dir;
1423
+ }
1424
+ return this.toolLogs!.open(call);
1425
+ }
1426
+
1427
+ /** The step journal of this session's folder (recreated when `sessionId` changes). */
1428
+ private journalFor(root: string): SessionJournal {
1429
+ const dir = sessionDirFor(root, this.sessionId);
1430
+ if (this.journal?.dir !== dir) this.journal = new SessionJournal(dir);
1431
+ return this.journal;
1432
+ }
1433
+
1171
1434
  /** Synchronous persistence write behind the coalesced `persistNow()` gate. */
1172
1435
  private doPersist(): void {
1173
1436
  const cfg = this.persistConfig;
@@ -1176,7 +1439,7 @@ export class Agent {
1176
1439
  if (this.stateless && !(cfg as unknown as { allowStateless?: unknown }).allowStateless) return;
1177
1440
  } catch {}
1178
1441
  try {
1179
- if (cfg.dir) saveSessionDir(cfg.dir, this as any, null);
1442
+ if (cfg.dir) this.journalFor(cfg.dir).sync(this as any);
1180
1443
  else if (cfg.file) saveSessionFile(cfg.file, this as any, null);
1181
1444
  } catch (err) {
1182
1445
  // Q-28: surface via hook instead of swallowing silently.
@@ -1285,6 +1548,8 @@ export class Agent {
1285
1548
  this.context.messages = Array.isArray(data.messages) ? [...data.messages] : [];
1286
1549
  }
1287
1550
  this.context.thoughtSignatures = [];
1551
+ this.runMarks.splice(0, this.runMarks.length, ...(data.runs ?? []).filter((r) => r.i < this.context.messages.length));
1552
+ this.restoreUsage(data.usage);
1288
1553
  if (data.systemPrompt) {
1289
1554
  this.context.systemPrompt = data.systemPrompt;
1290
1555
  } else {
@@ -1324,7 +1589,7 @@ export class Agent {
1324
1589
  } catch {}
1325
1590
  }
1326
1591
 
1327
- private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): void {
1592
+ private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): SessionRunMark | undefined {
1328
1593
  // Q-20: prompt validation throws synchronously in both run() and
1329
1594
  // stream() (same timing; documented here).
1330
1595
  if (Array.isArray(prompt) && prompt.length === 0) {
@@ -1352,7 +1617,28 @@ export class Agent {
1352
1617
  } else if (ctx.systemPrompt !== fullInstructions) {
1353
1618
  ctx.systemPrompt = fullInstructions;
1354
1619
  }
1620
+ const mark = ctx === this.context ? this.markRunStart(ctx.messages.length) : undefined;
1355
1621
  ctx.addUserMessage(prompt);
1622
+ return mark;
1623
+ }
1624
+
1625
+ // Records where a run of the default session begins; in: prepareTurn, out: runMarks, saved by the session journal.
1626
+ private markRunStart(index: number): SessionRunMark {
1627
+ for (const old of this.runMarks) if (!old.end) old.end = "abandoned"; // a newer run replaces a cut-off one
1628
+ this.toolRecords.length = 0;
1629
+ const mark: SessionRunMark = { id: newRunId(), i: index, at: Date.now() };
1630
+ this.runMarks.push(mark);
1631
+ this.activeMark = mark;
1632
+ return mark;
1633
+ }
1634
+
1635
+ /** Prompts still waiting (steers not yet injected, queued follow-ups), as the session journal saves them. @internal */
1636
+ pendingPrompts(): SessionPending {
1637
+ const pack = (e: { prompt: string | ContentPart[]; options?: AgentRunOptions }) => ({
1638
+ prompt: e.prompt,
1639
+ ...(e.options?.additionalContext ? { additionalContext: e.options.additionalContext } : {}),
1640
+ });
1641
+ return { steer: (this.currentRun?.steerInbox ?? []).map(pack), queue: this.pendingQueue.map(pack) };
1356
1642
  }
1357
1643
 
1358
1644
  /**
@@ -1432,6 +1718,7 @@ export class Agent {
1432
1718
  }
1433
1719
  return new Promise<AgentResponse>((resolve, reject) => {
1434
1720
  this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
1721
+ this.persistNow();
1435
1722
  try {
1436
1723
  this.currentRun?.outerStream?.push({
1437
1724
  type: "queued",
@@ -1615,6 +1902,7 @@ export class Agent {
1615
1902
  } catch {}
1616
1903
  });
1617
1904
  this.pendingQueue.push(queuedEntry as any);
1905
+ this.persistNow();
1618
1906
  // Attach deferred completion to queued promise without exposing it.
1619
1907
  // Real events are piped in pumpQueue() when the turn starts.
1620
1908
  void done.then(
@@ -1852,6 +2140,35 @@ export class Agent {
1852
2140
  }
1853
2141
  }
1854
2142
 
2143
+ /** What a failed run attempt got done before it failed (the `partial` the loop attaches to its error). */
2144
+ type FailedAttempt = Partial<AgentResponse> & { usage: TokenUsage };
2145
+
2146
+ // Adds what failed attempts already did (tool calls, results, turns, token usage) to the response of the attempt that finally worked, so a retry never hides side effects or cost; in: the final response + each failed attempt's `err.partial`, out: the run's AgentResponse.
2147
+ function withFailedAttempts(res: AgentResponse, failed: FailedAttempt[]): AgentResponse {
2148
+ const usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
2149
+ for (const f of failed) accumulateUsage(usage, f.usage);
2150
+ accumulateUsage(usage, res.usage);
2151
+ return new AgentResponse({
2152
+ text: res.text,
2153
+ ...(res.parsed !== undefined ? { parsed: res.parsed } : {}),
2154
+ thinking: res.thinking,
2155
+ thoughtSignature: res.thoughtSignature,
2156
+ toolCalls: [...failed.flatMap((f) => f.toolCalls ?? []), ...res.toolCalls],
2157
+ toolResults: [...failed.flatMap((f) => f.toolResults ?? []), ...res.toolResults],
2158
+ subagents: res.subagents,
2159
+ usage,
2160
+ responseId: res.responseId,
2161
+ model: res.model,
2162
+ provider: res.provider,
2163
+ finishReason: res.finishReason,
2164
+ rawFinishReason: res.rawFinishReason,
2165
+ durationMs: res.durationMs,
2166
+ raw: res.raw,
2167
+ turns: res.turns + failed.reduce((n, f) => n + (typeof f.turns === "number" ? f.turns : 0), 0),
2168
+ providerSteps: res.providerSteps,
2169
+ });
2170
+ }
2171
+
1855
2172
  /** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
1856
2173
  export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
1857
2174
  // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max