Skip to content

Commit b92f871

Browse files
authored
feat: add truncation plugin for automatic tool output management (#718)
1 parent d2e34c9 commit b92f871

File tree

7 files changed

+507
-2
lines changed

7 files changed

+507
-2
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Truncation 插件化重构
2+
3+
**Date:** 2026-01-28
4+
5+
## Context
6+
7+
当前 `src/compression.ts` 中的 `Compression.truncate()` 是一个纯函数实现,存在以下问题:
8+
9+
1. **未与工具执行流程集成**:truncate 函数只是一个工具函数,没有自动应用到工具输出
10+
2. **不支持无损保存**:截断时不会将完整输出保存到本地文件
11+
3. **不符合插件化架构**:没有使用项目现有的插件机制
12+
13+
根据 `Truncation截断机制详细实现文档.md` 的设计,需要通过插件机制的 `toolResult` hook 实现更完善的 Truncation 能力,参考 `src/plugins/notification.ts` 的插件模式。
14+
15+
## Discussion
16+
17+
### 关键问题与决策
18+
19+
1. **存储位置**
20+
- 选项:项目工作目录、全局数据目录、临时目录
21+
- **决策**:全局数据目录 (~/.takumi/tool-output/),跨项目共享
22+
23+
2. **清理机制**
24+
- 选项:Scheduler 定时清理、随机概率清理、不自动清理
25+
- **决策**:不自动清理,由用户或系统自行管理
26+
27+
3. **插件定位**
28+
- 选项:新建独立文件、复用并改造 compression.ts、内联在 project.ts
29+
- **决策**:新建独立文件 `src/plugins/truncation.ts`,责任边界清晰
30+
31+
4. **旧代码处理**
32+
- 选项:删除旧逻辑、保留并标记 deprecated、复用作为内部工具
33+
- **决策**:删除旧 truncate 逻辑,避免维护两套代码
34+
35+
5. **设计方案选择**
36+
- 方案 A:纯 Hook 实现,所有工具统一截断
37+
- 方案 B:Hook + 工具协作机制,支持工具自定义截断
38+
- **决策**:方案 B,通过 `truncated` 标记实现工具协作,更符合文档设计
39+
40+
6. **配置开关**
41+
- 需要支持通过配置关闭 truncation 功能,实现最小影响
42+
- 添加 `truncation?: boolean` 配置项
43+
44+
## Approach
45+
46+
通过插件机制的 `toolResult` hook 拦截工具执行结果,实现自动截断:
47+
48+
1. **插件拦截**:在工具执行后,通过 `toolResult` hook 检查输出大小
49+
2. **智能跳过**:如果工具已设置 `truncated` 标记,则跳过自动处理
50+
3. **无损保存**:超限时保存完整内容到本地文件,返回截断预览
51+
4. **配置控制**:支持通过 `truncation: false` 配置关闭功能
52+
53+
### 核心流程
54+
55+
```
56+
工具执行 → ToolResult → 插件 toolResult hook →
57+
- 若 truncated 已定义 → 跳过处理
58+
- 若 truncation 配置为 false → 跳过处理
59+
- 若未超限 → 标记 truncated: false
60+
- 若超限 → 截断 + 保存文件 + 返回修改后的 ToolResult
61+
```
62+
63+
## Architecture
64+
65+
### 文件变更清单
66+
67+
| 文件 | 操作 | 说明 |
68+
|------|------|------|
69+
| `src/plugins/truncation.ts` | 新建 | Truncation 插件主体 |
70+
| `src/tool.ts` | 修改 | ToolResult 类型添加 `truncated``outputPath` 字段 |
71+
| `src/context.ts` | 修改 | 导入并注册 truncationPlugin |
72+
| `src/config.ts` | 修改 | 添加 `truncation?: boolean` 配置项 |
73+
| `src/compression.ts` | 删除部分 | 移除 `truncate` 函数和 `TruncateResult` 类型 |
74+
75+
### ToolResult 类型扩展
76+
77+
```typescript
78+
// src/tool.ts
79+
export type ToolResult = {
80+
llmContent: string | (TextPart | ImagePart)[];
81+
returnDisplay?: ReturnDisplay;
82+
isError?: boolean;
83+
metadata?: { ... };
84+
85+
// 新增字段
86+
truncated?: boolean; // 是否已截断
87+
outputPath?: string; // 完整输出文件路径
88+
};
89+
```
90+
91+
### 插件核心实现
92+
93+
```typescript
94+
// src/plugins/truncation.ts
95+
export const truncationPlugin: Plugin = {
96+
name: 'truncation',
97+
enforce: 'post',
98+
99+
async toolResult(toolResult, opts) {
100+
// 检查是否禁用
101+
if (this.config.truncation === false) {
102+
return toolResult;
103+
}
104+
105+
// 工具已自行处理
106+
if (toolResult.truncated !== undefined) {
107+
return toolResult;
108+
}
109+
110+
// 错误结果不截断
111+
if (toolResult.isError) {
112+
return toolResult;
113+
}
114+
115+
// 检查大小并执行截断
116+
// ...
117+
},
118+
};
119+
```
120+
121+
### 配置项
122+
123+
```typescript
124+
// src/config.ts
125+
export type Config = {
126+
// ...
127+
truncation?: boolean; // 默认 true
128+
};
129+
```
130+
131+
### 边界情况处理
132+
133+
| 场景 | 处理方式 |
134+
|------|---------|
135+
| 错误结果 (`isError: true`) | 跳过截断 |
136+
| 工具已设置 `truncated` | 跳过截断 |
137+
| llmContent 是数组(含图片) | 仅处理文本部分 |
138+
| 内容恰好在边界 | `<=` 判断,不截断 |
139+
| 输出目录不存在 | 自动创建 |
140+
| 文件写入失败 | 记录日志,降级返回截断内容 |
141+
142+
### 用户配置方式
143+
144+
```bash
145+
# 全局关闭
146+
takumi config set truncation false
147+
148+
# 项目级配置 (.takumi/config.json)
149+
{ "truncation": false }
150+
```

src/config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export type Config = {
6363
* @default true
6464
*/
6565
autoCompact?: boolean;
66+
/**
67+
* Controls whether automatic tool output truncation is enabled.
68+
* When enabled, large tool outputs (>2000 lines or >50KB) will be truncated
69+
* and full content saved to a local file.
70+
*
71+
* @default true
72+
*/
73+
truncation?: boolean;
6674
commit?: CommitConfig;
6775
outputStyle?: string;
6876
outputFormat?: 'text' | 'stream-json' | 'json';
@@ -109,6 +117,7 @@ const DEFAULT_CONFIG: Partial<Config> = {
109117
provider: {},
110118
todo: true,
111119
autoCompact: true,
120+
truncation: true,
112121
outputFormat: 'text',
113122
autoUpdate: true,
114123
extensions: {},
@@ -124,6 +133,7 @@ const VALID_CONFIG_KEYS = [
124133
'systemPrompt',
125134
'todo',
126135
'autoCompact',
136+
'truncation',
127137
'commit',
128138
'outputStyle',
129139
'autoUpdate',
@@ -145,7 +155,13 @@ const OBJECT_CONFIG_KEYS = [
145155
'tools',
146156
'agent',
147157
];
148-
const BOOLEAN_CONFIG_KEYS = ['quiet', 'todo', 'autoCompact', 'autoUpdate'];
158+
const BOOLEAN_CONFIG_KEYS = [
159+
'quiet',
160+
'todo',
161+
'autoCompact',
162+
'autoUpdate',
163+
'truncation',
164+
];
149165
export const GLOBAL_ONLY_KEYS: string[] = [];
150166

151167
function assertGlobalAllowed(global: boolean, key: string) {

src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ export enum AGENT_TYPE {
8181
GENERAL_PURPOSE = 'GeneralPurpose',
8282
NEOVATE_CODE_GUIDE = 'neovate-code-guide',
8383
}
84+
85+
// Truncation configuration
86+
export const TRUNCATE_MAX_LINES = 2000; // Maximum lines
87+
export const TRUNCATE_MAX_BYTES = 50 * 1024; // Maximum bytes (50KB)

src/context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
PluginManager,
1616
} from './plugin';
1717
import { notificationSoundPlugin } from './plugins/notification';
18+
import { truncationPlugin } from './plugins/truncation';
1819
import { SkillManager } from './skill';
1920

2021
type ContextOpts = {
@@ -109,7 +110,10 @@ export class Context {
109110
opts.argvConfig || {},
110111
);
111112
const initialConfig = configManager.config;
112-
const buildInPlugins: Plugin[] = [notificationSoundPlugin];
113+
const buildInPlugins: Plugin[] = [
114+
notificationSoundPlugin,
115+
truncationPlugin,
116+
];
113117
const globalPlugins = scanPlugins(
114118
path.join(paths.globalConfigDir, 'plugins'),
115119
);

src/plugins/truncation.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import fs from 'fs/promises';
2+
import os from 'os';
3+
import path from 'pathe';
4+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
5+
import { TRUNCATE_MAX_BYTES, TRUNCATE_MAX_LINES } from '../constants';
6+
import { truncationPlugin } from './truncation';
7+
8+
// Mock context
9+
const createMockContext = (config: { truncation?: boolean } = {}) => ({
10+
config: {
11+
truncation: config.truncation ?? true,
12+
},
13+
paths: {
14+
globalConfigDir: path.join(os.tmpdir(), `truncation-test-${Date.now()}`),
15+
},
16+
});
17+
18+
// Mock opts
19+
const createMockOpts = (toolName: string = 'test-tool') => ({
20+
toolUse: { name: toolName },
21+
approved: true,
22+
sessionId: 'test-session',
23+
});
24+
25+
describe('truncationPlugin', () => {
26+
let tempDir: string;
27+
28+
beforeEach(async () => {
29+
tempDir = path.join(os.tmpdir(), `truncation-test-${Date.now()}`);
30+
await fs.mkdir(tempDir, { recursive: true });
31+
});
32+
33+
afterEach(async () => {
34+
try {
35+
await fs.rm(tempDir, { recursive: true, force: true });
36+
} catch {
37+
// Ignore cleanup errors
38+
}
39+
});
40+
41+
test('should skip when truncation is disabled', async () => {
42+
const context = createMockContext({ truncation: false });
43+
const toolResult = { llmContent: 'test content' };
44+
const opts = createMockOpts();
45+
46+
const result = await truncationPlugin.toolResult!.call(
47+
context as any,
48+
toolResult as any,
49+
opts as any,
50+
);
51+
52+
expect(result).toEqual(toolResult);
53+
expect(result.truncated).toBeUndefined();
54+
});
55+
56+
test('should skip when truncated is already defined', async () => {
57+
const context = createMockContext();
58+
const toolResult = { llmContent: 'test content', truncated: true };
59+
const opts = createMockOpts();
60+
61+
const result = await truncationPlugin.toolResult!.call(
62+
context as any,
63+
toolResult as any,
64+
opts as any,
65+
);
66+
67+
expect(result.truncated).toBe(true);
68+
});
69+
70+
test('should skip error results', async () => {
71+
const context = createMockContext();
72+
const toolResult = { llmContent: 'error message', isError: true };
73+
const opts = createMockOpts();
74+
75+
const result = await truncationPlugin.toolResult!.call(
76+
context as any,
77+
toolResult as any,
78+
opts as any,
79+
);
80+
81+
expect(result.truncated).toBeUndefined();
82+
});
83+
84+
test('should not truncate content under limits', async () => {
85+
const context = createMockContext();
86+
context.paths.globalConfigDir = tempDir;
87+
const toolResult = { llmContent: 'short content' };
88+
const opts = createMockOpts();
89+
90+
const result = await truncationPlugin.toolResult!.call(
91+
context as any,
92+
toolResult as any,
93+
opts as any,
94+
);
95+
96+
expect(result.truncated).toBe(false);
97+
expect(result.llmContent).toBe('short content');
98+
});
99+
100+
test('should truncate content exceeding line limit', async () => {
101+
const context = createMockContext();
102+
context.paths.globalConfigDir = tempDir;
103+
104+
// Generate content exceeding 2000 lines
105+
const lineCount = TRUNCATE_MAX_LINES + 500;
106+
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`);
107+
const toolResult = { llmContent: lines.join('\n') };
108+
const opts = createMockOpts();
109+
110+
const result = await truncationPlugin.toolResult!.call(
111+
context as any,
112+
toolResult as any,
113+
opts as any,
114+
);
115+
116+
expect(result.truncated).toBe(true);
117+
expect(result.outputPath).toBeDefined();
118+
expect(result.llmContent).toContain('truncated');
119+
expect(result.llmContent).toContain('Full output saved to');
120+
121+
// Verify file was created with full content
122+
if (result.outputPath) {
123+
const savedContent = await fs.readFile(result.outputPath, 'utf-8');
124+
expect(savedContent).toBe(lines.join('\n'));
125+
}
126+
});
127+
128+
test('should truncate content exceeding byte limit', async () => {
129+
const context = createMockContext();
130+
context.paths.globalConfigDir = tempDir;
131+
132+
// Generate content exceeding 50KB
133+
const longLine = 'x'.repeat(TRUNCATE_MAX_BYTES + 10 * 1024); // 60KB
134+
const toolResult = { llmContent: longLine };
135+
const opts = createMockOpts();
136+
137+
const result = await truncationPlugin.toolResult!.call(
138+
context as any,
139+
toolResult as any,
140+
opts as any,
141+
);
142+
143+
expect(result.truncated).toBe(true);
144+
expect(result.outputPath).toBeDefined();
145+
expect(result.llmContent).toContain('bytes truncated');
146+
});
147+
148+
test('should skip non-string content', async () => {
149+
const context = createMockContext();
150+
const toolResult = { llmContent: [{ type: 'text', text: 'test' }] };
151+
const opts = createMockOpts();
152+
153+
const result = await truncationPlugin.toolResult!.call(
154+
context as any,
155+
toolResult as any,
156+
opts as any,
157+
);
158+
159+
expect(result.truncated).toBeUndefined();
160+
expect(result.llmContent).toEqual([{ type: 'text', text: 'test' }]);
161+
});
162+
});

0 commit comments

Comments
 (0)