agent-accelerator 0.5.9 → 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.
- package/README.md +3 -1
- package/examples/prompts/SYSTEM_PROMPT.md +2 -0
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
- package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
- package/package.json +4 -1
- package/src/agent/agent.ts +366 -161
- package/src/agent/architecture.md +13 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +8 -3
- package/src/agent/delegation.ts +82 -29
- package/src/agent/loop.ts +444 -365
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +45 -7
- package/src/agent/subagents.md +3 -0
- package/src/agent/turns.ts +3 -1
- package/src/agent/worker.ts +56 -27
- package/src/agent/workers.ts +113 -20
- package/src/architecture.md +16 -15
- package/src/index.ts +34 -10
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +39 -30
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +147 -59
- package/src/providers/openai-compat.ts +57 -49
- package/src/providers/openai.ts +54 -37
- package/src/providers/openrouter.ts +85 -30
- package/src/providers/registry.ts +6 -21
- package/src/providers/shared.ts +87 -33
- package/src/providers.ts +56 -115
- package/src/scheduler/architecture.md +7 -6
- package/src/scheduler/scheduled-actions.md +4 -0
- package/src/scheduler/scheduler.ts +186 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +33 -7
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +38 -12
- package/src/session/store.ts +122 -61
- package/src/session/usage.ts +58 -9
- package/src/streaming/architecture.md +4 -3
- package/src/streaming/event-stream.ts +26 -12
- package/src/telemetry.ts +6 -2
- package/src/tools/architecture.md +8 -7
- package/src/tools/executor.ts +50 -48
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +2 -21
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +1 -9
- package/src/types/errors.ts +26 -4
- package/src/types/model.ts +0 -4
- package/src/types/tool.ts +12 -2
- package/src/update-models.ts +5 -5
- package/src/utils/architecture.md +16 -15
- package/src/utils/cache.ts +1 -3
- package/src/utils/durable.ts +17 -5
- package/src/utils/env.ts +9 -7
- package/src/utils/errors.ts +6 -14
- package/src/utils/headers.ts +3 -10
- package/src/utils/media.ts +23 -8
- package/src/utils/raw-capture.ts +9 -2
- package/src/utils/retry.ts +15 -3
- package/src/utils/safe-io.ts +29 -7
- package/src/utils/serialization.ts +1 -4
- package/src/utils/session.ts +1 -2
package/src/scheduler/tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
|
|
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(
|
|
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 = {
|
|
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(
|
|
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(
|
|
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
|
|
6
|
-
|
|
|
7
|
-
| `store.ts`
|
|
8
|
-
| `journal.ts`
|
|
9
|
-
| `usage.ts`
|
|
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`.
|
package/src/session/journal.ts
CHANGED
|
@@ -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({
|
|
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++)
|
|
84
|
-
|
|
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))
|
|
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(
|
|
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 }) +
|
|
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)
|
|
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 {
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
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
|
}
|
package/src/session/store.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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")
|
|
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
|
|
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")
|
|
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(
|
|
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"]))
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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 = [
|
|
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(
|
|
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: {
|
|
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 (
|
|
1039
|
-
|
|
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 = [
|
|
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
|
|
1152
|
-
function sessionLoadError(message: string):
|
|
1153
|
-
|
|
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
|
|
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
|
|
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
|
-
): {
|
|
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<{
|
|
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
|
-
|
|
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;
|