import fs from "fs/promises" import path from "path" import { Global } from "../global" import { Identifier } from "../id/id" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" import { Scheduler } from "../scheduler" export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 export const DIR = path.join(Global.Path.data, "tool-output") export const GLOB = path.join(DIR, "*") const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const HOUR_MS = 60 * 60 * 1000 export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } export interface Options { maxLines?: number maxBytes?: number direction?: "head" | "tail" } export function init() { Scheduler.register({ id: "tool.truncation.cleanup", interval: HOUR_MS, run: cleanup, scope: "global", }) } export async function cleanup() { const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS)) const glob = new Bun.Glob("tool_*") const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[]) for (const entry of entries) { if (Identifier.timestamp(entry) >= cutoff) continue await fs.unlink(path.join(DIR, entry)).catch(() => {}) } } function hasTaskTool(agent?: Agent.Info): boolean { if (!agent?.permission) return false const rule = PermissionNext.evaluate("task", "*", agent.permission) return rule.action !== "deny" } export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES const direction = options.direction ?? "head" const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") if (lines.length <= maxLines && totalBytes <= maxBytes) { return { content: text, truncated: false } } const out: string[] = [] let i = 0 let bytes = 0 let hitBytes = false if (direction === "head") { for (i = 0; i < lines.length && i < maxLines; i++) { const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) if (bytes + size > maxBytes) { hitBytes = true break } out.push(lines[i]) bytes += size } } else { for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) if (bytes + size > maxBytes) { hitBytes = true break } out.unshift(lines[i]) bytes += size } } const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") const id = Identifier.ascending("tool") const filepath = path.join(DIR, id) await Bun.write(Bun.file(filepath), text) const hint = hasTaskTool(agent) ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` const message = direction === "head" ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}` return { content: message, truncated: true, outputPath: filepath } } }