This commit is contained in:
Aiden Cline
2026-01-07 12:15:35 -06:00
parent 6590c1641f
commit f939060cd1
6 changed files with 92 additions and 31 deletions

View File

@@ -4,6 +4,7 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { Truncate } from "../session/truncation"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -46,7 +47,10 @@ export namespace Agent {
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: "ask",
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
},
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -9,6 +9,7 @@ export namespace Identifier {
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
} as const
export function schema(prefix: keyof typeof prefixes) {

View File

@@ -1,10 +1,20 @@
import fs from "fs/promises"
import path from "path"
import { Global } from "../global"
import { Identifier } from "../id/id"
import { iife } from "../util/iife"
import { lazy } from "../util/lazy"
export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export interface Result {
content: string
truncated: boolean
outputPath?: string
}
export interface Options {
@@ -13,7 +23,22 @@ export namespace Truncate {
direction?: "head" | "tail"
}
export function output(text: string, options: Options = {}): Result {
const init = lazy(async () => {
const cutoff = Date.now() - RETENTION_MS
const entries = await fs.readdir(DIR).catch(() => [] as string[])
for (const entry of entries) {
if (!entry.startsWith("tool_")) continue
const timestamp = iife(() => {
const hex = entry.slice(5, 17)
const now = BigInt("0x" + hex)
return Number(now / BigInt(0x1000))
})
if (timestamp >= cutoff) continue
await fs.rm(path.join(DIR, entry), { force: true }).catch(() => {})
}
})
export async function output(text: string, options: Options = {}): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
@@ -39,22 +64,32 @@ export namespace Truncate {
out.push(lines[i])
bytes += size
}
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "chars" : "lines"
return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
} 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
}
}
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 ? "chars" : "lines"
return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
const preview = out.join("\n")
await init()
const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.`
: `...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.\n\n${preview}`
return { content: message, truncated: true, outputPath: filepath }
}
}

View File

@@ -65,7 +65,7 @@ export namespace ToolRegistry {
description: def.description,
execute: async (args, ctx) => {
const result = await def.execute(args as any, ctx)
const out = Truncate.output(result)
const out = await Truncate.output(result)
return {
title: "",
output: out.truncated ? out.content : result,

View File

@@ -66,7 +66,7 @@ export namespace Tool {
)
}
const result = await execute(args, ctx)
const truncated = Truncate.output(result.output)
const truncated = await Truncate.output(result.output)
return {
...result,
output: truncated.content,

View File

@@ -8,41 +8,40 @@ describe("Truncate", () => {
describe("output", () => {
test("truncates large json file by bytes", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = Truncate.output(content)
const result = await Truncate.output(content)
expect(result.truncated).toBe(true)
expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
expect(result.content).toContain("truncated...")
expect(result.outputPath).toBeDefined()
})
test("returns content unchanged when under limits", () => {
test("returns content unchanged when under limits", async () => {
const content = "line1\nline2\nline3"
const result = Truncate.output(content)
const result = await Truncate.output(content)
expect(result.truncated).toBe(false)
expect(result.content).toBe(content)
})
test("truncates by line count", () => {
test("truncates by line count", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 10 })
const result = await Truncate.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
expect(result.content).toContain("...90 lines truncated...")
})
test("truncates by byte count", () => {
test("truncates by byte count", async () => {
const content = "a".repeat(1000)
const result = Truncate.output(content, { maxBytes: 100 })
const result = await Truncate.output(content, { maxBytes: 100 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("truncated...")
})
test("truncates from head by default", () => {
test("truncates from head by default", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 3 })
const result = await Truncate.output(lines, { maxLines: 3 })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
@@ -51,9 +50,9 @@ describe("Truncate", () => {
expect(result.content).not.toContain("line9")
})
test("truncates from tail when direction is tail", () => {
test("truncates from tail when direction is tail", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
expect(result.truncated).toBe(true)
expect(result.content).toContain("line7")
@@ -69,11 +68,33 @@ describe("Truncate", () => {
test("large single-line file truncates with byte message", async () => {
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
const result = Truncate.output(content)
const result = await Truncate.output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("chars truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
})
test("writes full output to file when truncated", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })
expect(result.truncated).toBe(true)
expect(result.outputPath).toBeDefined()
expect(result.outputPath).toContain("tool_")
expect(result.content).toContain("Full output written to:")
expect(result.content).toContain("Use Read or Grep to view the full content")
const written = await Bun.file(result.outputPath!).text()
expect(written).toBe(lines)
})
test("does not write file when not truncated", async () => {
const content = "short content"
const result = await Truncate.output(content)
expect(result.truncated).toBe(false)
expect(result.outputPath).toBeUndefined()
})
})
})