/** * tool-output-limit — official Pi tool_result 输出限制模块。 * * 官方 Pi 没有全局 settings 值能限制内置工具(如 bash/read)的字节/行数, * 内置工具构造函数也不接受 maxBytes 选项。本模块在官方公共 `tool_result` * extension 边界上实现同等的"模型可见输出预算": * * tool execute * -> 官方 Pi 发出 tool_result * -> 本模块在预算超限时持久化完整文本并替换 content * -> 官方 Pi 把替换后的结果写入 agent/session 上下文 * * 只替换 content;details/isError/usage 等字段由官方保留。图片块原样保留, * 块顺序不变,UTF-8 多字节字符不会被切断。bash 保留尾部,其余工具保留头部。 * 完整输出在截断前落盘,并返回可读路径供模型/用户找回。 * * 不做任何 runtime patch:不覆盖内置工具、不 monkey-patch 官方常量、 * 不读取私有 tool 定义、不触碰 provider payload 或 active-tool 状态。 */ import { mkdirSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { ToolResultEvent } from "@earendil-works/pi-coding-agent"; import { EXT_NAME, type ToolOutputConfig } from "./config.ts"; /** * 本 handler 的严格本地返回契约:只携带替换用的 content。官方 runner 按字段 * 合并(content/details/isError/usage),未提供的字段保留事件原值,因此返回 * { content } 足以替换文本而保留 details/isError/usage/toolCallId。官方 * ToolResultEventResult 不在 @earendil-works/pi-coding-agent 根导出中,这里 * 用等价的最小结构类型,不依赖深层类型路径。 */ export interface ToolResultReplacement { content: (TextContent | ImageContent)[]; } /** 默认持久化目录:完整输出可跨会话找回,且不在扩展自动发现目录内。 */ export function getToolOutputDir(): string { return join(homedir(), ".pi", "pi-cache-stack", "tool-output"); } /** 单个 code point 的 UTF-8 编码字节数。 */ function codePointUtf8Bytes(codePoint: number): number { if (codePoint <= 0x7f) return 1; if (codePoint <= 0x7ff) return 2; if (codePoint <= 0xffff) return 3; return 4; } /** 文本的 UTF-8 字节长度(完整计算,不截断多字节字符)。 */ export function utf8ByteLength(text: string): number { let bytes = 0; for (let index = 0; index < text.length;) { const codePoint = text.codePointAt(index)!; bytes += codePointUtf8Bytes(codePoint); index += codePoint > 0xffff ? 2 : 1; } return bytes; } /** * 行数定义:空字符串为 0 行;末尾换行不额外开新行 * ("a\\nb\\n" 与 "a\\nb" 都是 2 行)。 */ export function countLines(text: string): number { if (text === "") return 0; let newlines = 0; for (let index = 0; index < text.length; index += 1) { if (text.charCodeAt(index) === 0x0a) newlines += 1; } const lines = newlines + 1; return text.endsWith("\n") ? Math.max(1, lines - 1) : lines; } /** * 在 code point 边界上截断文本,使结果同时满足字节与行数预算。 * - head:保留最长前缀(用于 read/grep/find/ls 等); * - tail:保留最长后缀,并从行边界开始,避免截断后产生前导空行(用于 bash)。 * 不会切断 UTF-8 多字节字符;任一约束先到即停。 */ export function limitText( text: string, maxBytes: number, maxLines: number, direction: "head" | "tail", ): string { if (text === "" || maxBytes <= 0 || maxLines <= 0) return ""; if (direction === "head") { let bytes = 0; let newlines = 0; let end = 0; for (let index = 0; index < text.length;) { const codePoint = text.codePointAt(index)!; const charLength = codePoint > 0xffff ? 2 : 1; const addBytes = codePointUtf8Bytes(codePoint); if (bytes + addBytes > maxBytes) break; if (newlines + 1 > maxLines) break; bytes += addBytes; if (codePoint === 0x0a) newlines += 1; end = index + charLength; index = end; } return text.slice(0, end); } // tail:先在行边界定位保留区(最多 maxLines 行),再按字节预算从左侧收紧。 let keepFrom = 0; const totalLines = countLines(text); if (totalLines > maxLines) { const cut = totalLines - maxLines; let newlines = 0; for (let index = 0; index < text.length; index += 1) { if (text.charCodeAt(index) === 0x0a) { newlines += 1; if (newlines === cut) { keepFrom = index + 1; break; } } } } let bytes = utf8ByteLength(text.slice(keepFrom)); if (bytes > maxBytes) { for (let index = keepFrom; index < text.length && bytes > maxBytes;) { const codePoint = text.codePointAt(index)!; bytes -= codePointUtf8Bytes(codePoint); index += codePoint > 0xffff ? 2 : 1; keepFrom = index; } } const retained = text.slice(keepFrom); if (keepFrom > 0) { // 字节收紧若落在换行符上,去掉被丢弃行的残留终止符,避免前导空行。 let start = 0; while (start < retained.length && retained.charCodeAt(start) === 0x0a) start += 1; if (start > 0) return retained.slice(start); } return retained; } /** 保留块类型:仅 text 与 image;未知形状不做猜测。 */ function isContentBlock(value: unknown): value is TextContent | ImageContent { if (typeof value !== "object" || value === null) return false; const block = value as Record; if (block.type === "text") return typeof block.text === "string"; if (block.type === "image") { return typeof block.data === "string" && typeof block.mimeType === "string"; } return false; } export interface ContentLimits { maxBytes: number; maxLines: number; } export interface LimitedContent { content: (TextContent | ImageContent)[]; truncated: boolean; } /** * 对内容数组施加一个跨所有文本块的聚合预算:图片块原样保留且保持相对位置, * 文本块按 direction 依次取头部/尾部,直至预算耗尽;被完全清空的文本块移除。 */ export function limitContent( content: readonly (TextContent | ImageContent)[], limits: ContentLimits, direction: "head" | "tail", ): LimitedContent { let remainingBytes = limits.maxBytes; let remainingLines = limits.maxLines; let truncated = false; const takeText = (text: string, budgetBytes: number, budgetLines: number): string => { return limitText(text, budgetBytes, budgetLines, direction); }; if (direction === "head") { const kept: (TextContent | ImageContent)[] = []; for (const block of content) { if (block.type === "image") { kept.push(block); continue; } if (remainingBytes <= 0 || remainingLines <= 0) { truncated = true; continue; } const text = takeText(block.text, remainingBytes, remainingLines); remainingBytes -= utf8ByteLength(text); remainingLines -= countLines(text); if (text.length < block.text.length) truncated = true; if (text.length > 0) kept.push({ ...block, text }); } return { content: kept, truncated }; } const reversed: (TextContent | ImageContent)[] = []; for (let index = content.length - 1; index >= 0; index -= 1) { const block = content[index]; if (block.type === "image") { reversed.push(block); continue; } if (remainingBytes <= 0 || remainingLines <= 0) { truncated = true; continue; } const text = takeText(block.text, remainingBytes, remainingLines); remainingBytes -= utf8ByteLength(text); remainingLines -= countLines(text); if (text.length < block.text.length) truncated = true; if (text.length > 0) reversed.push({ ...block, text }); } reversed.reverse(); return { content: reversed, truncated }; } function formatBytes(bytes: number): string { if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${bytes}B`; } function sanitizeSegment(value: string): string { const cleaned = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); return cleaned.length > 0 ? cleaned : "unknown"; } /** * 在截断前持久化完整文本结果。路径包含工具名、时间戳与 tool-call id, * 避免同一时间戳/同名的碰撞。失败时抛错,由 handler fail-open。 */ export function persistFullOutput( text: string, meta: { toolName: string; toolCallId: string }, dir?: string, ): string { const outputDir = dir ?? getToolOutputDir(); mkdirSync(outputDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `${sanitizeSegment(meta.toolName)}-${stamp}-${sanitizeSegment(meta.toolCallId)}.txt`; const filePath = join(outputDir, fileName); const header = [ "# pi-cache-stack full tool output", `# tool: ${meta.toolName}`, `# tool call: ${meta.toolCallId}`, "#", "", ].join("\n"); writeFileSync(filePath, header + text, "utf8"); return filePath; } function buildNotice( cfg: ToolOutputConfig, toolName: string, direction: "head" | "tail", fullPath: string, ): TextContent { const kept = direction === "tail" ? "tail" : "head"; return { type: "text", text: [ `[${EXT_NAME}] tool_result truncated (${toolName}): kept the ${kept} up to ${formatBytes(cfg.maxBytes)} / ${cfg.maxLines} lines before it enters the model context.`, `Full output saved to: ${fullPath}`, ].join("\n"), }; } export interface ToolResultHandlerOptions { /** 覆盖完整输出持久化目录(测试注入;默认 ~/.pi/pi-cache-stack/tool-output)。 */ outputDir?: string; } /** * 创建官方 `tool_result` handler。配置在每次调用时读取,因此 index.ts 在 * session_start 重载 config 后立即生效,无需重启。 * * 返回契约:不超限/未选中/无文本/配置关闭时返回 undefined(官方保留原结果); * 截断时只返回 { content }(官方保留 details/isError/usage)。 */ export function createToolResultHandler( getConfig: () => ToolOutputConfig, options: ToolResultHandlerOptions = {}, ): (event: ToolResultEvent) => ToolResultReplacement | undefined { return (event: ToolResultEvent) => { const cfg = getConfig(); if (!cfg.enabled) return undefined; if (!cfg.tools.includes(event.toolName)) return undefined; if (!Array.isArray(event.content) || event.content.length === 0) return undefined; const textBlocks: TextContent[] = []; for (const block of event.content) { if (!isContentBlock(block)) return undefined; if (block.type === "text") textBlocks.push(block); } if (textBlocks.length === 0) return undefined; const totalBytes = textBlocks.reduce((sum, block) => sum + utf8ByteLength(block.text), 0); const totalLines = textBlocks.reduce((sum, block) => sum + countLines(block.text), 0); if (totalBytes <= cfg.maxBytes && totalLines <= cfg.maxLines) return undefined; const direction: "head" | "tail" = event.toolName === "bash" ? "tail" : "head"; const limited = limitContent(event.content, { maxBytes: cfg.maxBytes, maxLines: cfg.maxLines }, direction); if (!limited.truncated) return undefined; const fullText = textBlocks.map((block) => block.text).join("\n"); let fullPath: string; try { fullPath = persistFullOutput( fullText, { toolName: event.toolName, toolCallId: event.toolCallId }, options.outputDir, ); } catch (error) { // fail open:持久化失败时保留原结果,不丢弃不可恢复的文本。 console.error(`[${EXT_NAME}] failed to persist full tool output for ${event.toolName}:`, error); return undefined; } return { content: [...limited.content, buildNotice(cfg, event.toolName, direction, fullPath)], }; }; }