Repository Public
1/**2 * Tool output handling (CONTRACT §4): outputs longer than 12 000 chars are stored as an artifact; the model sees3 * the first 6 000 and last 3 000 chars plus a note telling it how to read the rest with `read_artifact`.4 */5import { createHash } from 'node:crypto';6import { readdir, readFile } from 'node:fs/promises';7import path from 'node:path';8import type { ToolContext } from './types.js';910export const OUTPUT_OFFLOAD_THRESHOLD = 12_000;11export const OUTPUT_HEAD_CHARS = 6_000;12export const OUTPUT_TAIL_CHARS = 3_000;1314export interface FinalizedOutput {15 /** What the model sees. */16 content: string;17 /** Set when the full output was offloaded. Same shape as `ToolResult.artifact` in @aos/shared. */18 artifact: { name: string; sha256: string; size: number } | null;19}2021export const sha256Hex = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex');2223const ARTIFACT_NAME_RE = /^[a-z_]{1,40}-([0-9a-f]{16})\.txt$/;2425/** Deterministic artifact name for an offloaded output: `<tool>-<sha256[0:16]>.txt`. */26export function artifactNameFor(toolName: string, sha256: string): string {27 const safe = toolName.toLowerCase().replace(/[^a-z_]/g, '_').slice(0, 40) || 'tool';28 return `${safe}-${sha256.slice(0, 16)}.txt`;29}3031/**32 * Engine-side helper: call it on every tool output before building the `ToolResult` shown to the model.33 * Deterministic given (toolName, output); the artifact store is content-addressed so replays are harmless.34 */35export async function finalizeToolOutput(toolName: string, output: string, ctx: Pick<ToolContext, 'putArtifact'>): Promise<FinalizedOutput> {36 if (output.length <= OUTPUT_OFFLOAD_THRESHOLD) return { content: output, artifact: null };37 const sha = sha256Hex(output);38 const name = artifactNameFor(toolName, sha);39 const stored = await ctx.putArtifact(name, output, 'text/plain; charset=utf-8');40 const head = output.slice(0, OUTPUT_HEAD_CHARS);41 const tail = output.slice(output.length - OUTPUT_TAIL_CHARS);42 const omitted = output.length - OUTPUT_HEAD_CHARS - OUTPUT_TAIL_CHARS;43 const lines = output.split('\n').length;44 const content =45 `${head}\n\n[… ${omitted} characters omitted …]\n\n${tail}\n\n` +46 `[Output truncated: ${output.length} characters, ${lines} lines in total. The full output is stored as artifact "${name}" ` +47 `(${stored.size} bytes). To read more, call read_artifact with {"name":"${name}","offset":<first line, 1-based>,"limit":<number of lines>}. ` +48 `Do not use read_file for artifacts.]`;49 return { content, artifact: { name, sha256: stored.sha256, size: stored.size } };50}5152/**53 * Load an artifact by name. Uses `ctx.getArtifact` when the engine provides it; otherwise resolves names produced54 * by `finalizeToolOutput` in the content-addressed store `<dataDir>/artifacts/<sha[0:2]>/<sha>` and verifies the hash.55 */56export async function loadArtifact(name: string, ctx: Pick<ToolContext, 'dataDir' | 'getArtifact'>): Promise<Buffer | null> {57 if (ctx.getArtifact) return ctx.getArtifact(name);58 const m = ARTIFACT_NAME_RE.exec(name);59 if (!m) return null;60 const prefix = m[1] as string;61 const dir = path.join(ctx.dataDir, 'artifacts', prefix.slice(0, 2));62 let entries: string[];63 try {64 entries = await readdir(dir);65 } catch {66 return null;67 }68 const matches = entries.filter((e) => /^[0-9a-f]{64}$/.test(e) && e.startsWith(prefix));69 if (matches.length !== 1) return null;70 const sha = matches[0] as string;71 const buf = await readFile(path.join(dir, sha));72 return sha256Hex(buf) === sha ? buf : null;73}74