agent-accelerator 0.5.8 → 0.6.0

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.
Files changed (67) hide show
  1. package/README.md +26 -5
  2. package/examples/prompts/SYSTEM_PROMPT.md +2 -0
  3. package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
  4. package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
  5. package/package.json +4 -1
  6. package/src/agent/agent.ts +374 -167
  7. package/src/agent/architecture.md +14 -12
  8. package/src/agent/config-blocks.ts +39 -12
  9. package/src/agent/context.ts +8 -3
  10. package/src/agent/delegation.ts +82 -29
  11. package/src/agent/loop.ts +449 -373
  12. package/src/agent/subagent.ts +1 -3
  13. package/src/agent/subagents-tool.ts +45 -7
  14. package/src/agent/subagents.md +3 -0
  15. package/src/agent/turns.ts +3 -1
  16. package/src/agent/worker.ts +58 -28
  17. package/src/agent/workers.ts +113 -20
  18. package/src/architecture.md +16 -15
  19. package/src/index.ts +34 -10
  20. package/src/models/architecture.md +4 -3
  21. package/src/models/catalog-cache.ts +6 -5
  22. package/src/models/catalog.ts +46 -49
  23. package/src/providers/architecture.md +10 -8
  24. package/src/providers/google.ts +150 -62
  25. package/src/providers/openai-compat.ts +60 -58
  26. package/src/providers/openai.ts +57 -39
  27. package/src/providers/openrouter.ts +87 -32
  28. package/src/providers/registry.ts +12 -26
  29. package/src/providers/shared.ts +89 -39
  30. package/src/providers.ts +56 -115
  31. package/src/scheduler/architecture.md +9 -8
  32. package/src/scheduler/scheduled-actions.md +4 -0
  33. package/src/scheduler/scheduler.ts +186 -60
  34. package/src/scheduler/store.ts +23 -10
  35. package/src/scheduler/time.ts +17 -3
  36. package/src/scheduler/tool.ts +33 -7
  37. package/src/session/architecture.md +7 -6
  38. package/src/session/journal.ts +38 -12
  39. package/src/session/store.ts +122 -61
  40. package/src/session/usage.ts +58 -9
  41. package/src/streaming/architecture.md +4 -3
  42. package/src/streaming/event-stream.ts +26 -12
  43. package/src/telemetry.ts +6 -2
  44. package/src/tools/architecture.md +8 -7
  45. package/src/tools/executor.ts +50 -48
  46. package/src/tools/schema.ts +10 -6
  47. package/src/tools/subagent-result.ts +6 -1
  48. package/src/tools/tool.ts +9 -11
  49. package/src/types/agent.ts +2 -21
  50. package/src/types/architecture.md +11 -10
  51. package/src/types/core.ts +1 -9
  52. package/src/types/errors.ts +26 -4
  53. package/src/types/model.ts +0 -4
  54. package/src/types/tool.ts +12 -2
  55. package/src/update-models.ts +5 -5
  56. package/src/utils/architecture.md +16 -15
  57. package/src/utils/cache.ts +1 -3
  58. package/src/utils/durable.ts +17 -5
  59. package/src/utils/env.ts +9 -7
  60. package/src/utils/errors.ts +50 -44
  61. package/src/utils/headers.ts +3 -10
  62. package/src/utils/media.ts +23 -8
  63. package/src/utils/raw-capture.ts +9 -2
  64. package/src/utils/retry.ts +17 -31
  65. package/src/utils/safe-io.ts +29 -7
  66. package/src/utils/serialization.ts +1 -4
  67. package/src/utils/session.ts +1 -2
@@ -50,7 +50,12 @@ const scheduledActionSchema = z.object({
50
50
  startCookingInAdvance: z.string().optional().describe('Duration "DD:HH:MM" before delivery when the run starts.'),
51
51
  repeatEvery: z.string().optional().describe('Duration "DD:HH:MM" between deliveries. Omit to run once.'),
52
52
  retries: z.number().optional().describe("Extra attempts if a run fails."),
53
- onMissed: z.enum(["skip", "run"]).optional().describe('If the host program was not running at delivery time: "skip" = move on to the next time, "run" = run when it is back. Omit to decide later.'),
53
+ onMissed: z
54
+ .enum(["skip", "run"])
55
+ .optional()
56
+ .describe(
57
+ 'If the host program was not running at delivery time: "skip" = move on to the next time, "run" = run when it is back. Omit to decide later.'
58
+ ),
54
59
  });
55
60
 
56
61
  const describedId = "Full action id including its 6-character suffix (get, update, delete, run_now, skip).";
@@ -85,8 +90,16 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
85
90
  role: z.string().optional().describe("Short persona for the worker."),
86
91
  instructions: z.string().optional().describe("The worker's system prompt (create, update)."),
