Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions docs/designs/2026-01-28-truncation-plugin-refactor.md
Original file line number Diff line number Diff line change
@@ -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 }
```
18 changes: 17 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -109,6 +117,7 @@ const DEFAULT_CONFIG: Partial<Config> = {
provider: {},
todo: true,
autoCompact: true,
truncation: true,
outputFormat: 'text',
autoUpdate: true,
extensions: {},
Expand All @@ -124,6 +133,7 @@ const VALID_CONFIG_KEYS = [
'systemPrompt',
'todo',
'autoCompact',
'truncation',
'commit',
'outputStyle',
'autoUpdate',
Expand All @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 5 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PluginManager,
} from './plugin';
import { notificationSoundPlugin } from './plugins/notification';
import { truncationPlugin } from './plugins/truncation';
import { SkillManager } from './skill';

type ContextOpts = {
Expand Down Expand Up @@ -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'),
);
Expand Down
162 changes: 162 additions & 0 deletions src/plugins/truncation.test.ts
Original file line number Diff line number Diff line change
@@ -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' }]);
});
});
Loading
Loading