mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 13:54:01 +00:00
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
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<Result> {
|
|
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 }
|
|
}
|
|
}
|