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.
@@ -1,8 +1,9 @@
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";
@@ -19,10 +20,12 @@ import * as path from "node:path";
19
20
  import { registerAgent } from "../scheduler/scheduler.ts";
20
21
  import { resolveHome } from "../scheduler/store.ts";
21
22
  import { persistTelemetry } from "../telemetry-file.ts";
23
+ import { lockFolder } from "../utils/durable.ts";
22
24
  import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
23
25
  import { assertNoToolCollisions } from "../tools/executor.ts";
24
- import { runAgentLoop, streamAgentLoop, type SteerEntry } from "./loop.ts";
25
- import { createSessionId, nowMs } from "../utils/session.ts";
26
+ import { runAgentLoop, streamAgentLoop, unfinishedToolCalls, endsWithStop, accumulateUsage, type SteerEntry } from "./loop.ts";
27
+ import { isRetryableRun, readRetryAfterMs, sleep } from "../utils/retry.ts";
28
+ import { createSessionId, nowMs, newRunId } from "../utils/session.ts";
26
29
  import {
27
30
  chargeBudgetUsage,
28
31
  checkBudget,
@@ -31,7 +34,9 @@ import {
31
34
  listSubAgentTraceIdsForSessions,
32
35
  type SubAgentTrace,
33
36
  } from "./delegation.ts";
34
- import { saveSessionDir, saveSessionFile, findInvalidMessage, telemetryBus } from "../session/store.ts";
37
+ import { saveSessionFile, sessionDirFor, loadSessionDir, findInvalidMessage, telemetryBus, type SessionRunMark, type SessionRunEnd, type SessionPending, type SessionToolRecord } from "../session/store.ts";
38
+ import { SessionJournal } from "../session/journal.ts";
39
+ import { ToolLogs, type ToolLogWriter } from "../session/tool-log.ts";
35
40
  import { isLocalPathInput } from "../utils/safe-io.ts";
36
41
  import { clearSessionRouting, normalizeStructuredOutput, normalizeStop } from "../providers.ts";
37
42
  import type { StructuredOutputSpec } from "../types/model.ts";
@@ -53,7 +58,7 @@ export function resolveEffectiveThinking(
53
58
  return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
54
59
  }
55
60
 
56
- import { ConfigError, SessionLoadError } from "../types/errors.ts";
61
+ import { ConfigError, SessionLoadError, isAbortError } from "../types/errors.ts";
57
62
 
58
63
  /**
59
64
  * Disposers for the manual user-signal forward listeners (Q-21 fallback when
@@ -62,6 +67,9 @@ import { ConfigError, SessionLoadError } from "../types/errors.ts";
62
67
  */
63
68
  const mergeDisposers = new WeakMap<AbortController, () => void>();
64
69
 
70
+ /** Waits before run retry 1, 2, 3 ... (the last one repeats). Module-level so tests can shrink it; not public API. */
71
+ export const RUN_RETRY_DELAYS_MS: number[] = [5_000, 15_000, 45_000, 60_000];
72
+
65
73
  /** Max named sessions kept per Agent (Q-19a LRU). */
66
74
  const SESSION_LRU_MAX = 100;
67
75
  /** Idle TTL for named sessions (Q-19a). */
@@ -246,6 +254,23 @@ export class Agent {
246
254
  private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
247
255
  /** Coalesced persistence single-flight flag (Q-28). */
248
256
  private persistScheduled = false;
257
+ /** Step journal of the persisted session folder (dir mode only). */
258
+ private journal?: SessionJournal;
259
+ /** Log files of tool calls in the persisted session folder (dir mode only). */
260
+ private toolLogs?: ToolLogs;
261
+ private toolLogsDir?: string;
262
+ /** Where each run of the default session began (saved with the session). @internal */
263
+ readonly runMarks: SessionRunMark[] = [];
264
+ /** Tool calls finished in the current run; saved the moment each finishes so a crash never repeats them. @internal */
265
+ readonly toolRecords: SessionToolRecord[] = [];
266
+ /** The run in flight (its mark has no ending yet, but it is not "interrupted"). */
267
+ private activeMark?: SessionRunMark;
268
+ /** What to do with a cut-off run at construction (see `AgentConfig.resume`). */
269
+ private readonly resumeMode: "automatic" | "explicit";
270
+ /** How often a failed run is tried again (see `AgentConfig.runRetries`). */
271
+ private readonly runRetries: number;
272
+ /** The resume in flight, so a second `resume()` returns the same promise. */
273
+ private resuming?: Promise<AgentResponse | undefined>;
249
274
 
250
275
  /**
251
276
  * Creates an agent and registers its model, tools, cache, and delegation settings.
@@ -313,6 +338,11 @@ export class Agent {
313
338
  config.persist && !config.persist.dir && !config.persist.file
314
339
  ? { ...config.persist, dir: path.join(this.home, "sessions") }
315
340
  : config.persist;
341
+ // One process per folder: fail fast here, before anything is written (the home for `persist: {}`, else the folder named).
342
+ if (config.persist) {
343
+ const { dir, file } = config.persist;
344
+ lockFolder(dir ?? (file ? path.dirname(path.resolve(file)) : this.home));
345
+ }
316
346
  // `whenBusy` (canonical) wins over legacy `mode`; anything else is "auto".
317
347
  const normalizedMode = resolveWhenBusy(config.midSession);
318
348
  const rawMaxQueued = (config.midSession as any)?.maxQueued;
@@ -347,6 +377,14 @@ export class Agent {
347
377
  );
348
378
  }
349
379
  this.cache = config.cache;
380
+ if (config.resume !== undefined && config.resume !== "automatic" && config.resume !== "explicit") {
381
+ throw new ConfigError(`[Agent Accelerator] Invalid resume ${JSON.stringify(config.resume)}: expected "automatic" (default) or "explicit".`);
382
+ }
383
+ this.resumeMode = config.resume ?? "automatic";
384
+ if (config.runRetries !== undefined && !(Number.isInteger(config.runRetries) && config.runRetries >= 0)) {
385
+ throw new ConfigError(`[Agent Accelerator] Invalid runRetries ${JSON.stringify(config.runRetries)}: expected a whole number >= 0 (default 3, 0 turns it off).`);
386
+ }
387
+ this.runRetries = config.runRetries ?? 3;
350
388
 
351
389
  this.serviceTier = config.serviceTier;
352
390
  this.onFailure =
@@ -415,6 +453,115 @@ export class Agent {
415
453
 
416
454
  this.context = new AgentContext(this.getFullInstructions());
417
455
  if (dyn?.allowScheduledActions) registerAgent(this);
456
+ if (this.recoverSession() && this.resumeMode === "automatic") {
457
+ // Synchronously, so a run() issued right after construction queues BEHIND the resumed run.
458
+ this.resume().catch((err) => this.reportResumeFailure(err));
459
+ }
460
+ }
461
+
462
+ // 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.
463
+ private recoverSession(): boolean {
464
+ const root = this.persistConfig?.dir;
465
+ if (!root || this.stateless) return false;
466
+ let dir: string;
467
+ try {
468
+ dir = sessionDirFor(root, this.sessionId);
469
+ } catch {
470
+ return false; // not a usable session id for a folder name: nothing was ever saved under it
471
+ }
472
+ const session = loadSessionDir(dir)?.session; // a session over the load limits throws SessionLoadError: better than overwriting it
473
+ if (!session) return false;
474
+ const waiting = [...(session.pending?.steer ?? []), ...(session.pending?.queue ?? [])];
475
+ if (!(session.runs ?? []).some((r) => !r.end) && waiting.length === 0) return false;
476
+ this.context.messages = session.messages;
477
+ this.runMarks.splice(0, this.runMarks.length, ...(session.runs ?? []));
478
+ const open = new Set(unfinishedToolCalls(this.context.messages).map((c) => c.id));
479
+ this.toolRecords.splice(0, this.toolRecords.length, ...(session.tools ?? []).filter((t) => open.has(t.id)));
480
+ for (const w of waiting) {
481
+ this.pendingQueue.push({
482
+ prompt: w.prompt,
483
+ options: w.additionalContext ? { additionalContext: w.additionalContext } : undefined,
484
+ isStream: false,
485
+ resolve: () => {},
486
+ reject: (err) => this.reportResumeFailure(err),
487
+ });
488
+ }
489
+ return true;
490
+ }
491
+
492
+ // Says on the telemetry bus that resuming (or a prompt that was waiting) failed; in: a rejected resume, out: telemetryBus.
493
+ private reportResumeFailure(err: unknown): void {
494
+ telemetryBus.emitWarning("agent.resume_failed", `[Agent Accelerator] Resuming session ${this.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`, { sessionId: this.sessionId });
495
+ }
496
+
497
+ /** 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. */
498
+ interrupted(): InterruptedRun[] {
499
+ const calls = unfinishedToolCalls(this.context.messages).map((c) => c.name);
500
+ return this.runMarks
501
+ .filter((m) => !m.end && m !== this.activeMark)
502
+ .map((m) => ({
503
+ runId: m.id,
504
+ startedAt: m.at,
505
+ prompt: this.previewPrompt((this.context.messages[m.i]?.content ?? "") as string | ContentPart[]),
506
+ pendingToolCalls: calls,
507
+ }));
508
+ }
509
+
510
+ /**
511
+ * Continues the run a crash cut off, without a new prompt: tool calls that already have a saved result are NOT run
512
+ * again (only calls with no result are), then the model carries on from the saved conversation, and prompts that
513
+ * were waiting run afterwards. Resolves to that run's response, or `undefined` when nothing was cut off. Returns the
514
+ * same promise while a resume is in flight. With `resume: "automatic"` (default) the constructor already did this.
515
+ */
516
+ resume(): Promise<AgentResponse | undefined> {
517
+ if (this.resuming) return this.resuming;
518
+ if (this.currentRun) throw new Error("[Agent Accelerator] resume() needs an idle agent: a run is already active.");
519
+ const mark = this.runMarks.filter((m) => !m.end && m !== this.activeMark).pop();
520
+ const last = this.context.messages[this.context.messages.length - 1];
521
+ const nothingOpen = unfinishedToolCalls(this.context.messages).length === 0;
522
+ if (!mark || (nothingOpen && (last?.role === "assistant" || endsWithStop(this.context.messages)))) {
523
+ // The answer was complete (or a tool had ended the run); only the note that it ended was lost.
524
+ if (mark) this.endRun(mark, endsWithStop(this.context.messages) ? "stopped" : "done");
525
+ this.pumpQueue();
526
+ return Promise.resolve(undefined);
527
+ }
528
+ telemetryBus.emit({
529
+ code: "agent.resumed",
530
+ message: `[Agent Accelerator] resuming run ${mark.id} of session ${this.sessionId}`,
531
+ data: { sessionId: this.sessionId, runId: mark.id, pendingToolCalls: unfinishedToolCalls(this.context.messages).map((c) => c.name) },
532
+ });
533
+ const run = this.startNonStreamingRun("", undefined, mark);
534
+ const clear = (): void => {
535
+ if (this.resuming === run) this.resuming = undefined;
536
+ };
537
+ this.resuming = run;
538
+ run.then(clear, clear);
539
+ return run;
540
+ }
541
+
542
+ // The finished tool calls of the current run as results the loop can reuse; in: toolRecords, out: loopConfig.resume.stored.
543
+ private storedToolResults(): Map<string, ToolResultRecord> {
544
+ 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]));
545
+ }
546
+
547
+ // 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.
548
+ private endRun(mark: SessionRunMark | undefined, status: SessionRunEnd): void {
549
+ if (!mark) return;
550
+ if (!mark.end) mark.end = status;
551
+ if (this.activeMark === mark) this.activeMark = undefined;
552
+ this.doPersist();
553
+ }
554
+
555
+ // 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.
556
+ private recordTool(result: ToolResultRecord): void {
557
+ this.toolRecords.push({
558
+ id: result.id,
559
+ name: result.name,
560
+ result: isSubagentResult(result.result) ? result.result.xml : result.result,
561
+ ...(result.isError ? { isError: true } : {}),
562
+ ...(result.stopped ? { stopped: true } : {}),
563
+ });
564
+ this.doPersist();
418
565
  }