87
92
  task: z.string().optional().describe("The concrete assignment for every run (create, update)."),
88
- tools: z.array(z.string()).optional().describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"}.`),
89
- timeoutMs: z.number().optional().describe("Stop a run that takes longer than this many ms (> 0). Omit it for no limit of your own; the host program may cap it."),
93
+ tools: z
94
+ .array(z.string())
95
+ .optional()
96
+ .describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"}.`),
97
+ timeoutMs: z
98
+ .number()
99
+ .optional()
100
+ .describe(
101
+ "Stop a run that takes longer than this many ms (> 0). Omit it for no limit of your own; the host program may cap it."
102
+ ),
90
103
  scheduled_action: scheduledActionSchema.optional(),
91
104
  force: z.boolean().optional().describe("delete: abort a run that is cooking. Ask the user first."),
92
105
  purge: z.boolean().optional().describe("delete: also delete past run files."),
@@ -96,14 +109,23 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
96
109
  const needId = (): string => {
97
110
  const id = a.id ?? sa.id;
98
111
  if (!id) {
99
- throw new ToolError(`${a.command} needs "id" (the full id including its 6-character suffix; see list). Example: ${JSON.stringify({ command: a.command, id: "daily-ai-brief-d64aj2" })}`);
112
+ throw new ToolError(
113
+ `${a.command} needs "id" (the full id including its 6-character suffix; see list). Example: ${JSON.stringify({ command: a.command, id: "daily-ai-brief-d64aj2" })}`
114
+ );
100
115
  }
101
116
  return id;
102
117
  };
103
118
 
104
119
  switch (a.command) {
105
120
  case "create": {
106
- const input = { role: a.role, instructions: a.instructions, task: a.task, tools: a.tools, timeoutMs: a.timeoutMs, scheduled_action: sa } as ScheduledActionInput;
121
+ const input = {
122
+ role: a.role,
123
+ instructions: a.instructions,
124
+ task: a.task,
125
+ tools: a.tools,
126
+ timeoutMs: a.timeoutMs,
127
+ scheduled_action: sa,
128
+ } as ScheduledActionInput;
107
129
  const problems = validateInput(input, poolTools, getScheduler(home).now(), { requireLead: true });
108
130
  if (problems.length > 0) {
109
131
  throw new ToolError(
@@ -134,7 +156,9 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
134
156
  };
135
157
  const changes = [a.role, a.instructions, a.task, a.tools, a.timeoutMs, ...Object.values(schedule)];
136
158
  if (changes.every((v) => v === undefined)) {
137
- throw new ToolError(`update needs at least one field to change. Example: ${JSON.stringify({ command: "update", id, task: "The new assignment." })}`);
159
+ throw new ToolError(
160
+ `update needs at least one field to change. Example: ${JSON.stringify({ command: "update", id, task: "The new assignment." })}`
161
+ );
138
162
  }
139
163
  return updateScheduledAction(id, patch, { home });
140
164
  }
@@ -145,7 +169,9 @@ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
145
169
  case "skip":
146
170
  return skipScheduledAction(needId(), { home });
147
171
  default:
148
- throw new ToolError(`Unknown command ${JSON.stringify((a as { command?: unknown }).command)}. Use one of: ${COMMANDS.join(", ")}.`);
172
+ throw new ToolError(
173
+ `Unknown command ${JSON.stringify((a as { command?: unknown }).command)}. Use one of: ${COMMANDS.join(", ")}.`
174
+ );
149
175
  }
150
176
  },
151
177
  });
@@ -2,14 +2,15 @@
2
2
 
3
3
  Saving a conversation, its spending and its tool logs.
4
4
 
5
- | File | What it is |
6
- | :-- | :-- |
7
- | `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
8
- | `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
9
- | `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
10
- | `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
5
+ | File | What it is |
6
+ | :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
8
+ | `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
9
+ | `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
10
+ | `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
11
11
 
12
12
  ## Invariants
13
+
13
14
  - Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, telemetry.ndjson, media/, tool-logs/, workers/}`. `telemetry.ndjson` is written by `telemetry-file.ts` (opt-in), not by the session code; a folder holding only that file is not a session.
14
15
  - A journal whose header epoch differs from the snapshot's is ignored on load.
15
16
  - Journal record types: `base`, `msg`, `meta`, `run`, `end`, `tool`, `pending`, `usage`. Replay ignores unknown types, drops a torn last line, and stops at earlier damage with `session.journal_corrupt`.
@@ -32,7 +32,14 @@ const EMPTY_PENDING = JSON.stringify({ steer: [], queue: [] });
32
32
 
33
33
  // One `tool` journal line; the result is saved as given (tool results are JSON-safe by the time the executor returns them); in: a finished tool call, out: the journal.
