SPB Git

Repository Public

output.ts 3.5 KB 74 lines typescript

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
Files main
Menu

Theme