419
566
 
420
567
  private getFullInstructions(): string | undefined {
@@ -461,6 +608,7 @@ export class Agent {
461
608
  this.context.messages = [];
462
609
  this.context.thoughtSignatures = [];
463
610
  this.context.systemPrompt = this.getFullInstructions();
611
+ this.runMarks.length = 0;
464
612
  // Q-25: reset also drops session-scoped provider chaining/routing.
465
613
  try { clearInteractionChains(sid); } catch {}
466
614
  try { clearSessionRouting(sid); } catch {}
@@ -546,6 +694,7 @@ export class Agent {
546
694
  try { entry.reject(err); } catch {}
547
695
  try { entry.deferredStream?.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
548
696
  }
697
+ this.persistNow();
549
698
  return n;
550
699
  }
551
700
 
@@ -716,6 +865,7 @@ export class Agent {
716
865
  }
717
866
  return new Promise<AgentResponse>((resolve, reject) => {
718
867
  this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
868
+ this.persistNow();
719
869
  try {
720
870
  this.currentRun?.outerStream?.push({
721
871
  type: "queued",
@@ -730,6 +880,7 @@ export class Agent {
730
880
  if (this.currentRun !== null) return;
731
881
  const next = this.pendingQueue.shift();
732
882
  if (!next) return;
883
+ this.persistNow();
733
884
  // Q-20: reject queued items whose signal aborted before start.
734
885
  try {
735
886
  if ((next.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
@@ -788,6 +939,8 @@ export class Agent {
788
939
  if (leftover.length > 0) {
789
940
  try {
790
941
  const max = this.midSessionConfig.maxQueued ?? 20;
942
+ // Late steers were typed BEFORE anything already queued: they go to the front, in the order they arrived.
943
+ const lateSteers: Agent["pendingQueue"] = [];
791
944
  for (const entry of leftover) {
792
945
  try {
793
946
  if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
@@ -795,14 +948,14 @@ export class Agent {
795
948
  continue;
796
949
  }
797
950
  } catch { continue; }
798
- if (this.pendingQueue.length >= max) {
951
+ if (this.pendingQueue.length + lateSteers.length >= max) {
799
952
  entry.settle?.reject(new Error("[Agent Accelerator] Steer dropped: the follow-up queue is full."));
800
953
  continue;
801
954
  }
802
955
  // The steer was never injected: it now runs as a follow-up, and the
803
956
  // caller's promise resolves with that run's response.
804
957
  if (entry.settle) entry.settle.deferredToQueue = true;
805
- this.pendingQueue.push({
958
+ lateSteers.push({
806
959
  prompt: entry.prompt,
807
960
  options: entry.options,
808
961
  isStream: false,
@@ -810,8 +963,10 @@ export class Agent {
810
963
  reject: entry.settle?.reject ?? (() => {}),
811
964
  });
812
965
  }
966
+ this.pendingQueue.unshift(...lateSteers);
813
967
  } catch {}
814
968
  }
969
+ this.persistNow();
815
970
  // Defer pump so the completing run's .then handlers settle first.
816
971
  queueMicrotask(() => this.pumpQueue());
817
972
  }
@@ -962,9 +1117,11 @@ export class Agent {
962
1117
 
963
1118
  private startNonStreamingRun(
964
1119
  prompt: string | ContentPart[],
965
- options?: AgentRunOptions
1120
+ options?: AgentRunOptions,
1121
+ resumeMark?: SessionRunMark
966
1122
  ): Promise<AgentResponse> {
967
1123
  this.resetBudgetState();
1124
+ let mark: SessionRunMark | undefined;
968
1125
  const runController = new AbortController();
969
1126
  const steerInbox: SteerEntry[] = [];
970
1127
  const mergedSignal = this.mergeUserSignal(options?.signal, runController);
@@ -978,7 +1135,14 @@ export class Agent {
978
1135
  if (effectiveLevel) {
979
1136
  validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
980
1137
  }
981
- this.prepareTurn(prompt, runOptions, targetContext);
1138
+ if (resumeMark) {
1139
+ // Continuing a cut-off run: no new prompt, the saved conversation is the input.
1140
+ mark = resumeMark;
1141
+ this.activeMark = mark;
1142
+ targetContext.systemPrompt = this.getFullInstructions();
1143
+ } else {
1144
+ mark = this.prepareTurn(prompt, runOptions, targetContext);
1145
+ }
982
1146
  this.persistNow();
983
1147
 
984
1148
  const providerOptions = this.buildProviderOptions(runOptions);
@@ -997,6 +1161,9 @@ export class Agent {
997
1161
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
998
1162
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
999
1163
  onProgress: () => this.persistNow(),
1164
+ onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1165
+ openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1166
+ ...(resumeMark ? { resume: { stored: this.storedToolResults() } } : {}),
1000
1167
  steerInbox,
1001
1168
  onSteerInjected: () => this.persistNow(),
1002
1169
  // Q-22: loop enforces these (plus local timeout races).
@@ -1004,7 +1171,28 @@ export class Agent {
1004
1171
  ...(this.output !== undefined ? { output: this.output } : {}),
1005
1172
  };
1006
1173
 
1007
- const res = await runAgentLoop(loopConfig);
1174
+ // A run that failed for a transient reason is tried again from where it stopped (the prompt is already in history,
1175
+ // finished tool calls are kept), holding the agent busy meanwhile; anything else fails at once.
1176
+ let res: AgentResponse;
1177
+ const failedAttempts: FailedAttempt[] = [];
1178
+ for (let attempt = 0; ; attempt++) {
1179
+ try {
1180
+ res = await runAgentLoop(attempt === 0 ? loopConfig : { ...loopConfig, resume: { stored: this.storedToolResults() } });
1181
+ break;
1182
+ } catch (err) {
1183
+ if (attempt >= this.runRetries || runOptions?.signal?.aborted || !isRetryableRun(err)) throw err;
1184
+ const partial = (err as { partial?: FailedAttempt }).partial;
1185
+ if (partial?.usage) failedAttempts.push(partial);
1186
+ const delayMs = Math.max(RUN_RETRY_DELAYS_MS[Math.min(attempt, RUN_RETRY_DELAYS_MS.length - 1)]!, readRetryAfterMs(err) ?? 0);
1187
+ telemetryBus.emitWarning(
1188
+ "agent.run_retry",
1189
+ `[Agent Accelerator] Run failed (${err instanceof Error ? err.message : String(err)}); retry ${attempt + 1} of ${this.runRetries} in ${delayMs} ms.`,
1190
+ { sessionId: this.sessionId, attempt: attempt + 1, of: this.runRetries, delayMs }
1191
+ );
1192
+ await sleep(delayMs, runOptions?.signal);
1193
+ }
1194
+ }
1195
+ if (failedAttempts.length > 0) res = withFailedAttempts(res, failedAttempts);
1008
1196
  this.mergeSubagentTraces(res.subagents as any);
1009
1197
  this.persistNow();
1010
1198
  if (this.stateless) {
@@ -1015,6 +1203,10 @@ export class Agent {
1015
1203
  })();
1016
1204
 
1017
1205
  this.currentRun = { steerInbox, abortController: runController, runPromise: task };
1206
+ task.then(
1207
+ (res) => this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done"),
1208
+ (err) => this.endRun(mark, isAbortError(err) ? "aborted" : "failed")
1209
+ );
1018
1210
  const cleanup = () => {
1019
1211
  try { this.cleanupMerge(runController); } catch {}
1020
1212
  if (this.currentRun?.runPromise === task) this.finishCurrentRun();
@@ -1034,7 +1226,7 @@ export class Agent {
1034
1226
  }
1035
1227
  // Q-19a: named session routing (default session for compat).
1036
1228
  const targetContext = this.getSessionContext(options?.sessionId || this.sessionId);
1037
- this.prepareTurn(prompt, options, targetContext);
1229
+ const mark = this.prepareTurn(prompt, options, targetContext);
1038
1230
  this.persistNow();
1039
1231
 
1040
1232
  this.resetBudgetState();
@@ -1059,6 +1251,8 @@ export class Agent {
1059
1251
  ...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
1060
1252
  ...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
1061
1253
  onProgress: () => this.persistNow(),
1254
+ onToolResult: (r: ToolResultRecord) => this.recordTool(r),
1255
+ openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
1062
1256
  steerInbox,
1063
1257
  onSteerInjected: () => this.persistNow(),
1064
1258
  // Q-22: loop enforces these (plus local timeout races).
@@ -1070,6 +1264,7 @@ export class Agent {
1070
1264
  this.currentRun = { steerInbox, outerStream: s, abortController: runController };
1071
1265
  s.result().then(
1072
1266
  (res) => {
1267
+ this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done");
1073
1268
  try {
1074
1269
  this.mergeSubagentTraces(res.subagents as any);
1075
1270
  this.persistNow();
@@ -1081,7 +1276,8 @@ export class Agent {
1081
1276
  }
1082
1277
  if (this.currentRun?.outerStream === s) this.finishCurrentRun();
1083
1278
  },
1084
- () => {
1279
+ (err) => {
1280
+ this.endRun(mark, isAbortError(err) ? "aborted" : "failed");
1085
1281
  try { this.cleanupMerge(runController); } catch {}
1086
1282
  if (this.currentRun?.outerStream === s) this.finishCurrentRun();
1087
1283
  }
@@ -1168,6 +1364,25 @@ export class Agent {
1168
1364
  });
1169
1365
  }
1170
1366
 
1367
+ // 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`.
1368
+ private openToolLog(call: { name: string }, sessionId: string | undefined): ToolLogWriter | undefined {
1369
+ const root = this.persistConfig?.dir;
1370
+ if (!root || this.stateless || sessionId !== this.sessionId) return undefined;
1371
+ const dir = sessionDirFor(root, this.sessionId);
1372
+ if (this.toolLogsDir !== dir) {
1373
+ this.toolLogs = new ToolLogs(dir);
1374
+ this.toolLogsDir = dir;
1375
+ }
1376
+ return this.toolLogs!.open(call);
1377
+ }
1378
+
1379
+ /** The step journal of this session's folder (recreated when `sessionId` changes). */
1380
+ private journalFor(root: string): SessionJournal {
1381
+ const dir = sessionDirFor(root, this.sessionId);
1382
+ if (this.journal?.dir !== dir) this.journal = new SessionJournal(dir);
1383
+ return this.journal;
1384
+ }
1385
+
1171
1386
  /** Synchronous persistence write behind the coalesced `persistNow()` gate. */
1172
1387
  private doPersist(): void {
1173
1388
  const cfg = this.persistConfig;
@@ -1176,7 +1391,7 @@ export class Agent {
1176
1391
  if (this.stateless && !(cfg as unknown as { allowStateless?: unknown }).allowStateless) return;
1177
1392
  } catch {}
1178
1393
  try {
1179
- if (cfg.dir) saveSessionDir(cfg.dir, this as any, null);
1394
+ if (cfg.dir) this.journalFor(cfg.dir).sync(this as any);
1180
1395
  else if (cfg.file) saveSessionFile(cfg.file, this as any, null);
1181
1396
  } catch (err) {
1182
1397
  // Q-28: surface via hook instead of swallowing silently.
@@ -1285,6 +1500,7 @@ export class Agent {
1285
1500
  this.context.messages = Array.isArray(data.messages) ? [...data.messages] : [];
1286
1501
  }
1287
1502
  this.context.thoughtSignatures = [];
1503
+ this.runMarks.splice(0, this.runMarks.length, ...(data.runs ?? []).filter((r) => r.i < this.context.messages.length));
1288
1504
  if (data.systemPrompt) {
1289
1505
  this.context.systemPrompt = data.systemPrompt;
1290
1506
  } else {
@@ -1324,7 +1540,7 @@ export class Agent {
1324
1540
  } catch {}
1325
1541
  }
1326
1542
 
1327
- private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): void {
1543
+ private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): SessionRunMark | undefined {
1328
1544
  // Q-20: prompt validation throws synchronously in both run() and
1329
1545
  // stream() (same timing; documented here).
1330
1546
  if (Array.isArray(prompt) && prompt.length === 0) {
@@ -1352,7 +1568,28 @@ export class Agent {
1352
1568
  } else if (ctx.systemPrompt !== fullInstructions) {
1353
1569
  ctx.systemPrompt = fullInstructions;
1354
1570
  }
1571
+ const mark = ctx === this.context ? this.markRunStart(ctx.messages.length) : undefined;
1355
1572
  ctx.addUserMessage(prompt);
1573
+ return mark;
1574
+ }
1575
+
1576
+ // Records where a run of the default session begins; in: prepareTurn, out: runMarks, saved by the session journal.
1577
+ private markRunStart(index: number): SessionRunMark {
1578
+ for (const old of this.runMarks) if (!old.end) old.end = "abandoned"; // a newer run replaces a cut-off one
1579
+ this.toolRecords.length = 0;
1580
+ const mark: SessionRunMark = { id: newRunId(), i: index, at: Date.now() };
1581
+ this.runMarks.push(mark);
1582
+ this.activeMark = mark;
1583
+ return mark;
1584
+ }
1585
+
1586
+ /** Prompts still waiting (steers not yet injected, queued follow-ups), as the session journal saves them. @internal */
1587
+ pendingPrompts(): SessionPending {
1588
+ const pack = (e: { prompt: string | ContentPart[]; options?: AgentRunOptions }) => ({
1589
+ prompt: e.prompt,
1590
+ ...(e.options?.additionalContext ? { additionalContext: e.options.additionalContext } : {}),
1591
+ });
1592
+ return { steer: (this.currentRun?.steerInbox ?? []).map(pack), queue: this.pendingQueue.map(pack) };
1356
1593
  }
1357
1594
 
1358
1595
  /**
@@ -1432,6 +1669,7 @@ export class Agent {
1432
1669
  }
1433
1670
  return new Promise<AgentResponse>((resolve, reject) => {
1434
1671
  this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
1672
+ this.persistNow();
1435
1673
  try {
1436
1674
  this.currentRun?.outerStream?.push({
1437
1675
  type: "queued",
@@ -1615,6 +1853,7 @@ export class Agent {
1615
1853
  } catch {}
1616
1854
  });
1617
1855
  this.pendingQueue.push(queuedEntry as any);
1856
+ this.persistNow();
1618
1857
  // Attach deferred completion to queued promise without exposing it.
1619
1858
  // Real events are piped in pumpQueue() when the turn starts.
1620
1859
  void done.then(
@@ -1852,6 +2091,35 @@ export class Agent {
1852
2091
  }
1853
2092
  }
1854
2093
 
2094
+ /** What a failed run attempt got done before it failed (the `partial` the loop attaches to its error). */
2095
+ type FailedAttempt = Partial<AgentResponse> & { usage: TokenUsage };
2096
+
2097
+ // 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.
2098
+ function withFailedAttempts(res: AgentResponse, failed: FailedAttempt[]): AgentResponse {
2099
+ const usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
2100
+ for (const f of failed) accumulateUsage(usage, f.usage);
2101
+ accumulateUsage(usage, res.usage);
2102
+ return new AgentResponse({
2103
+ text: res.text,
2104
+ ...(res.parsed !== undefined ? { parsed: res.parsed } : {}),
2105
+ thinking: res.thinking,
2106
+ thoughtSignature: res.thoughtSignature,
2107
+ toolCalls: [...failed.flatMap((f) => f.toolCalls ?? []), ...res.toolCalls],
2108
+ toolResults: [...failed.flatMap((f) => f.toolResults ?? []), ...res.toolResults],
2109
+ subagents: res.subagents,
2110
+ usage,
2111
+ responseId: res.responseId,
2112
+ model: res.model,
2113
+ provider: res.provider,
2114
+ finishReason: res.finishReason,
2115
+ rawFinishReason: res.rawFinishReason,
2116
+ durationMs: res.durationMs,
2117
+ raw: res.raw,
2118
+ turns: res.turns + failed.reduce((n, f) => n + (typeof f.turns === "number" ? f.turns : 0), 0),
2119
+ providerSteps: res.providerSteps,
2120
+ });
2121
+ }
2122
+
1855
2123
  /** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
1856
2124
  export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
1857
2125
  // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
@@ -137,6 +137,8 @@ export class AgentContext {
137
137
  name: res.name,
138
138
  result: res.result,
139
139
  isError: res.isError,
140
+ ...(res.logFile ? { logFile: res.logFile } : {}),
141
+ ...(res.stopped ? { stopped: true } : {}),
140
142
  },
141
143
  ],
142
144
  });