agent-accelerator 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -5
- package/package.json +1 -1
- package/src/agent/agent.ts +283 -15
- package/src/agent/context.ts +2 -0
- package/src/agent/loop.ts +127 -8
- package/src/index.ts +2 -1
- package/src/models/catalog-cache.ts +3 -10
- 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 +4 -0
- package/src/types/response.ts +2 -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 +15 -1
package/src/session/store.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { getModelFromCatalog } from "../models/catalog.ts";
|
|
|
32
32
|
import type { SubAgentStep } from "../types/response.ts";
|
|
33
33
|
import { SessionLoadError } from "../types/errors.ts";
|
|
34
34
|
import { createSessionId } from "../utils/session.ts";
|
|
35
|
+
import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
|
|
35
36
|
|
|
36
37
|
/** Cumulative per-session token + cost totals (JSON-safe). */
|
|
37
38
|
export interface SessionTotals {
|
|
@@ -61,6 +62,40 @@ export interface PersistedSubAgentTrace {
|
|
|
61
62
|
error?: string;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/** How a run ended; a run with no ending was cut off (a crash). `abandoned` = a newer run started instead of resuming it. */
|
|
66
|
+
export type SessionRunEnd = "done" | "failed" | "aborted" | "stopped" | "abandoned";
|
|
67
|
+
|
|
68
|
+
/** Where one run began: `i` is the index of its user message in `messages` (turn boundaries for later phases). */
|
|
69
|
+
export interface SessionRunMark {
|
|
70
|
+
id: string;
|
|
71
|
+
i: number;
|
|
72
|
+
at: number;
|
|
73
|
+
/** Unset while the run is active or when it was cut off. */
|
|
74
|
+
end?: SessionRunEnd;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The result of a tool call that finished, saved the moment it finished so a crash never repeats it. */
|
|
78
|
+
export interface SessionToolRecord {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
result: unknown;
|
|
82
|
+
isError?: boolean;
|
|
83
|
+
/** The tool ended the run with `ctx.stop`. */
|
|
84
|
+
stopped?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A prompt that was waiting when the process stopped (its options other than `additionalContext` are not saved). */
|
|
88
|
+
export interface SessionPendingPrompt {
|
|
89
|
+
prompt: string | ContentPart[];
|
|
90
|
+
additionalContext?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Steers not yet injected into the active run, and queued follow-ups, as last journaled. */
|
|
94
|
+
export interface SessionPending {
|
|
95
|
+
steer: SessionPendingPrompt[];
|
|
96
|
+
queue: SessionPendingPrompt[];
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
/** Single-file session snapshot (v1). Written as one JSON object. */
|
|
65
100
|
export interface PersistedAgentSession {
|
|
66
101
|
version: 1;
|
|
@@ -79,6 +114,14 @@ export interface PersistedAgentSession {
|
|
|
79
114
|
parentSessionId?: string;
|
|
80
115
|
/** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
|
|
81
116
|
subagents?: Record<string, PersistedSubAgentTrace>;
|
|
117
|
+
/** Written with a journal (dir mode): only a `journal.ndjson` whose header names this id belongs to this snapshot. */
|
|
118
|
+
epoch?: string;
|
|
119
|
+
/** Where each run began, oldest first. */
|
|
120
|
+
runs?: SessionRunMark[];
|
|
121
|
+
/** Read from the journal only (never in `session.json`): prompts that were still waiting. */
|
|
122
|
+
pending?: SessionPending;
|
|
123
|
+
/** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
|
|
124
|
+
tools?: SessionToolRecord[];
|
|
82
125
|
}
|
|
83
126
|
|
|
84
127
|
export function emptyTotals(): SessionTotals {
|
|
@@ -286,6 +329,12 @@ export interface SessionAgentLike {
|
|
|
286
329
|
instructions?: string;
|
|
287
330
|
parentSessionId?: unknown;
|
|
288
331
|
subagentTraces?: Record<string, PersistedSubAgentTrace>;
|
|
332
|
+
/** Run boundaries (see {@link SessionRunMark}). */
|
|
333
|
+
runMarks?: SessionRunMark[];
|
|
334
|
+
/** Prompts waiting in the steer inbox / follow-up queue; called by the journal on every sync. */
|
|
335
|
+
pendingPrompts?: () => SessionPending;
|
|
336
|
+
/** Tool calls finished in the current run (cleared when the next run starts). */
|
|
337
|
+
toolRecords?: SessionToolRecord[];
|
|
289
338
|
context: { systemPrompt?: string; messages: Message[] };
|
|
290
339
|
}
|
|
291
340
|
|
|
@@ -354,7 +403,7 @@ function stableStringify(value: unknown): string {
|
|
|
354
403
|
* stored `arguments` (pure duplicate, the common case). Divergent or
|
|
355
404
|
* unparseable wire strings are kept — they carry info parsing lost.
|
|
356
405
|
*/
|
|
357
|
-
function stripRedundantRawArguments(messages: Message[]): Message[] {
|
|
406
|
+
export function stripRedundantRawArguments(messages: Message[]): Message[] {
|
|
358
407
|
return messages.map((m) => {
|
|
359
408
|
if (!Array.isArray((m as Message).content)) return m;
|
|
360
409
|
let changed = false;
|
|
@@ -432,6 +481,7 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
|
|
|
432
481
|
messages,
|
|
433
482
|
...(parentSessionId ? { parentSessionId } : {}),
|
|
434
483
|
...(subagents ? { subagents } : {}),
|
|
484
|
+
...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
|
|
435
485
|
};
|
|
436
486
|
}
|
|
437
487
|
|
|
@@ -619,6 +669,8 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
619
669
|
totals: obj.totals,
|
|
620
670
|
parentSessionId: obj.parentSessionId,
|
|
621
671
|
subagents: obj.subagents,
|
|
672
|
+
...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
|
|
673
|
+
...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
|
|
622
674
|
};
|
|
623
675
|
} catch {
|
|
624
676
|
return null;
|
|
@@ -654,14 +706,14 @@ export function inspectSessionPayload(
|
|
|
654
706
|
/** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
|
|
655
707
|
export function loadSessionFile(filePath: string, limits?: SessionLimits): PersistedAgentSession | null {
|
|
656
708
|
const resolved = path.resolve(filePath);
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
return
|
|
709
|
+
const read = readWithBackup(resolved, isLoadableSession);
|
|
710
|
+
return read ? sessionFromPayload(read.text, resolved, limits) : null;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// True when a stored payload is a usable snapshot (a valid one over the load limits counts); in: raw file text, out: readWithBackup's fallback decision.
|
|
714
|
+
function isLoadableSession(raw: string): boolean {
|
|
715
|
+
const status = inspectSessionPayload(raw).status;
|
|
716
|
+
return status === "ok" || status === "over_limit";
|
|
665
717
|
}
|
|
666
718
|
|
|
667
719
|
/**
|
|
@@ -680,25 +732,8 @@ function sessionFromPayload(raw: string, file: string, limits?: SessionLimits):
|
|
|
680
732
|
return res.session ?? null;
|
|
681
733
|
}
|
|
682
734
|
|
|
683
|
-
/**
|
|
684
|
-
export
|
|
685
|
-
const dir = path.dirname(filePath);
|
|
686
|
-
try {
|
|
687
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
688
|
-
} catch {}
|
|
689
|
-
let tag = "tmp";
|
|
690
|
-
try {
|
|
691
|
-
const pid = (globalThis as any)?.process?.pid;
|
|
692
|
-
const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
|
|
693
|
-
tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
|
|
694
|
-
} catch {
|
|
695
|
-
tag = Math.random().toString(36).slice(2, 8);
|
|
696
|
-
}
|
|
697
|
-
const tmp = `${filePath}.tmp-${tag}`;
|
|
698
|
-
fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
|
|
699
|
-
try { fs.chmodSync(tmp, 0o600); } catch {}
|
|
700
|
-
fs.renameSync(tmp, filePath);
|
|
701
|
-
}
|
|
735
|
+
/** Public name of the durable writer (temp file, fsync, `.bak`, atomic rename): a crash or power cut never leaves half-written JSON. */
|
|
736
|
+
export const writeFileAtomic = writeFileDurable;
|
|
702
737
|
|
|
703
738
|
/** Session id grammar: no traversal, no slashes, no hidden files. */
|
|
704
739
|
export function assertValidSessionId(sessionId: string): void {
|
|
@@ -707,6 +742,15 @@ export function assertValidSessionId(sessionId: string): void {
|
|
|
707
742
|
}
|
|
708
743
|
}
|
|
709
744
|
|
|
745
|
+
/** 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). */
|
|
746
|
+
export function quarantineIfCorrupt(file: string): void {
|
|
747
|
+
try {
|
|
748
|
+
if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt") fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
|
|
749
|
+
} catch {
|
|
750
|
+
// why: no file yet, or it cannot be read: nothing to quarantine.
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
710
754
|
/** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
|
|
711
755
|
export function saveSessionFile(
|
|
712
756
|
filePath: string,
|
|
@@ -714,20 +758,7 @@ export function saveSessionFile(
|
|
|
714
758
|
telemetry?: SessionTelemetry | SessionTotals | null
|
|
715
759
|
): void {
|
|
716
760
|
const resolved = path.resolve(filePath);
|
|
717
|
-
|
|
718
|
-
// as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
|
|
719
|
-
try {
|
|
720
|
-
if (fs.existsSync(resolved)) {
|
|
721
|
-
try {
|
|
722
|
-
const raw = fs.readFileSync(resolved, "utf8");
|
|
723
|
-
// Only genuinely corrupt payloads are quarantined; a healthy snapshot
|
|
724
|
-
// over the load limits is simply rewritten (it is our own prior save).
|
|
725
|
-
if (inspectSessionPayload(raw).status === "corrupt") {
|
|
726
|
-
try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
|
|
727
|
-
}
|
|
728
|
-
} catch {}
|
|
729
|
-
}
|
|
730
|
-
} catch {}
|
|
761
|
+
quarantineIfCorrupt(resolved);
|
|
731
762
|
const data = buildSessionData(agent, telemetry);
|
|
732
763
|
try {
|
|
733
764
|
writeFileAtomic(resolved, serializeSession(data) + "\n");
|
|
@@ -790,6 +821,28 @@ function extForMedia(mimeType?: string, filename?: string): string {
|
|
|
790
821
|
return "bin";
|
|
791
822
|
}
|
|
792
823
|
|
|
824
|
+
// 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.
|
|
825
|
+
export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
|
|
826
|
+
const p = part as unknown as Record<string, unknown> | null;
|
|
827
|
+
if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"])) return undefined;
|
|
828
|
+
const key = p["type"] as (typeof MEDIA_PART_KEYS)[number];
|
|
829
|
+
const raw = p[key];
|
|
830
|
+
let bytes: Uint8Array;
|
|
831
|
+
let mimeType = typeof p["mimeType"] === "string" ? (p["mimeType"] as string) : undefined;
|
|
832
|
+
if (raw instanceof Uint8Array) bytes = raw;
|
|
833
|
+
else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
|
|
834
|
+
else if (typeof raw === "string") {
|
|
835
|
+
if (isHttpRef(raw)) return undefined; // remote reference: the provider fetches it per turn
|
|
836
|
+
const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
|
|
837
|
+
if (dataUrl) {
|
|
838
|
+
bytes = base64ToBytes(dataUrl.base64);
|
|
839
|
+
mimeType = mimeType ?? dataUrl.mimeType;
|
|
840
|
+
} else if (looksLikeEmbeddedBase64(raw)) bytes = base64ToBytes(raw.replace(/\s/g, ""));
|
|
841
|
+
else return undefined; // local path or short label: keep as reference
|
|
842
|
+
} else return undefined;
|
|
843
|
+
return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
|
|
844
|
+
}
|
|
845
|
+
|
|
793
846
|
function cloneMessages(messages: Message[]): Message[] {
|
|
794
847
|
try {
|
|
795
848
|
const g: any = globalThis as any;
|
|
@@ -830,35 +883,15 @@ export function extractMediaToDir(
|
|
|
830
883
|
} catch {}
|
|
831
884
|
const wrote: string[] = [];
|
|
832
885
|
const counters: Record<string, number> = {};
|
|
833
|
-
let hasBinary = false;
|
|
834
886
|
|
|
835
887
|
for (const msg of cloned) {
|
|
836
888
|
if (!Array.isArray((msg as Message).content)) continue;
|
|
837
889
|
for (const part of (msg as Message).content as ContentPart[]) {
|
|
838
|
-
const
|
|
839
|
-
if (!
|
|
840
|
-
|
|
841
|
-
const key = p.type as (typeof MEDIA_PART_KEYS)[number];
|
|
842
|
-
const raw = p[key] as unknown;
|
|
843
|
-
let bytes: Uint8Array | undefined;
|
|
844
|
-
let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
|
|
845
|
-
if (raw instanceof Uint8Array) bytes = raw;
|
|
846
|
-
else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
|
|
847
|
-
else if (typeof raw === "string") {
|
|
848
|
-
if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
|
|
849
|
-
const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
|
|
850
|
-
if (dataUrl) {
|
|
851
|
-
bytes = base64ToBytes(dataUrl.base64);
|
|
852
|
-
mimeType = mimeType ?? dataUrl.mimeType;
|
|
853
|
-
} else if (looksLikeEmbeddedBase64(raw)) {
|
|
854
|
-
bytes = base64ToBytes(raw.replace(/\s/g, ""));
|
|
855
|
-
} else continue; // local path or short label — keep as reference
|
|
856
|
-
} else continue;
|
|
857
|
-
|
|
858
|
-
hasBinary = true;
|
|
890
|
+
const found = embeddedMedia(part);
|
|
891
|
+
if (!found) continue;
|
|
892
|
+
const { key, bytes, mimeType } = found;
|
|
859
893
|
const n = (counters[key] = (counters[key] ?? 0) + 1);
|
|
860
|
-
const
|
|
861
|
-
const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
|
|
894
|
+
const file = `${key}-${String(n).padStart(3, "0")}.${found.ext}`;
|
|
862
895
|
try {
|
|
863
896
|
fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
|
|
864
897
|
const dest = path.resolve(mediaDir, file);
|
|
@@ -868,8 +901,9 @@ export function extractMediaToDir(
|
|
|
868
901
|
} catch {
|
|
869
902
|
continue;
|
|
870
903
|
}
|
|
904
|
+
const p = part as unknown as Record<string, unknown>;
|
|
871
905
|
p[key] = `media/${file}`;
|
|
872
|
-
if (mimeType) p
|
|
906
|
+
if (mimeType) p["mimeType"] = mimeType;
|
|
873
907
|
wrote.push(`media/${file}`);
|
|
874
908
|
}
|
|
875
909
|
}
|
|
@@ -937,17 +971,7 @@ export function saveSessionDir(
|
|
|
937
971
|
);
|
|
938
972
|
try {
|
|
939
973
|
const target = path.join(sessionDir, "session.json");
|
|
940
|
-
|
|
941
|
-
try {
|
|
942
|
-
if (fs.existsSync(target)) {
|
|
943
|
-
try {
|
|
944
|
-
const raw = fs.readFileSync(target, "utf8");
|
|
945
|
-
if (inspectSessionPayload(raw).status === "corrupt") {
|
|
946
|
-
try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
|
|
947
|
-
}
|
|
948
|
-
} catch {}
|
|
949
|
-
}
|
|
950
|
-
} catch {}
|
|
974
|
+
quarantineIfCorrupt(target);
|
|
951
975
|
writeFileAtomic(target, serializeSession(data) + "\n");
|
|
952
976
|
} catch {
|
|
953
977
|
// persistence must never crash a chat turn
|
|
@@ -955,6 +979,67 @@ export function saveSessionDir(
|
|
|
955
979
|
return sessionDir;
|
|
956
980
|
}
|
|
957
981
|
|
|
982
|
+
/** Name of the append-only step log next to `session.json` (see `session/journal.ts`). */
|
|
983
|
+
export const JOURNAL_FILE = "journal.ndjson";
|
|
984
|
+
|
|
985
|
+
// Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
|
|
986
|
+
const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
|
|
987
|
+
|
|
988
|
+
// 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, `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.
|
|
989
|
+
function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
|
|
990
|
+
if (!snapshot.epoch) return snapshot;
|
|
991
|
+
const lines = text.split("\n");
|
|
992
|
+
const session: PersistedAgentSession = { ...snapshot, messages: [...snapshot.messages] };
|
|
993
|
+
let sawHeader = false;
|
|
994
|
+
const stop = (reason: string, line: number): void => {
|
|
995
|
+
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 });
|
|
996
|
+
};
|
|
997
|
+
for (let k = 0; k < lines.length; k++) {
|
|
998
|
+
const line = lines[k]!.trim();
|
|
999
|
+
if (!line) continue;
|
|
1000
|
+
let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
|
|
1001
|
+
try {
|
|
1002
|
+
rec = JSON.parse(line);
|
|
1003
|
+
} catch {
|
|
1004
|
+
if (lines.slice(k + 1).some((l) => l.trim())) stop("not valid JSON", k); // a torn LAST line is just a cut-off write
|
|
1005
|
+
break;
|
|
1006
|
+
}
|
|
1007
|
+
if (!sawHeader) {
|
|
1008
|
+
sawHeader = true;
|
|
1009
|
+
if (rec.t !== "base" || rec.e !== snapshot.epoch) return snapshot; // a journal from before this snapshot
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (rec.t === "msg" && typeof rec.i === "number") {
|
|
1013
|
+
if (rec.i < session.messages.length) continue; // already in the snapshot
|
|
1014
|
+
if (rec.i > session.messages.length || findInvalidMessage([rec.m]) !== -1) {
|
|
1015
|
+
stop(rec.i > session.messages.length ? "a message is missing before this one" : "invalid message", k);
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
session.messages.push(rec.m as Message);
|
|
1019
|
+
} else if (rec.t === "run" && typeof rec.id === "string" && typeof rec.i === "number" && typeof rec.at === "number") {
|
|
1020
|
+
if (!session.runs?.some((r) => r.id === rec.id)) session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
|
|
1021
|
+
} else if (rec.t === "end" && typeof rec.id === "string" && typeof rec.status === "string") {
|
|
1022
|
+
const mark = session.runs?.find((r) => r.id === rec.id);
|
|
1023
|
+
if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
|
|
1024
|
+
} else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
|
|
1025
|
+
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 } : {}) }];
|
|
1026
|
+
} else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
|
|
1027
|
+
const p = rec.p as SessionPending; // latest record wins
|
|
1028
|
+
if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
|
|
1029
|
+
if (p.steer.length + p.queue.length > 0) session.pending = p;
|
|
1030
|
+
else delete session.pending;
|
|
1031
|
+
}
|
|
1032
|
+
} else if (rec.t === "meta" && rec.m && typeof rec.m === "object") {
|
|
1033
|
+
const meta = rec.m as Record<string, unknown>;
|
|
1034
|
+
for (const key of JOURNAL_META_KEYS) {
|
|
1035
|
+
if (key in meta) (session as unknown as Record<string, unknown>)[key] = meta[key];
|
|
1036
|
+
else delete session[key];
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return session;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
958
1043
|
/** A loaded session folder: snapshot (media paths resolved) + folder location. */
|
|
959
1044
|
export interface LoadedSessionDir {
|
|
960
1045
|
session: PersistedAgentSession;
|
|
@@ -985,9 +1070,17 @@ export function loadSessionDir(sessionPath: string, limits?: SessionLimits): Loa
|
|
|
985
1070
|
// path does not exist yet — fall through to session.json lookup
|
|
986
1071
|
}
|
|
987
1072
|
const file = path.join(dir, "session.json");
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1073
|
+
const read = readWithBackup(file, isLoadableSession);
|
|
1074
|
+
if (!read) return null;
|
|
1075
|
+
const snapshot = sessionFromPayload(read.text, file, limits);
|
|
1076
|
+
if (!snapshot) return null;
|
|
1077
|
+
let journal: string | undefined;
|
|
1078
|
+
try {
|
|
1079
|
+
journal = fs.readFileSync(path.join(dir, JOURNAL_FILE), "utf8");
|
|
1080
|
+
} catch {
|
|
1081
|
+
journal = undefined; // no journal: the snapshot is the whole session
|
|
1082
|
+
}
|
|
1083
|
+
const data = journal ? replayJournal(snapshot, journal, path.join(dir, JOURNAL_FILE)) : snapshot;
|
|
991
1084
|
data.messages = resolveMediaPaths(data.messages, dir);
|
|
992
1085
|
return { session: data, dir };
|
|
993
1086
|
} catch (err) {
|
|
@@ -1007,7 +1100,13 @@ export function findLatestSessionDir(rootDir: string): string | null {
|
|
|
1007
1100
|
try {
|
|
1008
1101
|
const st = fs.statSync(file);
|
|
1009
1102
|
if (!st.isFile()) continue;
|
|
1010
|
-
|
|
1103
|
+
let mtime = st.mtimeMs;
|
|
1104
|
+
try {
|
|
1105
|
+
mtime = Math.max(mtime, fs.statSync(path.join(root, entry, JOURNAL_FILE)).mtimeMs); // steps land in the journal
|
|
1106
|
+
} catch {
|
|
1107
|
+
// why: no journal yet.
|
|
1108
|
+
}
|
|
1109
|
+
if (!best || mtime > best.mtime) best = { dir: path.join(root, entry), mtime };
|
|
1011
1110
|
} catch {
|
|
1012
1111
|
continue;
|
|
1013
1112
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One log file per tool call, inside the session folder: `tool-logs/<n>-<tool>.ndjson`. The executor writes its own
|
|
3
|
+
* steps into it (input, attempts, retries, result or error, duration) and a tool adds its own with `ctx.log(...)`.
|
|
4
|
+
* Every line is flushed to disk as it is written, so after a crash the file shows how far the call got. The file is for
|
|
5
|
+
* developers and harnesses: it is never sent to the model, and nothing in the SDK reads it back.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { fsyncFolder } from "../utils/durable.ts";
|
|
10
|
+
|
|
11
|
+
/** The log of one tool call, as the executor sees it. */
|
|
12
|
+
export interface ToolLogWriter {
|
|
13
|
+
/** Path relative to the session folder (`tool-logs/003-send_email.ndjson`); saved on the call's result. */
|
|
14
|
+
readonly file: string;
|
|
15
|
+
/** Appends one line (`timestamp` is added) and flushes it. Never throws. */
|
|
16
|
+
write(entry: Record<string, unknown>): void;
|
|
17
|
+
/** Closes the file; later writes (a tool still running after a timeout) are ignored. */
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Hands out the log files of one session folder, numbering them in the order the calls start. */
|
|
22
|
+
export class ToolLogs {
|
|
23
|
+
private next: number | undefined;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly sessionDir: string) {}
|
|
26
|
+
|
|
27
|
+
// Opens `tool-logs/<n>-<tool>.ndjson` for one call; the numbering continues after files from earlier runs and restarts; in: a tool call as the executor starts it, out: the writer the executor and `ctx.log` write through.
|
|
28
|
+
open(call: { name: string }): ToolLogWriter | undefined {
|
|
29
|
+
const dir = path.join(this.sessionDir, "tool-logs");
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
this.next ??= fs.readdirSync(dir).reduce((max, f) => Math.max(max, Number(/^(\d+)-/.exec(f)?.[1] ?? 0)), 0) + 1;
|
|
33
|
+
const name = `${String(this.next++).padStart(3, "0")}-${call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)}.ndjson`;
|
|
34
|
+
const fd = fs.openSync(path.join(dir, name), "a", 0o600);
|
|
35
|
+
fsyncFolder(dir);
|
|
36
|
+
let open = true;
|
|
37
|
+
return {
|
|
38
|
+
file: `tool-logs/${name}`,
|
|
39
|
+
write(entry) {
|
|
40
|
+
if (!open) return;
|
|
41
|
+
try {
|
|
42
|
+
fs.writeSync(fd, JSON.stringify({ timestamp: Date.now(), ...entry }) + "\n");
|
|
43
|
+
fs.fdatasyncSync(fd);
|
|
44
|
+
} catch {
|
|
45
|
+
// why: a full disk must never fail the tool call being logged.
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
close() {
|
|
49
|
+
if (!open) return;
|
|
50
|
+
open = false;
|
|
51
|
+
try {
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
} catch {
|
|
54
|
+
// why: nothing left to flush.
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined; // logging is best effort: the call runs without a log
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/telemetry-file.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Each event is a single synchronous append, so a process crash keeps every
|
|
5
5
|
* event already emitted: a `tool.call_started` with no ending event was in
|
|
6
6
|
* flight, and the last `tool.progress` shows how far it got. Power loss is not
|
|
7
|
-
* covered (no fsync). `turn.thinking`
|
|
7
|
+
* covered (no fsync, by design: this is observability, not state). `turn.thinking`
|
|
8
|
+
* (full reasoning text) is never written. Takes the home's one-process lock.
|
|
8
9
|
*/
|
|
9
10
|
import * as fs from "node:fs";
|
|
10
11
|
import * as path from "node:path";
|
|
11
12
|
import { telemetryBus } from "./telemetry.ts";
|
|
12
13
|
import { safeStringify } from "./utils/serialization.ts";
|
|
14
|
+
import { lockFolder } from "./utils/durable.ts";
|
|
13
15
|
|
|
14
16
|
const sinks = new Set<string>();
|
|
15
17
|
|
|
@@ -17,6 +19,7 @@ const sinks = new Set<string>();
|
|
|
17
19
|
export function persistTelemetry(home: string): void {
|
|
18
20
|
const dir = path.join(home, "telemetry");
|
|
19
21
|
if (sinks.has(dir)) return;
|
|
22
|
+
lockFolder(home);
|
|
20
23
|
sinks.add(dir);
|
|
21
24
|
telemetryBus.onEvent((event) => {
|
|
22
25
|
if (event.code === "turn.thinking") return;
|