truncation.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { Global } from "../global"
  4. import { Identifier } from "../id/id"
  5. import { PermissionNext } from "../permission/next"
  6. import type { Agent } from "../agent/agent"
  7. import { Scheduler } from "../scheduler"
  8. export namespace Truncate {
  9. export const MAX_LINES = 2000
  10. export const MAX_BYTES = 50 * 1024
  11. export const DIR = path.join(Global.Path.data, "tool-output")
  12. export const GLOB = path.join(DIR, "*")
  13. const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
  14. const HOUR_MS = 60 * 60 * 1000
  15. export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
  16. export interface Options {
  17. maxLines?: number
  18. maxBytes?: number
  19. direction?: "head" | "tail"
  20. }
  21. export function init() {
  22. Scheduler.register({
  23. id: "tool.truncation.cleanup",
  24. interval: HOUR_MS,
  25. run: cleanup,
  26. scope: "global",
  27. })
  28. }
  29. export async function cleanup() {
  30. const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
  31. const glob = new Bun.Glob("tool_*")
  32. const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
  33. for (const entry of entries) {
  34. if (Identifier.timestamp(entry) >= cutoff) continue
  35. await fs.unlink(path.join(DIR, entry)).catch(() => {})
  36. }
  37. }
  38. function hasTaskTool(agent?: Agent.Info): boolean {
  39. if (!agent?.permission) return false
  40. const rule = PermissionNext.evaluate("task", "*", agent.permission)
  41. return rule.action !== "deny"
  42. }
  43. export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
  44. const maxLines = options.maxLines ?? MAX_LINES
  45. const maxBytes = options.maxBytes ?? MAX_BYTES
  46. const direction = options.direction ?? "head"
  47. const lines = text.split("\n")
  48. const totalBytes = Buffer.byteLength(text, "utf-8")
  49. if (lines.length <= maxLines && totalBytes <= maxBytes) {
  50. return { content: text, truncated: false }
  51. }
  52. const out: string[] = []
  53. let i = 0
  54. let bytes = 0
  55. let hitBytes = false
  56. if (direction === "head") {
  57. for (i = 0; i < lines.length && i < maxLines; i++) {
  58. const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
  59. if (bytes + size > maxBytes) {
  60. hitBytes = true
  61. break
  62. }
  63. out.push(lines[i])
  64. bytes += size
  65. }
  66. } else {
  67. for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
  68. const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
  69. if (bytes + size > maxBytes) {
  70. hitBytes = true
  71. break
  72. }
  73. out.unshift(lines[i])
  74. bytes += size
  75. }
  76. }
  77. const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
  78. const unit = hitBytes ? "bytes" : "lines"
  79. const preview = out.join("\n")
  80. const id = Identifier.ascending("tool")
  81. const filepath = path.join(DIR, id)
  82. await Bun.write(Bun.file(filepath), text)
  83. const hint = hasTaskTool(agent)
  84. ? `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.`
  85. : `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.`
  86. const message =
  87. direction === "head"
  88. ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
  89. : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
  90. return { content: message, truncated: true, outputPath: filepath }
  91. }
  92. }