34
34
  function toolLine(t: SessionToolRecord): string {
35
- return JSON.stringify({ t: "tool", call: t.id, name: t.name, result: t.result, ...(t.isError ? { isError: true } : {}), ...(t.stopped ? { stopped: true } : {}) });
35
+ return JSON.stringify({
36
+ t: "tool",
37
+ call: t.id,
38
+ name: t.name,
39
+ result: t.result,
40
+ ...(t.isError ? { isError: true } : {}),
41
+ ...(t.stopped ? { stopped: true } : {}),
42
+ });
36
43
  }
37
44
 
38
45
  /** Persists one session folder (`sessions/<id>/`) step by step. */
@@ -80,10 +87,13 @@ export class SessionJournal {
80
87
  return;
81
88
  }
82
89
  const records: string[] = [];
83
- for (let i = this.count; i < messages.length; i++) records.push(JSON.stringify({ t: "msg", i, m: this.toDisk(messages[i]!) }));
84
- for (let r = this.runCount; r < runs.length; r++) records.push(JSON.stringify({ t: "run", id: runs[r]!.id, i: runs[r]!.i, at: runs[r]!.at }));
90
+ for (let i = this.count; i < messages.length; i++)
91
+ records.push(JSON.stringify({ t: "msg", i, m: this.toDisk(messages[i]!) }));
92
+ for (let r = this.runCount; r < runs.length; r++)
93
+ records.push(JSON.stringify({ t: "run", id: runs[r]!.id, i: runs[r]!.i, at: runs[r]!.at }));
85
94
  for (const mark of runs) {
86
- if (mark.end && !this.endsOnDisk.has(mark.id)) records.push(JSON.stringify({ t: "end", id: mark.id, status: mark.end }));
95
+ if (mark.end && !this.endsOnDisk.has(mark.id))
96
+ records.push(JSON.stringify({ t: "end", id: mark.id, status: mark.end }));
87
97
  }
88
98
  const tools = agent.toolRecords ?? [];
89
99
  const firstNewTool = this.toolCount > 0 && tools[this.toolCount - 1] === this.lastTool ? this.toolCount : 0; // a new run starts the list over
@@ -129,13 +139,19 @@ export class SessionJournal {
129
139
  const epoch = randomUUID();
130
140
  const target = path.join(this.dir, "session.json");
131
141
  if (this.epoch === undefined) quarantineIfCorrupt(target);
132
- const data = buildSessionData({ ...agent, context: { ...agent.context, messages: messages.map((m) => this.toDisk(m)) } }, null);
142
+ const data = buildSessionData(
143
+ { ...agent, context: { ...agent.context, messages: messages.map((m) => this.toDisk(m)) } },
144
+ null
145
+ );
133
146
  writeFileDurable(target, serializeSession({ ...data, epoch }) + "\n");
134
147
  const pending = this.pendingOf(agent);
135
148
  const tools = agent.toolRecords ?? [];
136
149
  // Waiting prompts and the current run's finished tool results live only in the journal, so a new journal starts with them.
137
150
  const header =
138
- JSON.stringify({ t: "base", e: epoch }) + "\n" + (pending === EMPTY_PENDING ? "" : `{"t":"pending","p":${pending}}\n`) + tools.map((t) => toolLine(t) + "\n").join("");
151
+ JSON.stringify({ t: "base", e: epoch }) +
152
+ "\n" +
153
+ (pending === EMPTY_PENDING ? "" : `{"t":"pending","p":${pending}}\n`) +
154
+ tools.map((t) => toolLine(t) + "\n").join("");
139
155
  writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
140
156
  this.epoch = epoch;
141
157
  this.pending = pending;
@@ -165,7 +181,8 @@ export class SessionJournal {
165
181
  if (!waiting || waiting.steer.length + waiting.queue.length === 0) return EMPTY_PENDING;
166
182
  const parts = (prompt: ContentPart[]): ContentPart[] => {
167
183
  let disk = this.promptOnDisk.get(prompt);
168
- if (!disk) this.promptOnDisk.set(prompt, (disk = this.toDisk({ role: "user", content: prompt }).content as ContentPart[]));
184
+ if (!disk)
185
+ this.promptOnDisk.set(prompt, (disk = this.toDisk({ role: "user", content: prompt }).content as ContentPart[]));
169
186
  return disk;
170
187
  };
171
188
  const slim = (list: SessionPendingPrompt[]): SessionPendingPrompt[] =>
@@ -180,10 +197,15 @@ export class SessionJournal {
180
197
 
181
198
  // JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
182
199
  private metaOf(agent: SessionAgentLike): string {
183
- const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, usage: _u, ...rest } = buildSessionData(
184
- { ...agent, context: { ...agent.context, messages: [] } },
185
- null
186
- );
200
+ const {
201
+ version: _v,
202
+ sessionId: _s,
203
+ messages: _m,
204
+ totals: _t,
205
+ runs: _r,
206
+ usage: _u,
207
+ ...rest
208
+ } = buildSessionData({ ...agent, context: { ...agent.context, messages: [] } }, null);
187
209
  return JSON.stringify(rest);
188
210
  }
189
211
 
@@ -202,7 +224,11 @@ export class SessionJournal {
202
224
  const dest = path.join(this.dir, "media", file);
203
225
  if (!fs.existsSync(dest)) writeFileDurable(dest, found.bytes, { backup: false });
204
226
  changed = true;
205
- return { ...(part as unknown as Record<string, unknown>), [found.key]: `media/${file}`, ...(found.mimeType ? { mimeType: found.mimeType } : {}) } as unknown as ContentPart;
227
+ return {
228
+ ...(part as unknown as Record<string, unknown>),
229
+ [found.key]: `media/${file}`,
230
+ ...(found.mimeType ? { mimeType: found.mimeType } : {}),
231
+ } as unknown as ContentPart;
206
232
  });
207
233
  if (changed) out = { ...slim, content };
208
234
  }
