From c61672c98e5cb5e4f74ea22f7e7163f45364e40d Mon Sep 17 00:00:00 2001 From: xierenhong Date: Wed, 28 Jan 2026 19:26:36 +0800 Subject: [PATCH] feat: add truncation plugin for automatic tool output management --- .../2026-01-28-truncation-plugin-refactor.md | 150 ++++++++++++++++ src/config.ts | 18 +- src/constants.ts | 4 + src/context.ts | 6 +- src/plugins/truncation.test.ts | 162 +++++++++++++++++ src/plugins/truncation.ts | 166 ++++++++++++++++++ src/tool.ts | 3 + 7 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 docs/designs/2026-01-28-truncation-plugin-refactor.md create mode 100644 src/plugins/truncation.test.ts create mode 100644 src/plugins/truncation.ts diff --git a/docs/designs/2026-01-28-truncation-plugin-refactor.md b/docs/designs/2026-01-28-truncation-plugin-refactor.md new file mode 100644 index 000000000..2eeebfc4f --- /dev/null +++ b/docs/designs/2026-01-28-truncation-plugin-refactor.md @@ -0,0 +1,150 @@ +# Truncation 插件化重构 + +**Date:** 2026-01-28 + +## Context + +当前 `src/compression.ts` 中的 `Compression.truncate()` 是一个纯函数实现,存在以下问题: + +1. **未与工具执行流程集成**:truncate 函数只是一个工具函数,没有自动应用到工具输出 +2. **不支持无损保存**:截断时不会将完整输出保存到本地文件 +3. **不符合插件化架构**:没有使用项目现有的插件机制 + +根据 `Truncation截断机制详细实现文档.md` 的设计,需要通过插件机制的 `toolResult` hook 实现更完善的 Truncation 能力,参考 `src/plugins/notification.ts` 的插件模式。 + +## Discussion + +### 关键问题与决策 + +1. **存储位置** + - 选项:项目工作目录、全局数据目录、临时目录 + - **决策**:全局数据目录 (~/.takumi/tool-output/),跨项目共享 + +2. **清理机制** + - 选项:Scheduler 定时清理、随机概率清理、不自动清理 + - **决策**:不自动清理,由用户或系统自行管理 + +3. **插件定位** + - 选项:新建独立文件、复用并改造 compression.ts、内联在 project.ts + - **决策**:新建独立文件 `src/plugins/truncation.ts`,责任边界清晰 + +4. **旧代码处理** + - 选项:删除旧逻辑、保留并标记 deprecated、复用作为内部工具 + - **决策**:删除旧 truncate 逻辑,避免维护两套代码 + +5. **设计方案选择** + - 方案 A:纯 Hook 实现,所有工具统一截断 + - 方案 B:Hook + 工具协作机制,支持工具自定义截断 + - **决策**:方案 B,通过 `truncated` 标记实现工具协作,更符合文档设计 + +6. **配置开关** + - 需要支持通过配置关闭 truncation 功能,实现最小影响 + - 添加 `truncation?: boolean` 配置项 + +## Approach + +通过插件机制的 `toolResult` hook 拦截工具执行结果,实现自动截断: + +1. **插件拦截**:在工具执行后,通过 `toolResult` hook 检查输出大小 +2. **智能跳过**:如果工具已设置 `truncated` 标记,则跳过自动处理 +3. **无损保存**:超限时保存完整内容到本地文件,返回截断预览 +4. **配置控制**:支持通过 `truncation: false` 配置关闭功能 + +### 核心流程 + +``` +工具执行 → ToolResult → 插件 toolResult hook → + - 若 truncated 已定义 → 跳过处理 + - 若 truncation 配置为 false → 跳过处理 + - 若未超限 → 标记 truncated: false + - 若超限 → 截断 + 保存文件 + 返回修改后的 ToolResult +``` + +## Architecture + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `src/plugins/truncation.ts` | 新建 | Truncation 插件主体 | +| `src/tool.ts` | 修改 | ToolResult 类型添加 `truncated`、`outputPath` 字段 | +| `src/context.ts` | 修改 | 导入并注册 truncationPlugin | +| `src/config.ts` | 修改 | 添加 `truncation?: boolean` 配置项 | +| `src/compression.ts` | 删除部分 | 移除 `truncate` 函数和 `TruncateResult` 类型 | + +### ToolResult 类型扩展 + +```typescript +// src/tool.ts +export type ToolResult = { + llmContent: string | (TextPart | ImagePart)[]; + returnDisplay?: ReturnDisplay; + isError?: boolean; + metadata?: { ... }; + + // 新增字段 + truncated?: boolean; // 是否已截断 + outputPath?: string; // 完整输出文件路径 +}; +``` + +### 插件核心实现 + +```typescript +// src/plugins/truncation.ts +export const truncationPlugin: Plugin = { + name: 'truncation', + enforce: 'post', + + async toolResult(toolResult, opts) { + // 检查是否禁用 + if (this.config.truncation === false) { + return toolResult; + } + + // 工具已自行处理 + if (toolResult.truncated !== undefined) { + return toolResult; + } + + // 错误结果不截断 + if (toolResult.isError) { + return toolResult; + } + + // 检查大小并执行截断 + // ... + }, +}; +``` + +### 配置项 + +```typescript +// src/config.ts +export type Config = { + // ... + truncation?: boolean; // 默认 true +}; +``` + +### 边界情况处理 + +| 场景 | 处理方式 | +|------|---------| +| 错误结果 (`isError: true`) | 跳过截断 | +| 工具已设置 `truncated` | 跳过截断 | +| llmContent 是数组(含图片) | 仅处理文本部分 | +| 内容恰好在边界 | `<=` 判断,不截断 | +| 输出目录不存在 | 自动创建 | +| 文件写入失败 | 记录日志,降级返回截断内容 | + +### 用户配置方式 + +```bash +# 全局关闭 +takumi config set truncation false + +# 项目级配置 (.takumi/config.json) +{ "truncation": false } +``` diff --git a/src/config.ts b/src/config.ts index 8d7d0c407..0700a23b5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,6 +63,14 @@ export type Config = { * @default true */ autoCompact?: boolean; + /** + * Controls whether automatic tool output truncation is enabled. + * When enabled, large tool outputs (>2000 lines or >50KB) will be truncated + * and full content saved to a local file. + * + * @default true + */ + truncation?: boolean; commit?: CommitConfig; outputStyle?: string; outputFormat?: 'text' | 'stream-json' | 'json'; @@ -109,6 +117,7 @@ const DEFAULT_CONFIG: Partial = { provider: {}, todo: true, autoCompact: true, + truncation: true, outputFormat: 'text', autoUpdate: true, extensions: {}, @@ -124,6 +133,7 @@ const VALID_CONFIG_KEYS = [ 'systemPrompt', 'todo', 'autoCompact', + 'truncation', 'commit', 'outputStyle', 'autoUpdate', @@ -145,7 +155,13 @@ const OBJECT_CONFIG_KEYS = [ 'tools', 'agent', ]; -const BOOLEAN_CONFIG_KEYS = ['quiet', 'todo', 'autoCompact', 'autoUpdate']; +const BOOLEAN_CONFIG_KEYS = [ + 'quiet', + 'todo', + 'autoCompact', + 'autoUpdate', + 'truncation', +]; export const GLOBAL_ONLY_KEYS: string[] = []; function assertGlobalAllowed(global: boolean, key: string) { diff --git a/src/constants.ts b/src/constants.ts index 1268b8f65..8d362a2e0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -81,3 +81,7 @@ export enum AGENT_TYPE { GENERAL_PURPOSE = 'GeneralPurpose', NEOVATE_CODE_GUIDE = 'neovate-code-guide', } + +// Truncation configuration +export const TRUNCATE_MAX_LINES = 2000; // Maximum lines +export const TRUNCATE_MAX_BYTES = 50 * 1024; // Maximum bytes (50KB) diff --git a/src/context.ts b/src/context.ts index ad716a0bb..2f27d7028 100644 --- a/src/context.ts +++ b/src/context.ts @@ -15,6 +15,7 @@ import { PluginManager, } from './plugin'; import { notificationSoundPlugin } from './plugins/notification'; +import { truncationPlugin } from './plugins/truncation'; import { SkillManager } from './skill'; type ContextOpts = { @@ -109,7 +110,10 @@ export class Context { opts.argvConfig || {}, ); const initialConfig = configManager.config; - const buildInPlugins: Plugin[] = [notificationSoundPlugin]; + const buildInPlugins: Plugin[] = [ + notificationSoundPlugin, + truncationPlugin, + ]; const globalPlugins = scanPlugins( path.join(paths.globalConfigDir, 'plugins'), ); diff --git a/src/plugins/truncation.test.ts b/src/plugins/truncation.test.ts new file mode 100644 index 000000000..3e1295328 --- /dev/null +++ b/src/plugins/truncation.test.ts @@ -0,0 +1,162 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'pathe'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { TRUNCATE_MAX_BYTES, TRUNCATE_MAX_LINES } from '../constants'; +import { truncationPlugin } from './truncation'; + +// Mock context +const createMockContext = (config: { truncation?: boolean } = {}) => ({ + config: { + truncation: config.truncation ?? true, + }, + paths: { + globalConfigDir: path.join(os.tmpdir(), `truncation-test-${Date.now()}`), + }, +}); + +// Mock opts +const createMockOpts = (toolName: string = 'test-tool') => ({ + toolUse: { name: toolName }, + approved: true, + sessionId: 'test-session', +}); + +describe('truncationPlugin', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `truncation-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test('should skip when truncation is disabled', async () => { + const context = createMockContext({ truncation: false }); + const toolResult = { llmContent: 'test content' }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result).toEqual(toolResult); + expect(result.truncated).toBeUndefined(); + }); + + test('should skip when truncated is already defined', async () => { + const context = createMockContext(); + const toolResult = { llmContent: 'test content', truncated: true }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBe(true); + }); + + test('should skip error results', async () => { + const context = createMockContext(); + const toolResult = { llmContent: 'error message', isError: true }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBeUndefined(); + }); + + test('should not truncate content under limits', async () => { + const context = createMockContext(); + context.paths.globalConfigDir = tempDir; + const toolResult = { llmContent: 'short content' }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBe(false); + expect(result.llmContent).toBe('short content'); + }); + + test('should truncate content exceeding line limit', async () => { + const context = createMockContext(); + context.paths.globalConfigDir = tempDir; + + // Generate content exceeding 2000 lines + const lineCount = TRUNCATE_MAX_LINES + 500; + const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`); + const toolResult = { llmContent: lines.join('\n') }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBe(true); + expect(result.outputPath).toBeDefined(); + expect(result.llmContent).toContain('truncated'); + expect(result.llmContent).toContain('Full output saved to'); + + // Verify file was created with full content + if (result.outputPath) { + const savedContent = await fs.readFile(result.outputPath, 'utf-8'); + expect(savedContent).toBe(lines.join('\n')); + } + }); + + test('should truncate content exceeding byte limit', async () => { + const context = createMockContext(); + context.paths.globalConfigDir = tempDir; + + // Generate content exceeding 50KB + const longLine = 'x'.repeat(TRUNCATE_MAX_BYTES + 10 * 1024); // 60KB + const toolResult = { llmContent: longLine }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBe(true); + expect(result.outputPath).toBeDefined(); + expect(result.llmContent).toContain('bytes truncated'); + }); + + test('should skip non-string content', async () => { + const context = createMockContext(); + const toolResult = { llmContent: [{ type: 'text', text: 'test' }] }; + const opts = createMockOpts(); + + const result = await truncationPlugin.toolResult!.call( + context as any, + toolResult as any, + opts as any, + ); + + expect(result.truncated).toBeUndefined(); + expect(result.llmContent).toEqual([{ type: 'text', text: 'test' }]); + }); +}); diff --git a/src/plugins/truncation.ts b/src/plugins/truncation.ts new file mode 100644 index 000000000..b42e7e917 --- /dev/null +++ b/src/plugins/truncation.ts @@ -0,0 +1,166 @@ +import createDebug from 'debug'; +import fs from 'fs/promises'; +import path from 'pathe'; +import { TRUNCATE_MAX_BYTES, TRUNCATE_MAX_LINES } from '../constants'; +import type { Plugin } from '../plugin'; +import type { ReturnDisplay } from '../tool'; + +const debug = createDebug('neovate:truncation'); + +// Tool output directory name +const TOOL_OUTPUT_DIR_NAME = 'tool-output'; + +/** + * Generate unique file ID for truncated output + */ +function generateFileId(sessionId: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).slice(2, 8); + return `tool_${sessionId}_${timestamp}_${random}`; +} + +/** + * Truncate text and save full content to file + */ +async function truncateAndSave( + text: string, + outputDir: string, + sessionId: string, +): Promise<{ content: string; outputPath: string }> { + const lines = text.split('\n'); + const totalBytes = Buffer.byteLength(text, 'utf-8'); + + // Truncate from head (keep first N lines/bytes) + const out: string[] = []; + let bytes = 0; + let hitBytes = false; + + for (let i = 0; i < lines.length && out.length < TRUNCATE_MAX_LINES; i++) { + const lineBytes = Buffer.byteLength(lines[i], 'utf-8') + (i > 0 ? 1 : 0); + if (bytes + lineBytes > TRUNCATE_MAX_BYTES) { + hitBytes = true; + break; + } + out.push(lines[i]); + bytes += lineBytes; + } + + // Calculate removed amount + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length; + const unit = hitBytes ? 'bytes' : 'lines'; + const preview = out.join('\n'); + + // Save full content to file + const id = generateFileId(sessionId); + const filepath = path.join(outputDir, `${id}.txt`); + + try { + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(filepath, text, 'utf-8'); + } catch (err) { + debug(`Failed to save truncated output: ${err}`); + // Fallback: still return truncated content without file path + return { + content: `${preview}\n\n...${removed} ${unit} truncated...\n\n(Failed to save full output to file)`, + outputPath: '', + }; + } + + // Generate hint message + const hint = + `Full output saved to: ${filepath}\n` + + `Use Grep to search the full content or Read with offset/limit to view specific sections.`; + + const content = `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`; + + return { content, outputPath: filepath }; +} + +// Skip truncating returnDisplay if it's smaller than this threshold (5KB) +const RETURN_DISPLAY_SKIP_THRESHOLD = 5000; + +/** + * Get the returnDisplay value for truncated result + * - If original is object (special type like diff_viewer), keep as-is + * - If original string is small enough, keep as-is + * - Otherwise use the truncated content + */ +function getReturnDisplay( + original: ReturnDisplay | undefined, + truncatedContent: string, +): ReturnDisplay | undefined { + // Special types (object) should be kept as-is + if (typeof original === 'object' && original !== null) { + return original; + } + // If original string is small enough, keep as-is + if ( + typeof original === 'string' && + Buffer.byteLength(original, 'utf-8') <= RETURN_DISPLAY_SKIP_THRESHOLD + ) { + return original; + } + // undefined or large string: use truncated content + return truncatedContent; +} + +export const truncationPlugin: Plugin = { + name: 'truncation', + enforce: 'post', // Execute after other plugins + + async toolResult(toolResult, opts) { + // 1. Check if truncation is disabled + if (this.config.truncation === false) { + debug('Truncation disabled by config'); + return toolResult; + } + + // 2. Skip if tool already handled truncation + if (toolResult.truncated !== undefined) { + debug(`[${opts.toolUse.name}] skipped: truncated already defined`); + return toolResult; + } + + // 3. Skip error results + if (toolResult.isError) { + return toolResult; + } + + // 4. Only handle string content + if (typeof toolResult.llmContent !== 'string') { + // TODO: Could extend to handle text parts in arrays + return toolResult; + } + + // 5. Check if truncation is needed + const text = toolResult.llmContent; + const lines = text.split('\n'); + const totalBytes = Buffer.byteLength(text, 'utf-8'); + + if ( + lines.length <= TRUNCATE_MAX_LINES && + totalBytes <= TRUNCATE_MAX_BYTES + ) { + return { ...toolResult, truncated: false }; + } + + // 6. Execute truncation + debug( + `[${opts.toolUse.name}] truncating: ${lines.length} lines, ${totalBytes} bytes`, + ); + + const outputDir = path.join( + this.paths.globalConfigDir, + TOOL_OUTPUT_DIR_NAME, + ); + const result = await truncateAndSave(text, outputDir, opts.sessionId); + + return { + ...toolResult, + llmContent: result.content, + returnDisplay: getReturnDisplay(toolResult.returnDisplay, result.content), + truncated: true, + outputPath: result.outputPath || undefined, + }; + }, +}; diff --git a/src/tool.ts b/src/tool.ts index b4ba6e354..b104f5457 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -347,6 +347,9 @@ export type ToolResult = { agentType?: string; [key: string]: any; }; + // Truncation related fields + truncated?: boolean; // Whether the output has been truncated + outputPath?: string; // Path to full output file (when truncated) }; export function createTool(config: {