diff --git a/docs/specs/tool-output-guardrails/plan.md b/docs/specs/tool-output-guardrails/plan.md new file mode 100644 index 000000000..638435e29 --- /dev/null +++ b/docs/specs/tool-output-guardrails/plan.md @@ -0,0 +1,87 @@ +# 实施计划 + +## 现状梳理 + +- 真正的工具路由在 `src/main/presenter/toolPresenter`: + - `ToolPresenter` + `ToolMapper`. + - `agentPresenter/tool` 下的 `ToolRegistry`/`toolRouter` 目前未被运行路径使用. +- `ToolCallProcessor` 会把工具结果直接拼接进 `conversationMessages`, 无大小控制. +- `directory_tree` 实现为无限递归. + +## 方案设计 + +### 1) `directory_tree` 深度控制 + +- 更新 schema: + - `src/main/presenter/agentPresenter/acp/agentToolManager.ts` + - `directory_tree` 增加 `depth?: number`(默认 1, 最大 3). + - `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` + - `DirectoryTreeArgsSchema` 同步增加 `depth`. +- 递归限制: + - 在 `directoryTree` 内实现 `currentDepth` 控制. + - root 深度为 0, 仅当 `currentDepth < depth` 时继续展开. + +### 2) 工具输出 offload + +- 触发阈值: 工具输出字符串长度 > 3000. +- offload 存储: + - 目录: `~/.deepchat/sessions//` + - 文件名: `tool_.offload` + - 内容: 原始完整工具输出(文本) +- stub 内容: + - 总字符数 + - 预览片段(1024 字符以内) + - 完整文件绝对路径 +- 执行位置: + - 在 `ToolCallProcessor` 中对工具输出 string 化后做长度判断. + - 仅替换 `tool_call_response` 和写入 `conversationMessages`. + - 保持 `tool_call_response_raw` 不变, 避免影响 MCP UI/搜索结果. + +### 3) 文件读取放行规则 + +- 文件类工具在读取 `~/.deepchat` 时需要额外校验: + - 只放行 `~/.deepchat/sessions/` 下的文件. + - 会话不匹配则拒绝访问. +- 实现位置建议: + - 在 `AgentFileSystemHandler.validatePath` 增加路径前缀校验(读取时). +- 路径安全: + - 参考 `skillSyncPresenter/security.ts` 的路径规范化/安全校验逻辑. + +### 4) 错误呈现 + +- 保证 error event 携带错误文本: + - `AgentLoopHandler`/`StreamGenerationHandler`/`AgentPresenter` 的 error 事件 + 统一包含 `error` 字段. +- UI 侧: + - `MessageBlockError.vue` 默认直接展示 raw text. + - 不依赖 i18n key 时也能显示完整错误内容. + +## 事件流 + +1. 工具调用完成 → `ToolCallProcessor` 取到输出. +2. 输出超过 3000 字符 → offload 写文件 + 生成 stub. +3. stub 进入 `conversationMessages` 和 `tool_call_response`. +4. UI 展示 stub; 模型可用 file 工具读取完整路径. +5. 出错时, error block 写入消息 + `STREAM_EVENTS.ERROR` 发送错误文本. + +## 数据/文件结构 + +- `~/.deepchat/sessions//tool_.offload` + - 原始完整工具输出文本 + +## 测试策略 + +- 单元测试: + - `directory_tree` 深度限制(0/1/3/4). + - tool output 超过 3000 字符时触发 offload, stub 格式正确. +- 集成/手动: + - 触发 `directory_tree` 大输出, 确认不再触发 10MB 失败. + - 触发 provider error, UI 能直接看到 raw text. + +## 风险与对策 + +- offload 文件增多: + - 可在后续增加清理策略(按时间或数量). +- conversationId 缺失场景: + - 需定义降级行为(例如仅截断不 offload). + - 若确认不存在此场景可忽略. diff --git a/docs/specs/tool-output-guardrails/spec.md b/docs/specs/tool-output-guardrails/spec.md new file mode 100644 index 000000000..cbc470cf0 --- /dev/null +++ b/docs/specs/tool-output-guardrails/spec.md @@ -0,0 +1,59 @@ +# Agent 工具输出保护与错误呈现 + +> 状态: Draft +> 日期: 2025-03-08 + +## 背景 + +当前 agent 运行中出现以下问题: + +- Provider 报错会出现在主进程日志, 但 UI 未必能看到错误信息. +- `directory_tree` 无深度限制, 可能产生巨量输出, 触发 10MB 限制. +- 工具返回过大时会被直接注入到 LLM 上下文, 容易导致请求失败. + +## 目标 + +- 让生成失败时的错误信息可见并可追溯. +- 给 `directory_tree` 增加深度控制, 最大不超过 3. +- 对过大的工具输出做 offload, 用小的 stub 替代进入上下文. + +## 非目标 + +- 不改动或替换 `agentPresenter/tool` 下的 `ToolRegistry`/`toolRouter`. +- 不改变 MCP UI 资源与搜索结果的解析逻辑. + +## 用户故事 + +1. 作为用户, 我希望生成失败时能在 UI 直接看到原始错误文本. +2. 作为模型, 我希望能指定目录树深度, 避免一次输出过大. +3. 作为系统, 我希望工具输出过大时自动 offload, 仍可在需要时读取完整内容. + +## 验收标准 + +### 错误呈现 + +- 生成失败会将消息状态置为 `error`, 并写入一个 error block. +- error block 直接显示 raw error 文本(不要求点击展开). +- `STREAM_EVENTS.ERROR` 会携带错误文本, 便于 UI 展示或通知. + +### `directory_tree` 深度控制 + +- `directory_tree` 增加 `depth` 可选参数, 默认值为 1. +- depth 最大为 3, 超出直接校验失败. +- 深度计数方式为 root=0. + - depth=0: 仅返回根目录下条目, 不展开子目录. + - depth=1: 展开一级子目录, 不包含孙级. +- 响应格式保持不变: JSON 数组 `{ name, type, children? }`. + +### 工具输出 offload + +- 当工具输出字符串长度 > 3000 字符时触发 offload. +- 完整内容写入: + - `~/.deepchat/sessions//tool_.offload` +- LLM 只收到 stub, 包含: + - 总字符数 + - 预览片段 + - 完整文件的绝对路径 +- 模型可以通过文件类工具读取上述路径. +- 文件类读取工具仅放行当前会话 `conversationId` 对应目录. +- `tool_call_response_raw` 不被改写, 避免影响 MCP UI/搜索结果处理. diff --git a/docs/specs/tool-output-guardrails/tasks.md b/docs/specs/tool-output-guardrails/tasks.md new file mode 100644 index 000000000..4896ff3b5 --- /dev/null +++ b/docs/specs/tool-output-guardrails/tasks.md @@ -0,0 +1,12 @@ +# 任务拆分 + +1. 更新 `directory_tree` schema 与描述, 增加 `depth`(默认 1, 最大 3). +2. 在 `AgentFileSystemHandler.directoryTree` 实现 depth 控制(root=0)并补充测试. +3. 在 `ToolCallProcessor` 增加工具输出长度检测: + - 超过 3000 字符 → 写入 `~/.deepchat/sessions//tool_.offload` + - 生成 stub 替换 `tool_call_response` 与上下文内容. +4. 在文件工具读路径校验中放行 `~/.deepchat/sessions/`: + - 仅限当前会话. +5. 统一 error event 的 `error` 字段传递, 并确保写入 error block. +6. 更新 `MessageBlockError.vue` 默认展示 raw text(不依赖 i18n key). +7. 运行 `pnpm run format` 与 `pnpm run lint`. diff --git a/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts b/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts index 006a97097..0623fca40 100644 --- a/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts +++ b/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises' import path from 'path' import os from 'os' +import { getSessionsRoot } from '../../sessionPresenter/sessionPaths' import { z } from 'zod' import { minimatch } from 'minimatch' import { diffLines } from 'diff' @@ -11,8 +12,25 @@ import { spawn } from 'child_process' import { RuntimeHelper } from '../../../lib/runtimeHelper' import { glob } from 'glob' +// Auto-truncate threshold for read_file to avoid triggering tool output offload +const READ_FILE_AUTO_TRUNCATE_THRESHOLD = 4500 + const ReadFileArgsSchema = z.object({ - paths: z.array(z.string()).min(1).describe('Array of file paths to read') + paths: z.array(z.string()).min(1).describe('Array of file paths to read'), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Starting character offset (0-based), applied to each file independently'), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum characters to read per file. Large files are auto-truncated if not specified' + ) }) const WriteFileArgsSchema = z.object({ @@ -89,7 +107,8 @@ const TextReplaceArgsSchema = z.object({ }) const DirectoryTreeArgsSchema = z.object({ - path: z.string() + path: z.string(), + depth: z.number().int().min(0).max(3).default(1) }) const GetFileInfoArgsSchema = z.object({ @@ -154,18 +173,23 @@ interface LineRange { interface PathValidationOptions { enforceAllowed?: boolean + accessType?: 'read' | 'write' } export class AgentFileSystemHandler { private allowedDirectories: string[] + private conversationId?: string + private readonly sessionsRoot: string - constructor(allowedDirectories: string[]) { + constructor(allowedDirectories: string[], options: { conversationId?: string } = {}) { if (allowedDirectories.length === 0) { throw new Error('At least one allowed directory must be provided') } this.allowedDirectories = allowedDirectories.map((dir) => this.normalizePath(path.resolve(this.expandHome(dir))) ) + this.conversationId = options.conversationId + this.sessionsRoot = this.normalizePath(getSessionsRoot()) } private normalizePath(p: string): string { @@ -211,6 +235,9 @@ export class AgentFileSystemHandler { ): Promise { const enforceAllowed = options.enforceAllowed ?? true const normalizedRequested = this.resolvePath(requestedPath, baseDirectory) + if (options.accessType === 'read') { + this.assertSessionReadAllowed(normalizedRequested) + } if (enforceAllowed) { const isAllowed = this.isPathAllowed(normalizedRequested) if (!isAllowed) { @@ -222,6 +249,9 @@ export class AgentFileSystemHandler { try { const realPath = await fs.realpath(normalizedRequested) const normalizedReal = this.normalizePath(realPath) + if (options.accessType === 'read') { + this.assertSessionReadAllowed(normalizedReal) + } if (enforceAllowed) { const isRealPathAllowed = this.isPathAllowed(normalizedReal) if (!isRealPathAllowed) { @@ -247,6 +277,29 @@ export class AgentFileSystemHandler { } } + private isWithinSessionsRoot(candidatePath: string): boolean { + if (candidatePath === this.sessionsRoot) return true + const rootWithSeparator = this.sessionsRoot.endsWith(path.sep) + ? this.sessionsRoot + : `${this.sessionsRoot}${path.sep}` + return candidatePath.startsWith(rootWithSeparator) + } + + private assertSessionReadAllowed(candidatePath: string): void { + if (!this.isWithinSessionsRoot(candidatePath)) return + if (!this.conversationId) { + throw new Error('Access denied - session files require an active conversation') + } + const sessionDir = this.normalizePath(path.join(this.sessionsRoot, this.conversationId)) + if (candidatePath === sessionDir) return + const sessionWithSeparator = sessionDir.endsWith(path.sep) + ? sessionDir + : `${sessionDir}${path.sep}` + if (!candidatePath.startsWith(sessionWithSeparator)) { + throw new Error('Access denied - session files outside current conversation') + } + } + private countLines(value: string): number { if (value.length === 0) return 0 const lineCount = value.split('\n').length @@ -507,7 +560,10 @@ export class AgentFileSystemHandler { args.push('--no-heading') // Search path - const validatedPath = await this.validatePath(rootPath, undefined, { enforceAllowed: false }) + const validatedPath = await this.validatePath(rootPath, undefined, { + enforceAllowed: false, + accessType: 'read' + }) args.push(validatedPath) return new Promise((resolve, reject) => { @@ -682,7 +738,10 @@ export class AgentFileSystemHandler { if (result.totalMatches >= maxResults) break const fullPath = path.join(currentPath, entry.name) try { - await this.validatePath(fullPath, undefined, { enforceAllowed: false }) + await this.validatePath(fullPath, undefined, { + enforceAllowed: false, + accessType: 'read' + }) if (entry.isFile()) { if (minimatch(entry.name, filePattern, { nocase: !caseSensitive })) { await searchInFile(fullPath) @@ -696,7 +755,10 @@ export class AgentFileSystemHandler { } } - const validatedPath = await this.validatePath(rootPath, undefined, { enforceAllowed: false }) + const validatedPath = await this.validatePath(rootPath, undefined, { + enforceAllowed: false, + accessType: 'read' + }) const stats = await fs.stat(validatedPath) if (stats.isFile()) { @@ -786,13 +848,46 @@ export class AgentFileSystemHandler { if (!parsed.success) { throw new Error(`Invalid arguments: ${parsed.error}`) } + + const { offset = 0, limit } = parsed.data + const results = await Promise.all( parsed.data.paths.map(async (filePath: string) => { try { const validPath = await this.validatePath(filePath, baseDirectory, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) - const content = await fs.readFile(validPath, 'utf-8') + const fullContent = await fs.readFile(validPath, 'utf-8') + const totalLength = fullContent.length + + // Determine effective limit + let effectiveLimit = limit + let autoTruncated = false + + // Auto-truncate large files when no explicit limit specified + if (limit === undefined && totalLength - offset > READ_FILE_AUTO_TRUNCATE_THRESHOLD) { + effectiveLimit = READ_FILE_AUTO_TRUNCATE_THRESHOLD + autoTruncated = true + } + + // Apply offset and limit + const content = + effectiveLimit !== undefined + ? fullContent.slice(offset, offset + effectiveLimit) + : fullContent.slice(offset) + + const endOffset = offset + content.length + + // Build result with metadata when pagination is active or auto-truncated + if (offset > 0 || limit !== undefined || autoTruncated) { + let header = `${filePath} [chars ${offset}-${endOffset} of ${totalLength}]` + if (autoTruncated) { + header += ` (auto-truncated, use offset/limit to read more)` + } + return `${header}:\n${content}\n` + } + return `${filePath}:\n${content}\n` } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -819,7 +914,8 @@ export class AgentFileSystemHandler { throw new Error(`Invalid arguments: ${parsed.error}`) } const validPath = await this.validatePath(parsed.data.path, baseDirectory, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) const entries = await fs.readdir(validPath, { withFileTypes: true }) const formatted = entries @@ -916,7 +1012,8 @@ export class AgentFileSystemHandler { } const validPath = await this.validatePath(parsed.data.path, baseDirectory, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) const result = await this.runGrepSearch(validPath, parsed.data.pattern, { filePattern: parsed.data.filePattern, @@ -999,9 +1096,11 @@ export class AgentFileSystemHandler { throw new Error(`Invalid arguments: ${parsed.error}`) } - const buildTree = async (currentPath: string): Promise => { + const depth = parsed.data.depth + const buildTree = async (currentPath: string, currentDepth: number): Promise => { const validPath = await this.validatePath(currentPath, baseDirectory, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) const entries = await fs.readdir(validPath, { withFileTypes: true }) const result: TreeEntry[] = [] @@ -1014,7 +1113,9 @@ export class AgentFileSystemHandler { if (entry.isDirectory()) { const subPath = path.join(currentPath, entry.name) - entryData.children = await buildTree(subPath) + if (currentDepth < depth) { + entryData.children = await buildTree(subPath, currentDepth + 1) + } } result.push(entryData) @@ -1023,7 +1124,7 @@ export class AgentFileSystemHandler { return result } - const treeData = await buildTree(parsed.data.path) + const treeData = await buildTree(parsed.data.path, 0) return JSON.stringify(treeData, null, 2) } @@ -1034,7 +1135,8 @@ export class AgentFileSystemHandler { } const validPath = await this.validatePath(parsed.data.path, baseDirectory, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) const info = await this.getFileStats(validPath) return Object.entries(info) @@ -1053,9 +1155,10 @@ export class AgentFileSystemHandler { // Determine root directory const searchRoot = root - ? await this.validatePath(root, baseDirectory, { enforceAllowed: false }) + ? await this.validatePath(root, baseDirectory, { enforceAllowed: false, accessType: 'read' }) : await this.validatePath(baseDirectory ?? this.allowedDirectories[0], undefined, { - enforceAllowed: false + enforceAllowed: false, + accessType: 'read' }) // Default exclusions diff --git a/src/main/presenter/agentPresenter/acp/agentToolManager.ts b/src/main/presenter/agentPresenter/acp/agentToolManager.ts index 90d7d57a0..86d932758 100644 --- a/src/main/presenter/agentPresenter/acp/agentToolManager.ts +++ b/src/main/presenter/agentPresenter/acp/agentToolManager.ts @@ -62,6 +62,20 @@ export class AgentToolManager { private readonly fileSystemSchemas = { read_file: z.object({ paths: z.array(z.string()).min(1), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Starting character offset (0-based), applied to each file independently'), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum characters to read per file. Large files are auto-truncated if not specified' + ), base_directory: z .string() .optional() @@ -167,6 +181,13 @@ export class AgentToolManager { }), directory_tree: z.object({ path: z.string(), + depth: z + .number() + .int() + .min(0) + .max(3) + .default(1) + .describe('Directory depth (root=0). Maximum is 3.'), base_directory: z.string().optional().describe('Base directory for resolving relative paths.') }), get_file_info: z.object({ @@ -342,7 +363,7 @@ export class AgentToolManager { function: { name: 'read_file', description: - "Read the contents of one or more files. When invoked from a skill context with relative paths, provide base_directory as the skill's root directory to ensure correct path resolution. Use absolute paths for files outside the skill or workspace.", + "Read the contents of one or more files. Supports pagination via offset/limit for large files (auto-truncated at 4500 chars if not specified). When invoked from a skill context with relative paths, provide base_directory as the skill's root directory.", parameters: zodToJsonSchema(schemas.read_file) as { type: string properties: Record @@ -467,7 +488,7 @@ export class AgentToolManager { function: { name: 'directory_tree', description: - 'Get a recursive directory tree as JSON. Provide base_directory for skill-relative paths.', + 'Get a directory tree as JSON with optional depth (root=0, max=3). Provide base_directory for skill-relative paths.', parameters: zodToJsonSchema(schemas.directory_tree) as { type: string properties: Record @@ -613,7 +634,7 @@ export class AgentToolManager { const workspaceRoot = dynamicWorkdir ?? this.agentWorkspacePath ?? this.getDefaultAgentWorkspacePath() const allowedDirectories = this.buildAllowedDirectories(workspaceRoot, conversationId) - const fileSystemHandler = new AgentFileSystemHandler(allowedDirectories) + const fileSystemHandler = new AgentFileSystemHandler(allowedDirectories, { conversationId }) try { switch (toolName) { diff --git a/src/main/presenter/agentPresenter/index.ts b/src/main/presenter/agentPresenter/index.ts index 65c2c641d..7642e8de6 100644 --- a/src/main/presenter/agentPresenter/index.ts +++ b/src/main/presenter/agentPresenter/index.ts @@ -174,8 +174,10 @@ export class AgentPresenter implements IAgentPresenter { .startStreamCompletion(agentId, assistantMessage.id, selectedVariantsMap) .catch((error) => { console.error('[AgentPresenter] Failed to start stream completion:', error) + const errorMessage = error instanceof Error ? error.message : String(error) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, { - eventId: assistantMessage.id + eventId: assistantMessage.id, + error: errorMessage }) }) @@ -202,8 +204,10 @@ export class AgentPresenter implements IAgentPresenter { .continueStreamCompletion(agentId, messageId, selectedVariantsMap) .catch((error) => { console.error('[AgentPresenter] Failed to continue stream completion:', error) + const errorMessage = error instanceof Error ? error.message : String(error) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, { - eventId: assistantMessage.id + eventId: assistantMessage.id, + error: errorMessage }) }) @@ -250,8 +254,10 @@ export class AgentPresenter implements IAgentPresenter { .startStreamCompletion(message.conversationId, messageId, selectedVariantsMap) .catch((error) => { console.error('[AgentPresenter] Failed to retry stream completion:', error) + const errorMessage = error instanceof Error ? error.message : String(error) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, { - eventId: assistantMessage.id + eventId: assistantMessage.id, + error: errorMessage }) }) diff --git a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts index aefeef096..c4b6cc3da 100644 --- a/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts +++ b/src/main/presenter/agentPresenter/loop/agentLoopHandler.ts @@ -1,22 +1,3 @@ -/** - * Session 架构说明: - * - * SessionPresenter (src/presenter/sessionPresenter/) - * - 职责:会话的 CRUD 操作(数据库层) - * - 主要方法:getConversation, createSession, updateConversationSettings 等 - * - 使用场景:当需要从数据库读取会话数据时 - * - * SessionManager (src/presenter/agentPresenter/session/) - * - 职责:单次对话的 runtime 状态管理(内存层) - * - 包含 SessionContext: - * - resolved: 解析后的配置 (chatMode, modelId, agentWorkspacePath) - * - runtime: 运行时状态 (toolCallCount, userStopRequested, status) - * - 使用场景:在 agent loop 期间管理对话状态 - * - * 访问方式: - * - 通过全局 presenter: presenter.sessionManager - * - SessionContext.resolved.modelId 获取模型 ID - */ import { ChatMessage, IConfigPresenter, LLMAgentEvent, MCPToolCall } from '@shared/presenter' import { presenter } from '@/presenter' import { eventBus, SendTarget } from '@/eventbus' diff --git a/src/main/presenter/agentPresenter/loop/errorClassification.ts b/src/main/presenter/agentPresenter/loop/errorClassification.ts index e31cf9dd1..9c028a96d 100644 --- a/src/main/presenter/agentPresenter/loop/errorClassification.ts +++ b/src/main/presenter/agentPresenter/loop/errorClassification.ts @@ -55,7 +55,7 @@ export function isNonRetryableError(error: Error | string): boolean { lowerMessage.includes('type error') || lowerMessage.includes('type mismatch') ) { - return true + return false } // Explicit permission denied (user explicitly denied, not a transient error) diff --git a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts index a8af6c92d..b36850a47 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts @@ -6,7 +6,10 @@ import { MCPToolResponse, ModelConfig } from '@shared/presenter' +import fs from 'fs/promises' +import path from 'path' import { isNonRetryableError } from './errorClassification' +import { resolveToolOffloadPath } from '../../sessionPresenter/sessionPaths' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise @@ -37,6 +40,22 @@ interface ToolCallProcessResult { needContinueConversation: boolean } +const TOOL_OUTPUT_OFFLOAD_THRESHOLD = 5000 +const TOOL_OUTPUT_PREVIEW_LENGTH = 1024 + +// Tools that require offload when output exceeds threshold +// Tools not in this list will never trigger offload (e.g., read_file has its own pagination) +const TOOLS_REQUIRING_OFFLOAD = new Set([ + 'execute_command', + 'directory_tree', + 'list_directory', + 'glob_search', + 'grep_search', + 'text_replace', + 'browser_read_links', + 'browser_get_clickable_elements' +]) + export class ToolCallProcessor { constructor(private readonly options: ToolCallProcessorOptions) {} @@ -177,12 +196,18 @@ export class ToolCallProcessor { const supportsFunctionCall = context.modelConfig?.functionCall || false + const toolContent = this.stringifyToolContent(toolResponse.content) + const toolContentForModel = await this.offloadToolContentIfNeeded( + toolContent, + toolCall.id, + context.conversationId, + toolCall.name + ) + if (supportsFunctionCall) { - this.appendNativeFunctionCallMessages( - context.conversationMessages, - toolCall, - toolResponse - ) + this.appendNativeFunctionCallMessages(context.conversationMessages, toolCall, { + content: toolContentForModel + }) yield { type: 'response', @@ -190,7 +215,7 @@ export class ToolCallProcessor { eventId: context.eventId, tool_call: 'end', tool_call_id: toolCall.id, - tool_call_response: this.stringifyToolContent(toolResponse.content), + tool_call_response: toolContentForModel, tool_call_name: toolCall.name, tool_call_params: toolCall.arguments, tool_call_server_name: toolDef.server.name, @@ -200,11 +225,9 @@ export class ToolCallProcessor { } } } else { - this.appendLegacyFunctionCallMessages( - context.conversationMessages, - toolCall, - toolResponse - ) + this.appendLegacyFunctionCallMessages(context.conversationMessages, toolCall, { + content: toolContentForModel + }) yield { type: 'response', @@ -212,7 +235,7 @@ export class ToolCallProcessor { eventId: context.eventId, tool_call: 'end', tool_call_id: toolCall.id, - tool_call_response: toolResponse.content, + tool_call_response: toolContentForModel, tool_call_name: toolCall.name, tool_call_params: toolCall.arguments, tool_call_server_name: toolDef.server.name, @@ -363,6 +386,46 @@ export class ToolCallProcessor { }) } + private async offloadToolContentIfNeeded( + content: string, + toolCallId: string, + conversationId?: string, + toolName?: string + ): Promise { + // Only offload tools in the whitelist + if (toolName && !TOOLS_REQUIRING_OFFLOAD.has(toolName)) { + return content + } + + if (content.length <= TOOL_OUTPUT_OFFLOAD_THRESHOLD) return content + if (!conversationId) return content + + const filePath = resolveToolOffloadPath(conversationId, toolCallId) + if (!filePath) return content + const sessionDir = path.dirname(filePath) + + try { + await fs.mkdir(sessionDir, { recursive: true }) + await fs.writeFile(filePath, content, 'utf-8') + } catch (error) { + console.warn('[ToolCallProcessor] Failed to offload tool output:', error) + return content + } + + const preview = content.slice(0, TOOL_OUTPUT_PREVIEW_LENGTH) + return this.buildToolOutputStub(content.length, preview, filePath) + } + + private buildToolOutputStub(totalLength: number, preview: string, filePath: string): string { + return [ + '[Tool output offloaded]', + `Total characters: ${totalLength}`, + `Full output saved to: ${filePath}`, + `first ${preview.length} chars:`, + preview + ].join('\n') + } + private appendToolError( conversationMessages: ChatMessage[], modelConfig: ModelConfig, diff --git a/src/main/presenter/agentPresenter/message/messageBuilder.ts b/src/main/presenter/agentPresenter/message/messageBuilder.ts index e6acbd406..ce39d457a 100644 --- a/src/main/presenter/agentPresenter/message/messageBuilder.ts +++ b/src/main/presenter/agentPresenter/message/messageBuilder.ts @@ -106,6 +106,7 @@ export async function preparePromptContent({ | 'acp agent') ?? 'chat' const isAgentMode = chatMode === 'agent' + const isToolPromptMode = chatMode !== 'chat' const isImageGeneration = modelType === ModelType.ImageGeneration @@ -117,6 +118,7 @@ export async function preparePromptContent({ const { providerId, modelId } = conversation.settings const supportsVision = modelCapabilities.supportsVision(providerId, modelId) + const toolCallCenter = new ToolCallCenter(presenter.toolPresenter) let toolDefinitions: MCPToolDefinition[] = [] let effectiveEnabledMcpTools = enabledMcpTools @@ -126,7 +128,6 @@ export async function preparePromptContent({ } if (!isImageGeneration) { - const toolCallCenter = new ToolCallCenter(presenter.toolPresenter) try { toolDefinitions = await toolCallCenter.getAllToolDefinitions({ enabledMcpTools: effectiveEnabledMcpTools, @@ -158,6 +159,13 @@ export async function preparePromptContent({ } } + if (!isImageGeneration && isToolPromptMode && toolDefinitions.length > 0) { + const toolPrompt = toolCallCenter.buildToolSystemPrompt({ + conversationId: conversation.id + }) + finalSystemPromptWithExtras = appendPromptSection(finalSystemPromptWithExtras, toolPrompt) + } + if (!isImageGeneration && isAgentMode) { try { const skillsMetadataPrompt = await buildSkillsMetadataPrompt() diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 068c2270a..2f23dc0fc 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -247,7 +247,14 @@ export class LLMEventHandler { const delta: Partial = {} if (content) delta.content = content - if (reasoning_content) delta.reasoning_content = reasoning_content + if (reasoning_content) { + delta.reasoning_content = reasoning_content + // Get the current reasoning_time from the last reasoning_content block + const lastBlock = state.message.content[state.message.content.length - 1] + if (lastBlock?.type === 'reasoning_content' && lastBlock.reasoning_time) { + delta.reasoning_time = lastBlock.reasoning_time + } + } if (image_data) delta.image_data = image_data if (totalUsage) delta.totalUsage = totalUsage @@ -261,6 +268,9 @@ export class LLMEventHandler { delta.tool_call_server_icons = tool_call_server_icons delta.tool_call_server_description = tool_call_server_description delta.tool_call_response_raw = tool_call_response_raw + if (msg.permission_request !== undefined) { + delta.permission_request = msg.permission_request + } } this.streamUpdateScheduler.enqueueDelta( @@ -284,14 +294,25 @@ export class LLMEventHandler { } this.contentBufferHandler.cleanupContentBuffer(state) + } + + // Flush stream buffers before persisting error to avoid stale snapshot overwrites. + await this.streamUpdateScheduler.flushAll(eventId, 'final') + + await this.messageManager.handleMessageError(eventId, String(error)) - await this.messageManager.handleMessageError(eventId, String(error)) + if (state) { this.generatingMessages.delete(eventId) presenter.sessionManager.setStatus(state.conversationId, 'error') presenter.sessionManager.clearPendingPermission(state.conversationId) + } else { + const message = await this.messageManager.getMessage(eventId) + if (message) { + presenter.sessionManager.setStatus(message.conversationId, 'error') + presenter.sessionManager.clearPendingPermission(message.conversationId) + } } - await this.streamUpdateScheduler.flushAll(eventId, 'final') this.searchingMessages.delete(eventId) eventBus.sendToRenderer(STREAM_EVENTS.ERROR, SendTarget.ALL_WINDOWS, msg) } diff --git a/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts b/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts index a42284bda..9a91cb4b2 100644 --- a/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts +++ b/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts @@ -8,6 +8,7 @@ export const STREAM_DB_FLUSH_INTERVAL_MS = 600 interface PendingDelta { content?: string reasoning_content?: string + reasoning_time?: { start: number; end: number } tool_call?: LLMAgentEventData['tool_call'] tool_call_id?: string tool_call_name?: string @@ -17,6 +18,7 @@ interface PendingDelta { tool_call_server_icons?: string tool_call_server_description?: string tool_call_response_raw?: unknown + permission_request?: LLMAgentEventData['permission_request'] maximum_tool_calls_reached?: boolean image_data?: { data: string; mimeType: string } rate_limit?: LLMAgentEventData['rate_limit'] @@ -140,6 +142,10 @@ export class StreamUpdateScheduler { state.pendingDelta.reasoning_content = (state.pendingDelta.reasoning_content ?? '') + delta.reasoning_content } + if (delta.reasoning_time) { + // Always use the latest reasoning_time (contains updated end time) + state.pendingDelta.reasoning_time = delta.reasoning_time + } if (delta.tool_call !== undefined) { state.pendingDelta.tool_call = delta.tool_call } @@ -167,6 +173,9 @@ export class StreamUpdateScheduler { if (delta.tool_call_response_raw !== undefined) { state.pendingDelta.tool_call_response_raw = delta.tool_call_response_raw } + if (delta.permission_request !== undefined) { + state.pendingDelta.permission_request = delta.permission_request + } if (delta.maximum_tool_calls_reached !== undefined) { state.pendingDelta.maximum_tool_calls_reached = delta.maximum_tool_calls_reached } @@ -241,6 +250,7 @@ export class StreamUpdateScheduler { seq: state.seq, content: delta.content, reasoning_content: delta.reasoning_content, + reasoning_time: delta.reasoning_time, tool_call: delta.tool_call, tool_call_id: delta.tool_call_id, tool_call_name: delta.tool_call_name, @@ -250,6 +260,7 @@ export class StreamUpdateScheduler { tool_call_server_icons: delta.tool_call_server_icons, tool_call_server_description: delta.tool_call_server_description, tool_call_response_raw: delta.tool_call_response_raw, + permission_request: delta.permission_request, maximum_tool_calls_reached: delta.maximum_tool_calls_reached, image_data: delta.image_data, rate_limit: delta.rate_limit, @@ -367,6 +378,7 @@ export class StreamUpdateScheduler { seq: state.seq, content: delta.content, reasoning_content: delta.reasoning_content, + reasoning_time: delta.reasoning_time, tool_call: delta.tool_call, tool_call_id: delta.tool_call_id, tool_call_name: delta.tool_call_name, @@ -376,6 +388,7 @@ export class StreamUpdateScheduler { tool_call_server_icons: delta.tool_call_server_icons, tool_call_server_description: delta.tool_call_server_description, tool_call_response_raw: delta.tool_call_response_raw, + permission_request: delta.permission_request, maximum_tool_calls_reached: delta.maximum_tool_calls_reached, image_data: delta.image_data, rate_limit: delta.rate_limit, diff --git a/src/main/presenter/agentPresenter/tool/toolCallCenter.ts b/src/main/presenter/agentPresenter/tool/toolCallCenter.ts index c57a59a59..2f365d0dd 100644 --- a/src/main/presenter/agentPresenter/tool/toolCallCenter.ts +++ b/src/main/presenter/agentPresenter/tool/toolCallCenter.ts @@ -22,4 +22,8 @@ export class ToolCallCenter { async callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> { return this.toolPresenter.callTool(request) } + + buildToolSystemPrompt(context: { conversationId?: string }): string { + return this.toolPresenter.buildToolSystemPrompt(context) + } } diff --git a/src/main/presenter/sessionPresenter/index.ts b/src/main/presenter/sessionPresenter/index.ts index 6db65b309..1671f0178 100644 --- a/src/main/presenter/sessionPresenter/index.ts +++ b/src/main/presenter/sessionPresenter/index.ts @@ -15,6 +15,7 @@ import type { } from '@shared/presenter' import type { AssistantMessageBlock, Message, UserMessageContent } from '@shared/chat' import type { NowledgeMemThread, NowledgeMemExportSummary } from '@shared/types/nowledgeMem' +import { promises as fs } from 'fs' import { presenter } from '@/presenter' import { eventBus } from '@/eventbus' import { TAB_EVENTS, CONVERSATION_EVENTS } from '@/events' @@ -24,6 +25,7 @@ import { buildUserMessageContext } from '../agentPresenter/message/messageFormat import { CommandPermissionService } from '../permission/commandPermissionService' import { ConversationManager, type CreateConversationOptions } from './managers/conversationManager' import type { ConversationExportFormat } from '../exporter/formats/conversationExporter' +import { resolveSessionDir } from './sessionPaths' const DEFAULT_MESSAGE_LENGTH = 300 @@ -432,6 +434,7 @@ export class SessionPresenter implements ISessionPresenter { async deleteConversation(conversationId: string): Promise { this.commandPermissionService.clearConversation(conversationId) presenter.filePermissionService?.clearConversation(conversationId) + await this.deleteSessionOffloadFiles(conversationId) await this.conversationManager.deleteConversation(conversationId) } @@ -490,6 +493,20 @@ export class SessionPresenter implements ISessionPresenter { return this.messageManager.getContextMessages(conversationId, messageCount) } + private async deleteSessionOffloadFiles(conversationId: string): Promise { + const sessionDir = resolveSessionDir(conversationId) + if (!sessionDir) return + + try { + await fs.rm(sessionDir, { recursive: true, force: true }) + } catch (error) { + console.warn('[SessionPresenter] Failed to delete session offload files', { + conversationId, + error + }) + } + } + async clearContext(conversationId: string): Promise { await this.sqlitePresenter.runTransaction(async () => { const conversation = await this.getConversation(conversationId) diff --git a/src/main/presenter/sessionPresenter/sessionPaths.ts b/src/main/presenter/sessionPresenter/sessionPaths.ts new file mode 100644 index 000000000..940255b99 --- /dev/null +++ b/src/main/presenter/sessionPresenter/sessionPaths.ts @@ -0,0 +1,32 @@ +import { app } from 'electron' +import path from 'path' + +export function getSessionsRoot(): string { + return path.resolve(app.getPath('home'), '.deepchat', 'sessions') +} + +export function resolveSessionDir(conversationId: string): string | null { + if (!conversationId.trim()) return null + const sessionsRoot = getSessionsRoot() + const resolvedSessionDir = path.resolve(sessionsRoot, conversationId) + const rootWithSeparator = sessionsRoot.endsWith(path.sep) + ? sessionsRoot + : `${sessionsRoot}${path.sep}` + if (resolvedSessionDir !== sessionsRoot && !resolvedSessionDir.startsWith(rootWithSeparator)) { + return null + } + return resolvedSessionDir +} + +export function resolveToolOffloadPath(conversationId: string, toolCallId: string): string | null { + const sessionDir = resolveSessionDir(conversationId) + if (!sessionDir) return null + const safeToolCallId = toolCallId.replace(/[\\/]/g, '_') + return path.join(sessionDir, `tool_${safeToolCallId}.offload`) +} + +export function resolveToolOffloadTemplatePath(conversationId: string): string | null { + const sessionDir = resolveSessionDir(conversationId) + if (!sessionDir) return null + return path.join(sessionDir, 'tool_.offload') +} diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 1ded2c8b0..b5d65c8e4 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -6,6 +6,7 @@ import type { MCPToolCall, MCPToolResponse } from '@shared/presenter' +import { resolveToolOffloadTemplatePath } from '../sessionPresenter/sessionPaths' import { ToolMapper } from './toolMapper' import { AgentToolManager, type AgentToolCallResult } from '../agentPresenter/acp' import { jsonrepair } from 'jsonrepair' @@ -19,6 +20,7 @@ export interface IToolPresenter { agentWorkspacePath?: string | null }): Promise callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> + buildToolSystemPrompt(context: { conversationId?: string }): string } interface ToolPresenterOptions { @@ -158,4 +160,17 @@ export class ToolPresenter implements IToolPresenter { } return response } + + buildToolSystemPrompt(context: { conversationId?: string }): string { + const conversationId = context.conversationId || '' + const offloadPath = + resolveToolOffloadTemplatePath(conversationId) ?? + '~/.deepchat/sessions//tool_.offload' + + return [ + 'Tool outputs may be offloaded when large.', + `When you see an offload stub, read the full output from: ${offloadPath}`, + 'Use file tools to read that path. Access is limited to the current conversation session.' + ].join('\n') + } } diff --git a/src/renderer/src/components/message/MessageBlockThink.vue b/src/renderer/src/components/message/MessageBlockThink.vue index e398f2ffb..23510e363 100644 --- a/src/renderer/src/components/message/MessageBlockThink.vue +++ b/src/renderer/src/components/message/MessageBlockThink.vue @@ -135,9 +135,9 @@ watch( watch( () => reasoningDuration.value, () => { - if (props.block.status !== 'loading') { - updateDisplayedSeconds() - } + // Always update displayed seconds when reasoning duration changes + // This ensures real-time updates during streaming + updateDisplayedSeconds() } ) diff --git a/src/renderer/src/components/message/MessageBlockToolCall.vue b/src/renderer/src/components/message/MessageBlockToolCall.vue index f3d670082..957b4f85c 100644 --- a/src/renderer/src/components/message/MessageBlockToolCall.vue +++ b/src/renderer/src/components/message/MessageBlockToolCall.vue @@ -48,18 +48,8 @@ {{ paramsCopyText }} -
- +
+ {{ paramsText }}
diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 9fc6342f1..abc4f30a1 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -880,6 +880,7 @@ export const useChatStore = defineStore('chat', () => { seq?: number content?: string reasoning_content?: string + reasoning_time?: { start: number; end: number } tool_call_id?: string tool_call_name?: string tool_call_params?: string @@ -889,7 +890,43 @@ export const useChatStore = defineStore('chat', () => { tool_call_server_icons?: string tool_call_server_description?: string tool_call_response_raw?: unknown - tool_call?: 'start' | 'end' | 'error' | 'update' | 'running' + tool_call?: + | 'start' + | 'end' + | 'error' + | 'update' + | 'running' + | 'permission-required' + | 'permission-granted' + | 'permission-denied' + | 'continue' + permission_request?: { + toolName: string + serverName: string + permissionType: 'read' | 'write' | 'all' | 'command' + description: string + command?: string + commandSignature?: string + commandInfo?: { + command: string + riskLevel: 'low' | 'medium' | 'high' | 'critical' + suggestion: string + signature?: string + baseCommand?: string + } + providerId?: string + requestId?: string + sessionId?: string + agentId?: string + agentName?: string + conversationId?: string + options?: Array<{ + id: string + label: string + description?: string + }> + rememberable?: boolean + } totalUsage?: { prompt_tokens: number completion_tokens: number @@ -995,7 +1032,98 @@ export const useChatStore = defineStore('chat', () => { } }) } else if (msg.tool_call) { - if (msg.tool_call === 'start') { + if (msg.tool_call === 'permission-required') { + finalizeAssistantMessageBlocks(assistantMsg.content) + const permissionRequest = msg.permission_request + const permissionExtra: Record = { + needsUserAction: true, + permissionType: permissionRequest?.permissionType ?? 'read' + } + if (permissionRequest) { + permissionExtra.permissionRequest = JSON.stringify(permissionRequest) + if (permissionRequest.commandInfo) { + permissionExtra.commandInfo = JSON.stringify(permissionRequest.commandInfo) + } + if (permissionRequest.toolName) { + permissionExtra.toolName = permissionRequest.toolName + } + if (permissionRequest.serverName) { + permissionExtra.serverName = permissionRequest.serverName + } + if (permissionRequest.providerId) { + permissionExtra.providerId = permissionRequest.providerId + } + if (permissionRequest.requestId) { + permissionExtra.permissionRequestId = permissionRequest.requestId + } + if (permissionRequest.rememberable === false) { + permissionExtra.rememberable = false + } + if (permissionRequest.agentId) { + permissionExtra.agentId = permissionRequest.agentId + } + if (permissionRequest.agentName) { + permissionExtra.agentName = permissionRequest.agentName + } + if (permissionRequest.sessionId) { + permissionExtra.sessionId = permissionRequest.sessionId + } + } + assistantMsg.content.push({ + type: 'action', + content: msg.tool_call_response || '', + status: 'pending', + timestamp: Date.now(), + action_type: 'tool_call_permission', + tool_call: { + id: msg.tool_call_id, + name: msg.tool_call_name, + params: msg.tool_call_params || '', + server_name: msg.tool_call_server_name, + server_icons: msg.tool_call_server_icons, + server_description: msg.tool_call_server_description + }, + extra: permissionExtra + }) + } else if ( + msg.tool_call === 'permission-granted' || + msg.tool_call === 'permission-denied' || + msg.tool_call === 'continue' + ) { + const permissionBlock = [...assistantMsg.content] + .reverse() + .find( + (block) => + block.type === 'action' && + block.action_type === 'tool_call_permission' && + (msg.tool_call_id + ? block.tool_call?.id === msg.tool_call_id + : block.tool_call?.name === msg.tool_call_name) + ) + if (permissionBlock?.type === 'action') { + if (msg.tool_call === 'permission-granted') { + permissionBlock.status = 'granted' + permissionBlock.content = msg.tool_call_response || '' + if (permissionBlock.extra) { + permissionBlock.extra.needsUserAction = false + if ( + !permissionBlock.extra.grantedPermissions && + typeof permissionBlock.extra.permissionType === 'string' + ) { + permissionBlock.extra.grantedPermissions = permissionBlock.extra.permissionType + } + } + } else if (msg.tool_call === 'permission-denied') { + permissionBlock.status = 'denied' + permissionBlock.content = msg.tool_call_response || '' + if (permissionBlock.extra) { + permissionBlock.extra.needsUserAction = false + } + } else { + permissionBlock.status = 'success' + } + } + } else if (msg.tool_call === 'start') { finalizeAssistantMessageBlocks(assistantMsg.content) playToolcallSound() assistantMsg.content.push({ @@ -1128,6 +1256,8 @@ export const useChatStore = defineStore('chat', () => { if (lastContentBlock?.type === 'content') { lastContentBlock.content += msg.content } else { + // Finalize previous blocks (e.g., reasoning_content) before adding new content block + finalizeAssistantMessageBlocks(assistantMsg.content) assistantMsg.content.push({ type: 'content', content: msg.content, @@ -1142,12 +1272,18 @@ export const useChatStore = defineStore('chat', () => { const lastReasoningBlock = assistantMsg.content[assistantMsg.content.length - 1] if (lastReasoningBlock?.type === 'reasoning_content') { lastReasoningBlock.content += msg.reasoning_content + // Update reasoning_time from stream data + if (msg.reasoning_time) { + lastReasoningBlock.reasoning_time = msg.reasoning_time + } } else { + const now = Date.now() assistantMsg.content.push({ type: 'reasoning_content', content: msg.reasoning_content, status: 'loading', - timestamp: Date.now() + timestamp: now, + reasoning_time: msg.reasoning_time ?? { start: now, end: now } }) } } @@ -1561,56 +1697,43 @@ export const useChatStore = defineStore('chat', () => { const handleMessageEdited = async (msgId: string) => { // 首先检查是否在生成缓存中 const cached = getGeneratingMessagesCache().get(msgId) - if (cached) { - // 如果在缓存中,获取最新的消息 - const updatedMessage = await threadP.getMessage(msgId) - // 处理 extra 信息 - const enrichedMessage = await enrichMessageWithExtra(updatedMessage) + const activeThreadId = getActiveThreadId() + if (!cached && !activeThreadId) return - // 更新缓存 + // 获取最新的消息 + const updatedMessage = await threadP.getMessage(msgId) + // 处理 extra 信息 + const enrichedMessage = await enrichMessageWithExtra(updatedMessage) + + // 更新缓存 + if (cached) { cached.message = enrichedMessage as AssistantMessage | UserMessage + } - // 如果是当前会话的消息,也更新显示 - if (cached.threadId === getActiveThreadId()) { - if (enrichedMessage.is_variant && enrichedMessage.parentId) { - const mainMessage = await threadP.getMainMessageByParentId( - cached.threadId, - enrichedMessage.parentId - ) - if (mainMessage) { - const enrichedMainMessage = await enrichMessageWithExtra(mainMessage) - cacheMessageForView(enrichedMainMessage as AssistantMessage | UserMessage) - ensureMessageId(enrichedMainMessage.id) - return - } - } + if (!activeThreadId) return - cacheMessageForView(enrichedMessage as AssistantMessage | UserMessage) - if (!enrichedMessage.is_variant) { - ensureMessageId(enrichedMessage.id) - } - } - } else if (getActiveThreadId()) { - const updatedMessage = await threadP.getMessage(msgId) - const enrichedMessage = await enrichMessageWithExtra(updatedMessage) + // 非当前会话的消息直接忽略,避免跨会话污染 + if (enrichedMessage.conversationId !== activeThreadId) { + return + } - if (enrichedMessage.is_variant && enrichedMessage.parentId) { - const mainMessage = await threadP.getMainMessageByParentId( - getActiveThreadId()!, - enrichedMessage.parentId - ) - if (mainMessage) { - const enrichedMainMessage = await enrichMessageWithExtra(mainMessage) - cacheMessageForView(enrichedMainMessage as AssistantMessage | UserMessage) - ensureMessageId(enrichedMainMessage.id) - return - } + // 如果是当前会话的消息,也更新显示 + if (enrichedMessage.is_variant && enrichedMessage.parentId) { + const mainMessage = await threadP.getMainMessageByParentId( + activeThreadId, + enrichedMessage.parentId + ) + if (mainMessage) { + const enrichedMainMessage = await enrichMessageWithExtra(mainMessage) + cacheMessageForView(enrichedMainMessage as AssistantMessage | UserMessage) + ensureMessageId(enrichedMainMessage.id) + return } + } - cacheMessageForView(enrichedMessage as AssistantMessage | UserMessage) - if (!enrichedMessage.is_variant) { - ensureMessageId(enrichedMessage.id) - } + cacheMessageForView(enrichedMessage as AssistantMessage | UserMessage) + if (!enrichedMessage.is_variant) { + ensureMessageId(enrichedMessage.id) } } diff --git a/src/shared/types/core/agent-events.ts b/src/shared/types/core/agent-events.ts index 7f3da5e33..8f5499070 100644 --- a/src/shared/types/core/agent-events.ts +++ b/src/shared/types/core/agent-events.ts @@ -10,6 +10,7 @@ export interface LLMAgentEventData { seq?: number content?: string reasoning_content?: string + reasoning_time?: { start: number; end: number } tool_call_id?: string tool_call_name?: string tool_call_params?: string @@ -19,7 +20,16 @@ export interface LLMAgentEventData { tool_call_server_icons?: string tool_call_server_description?: string tool_call_response_raw?: unknown - tool_call?: 'start' | 'running' | 'end' | 'error' | 'update' | 'permission-required' + tool_call?: + | 'start' + | 'running' + | 'end' + | 'error' + | 'update' + | 'permission-required' + | 'permission-granted' + | 'permission-denied' + | 'continue' permission_request?: { toolName: string serverName: string diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts index a51024d32..0ab9dbcc3 100644 --- a/src/shared/types/presenters/tool.presenter.d.ts +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -26,4 +26,9 @@ export interface IToolPresenter { * @param request Tool call request */ callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> + + /** + * Build system prompt section for tool-related behavior. + */ + buildToolSystemPrompt(context: { conversationId?: string }): string } diff --git a/test/main/presenter/agentPresenter/acp/agentFileSystemHandler.test.ts b/test/main/presenter/agentPresenter/acp/agentFileSystemHandler.test.ts index 0234574e1..579c34a67 100644 --- a/test/main/presenter/agentPresenter/acp/agentFileSystemHandler.test.ts +++ b/test/main/presenter/agentPresenter/acp/agentFileSystemHandler.test.ts @@ -90,4 +90,48 @@ describe('AgentFileSystemHandler diff responses', () => { expect(responseText.length).toBeGreaterThan(0) expect(() => JSON.parse(responseText)).toThrow() }) + + it('limits directoryTree depth based on depth option', async () => { + await fs.mkdir(path.join(testDir, 'level1', 'level2', 'level3'), { recursive: true }) + await fs.writeFile(path.join(testDir, 'root.txt'), 'root', 'utf-8') + await fs.writeFile(path.join(testDir, 'level1', 'file1.txt'), 'file1', 'utf-8') + await fs.writeFile(path.join(testDir, 'level1', 'level2', 'file2.txt'), 'file2', 'utf-8') + await fs.writeFile( + path.join(testDir, 'level1', 'level2', 'level3', 'file3.txt'), + 'file3', + 'utf-8' + ) + + const depthZero = JSON.parse( + await handler.directoryTree({ path: testDir, depth: 0 }) + ) as Array<{ name: string; type: string; children?: unknown }> + const level1AtZero = depthZero.find((entry) => entry.name === 'level1') + expect(level1AtZero).toBeDefined() + expect(level1AtZero?.children).toBeUndefined() + + const depthOne = JSON.parse(await handler.directoryTree({ path: testDir, depth: 1 })) as Array<{ + name: string + type: string + children?: any[] + }> + const level1AtOne = depthOne.find((entry) => entry.name === 'level1') + expect(level1AtOne?.children?.some((child) => child.name === 'file1.txt')).toBe(true) + const level2AtOne = level1AtOne?.children?.find((child) => child.name === 'level2') + expect(level2AtOne?.children).toBeUndefined() + + const depthTwo = JSON.parse(await handler.directoryTree({ path: testDir, depth: 2 })) as Array<{ + name: string + type: string + children?: any[] + }> + const level1AtTwo = depthTwo.find((entry) => entry.name === 'level1') + const level2AtTwo = level1AtTwo?.children?.find((child) => child.name === 'level2') + expect(level2AtTwo?.children?.some((child) => child.name === 'file2.txt')).toBe(true) + const level3AtTwo = level2AtTwo?.children?.find((child) => child.name === 'level3') + expect(level3AtTwo?.children).toBeUndefined() + }) + + it('rejects directoryTree depth above max', async () => { + await expect(handler.directoryTree({ path: testDir, depth: 4 })).rejects.toThrow() + }) }) diff --git a/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts b/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts new file mode 100644 index 000000000..a2a4168a0 --- /dev/null +++ b/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' +import { app } from 'electron' +import { ToolCallProcessor } from '@/presenter/agentPresenter/loop' +import type { + ChatMessage, + MCPToolDefinition, + MCPToolResponse, + ModelConfig +} from '@shared/presenter' + +describe('ToolCallProcessor tool output offload', () => { + let tempHome: string + let getPathSpy: ReturnType + + const toolDefinition = { + type: 'function', + function: { + name: 'mock_tool', + description: 'mock tool', + parameters: { + type: 'object', + properties: {} + } + }, + server: { + name: 'mock', + icons: '', + description: '' + } + } as MCPToolDefinition + + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'tool-offload-')) + getPathSpy = vi.spyOn(app, 'getPath').mockReturnValue(tempHome) + }) + + afterEach(async () => { + getPathSpy.mockRestore() + await fs.rm(tempHome, { recursive: true, force: true }) + }) + + it('offloads large tool responses and returns stub content', async () => { + const longOutput = 'x'.repeat(3001) + const rawData = { content: longOutput } as MCPToolResponse + const processor = new ToolCallProcessor({ + getAllToolDefinitions: async () => [toolDefinition], + callTool: async () => ({ content: longOutput, rawData }) + }) + + const conversationMessages: ChatMessage[] = [{ role: 'assistant', content: 'hello' }] + const conversationId = 'conv-123' + const modelConfig = { functionCall: true } as ModelConfig + + const events: any[] = [] + for await (const event of processor.process({ + eventId: 'event-1', + toolCalls: [{ id: 'tool-1', name: 'mock_tool', arguments: '{}' }], + enabledMcpTools: [], + conversationMessages, + modelConfig, + abortSignal: new AbortController().signal, + currentToolCallCount: 0, + maxToolCalls: 5, + conversationId + })) { + events.push(event) + } + + const endEvent = events.find( + (event) => event.type === 'response' && event.data?.tool_call === 'end' + ) + expect(endEvent).toBeDefined() + + const stub = endEvent.data.tool_call_response as string + const expectedPath = path.join( + tempHome, + '.deepchat', + 'sessions', + conversationId, + 'tool_tool-1.offload' + ) + expect(stub).toContain('[Tool output offloaded]') + expect(stub).toContain(`Total characters: ${longOutput.length}`) + expect(stub).toContain(expectedPath) + expect(endEvent.data.tool_call_response_raw).toBe(rawData) + + const saved = await fs.readFile(expectedPath, 'utf-8') + expect(saved).toBe(longOutput) + + const toolMessage = conversationMessages.find((message) => message.role === 'tool') + expect(toolMessage?.content).toContain('[Tool output offloaded]') + }) +}) diff --git a/test/main/presenter/sessionPresenter/permissionHandler.test.ts b/test/main/presenter/sessionPresenter/permissionHandler.test.ts index 3811a6310..8b1e8603a 100644 --- a/test/main/presenter/sessionPresenter/permissionHandler.test.ts +++ b/test/main/presenter/sessionPresenter/permissionHandler.test.ts @@ -109,7 +109,8 @@ describe('PermissionHandler - ACP permissions', () => { getToolPresenter: () => ({ getAllToolDefinitions: vi.fn(), - callTool: vi.fn() + callTool: vi.fn(), + buildToolSystemPrompt: vi.fn() }) as unknown as IToolPresenter, streamGenerationHandler: {} as StreamGenerationHandler, llmEventHandler: {} as LLMEventHandler, @@ -224,7 +225,8 @@ describe('PermissionHandler - permission block removal', () => { getToolPresenter: () => ({ getAllToolDefinitions: vi.fn(), - callTool: vi.fn() + callTool: vi.fn(), + buildToolSystemPrompt: vi.fn() }) as unknown as IToolPresenter, streamGenerationHandler: {} as StreamGenerationHandler, llmEventHandler: {} as LLMEventHandler,