Files
harness-tools/extensions/_workflows/artifacts.ts
T
2026-08-24 16:33:11 -04:00

180 lines
5.0 KiB
TypeScript

import type { TranscriptEntry, WorkflowDetails } from "./model.ts";
import {
safeStringify,
truncateUtf8,
writeFileAtomic,
} from "./serialization.ts";
import * as path from "node:path";
const ARTIFACT_TRANSCRIPT_MAX_BYTES = 32 * 1024;
const ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES = 8 * 1024;
export const WORKFLOW_CHECKPOINT_INTERVAL_MS = 500;
const ENTRY_TRUNCATION_MARKER = "\n[entry truncated]";
const TRANSCRIPT_TRUNCATION_MARKER =
"[artifact transcript truncated: older entries omitted]";
function textBytes(text: string) {
return Buffer.byteLength(text, "utf8");
}
function boundEntry(entry: TranscriptEntry, maxBytes: number) {
if (textBytes(entry.text) <= maxBytes) return { ...entry };
const markerBytes = textBytes(ENTRY_TRUNCATION_MARKER);
const text =
maxBytes > markerBytes
? `${truncateUtf8(entry.text, maxBytes - markerBytes)}${ENTRY_TRUNCATION_MARKER}`
: truncateUtf8(ENTRY_TRUNCATION_MARKER, maxBytes);
return { ...entry, text };
}
/** Keep the initial prompt plus the newest useful context within the artifact cap. */
export function boundedArtifactTranscript(
transcript: TranscriptEntry[],
options: { maxBytes?: number; entryMaxBytes?: number } = {},
) {
if (transcript.length === 0) return [];
const maxBytes = Math.max(
256,
options.maxBytes ?? ARTIFACT_TRANSCRIPT_MAX_BYTES,
);
const entryMaxBytes = Math.max(
64,
Math.min(
maxBytes,
options.entryMaxBytes ?? ARTIFACT_TRANSCRIPT_ENTRY_MAX_BYTES,
),
);
const bounded = transcript.map((entry) => boundEntry(entry, entryMaxBytes));
if (
bounded.reduce((total, entry) => total + textBytes(entry.text), 0) <=
maxBytes
) {
return bounded;
}
const initialIndex = transcript.findIndex((entry) => entry.role === "user");
const initial = boundEntry(
transcript[initialIndex >= 0 ? initialIndex : 0],
Math.min(entryMaxBytes, maxBytes - textBytes(TRANSCRIPT_TRUNCATION_MARKER)),
);
const marker: TranscriptEntry = {
role: "toolResult",
name: "transcript",
text: TRANSCRIPT_TRUNCATION_MARKER,
};
let remaining = maxBytes - textBytes(initial.text) - textBytes(marker.text);
const tail: TranscriptEntry[] = [];
for (
let index = transcript.length - 1;
index >= 0 && remaining > 0;
index--
) {
if (index === initialIndex || (initialIndex < 0 && index === 0)) continue;
const entry = boundEntry(
transcript[index],
Math.min(entryMaxBytes, remaining),
);
tail.push(entry);
remaining -= textBytes(entry.text);
}
tail.reverse();
return [initial, marker, ...tail];
}
function writeRunFile(runDir: string, name: string, content: string) {
writeFileAtomic(path.join(runDir, name), content);
}
export function persistWorkflowJson(runDir: string, details: WorkflowDetails) {
const transcripts = Object.fromEntries(
details.agents.map((agent) => [
agent.index,
boundedArtifactTranscript(agent.transcript),
]),
);
writeRunFile(
runDir,
"transcripts.json",
safeStringify(transcripts, { maxBytes: 2 * 1024 * 1024 }),
);
if (details.result !== undefined) {
writeRunFile(
runDir,
"result.json",
safeStringify(details.result, { maxBytes: 1024 * 1024 }),
);
}
const compact: WorkflowDetails = {
...details,
...(details.result !== undefined
? { result: "[stored in result.json]", resultArtifact: "result.json" }
: {}),
transcriptArtifact: "transcripts.json",
agents: details.agents.map((agent) => ({ ...agent, transcript: [] })),
};
writeRunFile(
runDir,
"workflow.json",
safeStringify(compact, { maxBytes: 1024 * 1024 }),
);
}
/** Coalesce live checkpoints while keeping final persistence synchronous. */
export function createWorkflowPersistence(
runDir: string,
details: WorkflowDetails,
options: {
intervalMs?: number;
persist?: (runDir: string, details: WorkflowDetails) => void;
} = {},
) {
const intervalMs = Math.max(
0,
options.intervalMs ?? WORKFLOW_CHECKPOINT_INTERVAL_MS,
);
const persist = options.persist ?? persistWorkflowJson;
let lastPersistedAt = Date.now();
let dirty = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const savePending = () => {
timer = undefined;
if (!dirty) return;
try {
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
} catch {
// Final flush retries and reports persistence failures synchronously.
}
};
return {
checkpoint(options: { immediate?: boolean } = {}) {
dirty = true;
if (options.immediate) {
if (timer) clearTimeout(timer);
timer = undefined;
savePending();
return;
}
if (timer) return;
const delay = Math.max(0, intervalMs - (Date.now() - lastPersistedAt));
if (delay === 0) {
savePending();
return;
}
timer = setTimeout(savePending, delay);
},
flush() {
if (timer) clearTimeout(timer);
timer = undefined;
persist(runDir, details);
dirty = false;
lastPersistedAt = Date.now();
},
};
}