@@ -30,7 +30,7 @@ import type { AgentResponse } from "../types/response.ts";
30
30
  import { base64ToBytes, bytesToBase64 } from "../utils/base64.ts";
31
31
  import { getModelFromCatalog } from "../models/catalog.ts";
32
32
  import type { SubAgentStep } from "../types/response.ts";
33
- import { SessionLoadError } from "../types/errors.ts";
33
+ import { SecurityPolicyError, SessionLoadError, ValidationError } from "../types/errors.ts";
34
34
  import { createSessionId } from "../utils/session.ts";
35
35
  import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
36
36
  import type { UsageTotals } from "./usage.ts";
@@ -106,7 +106,7 @@ export interface PersistedAgentSession {
106
106
  thinkingLevel?: string;
107
107
  cache?: CacheOption;
108
108
  /** Legacy read-only: pre-compact files duplicated base instructions here;
109
- * no longer written (`systemPrompt` already embeds them). */
109
+ * no longer written (`systemPrompt` already embeds them). */
110
110
  instructions?: string;
111
111
  systemPrompt?: string;
112
112
  messages: Message[];
@@ -230,7 +230,9 @@ export class SessionTelemetry {
230
230
  }
231
231
 
232
232
  /** Restores totals from a loaded session (tolerates legacy `metrics` shape). */
233
- static fromSaved(saved?: PersistedAgentSession | { totals?: SessionTotals; metrics?: Record<string, number> } | null): SessionTelemetry {
233
+ static fromSaved(
234
+ saved?: PersistedAgentSession | { totals?: SessionTotals; metrics?: Record<string, number> } | null
235
+ ): SessionTelemetry {
234
236
  const raw: any = (saved as any)?.totals ?? (saved as any)?.metrics ?? null;
235
237
  if (!raw) return new SessionTelemetry();
236
238
  // Legacy metrics files used {input,output,cacheRead,cacheWrite,reasoning,cost}
@@ -279,15 +281,12 @@ export class SessionTelemetry {
279
281
  * Shows per-turn tokens + `turn-CH%`, session totals + `total-CH%`, cost, context %.
280
282
  * `thinkingLevel` defaults to the agent's level when omitted.
281
283
  */
282
- formatBar(
283
- res: AgentResponse,
284
- opts?: { model?: string; thinkingLevel?: string }
285
- ): string {
284
+ formatBar(res: AgentResponse, opts?: { model?: string; thinkingLevel?: string }): string {
286
285
  const inTok = res.usage.inputTokens ?? 0;
287
286
  const outTok = res.usage.outputTokens ?? 0;
288
287
  const crTok = res.usage.cachedTokens ?? res.usage.cacheReadTokens ?? 0;
289
288
  const cwTurnTok = res.usage.cacheWriteTokens ?? 0;
290
- const turnRate = (this.turnHitRate(res.usage)).toFixed(1);
289
+ const turnRate = this.turnHitRate(res.usage).toFixed(1);
291
290
  const totalRate = this.totalHitRate().toFixed(1);
292
291
  const cwTurnLabel = cwTurnTok > 0 ? ` CW${formatSessionTokens(cwTurnTok)}` : "";
293
292
  const totCW = this.totals.cacheWrite;
@@ -350,7 +349,9 @@ export interface SessionAgentLike {
350
349
  }
351
350
 
352
351
  // Slims worker traces for the snapshot (one key per tracking id, repeated final text dropped, reasoning kept); in: buildSessionData, out: the `subagents` field.
353
- function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | undefined): Record<string, PersistedSubAgentTrace> | undefined {
352
+ function snapshotSubagents(
353
+ traces: Record<string, PersistedSubAgentTrace> | undefined
354
+ ): Record<string, PersistedSubAgentTrace> | undefined {
354
355
  if (!traces) return undefined;
355
356
  const keys = Object.keys(traces);
356
357
  if (keys.length === 0) return undefined;
@@ -376,12 +377,7 @@ function snapshotSubagents(traces: Record<string, PersistedSubAgentTrace> | unde
376
377
  // Reasoning is never dropped: `thinking`/`thoughtSignature` survive the
377
378
  // dedup so persisted worker steps keep their reasoning traces.
378
379
  const steps = (t.steps ?? []).map((s) => {
379
- if (
380
- s.type === "assistant" &&
381
- typeof s.text === "string" &&
382
- typeof t.text === "string" &&
383
- s.text === t.text
384
- ) {
380
+ if (s.type === "assistant" && typeof s.text === "string" && typeof t.text === "string" && s.text === t.text) {
385
381
  const marker: SubAgentStep = { turn: s.turn, type: s.type, timestamp: s.timestamp };
386
382
  if (typeof s.thinking === "string" && s.thinking) marker.thinking = s.thinking;
387
383
  const sig = (s as { thoughtSignature?: unknown }).thoughtSignature;
@@ -463,18 +459,20 @@ function inlineBinaryMedia(messages: Message[]): Message[] {
463
459
  }
464
460
 
465
461
  /** Builds a storable snapshot from a live agent + optional telemetry. */
466
- export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTelemetry | SessionTotals | null): PersistedAgentSession {
462
+ export function buildSessionData(
463
+ agent: SessionAgentLike,
464
+ telemetry?: SessionTelemetry | SessionTotals | null
465
+ ): PersistedAgentSession {
467
466
  const rawModel = agent.modelStringOrSpec as any;
468
467
  const model = typeof rawModel === "string" ? rawModel : (rawModel?.id ?? String(rawModel ?? ""));
469
468
  const rawSub = agent.subagentModel as any;
470
- const subagentModel =
471
- typeof rawSub === "string" ? rawSub : rawSub?.model ?? rawSub?.id ?? undefined;
472
- const totals = telemetry instanceof SessionTelemetry
473
- ? telemetry.toJSON()
474
- : telemetry ?? undefined;
469
+ const subagentModel = typeof rawSub === "string" ? rawSub : (rawSub?.model ?? rawSub?.id ?? undefined);
470
+ const totals = telemetry instanceof SessionTelemetry ? telemetry.toJSON() : (telemetry ?? undefined);
475
471
  const parentSessionId =
476
- typeof (agent as any)?.parentSessionId === "string" ? (agent as any).parentSessionId as string : undefined;
477
- const subagents = snapshotSubagents((agent as any)?.subagentTraces as Record<string, PersistedSubAgentTrace> | undefined);
472
+ typeof (agent as any)?.parentSessionId === "string" ? ((agent as any).parentSessionId as string) : undefined;
473
+ const subagents = snapshotSubagents(
474
+ (agent as any)?.subagentTraces as Record<string, PersistedSubAgentTrace> | undefined
475
+ );
478
476
  const messages = inlineBinaryMedia(stripRedundantRawArguments(agent.context.messages));
479
477
  // Field order is the on-disk layout contract: identity → config →
480
478
  // systemPrompt → totals → messages → lineage/traces. `instructions` is
@@ -494,7 +492,9 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
494
492
  ...(parentSessionId ? { parentSessionId } : {}),
495
493
  ...(subagents ? { subagents } : {}),
496
494
  ...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
497
- ...(agent.usageTotals && agent.usageTotals.turns > 0 ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals } : {}),
495
+ ...(agent.usageTotals && agent.usageTotals.turns > 0
496
+ ? { usage: JSON.parse(JSON.stringify(agent.usageTotals)) as UsageTotals }
497
+ : {}),
498
498
  };
499
499
  }
500
500
 
@@ -563,7 +563,8 @@ const OVER_LIMIT = Symbol("session-over-limit");
563
563
 
564
564
  // Reads a stored `cache` field: a current string value, or a pre-0.5.1 `{ retention }` object (short=5m, medium=1h, long=12h); in: a parsed session file, out: `PersistedAgentSession.cache`.
565
565
  function toCacheOption(raw: unknown): CacheOption | undefined {
566
- if (typeof raw === "string") return (CACHE_OPTIONS as readonly string[]).includes(raw) ? (raw as CacheOption) : undefined;
566
+ if (typeof raw === "string")
567
+ return (CACHE_OPTIONS as readonly string[]).includes(raw) ? (raw as CacheOption) : undefined;
567
568
  const legacy: Record<string, CacheOption> = { implicit: "implicit", short: "5m", medium: "1h", long: "12h" };
568
569
  const retention = (raw as { retention?: unknown } | null)?.retention;
569
570
  return typeof retention === "string" ? legacy[retention] : undefined;
@@ -753,14 +754,17 @@ export const writeFileAtomic = writeFileDurable;
753
754
  /** Session id grammar: no traversal, no slashes, no hidden files. */
754
755
  export function assertValidSessionId(sessionId: string): void {
755
756
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(sessionId)) {
756
- throw new Error(`[Agent Accelerator] Invalid sessionId "${sessionId}": must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`);
757
+ throw new ValidationError(
758
+ `[Agent Accelerator] Invalid sessionId "${sessionId}": must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/`
759
+ );
757
760
  }
758
761
  }
759
762
 
760
763
  /** Renames a genuinely corrupt snapshot to `*.corrupt-<ts>` before it is overwritten (a healthy one over the load limits is our own prior save and is just rewritten). */
761
764
  export function quarantineIfCorrupt(file: string): void {
762
765
  try {
763
- if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt") fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
766
+ if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt")
767
+ fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
764
768
  } catch {
765
769
  // why: no file yet, or it cannot be read: nothing to quarantine.
766
770
  }
@@ -840,9 +844,12 @@ function extForMedia(mimeType?: string, filename?: string): string {
840
844
  }
841
845
 
842
846
  // Finds embedded media bytes in a part (binary, `data:` URL or raw base64); remote URLs and local paths are references and give undefined; in: any content part, out: extractMediaToDir and the session journal.
843
- export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
847
+ export function embeddedMedia(
848
+ part: ContentPart
849
+ ): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
844
850
  const p = part as unknown as Record<string, unknown> | null;
845
- if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"])) return undefined;
851
+ if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"]))
852
+ return undefined;
846
853
  const key = p["type"] as (typeof MEDIA_PART_KEYS)[number];
847
854
  const raw = p[key];
848
855
  let bytes: Uint8Array;
@@ -858,7 +865,12 @@ export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS
858
865
  } else if (looksLikeEmbeddedBase64(raw)) bytes = base64ToBytes(raw.replace(/\s/g, ""));
859
866
  else return undefined; // local path or short label: keep as reference
860
867
  } else return undefined;
861
- return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
868
+ return {
869
+ key,
870
+ bytes,
871
+ ...(mimeType ? { mimeType } : {}),
872
+ ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined),
873
+ };
862
874
  }
863
875
 
864
876
  // Copy of the messages that keeps media bytes (structuredClone, else JSON); in: extractMediaToDir/resolveMediaPaths, out: a new array.
@@ -879,15 +891,12 @@ function cloneMessages(messages: Message[]): Message[] {
879
891
  * `<sessionDir>/media/` and rewrites those parts to relative `media/…`
880
892
  * paths. Remote URLs and filesystem paths are left as references.
881
893
  */
882
- export function extractMediaToDir(
883
- messages: Message[],
884
- sessionDir: string
885
- ): { messages: Message[]; wrote: string[] } {
894
+ export function extractMediaToDir(messages: Message[], sessionDir: string): { messages: Message[]; wrote: string[] } {
886
895
  const cloned = cloneMessages(messages);
887
896
  const root = path.resolve(sessionDir);
888
897
  const mediaDir = path.resolve(root, "media");
889
898
  if (!mediaDir.startsWith(root + path.sep) && mediaDir !== root) {
890
- throw new Error("[Agent Accelerator] Media dir escapes session dir");
899
+ throw new SecurityPolicyError("[Agent Accelerator] Media dir escapes session dir");
891
900
  }
892
901
  try {
893
902
  fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
@@ -896,7 +905,9 @@ export function extractMediaToDir(
896
905
  try {
897
906
  for (const f of fs.readdirSync(mediaDir)) {
898
907
  if (/^(image|audio|video|file)-\d+\.[a-z0-9]{1,5}$/.test(f)) {
899
- try { fs.unlinkSync(path.join(mediaDir, f)); } catch {}
908
+ try {
909
+ fs.unlinkSync(path.join(mediaDir, f));
910
+ } catch {}
900
911
  }
901
912
  }
902
913
  } catch {}
@@ -916,7 +927,9 @@ export function extractMediaToDir(
916
927
  const dest = path.resolve(mediaDir, file);
917
928
  if (!dest.startsWith(mediaDir + path.sep)) continue;
918
929
  fs.writeFileSync(dest, bytes, { mode: 0o600 });
919
- try { fs.chmodSync(dest, 0o600); } catch {}
930
+ try {
931
+ fs.chmodSync(dest, 0o600);
932
+ } catch {}
920
933
  } catch {
921
934
  continue;
922
935
  }
@@ -963,7 +976,7 @@ export function sessionDirFor(rootDir: string, sessionId: string): string {
963
976
  const dir = path.resolve(root, sessionId);
964
977
  const rel = path.relative(root, dir);
965
978
  if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
966
- throw new Error(`[Agent Accelerator] Session dir escapes root: ${sessionId}`);
979
+ throw new SecurityPolicyError(`[Agent Accelerator] Session dir escapes root: ${sessionId}`);
967
980
  }
968
981
  return dir;
969
982
  }
@@ -984,10 +997,7 @@ export function saveSessionDir(
984
997
  fs.mkdirSync(sessionDir, { recursive: true });
985
998
  } catch {}
986
999
  const { messages } = extractMediaToDir(agent.context.messages, sessionDir);
987
- const data = buildSessionData(
988
- { ...agent, context: { ...agent.context, messages } },
989
- telemetry
990
- );
1000
+ const data = buildSessionData({ ...agent, context: { ...agent.context, messages } }, telemetry);
991
1001
  try {
992
1002
  const target = path.join(sessionDir, "session.json");
993
1003
  quarantineIfCorrupt(target);
@@ -1002,7 +1012,15 @@ export function saveSessionDir(
1002
1012
  export const JOURNAL_FILE = "journal.ndjson";
1003
1013
 
1004
1014
  // Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
1005
- const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
1015
+ const JOURNAL_META_KEYS = [
1016
+ "model",
1017
+ "subagentModel",
1018
+ "thinkingLevel",
1019
+ "cache",
1020
+ "systemPrompt",
1021
+ "parentSessionId",
1022
+ "subagents",
1023
+ ] as const;
1006
1024
 
1007
1025
  // Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `usage` replaces the agent's spending totals, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
1008
1026
  function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
@@ -1011,12 +1029,31 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
1011
1029
  const session: PersistedAgentSession = { ...snapshot, messages: [...snapshot.messages] };
1012
1030
  let sawHeader = false;
1013
1031
  const stop = (reason: string, line: number): void => {
1014
- telemetryBus.emitWarning("session.journal_corrupt", `[Agent Accelerator] ${path.basename(file)} line ${line + 1}: ${reason}; the rest of the journal is ignored.`, { file, line: line + 1 });
1032
+ telemetryBus.emitWarning(
1033
+ "session.journal_corrupt",
1034
+ `[Agent Accelerator] ${path.basename(file)} line ${line + 1}: ${reason}; the rest of the journal is ignored.`,
1035
+ { file, line: line + 1 }
1036
+ );
1015
1037
  };
1016
1038
  for (let k = 0; k < lines.length; k++) {
1017
1039
  const line = lines[k]!.trim();
1018
1040
  if (!line) continue;
1019
- let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; u?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
1041
+ let rec: {
1042
+ t?: unknown;
1043
+ e?: unknown;
1044
+ i?: unknown;
1045
+ m?: unknown;
1046
+ p?: unknown;
1047
+ id?: unknown;
1048
+ at?: unknown;
1049
+ u?: unknown;
1050
+ status?: unknown;
1051
+ call?: unknown;
1052
+ name?: unknown;
1053
+ result?: unknown;
1054
+ isError?: unknown;
1055
+ stopped?: unknown;
1056
+ };
1020
1057
  try {
1021
1058
  rec = JSON.parse(line);
1022
1059
  } catch {
@@ -1035,13 +1072,28 @@ function replayJournal(snapshot: PersistedAgentSession, text: string, file: stri
1035
1072
  break;
1036
1073
  }
1037
1074
  session.messages.push(rec.m as Message);
1038
- } else if (rec.t === "run" && typeof rec.id === "string" && typeof rec.i === "number" && typeof rec.at === "number") {
1039
- if (!session.runs?.some((r) => r.id === rec.id)) session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
1075
+ } else if (
1076
+ rec.t === "run" &&
1077
+ typeof rec.id === "string" &&
1078
+ typeof rec.i === "number" &&
1079
+ typeof rec.at === "number"
1080
+ ) {
1081
+ if (!session.runs?.some((r) => r.id === rec.id))
1082
+ session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
1040
1083
  } else if (rec.t === "end" && typeof rec.id === "string" && typeof rec.status === "string") {
1041
1084
  const mark = session.runs?.find((r) => r.id === rec.id);
1042
1085
  if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
1043
1086
  } else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
1044
- session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
1087
+ session.tools = [
1088
+ ...(session.tools ?? []).filter((x) => x.id !== rec.call),
1089
+ {
1090
+ id: rec.call,
1091
+ name: rec.name,
1092
+ result: rec.result,
1093
+ ...(rec.isError ? { isError: true } : {}),
1094
+ ...(rec.stopped ? { stopped: true } : {}),
1095
+ },
1096
+ ];
1045
1097
  } else if (rec.t === "usage" && rec.u && typeof rec.u === "object") {
1046
1098
  session.usage = rec.u as UsageTotals; // latest record wins
1047
1099
  } else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
@@ -1148,11 +1200,9 @@ export const MAX_SESSION_MESSAGES = 50_000;
1148
1200
  /** Default max raw JSON size accepted when loading a session snapshot (override via `SessionLimits`). */
1149
1201
  export const MAX_SESSION_JSON_BYTES = 100 * 1024 * 1024;
1150
1202
 
1151
- // A SessionLoadError-named Error with the SDK prefix; in: validateSessionData, out: the error.
1152
- function sessionLoadError(message: string): Error {
1153
- const e = new Error(`[Agent Accelerator] Invalid session: ${message}`);
1154
- e.name = "SessionLoadError";
1155
- return e;
1203
+ // A SessionLoadError with the SDK prefix; in: validateSessionData, out: the error.
1204
+ function sessionLoadError(message: string): SessionLoadError {
1205
+ return new SessionLoadError(`[Agent Accelerator] Invalid session: ${message}`);
1156
1206
  }
1157
1207
 
1158
1208
  /**
@@ -1245,12 +1295,12 @@ export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown
1245
1295
  * (reasoning is generated output); providers that omit one side are
1246
1296
  * accepted, only impossible `thinking > output` with both present fails.
1247
1297
  *
1248
- * Test helper: throws an Error describing the first violation, returns void
1298
+ * Test helper: throws a ValidationError describing the first violation, returns void
1249
1299
  * when invariants hold. Not called in hot paths (validation only).
1250
1300
  */
1251
1301
  export function assertUsageInvariants(usage: TokenUsage): void {
1252
1302
  const fail = (msg: string): never => {
1253
- throw new Error(`[Agent Accelerator] Invalid usage: ${msg}`);
1303
+ throw new ValidationError(`[Agent Accelerator] Invalid usage: ${msg}`);
1254
1304
  };
1255
1305
  const ints: Array<[string, unknown]> = [
1256
1306
  ["inputTokens", usage.inputTokens],
@@ -1356,7 +1406,14 @@ function toDollars(microUSD: number): number {
1356
1406
  function resolveEffectivePricing(
1357
1407
  spec: ModelSpec | undefined,
1358
1408
  inputTokens: number
1359
- ): { input: number; output: number; cacheRead: number | undefined; cacheWrite: number | undefined; audio: number; tier: string } {
1409
+ ): {
1410
+ input: number;
1411
+ output: number;
1412
+ cacheRead: number | undefined;
1413
+ cacheWrite: number | undefined;
1414
+ audio: number;
1415
+ tier: string;
1416
+ } {
1360
1417
  const base = {
1361
1418
  input: spec?.pricing?.inputPerMillion ?? (spec?.cost?.input as number | undefined) ?? 0,
1362
1419
  output: spec?.pricing?.outputPerMillion ?? (spec?.cost?.output as number | undefined) ?? 0,
@@ -1380,7 +1437,12 @@ function resolveEffectivePricing(
1380
1437
  const tiers = spec.cost?.tiers;
1381
1438
  if (Array.isArray(tiers) && tiers.length > 0) {
1382
1439
  let best: { input: number; output: number; cache_read?: number; size: number } | undefined;
1383
- for (const t of tiers as Array<{ tier?: { type?: string; size?: number }; input?: number; output?: number; cache_read?: number }>) {
1440
+ for (const t of tiers as Array<{
1441
+ tier?: { type?: string; size?: number };
1442
+ input?: number;
1443
+ output?: number;
1444
+ cache_read?: number;
1445
+ }>) {
1384
1446
  const size = typeof t?.tier?.size === "number" ? t.tier.size : undefined;
1385
1447
  if (size === undefined || size > inputTokens) continue;
1386
1448
  const type = String(t?.tier?.type ?? "").toLowerCase();
@@ -1455,10 +1517,9 @@ export function priceUsage(
1455
1517
  const cachedTokens = usage.cachedTokens ?? usage.cacheReadTokens ?? 0;
1456
1518
  const cacheWriteTokens = usage.cacheWriteTokens ?? 0;
1457
1519
  const audioTokens =
1458
- (usage as { audioTokens?: unknown }).audioTokens ??
1459
- (usage as { inputAudioTokens?: unknown }).inputAudioTokens ??
1460
- 0;
1461
- const audioCount = typeof audioTokens === "number" && Number.isFinite(audioTokens) && audioTokens > 0 ? Math.round(audioTokens) : 0;
1520
+ (usage as { audioTokens?: unknown }).audioTokens ?? (usage as { inputAudioTokens?: unknown }).inputAudioTokens ?? 0;
1521
+ const audioCount =
1522
+ typeof audioTokens === "number" && Number.isFinite(audioTokens) && audioTokens > 0 ? Math.round(audioTokens) : 0;
1462
1523
  const pricing = resolveEffectivePricing(spec, inputTokens);
1463
1524
  if (!pricing.input && !pricing.output && !pricing.cacheRead && !pricing.cacheWrite && !pricing.audio) {
1464
1525
  return undefined;