agent-accelerator 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -8
- package/package.json +1 -1
- package/src/agent/agent.ts +283 -15
- package/src/agent/context.ts +7 -2
- package/src/agent/loop.ts +137 -12
- package/src/index.ts +4 -1
- package/src/models/catalog-cache.ts +3 -10
- package/src/providers/google.ts +378 -120
- package/src/providers/openai-compat.ts +2 -0
- package/src/providers/openai.ts +2 -0
- package/src/providers/openrouter.ts +2 -0
- package/src/providers.ts +16 -0
- package/src/scheduler/scheduler.ts +2 -0
- package/src/scheduler/store.ts +34 -21
- package/src/session/journal.ts +201 -0
- package/src/session/store.ts +181 -82
- package/src/session/tool-log.ts +62 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +26 -0
- package/src/types/message.ts +19 -0
- package/src/types/model.ts +5 -1
- package/src/types/response.ts +7 -0
- package/src/types/tool.ts +18 -6
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +17 -2
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
mapGenerationParamsToChat,
|
|
91
91
|
normalizeStructuredOutput,
|
|
92
92
|
augmentSystemPromptWithStructuredOutput,
|
|
93
|
+
warnIgnoredMediaResolution,
|
|
93
94
|
} from "../providers.ts";
|
|
94
95
|
|
|
95
96
|
// ---------------------------------------------------------------------------
|
|
@@ -704,6 +705,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
704
705
|
}
|
|
705
706
|
// Strict endpoints reject unknown body fields: implicit caching only.
|
|
706
707
|
resolveCache(this.prefix, options?.cache, ["implicit"], `${this.prefix}/${modelId}`);
|
|
708
|
+
warnIgnoredMediaResolution(this.prefix, context.messages, `${this.prefix}/${modelId}`);
|
|
707
709
|
|
|
708
710
|
// Structured outputs: native `response_format` when supported, else
|
|
709
711
|
// emulated via system prompt (strict endpoints would 400 on json_schema
|
package/src/providers/openai.ts
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
normalizeStructuredOutput,
|
|
86
86
|
augmentSystemPromptWithStructuredOutput,
|
|
87
87
|
emitProviderWarning,
|
|
88
|
+
warnIgnoredMediaResolution,
|
|
88
89
|
} from "../providers.ts";
|
|
89
90
|
|
|
90
91
|
// ---------------------------------------------------------------------------
|
|
@@ -524,6 +525,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
524
525
|
const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
|
|
525
526
|
// `12h` maps to `prompt_cache_retention: "24h"`; everything else stays on implicit prompt_cache_key affinity.
|
|
526
527
|
const cache = resolveCache("openai", options?.cache, ["implicit", "12h"], `openai/${modelId}`);
|
|
528
|
+
warnIgnoredMediaResolution("openai", context.messages, `openai/${modelId}`);
|
|
527
529
|
|
|
528
530
|
// Structured outputs: native `text.format` when supported, else emulated
|
|
529
531
|
// via instructions (catalog `structured_output: false` would 400).
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
normalizeStructuredOutput,
|
|
78
78
|
augmentSystemPromptWithStructuredOutput,
|
|
79
79
|
emitProviderWarning,
|
|
80
|
+
warnIgnoredMediaResolution,
|
|
80
81
|
} from "../providers.ts";
|
|
81
82
|
|
|
82
83
|
// ---------------------------------------------------------------------------
|
|
@@ -576,6 +577,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
576
577
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
577
578
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
578
579
|
const cache = resolveCache("openrouter", options?.cache, modelId.startsWith("anthropic/") ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
|
|
580
|
+
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`);
|
|
579
581
|
|
|
580
582
|
// Structured outputs: native `response_format` when supported, else
|
|
581
583
|
// emulated via system-prompt instruction + client validation (catalog
|
package/src/providers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { CacheOption, ServiceTier, ThinkingLevel } from "./types/core.ts";
|
|
13
13
|
import type { StructuredOutputSpec } from "./types/model.ts";
|
|
14
|
+
import type { Message } from "./types/message.ts";
|
|
14
15
|
import { zodToJsonSchema } from "./tools/schema.ts";
|
|
15
16
|
import { ValidationError } from "./types/errors.ts";
|
|
16
17
|
import { TRUNCATED_ARGS_KEY } from "./types/tool.ts";
|
|
@@ -247,6 +248,21 @@ export function resolveCache(
|
|
|
247
248
|
return undefined;
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
/** Warns once per provider/model that per-media `resolution` is ignored (no such control on that wire); in: an adapter's request messages, out: telemetry bus only. */
|
|
252
|
+
export function warnIgnoredMediaResolution(provider: string, messages: Message[], modelRef: string): void {
|
|
253
|
+
const used = messages.some(
|
|
254
|
+
(m) => Array.isArray(m.content) && m.content.some((p) => (p.type === "image" || p.type === "video") && p.resolution)
|
|
255
|
+
);
|
|
256
|
+
if (!used) return;
|
|
257
|
+
emitProviderWarning({
|
|
258
|
+
provider,
|
|
259
|
+
capability: "media resolution",
|
|
260
|
+
requested: modelRef,
|
|
261
|
+
reason: "this provider has no per-media detail control (Google only).",
|
|
262
|
+
fallback: "the provider's default detail",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
250
266
|
/** OpenRouter Chat Completions `tool_choice` wire values. */
|
|
251
267
|
export type OpenRouterChatToolChoice =
|
|
252
268
|
| "auto"
|
|
@@ -16,6 +16,7 @@ import type { Agent } from "../agent/agent.ts";
|
|
|
16
16
|
import { createChildSessionId } from "../agent/delegation.ts";
|
|
17
17
|
import { buildDynamicWorkerConfig, grantWorkerTools, normalizeWorkerTimeoutValue } from "../agent/worker.ts";
|
|
18
18
|
import { telemetryBus } from "../telemetry.ts";
|
|
19
|
+
import { lockFolder } from "../utils/durable.ts";
|
|
19
20
|
import { ConfigError, ValidationError } from "../types/errors.ts";
|
|
20
21
|
import type { ToolDefinition } from "../types/tool.ts";
|
|
21
22
|
import type { AgentResponse } from "../types/response.ts";
|
|
@@ -649,6 +650,7 @@ const schedulers = new Map<string, Scheduler>();
|
|
|
649
650
|
export function getScheduler(home: string): Scheduler {
|
|
650
651
|
let s = schedulers.get(home);
|
|
651
652
|
if (!s) {
|
|
653
|
+
lockFolder(home); // every scheduler function goes through here: one process per home
|
|
652
654
|
migrateLegacyFolder(home);
|
|
653
655
|
schedulers.set(home, (s = new Scheduler(home)));
|
|
654
656
|
}
|
package/src/scheduler/store.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* <home>/scheduled_actions/<id>/<date>.md one file per run
|
|
7
7
|
*
|
|
8
8
|
* The files are the source of truth: every write re-reads first (never writes
|
|
9
|
-
* from a stale copy) and goes through `
|
|
9
|
+
* from a stale copy) and goes through `writeFileDurable` (temp file, fsync, `.bak`, rename).
|
|
10
10
|
* Sync `fs`, like the session store.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { z } from "zod";
|
|
15
|
-
import {
|
|
15
|
+
import { readWithBackup, writeFileDurable } from "../utils/durable.ts";
|
|
16
16
|
import { telemetryBus } from "../telemetry.ts";
|
|
17
17
|
import { resolveHome } from "../utils/home.ts";
|
|
18
18
|
export { resolveHome };
|
|
@@ -73,36 +73,49 @@ export function migrateLegacyFolder(home: string): void {
|
|
|
73
73
|
|
|
74
74
|
type Raw = Record<string, unknown>;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
let text: string;
|
|
76
|
+
// Parses a `{ version, <key>: [...] }` file into its object entries; undefined when the text is not that shape; in: raw file text, out: readList.
|
|
77
|
+
function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
|
|
79
78
|
try {
|
|
80
|
-
|
|
79
|
+
const list = (JSON.parse(text) as Raw | null)?.[key];
|
|
80
|
+
if (!Array.isArray(list)) return undefined;
|
|
81
|
+
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
81
82
|
} catch {
|
|
82
|
-
return
|
|
83
|
+
return undefined;
|
|
83
84
|
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reads a `{ version, <key>: [...] }` file. Missing = empty. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
|
|
89
|
+
* (the last good version) is valid it is restored, else the list starts empty.
|
|
90
|
+
*/
|
|
91
|
+
function readList(file: string, key: "tasks" | "pending"): Raw[] {
|
|
92
|
+
const read = readWithBackup(file, (t) => parseList(t, key) !== undefined);
|
|
93
|
+
if (!read) return [];
|
|
94
|
+
const list = parseList(read.text, key);
|
|
95
|
+
if (list && !read.fromBackup) return list;
|
|
96
|
+
const moved = `${file}.corrupt-${Date.now()}`;
|
|
84
97
|
try {
|
|
85
|
-
|
|
86
|
-
if (!Array.isArray(list)) throw new Error(`"${key}" is not an array`);
|
|
87
|
-
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
98
|
+
fs.renameSync(file, moved);
|
|
88
99
|
} catch {
|
|
89
|
-
|
|
100
|
+
// why: quarantine is best-effort; the file is ignored either way.
|
|
101
|
+
}
|
|
102
|
+
if (list) {
|
|
90
103
|
try {
|
|
91
|
-
|
|
104
|
+
writeFileDurable(file, read.text, { backup: false }); // keep the good .bak where it is
|
|
92
105
|
} catch {
|
|
93
|
-
// why:
|
|
106
|
+
// why: the backup content is still returned for this read.
|
|
94
107
|
}
|
|
95
|
-
telemetryBus.emitWarning(
|
|
96
|
-
"scheduled_action.store_corrupt",
|
|
97
|
-
`[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and starting empty.`,
|
|
98
|
-
{ file: moved }
|
|
99
|
-
);
|
|
100
|
-
return [];
|
|
101
108
|
}
|
|
109
|
+
telemetryBus.emitWarning(
|
|
110
|
+
"scheduled_action.store_corrupt",
|
|
111
|
+
`[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and ${list ? "restored from its backup" : "starting empty"}.`,
|
|
112
|
+
{ file: moved, restored: !!list }
|
|
113
|
+
);
|
|
114
|
+
return list ?? [];
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
|
|
105
|
-
|
|
118
|
+
writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
/** Every record as stored. Records may be invalid: check with {@link validateRecord} before acting on one. */
|
|
@@ -212,7 +225,7 @@ export function writeRunFile(home: string, id: string, deliverAt: Date, header:
|
|
|
212
225
|
const base = runFileName(deliverAt);
|
|
213
226
|
let name = `${base}.md`;
|
|
214
227
|
for (let n = 2; fs.existsSync(path.join(dir, name)); n++) name = `${base}-${n}.md`;
|
|
215
|
-
|
|
228
|
+
writeFileDurable(path.join(dir, name), renderRun(header, body), { backup: false }); // written once, never rewritten
|
|
216
229
|
return `${id}/${name}`;
|
|
217
230
|
}
|
|
218
231
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only step log for a persisted session (dir mode). `session.json` stays the snapshot; every step after it
|
|
3
|
+
* adds ONE short line to `journal.ndjson` instead of rewriting the whole file, so a long chat costs the same per step
|
|
4
|
+
* as a short one. When history changes any other way (rollback, repair, reset, import) or the journal grows past a
|
|
5
|
+
* limit, a fresh snapshot replaces both. The snapshot carries an `epoch` id and the journal header repeats it, so a
|
|
6
|
+
* journal left over from before a snapshot is ignored on load (see `replayJournal` in `store.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import type { Message, ContentPart } from "../types/message.ts";
|
|
12
|
+
import { writeFileDurable } from "../utils/durable.ts";
|
|
13
|
+
import {
|
|
14
|
+
JOURNAL_FILE,
|
|
15
|
+
buildSessionData,
|
|
16
|
+
embeddedMedia,
|
|
17
|
+
quarantineIfCorrupt,
|
|
18
|
+
serializeSession,
|
|
19
|
+
stripRedundantRawArguments,
|
|
20
|
+
type SessionAgentLike,
|
|
21
|
+
type SessionPending,
|
|
22
|
+
type SessionPendingPrompt,
|
|
23
|
+
type SessionToolRecord,
|
|
24
|
+
} from "./store.ts";
|
|
25
|
+
|
|
26
|
+
/** A journal past this many lines is folded into a fresh snapshot. */
|
|
27
|
+
const MAX_JOURNAL_LINES = 500;
|
|
28
|
+
/** A journal past this many bytes is folded into a fresh snapshot. */
|
|
29
|
+
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const EMPTY_PENDING = JSON.stringify({ steer: [], queue: [] });
|
|
32
|
+
|
|
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
|
+
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 } : {}) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Persists one session folder (`sessions/<id>/`) step by step. */
|
|
39
|
+
export class SessionJournal {
|
|
40
|
+
/** Epoch of the snapshot the journal belongs to; unset until the first snapshot (and after a failed append). */
|
|
41
|
+
private epoch: string | undefined;
|
|
42
|
+
/** How many messages are on disk, and the last one (identity check: history only grew since the last sync). */
|
|
43
|
+
private count = 0;
|
|
44
|
+
private last: Message | undefined;
|
|
45
|
+
private lines = 0;
|
|
46
|
+
private bytes = 0;
|
|
47
|
+
/** JSON of the last written non-message state (config, system prompt, worker traces). */
|
|
48
|
+
private meta = "";
|
|
49
|
+
/** How many run marks are on disk, and the last one. */
|
|
50
|
+
private runCount = 0;
|
|
51
|
+
private lastRun: unknown;
|
|
52
|
+
/** JSON of the last journaled waiting prompts. */
|
|
53
|
+
private pending = EMPTY_PENDING;
|
|
54
|
+
/** Run ids whose ending is on disk. */
|
|
55
|
+
private readonly endsOnDisk = new Set<string>();
|
|
56
|
+
/** How many of the current run's finished tool results are on disk, and the last one. */
|
|
57
|
+
private toolCount = 0;
|
|
58
|
+
private lastTool: unknown;
|
|
59
|
+
/** Disk form of each message already prepared (media replaced by `media/` files, redundant raw arguments dropped). */
|
|
60
|
+
private readonly onDisk = new WeakMap<Message, Message>();
|
|
61
|
+
/** Same for waiting prompts made of content parts. */
|
|
62
|
+
private readonly promptOnDisk = new WeakMap<ContentPart[], ContentPart[]>();
|
|
63
|
+
|
|
64
|
+
constructor(readonly dir: string) {}
|
|
65
|
+
|
|
66
|
+
// Brings the folder up to date with the agent: appends new messages and changed state, or writes a fresh snapshot when history changed another way; in: Agent.doPersist after every step, out: files in this.dir.
|
|
67
|
+
sync(agent: SessionAgentLike): void {
|
|
68
|
+
const messages = agent.context.messages;
|
|
69
|
+
const runs = agent.runMarks ?? [];
|
|
70
|
+
const grew =
|
|
71
|
+
this.epoch !== undefined &&
|
|
72
|
+
messages.length >= this.count &&
|
|
73
|
+
(this.count === 0 || messages[this.count - 1] === this.last) &&
|
|
74
|
+
runs.length >= this.runCount &&
|
|
75
|
+
(this.runCount === 0 || runs[this.runCount - 1] === this.lastRun);
|
|
76
|
+
if (!grew) {
|
|
77
|
+
this.snapshot(agent);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const records: string[] = [];
|
|
81
|
+
for (let i = this.count; i < messages.length; i++) records.push(JSON.stringify({ t: "msg", i, m: this.toDisk(messages[i]!) }));
|
|
82
|
+
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 }));
|
|
83
|
+
for (const mark of runs) {
|
|
84
|
+
if (mark.end && !this.endsOnDisk.has(mark.id)) records.push(JSON.stringify({ t: "end", id: mark.id, status: mark.end }));
|
|
85
|
+
}
|
|
86
|
+
const tools = agent.toolRecords ?? [];
|
|
87
|
+
const firstNewTool = this.toolCount > 0 && tools[this.toolCount - 1] === this.lastTool ? this.toolCount : 0; // a new run starts the list over
|
|
88
|
+
for (let k = firstNewTool; k < tools.length; k++) records.push(toolLine(tools[k]!));
|
|
89
|
+
const meta = this.metaOf(agent);
|
|
90
|
+
if (meta !== this.meta) records.push(`{"t":"meta","m":${meta}}`);
|
|
91
|
+
const pending = this.pendingOf(agent);
|
|
92
|
+
if (pending !== this.pending) records.push(`{"t":"pending","p":${pending}}`);
|
|
93
|
+
if (records.length === 0) return;
|
|
94
|
+
const text = records.join("\n") + "\n";
|
|
95
|
+
try {
|
|
96
|
+
const fd = fs.openSync(path.join(this.dir, JOURNAL_FILE), "a", 0o600);
|
|
97
|
+
try {
|
|
98
|
+
fs.writeFileSync(fd, text);
|
|
99
|
+
fs.fsyncSync(fd);
|
|
100
|
+
} finally {
|
|
101
|
+
fs.closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
this.epoch = undefined; // a half-written line may be on disk: the next sync starts over with a snapshot
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
this.count = messages.length;
|
|
108
|
+
this.last = messages[messages.length - 1];
|
|
109
|
+
this.meta = meta;
|
|
110
|
+
this.runCount = runs.length;
|
|
111
|
+
this.lastRun = runs[runs.length - 1];
|
|
112
|
+
this.pending = pending;
|
|
113
|
+
this.toolCount = tools.length;
|
|
114
|
+
this.lastTool = tools[tools.length - 1];
|
|
115
|
+
for (const mark of runs) if (mark.end) this.endsOnDisk.add(mark.id);
|
|
116
|
+
this.lines += records.length;
|
|
117
|
+
this.bytes += text.length;
|
|
118
|
+
if (this.lines >= MAX_JOURNAL_LINES || this.bytes >= MAX_JOURNAL_BYTES) this.snapshot(agent);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Writes `session.json` (new epoch) and then restarts the journal under that epoch; a crash in between leaves a journal with the old epoch, which load ignores; in: sync, out: both files.
|
|
122
|
+
private snapshot(agent: SessionAgentLike): void {
|
|
123
|
+
const messages = agent.context.messages;
|
|
124
|
+
const epoch = randomUUID();
|
|
125
|
+
const target = path.join(this.dir, "session.json");
|
|
126
|
+
if (this.epoch === undefined) quarantineIfCorrupt(target);
|
|
127
|
+
const data = buildSessionData({ ...agent, context: { ...agent.context, messages: messages.map((m) => this.toDisk(m)) } }, null);
|
|
128
|
+
writeFileDurable(target, serializeSession({ ...data, epoch }) + "\n");
|
|
129
|
+
const pending = this.pendingOf(agent);
|
|
130
|
+
const tools = agent.toolRecords ?? [];
|
|
131
|
+
// Waiting prompts and the current run's finished tool results live only in the journal, so a new journal starts with them.
|
|
132
|
+
const header =
|
|
133
|
+
JSON.stringify({ t: "base", e: epoch }) + "\n" + (pending === EMPTY_PENDING ? "" : `{"t":"pending","p":${pending}}\n`) + tools.map((t) => toolLine(t) + "\n").join("");
|
|
134
|
+
writeFileDurable(path.join(this.dir, JOURNAL_FILE), header, { backup: false });
|
|
135
|
+
this.epoch = epoch;
|
|
136
|
+
this.pending = pending;
|
|
137
|
+
this.runs(agent);
|
|
138
|
+
this.count = messages.length;
|
|
139
|
+
this.last = messages[messages.length - 1];
|
|
140
|
+
this.lines = (pending === EMPTY_PENDING ? 0 : 1) + tools.length;
|
|
141
|
+
this.toolCount = tools.length;
|
|
142
|
+
this.lastTool = tools[tools.length - 1];
|
|
143
|
+
this.endsOnDisk.clear();
|
|
144
|
+
for (const mark of agent.runMarks ?? []) if (mark.end) this.endsOnDisk.add(mark.id); // the snapshot holds them
|
|
145
|
+
this.bytes = 0;
|
|
146
|
+
this.meta = this.metaOf(agent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Remembers which run marks the snapshot already holds; in: snapshot, out: the next sync's "which marks are new".
|
|
150
|
+
private runs(agent: SessionAgentLike): void {
|
|
151
|
+
const runs = agent.runMarks ?? [];
|
|
152
|
+
this.runCount = runs.length;
|
|
153
|
+
this.lastRun = runs[runs.length - 1];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// JSON of the waiting prompts with media moved to files; in: the agent's steer inbox + follow-up queue, out: change detection + the `pending` record.
|
|
157
|
+
private pendingOf(agent: SessionAgentLike): string {
|
|
158
|
+
const waiting = agent.pendingPrompts?.();
|
|
159
|
+
if (!waiting || waiting.steer.length + waiting.queue.length === 0) return EMPTY_PENDING;
|
|
160
|
+
const parts = (prompt: ContentPart[]): ContentPart[] => {
|
|
161
|
+
let disk = this.promptOnDisk.get(prompt);
|
|
162
|
+
if (!disk) this.promptOnDisk.set(prompt, (disk = this.toDisk({ role: "user", content: prompt }).content as ContentPart[]));
|
|
163
|
+
return disk;
|
|
164
|
+
};
|
|
165
|
+
const slim = (list: SessionPendingPrompt[]): SessionPendingPrompt[] =>
|
|
166
|
+
list.map((w) => ({ ...w, prompt: typeof w.prompt === "string" ? w.prompt : parts(w.prompt) }));
|
|
167
|
+
return JSON.stringify({ steer: slim(waiting.steer), queue: slim(waiting.queue) } satisfies SessionPending);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// JSON of everything in the snapshot that is not a message, identity or totals; in: the live agent, out: change detection + the `meta` record.
|
|
171
|
+
private metaOf(agent: SessionAgentLike): string {
|
|
172
|
+
const { version: _v, sessionId: _s, messages: _m, totals: _t, runs: _r, ...rest } = buildSessionData(
|
|
173
|
+
{ ...agent, context: { ...agent.context, messages: [] } },
|
|
174
|
+
null
|
|
175
|
+
);
|
|
176
|
+
return JSON.stringify(rest);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The form of a message that goes to disk: embedded media written once as `media/<kind>-h<hash>.<ext>` (content-addressed, never deleted or renumbered), redundant raw arguments dropped; in: a live message, out: journal lines and snapshots.
|
|
180
|
+
private toDisk(message: Message): Message {
|
|
181
|
+
const known = this.onDisk.get(message);
|
|
182
|
+
if (known) return known;
|
|
183
|
+
const slim = stripRedundantRawArguments([message])[0]!;
|
|
184
|
+
let out = slim;
|
|
185
|
+
if (Array.isArray(slim.content)) {
|
|
186
|
+
let changed = false;
|
|
187
|
+
const content = (slim.content as ContentPart[]).map((part) => {
|
|
188
|
+
const found = embeddedMedia(part);
|
|
189
|
+
if (!found) return part;
|
|
190
|
+
const file = `${found.key}-h${createHash("sha256").update(found.bytes).digest("hex").slice(0, 16)}.${found.ext}`;
|
|
191
|
+
const dest = path.join(this.dir, "media", file);
|
|
192
|
+
if (!fs.existsSync(dest)) writeFileDurable(dest, found.bytes, { backup: false });
|
|
193
|
+
changed = true;
|
|
194
|
+
return { ...(part as unknown as Record<string, unknown>), [found.key]: `media/${file}`, ...(found.mimeType ? { mimeType: found.mimeType } : {}) } as unknown as ContentPart;
|
|
195
|
+
});
|
|
196
|
+
if (changed) out = { ...slim, content };
|
|
197
|
+
}
|
|
198
|
+
this.onDisk.set(message, out);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
}
|