diff --git a/docs/FLOWS.md b/docs/FLOWS.md index b3d1e5f69..b9a458f9c 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -317,64 +317,203 @@ async getAllToolDefinitions({chatMode, supportsVision, agentWorkspacePath}) { - AgentToolManager: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` - AgentFileSystemHandler: `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` -## 4. 权限请求与响应流程 +## 4. 权限请求与响应流程(Batch-level Permission + Resume Lock) + +### 完整流程 ```mermaid sequenceDiagram autonumber participant AgentLoop as agentLoopHandler + participant ToolProc as toolCallProcessor participant EventBus as EventBus participant UI as PermissionDialog.vue - participant AgentP as AgentPresenter + participant PermHandler as permissionHandler participant SessionMgr as SessionManager participant ToolP as ToolPresenter + participant McpP as McpPresenter - AgentLoop->>EventBus: send {tool_call: 'permission-required', permissionType, description} - Note over EventBus: permissionType: 'read' | 'write' | 'all' | 'command' + Note over AgentLoop: Agent Loop 遇到权限请求 + AgentLoop->>ToolProc: process(toolCalls) - EventBus->>UI: 渲染权限请求对话框 - UI->>User: 显示权限请求 + Note over ToolProc: Step 1: 批量预检查权限 + ToolProc->>ToolProc: batchPreCheckPermissions() - User->>UI: 点击"允许"或"拒绝" - UI->>AgentP: handlePermissionResponse(messageId, toolCallId, granted, permissionType) + loop 遍历每个 toolCall + ToolProc->>ToolP: callTool(request) + ToolP->>McpP: callTool(request) + McpP->>McpP: checkToolPermission() + + alt 需要权限请求 + McpP-->>ToolP: requiresPermission: true + ToolP-->>ToolProc: permission required + ToolProc->>EventBus: send {tool_call: 'permission-required', ...} + + Note over SessionMgr: 添加到 pendingPermissions 队列 + ToolProc->>SessionMgr: addPendingPermission({messageId, toolCallId, ...}) + else 权限已授予 + McpP->>McpP: 执行工具 + McpP-->>ToolP: toolResult + ToolP-->>ToolProc: toolResult + end + end - Note over AgentP,SessionMgr: 更新权限状态 - AgentP->>SessionMgr: clearPendingPermission() + alt 有待处理权限 + ToolProc->>AgentLoop: 暂停,等待用户响应 + EventBus->>UI: 显示权限请求对话框 + UI->>User: 显示权限请求 - alt granted == true - AgentP->>AgentP: 记录用户选择(remember?) + User->>UI: 点击"允许"或"拒绝" + UI->>PermHandler: handlePermissionResponse(messageId, toolCallId, granted, permissionType) + Note over PermHandler: Step 2: 批量更新权限块 + PermHandler->>PermHandler: updatePermissionBlocks() + Note over PermHandler: canBatchUpdate: 相同 tool_call.id 的权限批量更新 + + Note over SessionMgr: Step 3: 从队列移除 + PermHandler->>SessionMgr: removePendingPermission(conversationId, messageId, toolCallId) + + Note over PermHandler: Step 4: 获取 Resume Lock + PermHandler->>SessionMgr: acquirePermissionResumeLock(conversationId, messageId) + + Note over PermHandler: Step 5: 批准权限 alt permissionType == 'command' - Note over AgentP: 命令权限 - AgentP->>AgentP: CommandPermissionService.approve(signautre) + PermHandler->>PermHandler: CommandPermissionService.approve() + else agent-filesystem + PermHandler->>PermHandler: FilePermissionService.approve() + else deepchat-settings + PermHandler->>PermHandler: SettingsPermissionService.approve() else MCP 权限 - Note over AgentP: MCP 权限 - AgentP->>ToolP: grantPermission(serverName, permissionType, remember) + PermHandler->>McpP: grantPermission(serverName, permissionType, remember) else ACP 权限 - Note over AgentP: ACP 权限 - AgentP->>AgentP: resolveAgentPermission(requestId, true) + PermHandler->>PermHandler: handleAcpPermissionFlow() end - Note over AgentP,AgentP: 恢复 Agent Loop - AgentP->>SessionMgr: startLoop(conversationId, messageId) - AgentP->>AgentP: continueStreamCompletion(messageId) - Note over AgentP: 从断点继续执行工具调用 - else granted == false - Note over AgentP,AgentP: 拒绝权限 - AgentP->>AgentP: 返回错误消息"工具执行失败:用户拒绝权限" - AgentP->>AgentP: 继续生成(不在工具结果基础上继续) + Note over PermHandler: Step 6: 恢复工具执行(CRITICAL SECTION) + PermHandler->>PermHandler: resumeToolExecutionAfterPermissions() + + Note over PermHandler: 6a: 验证 Resume Lock + PermHandler->>SessionMgr: getPermissionResumeLock(conversationId) + SessionMgr-->>PermHandler: currentLock + + alt Lock 无效或过期 + PermHandler->>SessionMgr: releasePermissionResumeLock(conversationId) + PermHandler->>PermHandler: 跳过执行 + else Lock 有效 + Note over PermHandler: 6b: 重新加载消息状态 + PermHandler->>PermHandler: 从 DB 刷新 generating state + + Note over PermHandler: 6c: SYNCHRONOUS FLUSH + PermHandler->>PermHandler: flushStreamUpdates(messageId) + + Note over PermHandler: 6d: 执行工具(Lock 保持) + loop 遍历已授权工具 + PermHandler->>ToolP: callTool() + ToolP->>McpP: callTool() + McpP-->>ToolP: toolResult + ToolP-->>PermHandler: toolResult + end + + Note over PermHandler: 6e: 再次 FLUSH + PermHandler->>PermHandler: flushStreamUpdates(messageId) + + Note over PermHandler: 6f: 检查是否还有更多权限 + PermHandler->>PermHandler: hasPendingPermissionsInMessage() + + alt 还有更多权限 + PermHandler->>SessionMgr: releasePermissionResumeLock(conversationId) + PermHandler->>UI: 通知前端更新 + else 所有权限已处理 + PermHandler->>PermHandler: continueAfterToolsExecuted() + PermHandler->>SessionMgr: releasePermissionResumeLock(conversationId) + PermHandler->>AgentLoop: 继续 Agent Loop + end + end end ``` -**权限类型说明**: -- `read` - 读取操作(list_directory, read_file, get_file_info) -- `write` - 写入操作(write_file, create_directory, delete_file) -- `all` - 授予读写权限(常用于文件系统操作) -- `command` - 执行命令(需要额外审批) +### 关键机制说明 + +#### 1. Batch-level Permission Update + +```typescript +// 同一个 tool_call 的多个权限块可以批量更新 +function canBatchUpdate(target, granted, grantedType): boolean { + // 必须相同状态: pending + // 必须相同类型: tool_call_permission + // 必须相同 server + // CRITICAL: 必须相同 tool_call.id(防止误批准其他工具) + // 权限层级必须满足: grantedType >= targetType +} +``` + +#### 2. Resume Lock(MessageId-level) + +```typescript +// 获取锁 +acquirePermissionResumeLock(conversationId: string, messageId: string): boolean + +// 验证锁(防止过期/错误的恢复) +getPermissionResumeLock(conversationId: string): {messageId, timestamp} | null + +// 释放锁(单一出口点) +releasePermissionResumeLock(conversationId: string): void + +// CRITICAL SECTION 保证: +// - Early-exit checks prevent stale execution +// - Synchronous flush before executing tools +// - Lock released only at single exit point +// - All tools executed atomically (no lock release between tools) +``` + +#### 3. Pending Permissions Queue + +```typescript +// 支持多个并发权限请求 +interface PendingPermission { + messageId: string + toolCallId: string + permissionType: string + serverName: string + timestamp: number +} + +// SessionManager 管理队列 +pendingPermissions: PendingPermission[] + +// 队列操作 +addPendingPermission(conversationId, permission) +removePendingPermission(conversationId, messageId, toolCallId) +getNextPendingPermission(conversationId): PendingPermission | undefined +``` + +#### 4. Synchronous Flush + +```typescript +// 工具执行前同步刷新 UI 状态 +await llmEventHandler.flushStreamUpdates(messageId) + +// 保证: +// - 所有 tool_call 块已持久化到 DB +// - 前端 UI 状态已同步 +// - 断点恢复时状态一致 +``` + +### 权限类型层级 + +| 类型 | 层级 | 适用场景 | +|------|------|---------| +| `all` | 3 | 授予全部权限 | +| `write` | 2 | 写入操作(write_file, delete_file) | +| `read` | 1 | 读取操作(read_file, list_directory) | +| `command` | 0 | 命令执行(精确匹配) | + +**权限升级规则**:`all` > `write` > `read`,授予高级权限自动满足低级权限需求。 **关键文件位置**: - PermissionHandler: `src/main/presenter/agentPresenter/permission/permissionHandler.ts` -- agentLoopHandler permission 事件处理: `src/main/presenter/agentPresenter/loop/agentLoopHandler.ts:432-470` +- ToolCallProcessor: `src/main/presenter/agentPresenter/loop/toolCallProcessor.ts` +- SessionManager: `src/main/presenter/agentPresenter/session/sessionManager.ts` ## 5. 会话生命周期 diff --git a/docs/architecture/agent-system.md b/docs/architecture/agent-system.md index b76c0026b..729445019 100644 --- a/docs/architecture/agent-system.md +++ b/docs/architecture/agent-system.md @@ -621,7 +621,14 @@ class LLMEventHandler { ## 🔐 permissionHandler - 权限协调 -### 权限响应处理 +### 核心特性 + +1. **Batch-level Permission**: 同一 tool call 的多个权限块可以批量更新 +2. **Resume Lock**: messageId 级别的锁,确保权限恢复的原子性 +3. **Synchronous Flush**: 工具执行前同步刷新,确保 UI 状态已持久化 +4. **Pending Permissions Queue**: 支持多个待处理权限的队列管理 + +### 权限响应处理流程 ```typescript async handlePermissionResponse( @@ -629,37 +636,172 @@ async handlePermissionResponse( toolCallId: string, granted: boolean, permissionType: 'read' | 'write' | 'all' | 'command', - remember?: boolean -) { - const message = await this.getMessage(messageId) - const content = message.content as AssistantMessageBlock[] - - // 1. 更新权限块状态 - const permissionBlock = content.find( - block => block.type === 'action' && block.tool_call?.id === toolCallId + remember: boolean = true +): Promise { + // Step 1: 批量更新权限块状态 + const { updatedCount, targetPermissionBlock } = await this.updatePermissionBlocks( + messageId, + toolCallId, + granted, + permissionType ) - permissionBlock.status = granted ? 'granted' : 'denied' - await this.ctx.messageManager.editMessage(messageId, JSON.stringify(content)) - - // 2. 清除待处理权限 - this.ctx.sessionManager.clearPendingPermission(message.conversationId) - - if (granted) { - // 3. 批准权限 - if (isACPPermission) { - await this.ctx.llmProviderPresenter.resolveAgentPermission(requestId, true) - } else if (permissionType === 'command') { - CommandPermissionService.approve(conversationId, signature, remember) - } else { - await this.ctx.mcpPresenter.grantPermission(serverName, permissionType, remember) + + // Step 2: 从待处理队列移除 + presenter.sessionManager.removePendingPermission(conversationId, messageId, toolCallId) + + // Step 3: 处理特殊权限类型(ACP、command、agent-filesystem、deepchat-settings) + if (isAcpPermission) { + await this.handleAcpPermissionFlow(messageId, targetPermissionBlock, granted) + return + } + + // Step 4: 批准对应类型的权限 + if (permissionType === 'command') { + this.commandPermissionHandler.approve(conversationId, signature, remember) + } else if (serverName === 'agent-filesystem') { + presenter.filePermissionService?.approve(conversationId, paths, remember) + } else if (serverName === 'deepchat-settings') { + presenter.settingsPermissionService?.approve(conversationId, toolName, remember) + } else { + await this.getMcpPresenter().grantPermission(serverName, permissionType, remember, conversationId) + } + + // Step 5: 检查是否还有更多待处理权限,恢复工具执行 + await this.checkAndResumeToolExecution(conversationId, messageId, granted, toolCallId) +} +``` + +### 批量更新权限块 + +```typescript +// Batch update: only update blocks that can be safely batched +for (const block of content) { + if (canBatchUpdate(block, targetPermissionBlock, permissionType)) { + block.status = granted ? 'granted' : 'denied' + if (block.extra) { + block.extra.needsUserAction = false + if (granted && ['read', 'write', 'all'].includes(permissionType)) { + block.extra.grantedPermissions = permissionType + } } + updatedCount++ + } +} - // 4. 恢复 Agent Loop - await this.ctx.sessionManager.startLoop(conversationId, messageId) - await this.streamGenerationHandler.continueStreamCompletion(conversationId, messageId) +// canBatchUpdate 条件: +// 1. 必须是 pending 状态 +// 2. 必须是 tool_call_permission 类型 +// 3. 必须是相同的 server +// 4. CRITICAL: 必须是相同的 tool_call.id(防止误批准其他工具) +// 5. 权限层级必须满足(all > write > read) +``` + +### Resume Lock 机制 + +```typescript +/** + * Resume tool execution after permission is granted + * CRITICAL SECTION: Lock is held throughout the entire resume flow + * - Early-exit checks prevent stale execution + * - Synchronous flush before executing tools + * - Lock released only at single exit point + * - All tools executed atomically (no lock release between tools) + */ +private async resumeToolExecutionAfterPermissions( + messageId: string, + grantedToolCallId?: string +): Promise { + // CRITICAL SECTION: Lock must be held throughout this entire method + const session = presenter.sessionManager.getSessionSync(conversationId) + + // Verify the lock is still valid (same message) + const currentLock = presenter.sessionManager.getPermissionResumeLock(conversationId) + if (!currentLock || currentLock.messageId !== messageId) { + // Lock mismatch or expired, skip resume + presenter.sessionManager.releasePermissionResumeLock(conversationId) + return + } + + try { + // Step 1: Re-check session status + // Step 2: Refresh generating state from DB + // Step 3: Collect tool calls to execute + // Step 4: Validate tool calls with latest DB state + + // Step 5: SYNCHRONOUS FLUSH before executing tools + // This ensures all pending UI updates are persisted to DB before tool execution + await this.llmEventHandler.flushStreamUpdates(messageId) + + // Step 6: Execute tools sequentially (lock held throughout - NO RELEASE BETWEEN TOOLS) + for (const toolCall of toolCallsToExecute) { + const canContinue = await this.executeSingleToolCall(state, toolCall, conversationId) + if (!canContinue) { + hasNewPermissionRequest = true + break + } + } + + // Ensure tool_call end/error updates are persisted before rebuilding next-turn context + await this.llmEventHandler.flushStreamUpdates(messageId) + + // Step 7: Check if there are still pending permissions + const stillHasPending = await this.hasPendingPermissionsInMessage(messageId) + if (stillHasPending || hasNewPermissionRequest) { + // SINGLE EXIT POINT: Release lock + presenter.sessionManager.releasePermissionResumeLock(conversationId) + return + } + + // Step 8: All permissions resolved, continue with stream completion + await this.continueAfterToolsExecuted(state, conversationId, messageId) + // SINGLE EXIT POINT: Release lock after successful completion + presenter.sessionManager.releasePermissionResumeLock(conversationId) + } catch (error) { + // SINGLE EXIT POINT: Ensure lock is released on error + presenter.sessionManager.releasePermissionResumeLock(conversationId) + throw error + } +} +``` + +### Pending Permissions Queue + +```typescript +// SessionManager 中的队列管理 +interface PendingPermission { + messageId: string + toolCallId: string + permissionType: string + serverName: string + timestamp: number +} + +// 添加到队列 +addPendingPermission(conversationId: string, permission: PendingPermission): void { + const runtime = this.getRuntime(agentId) + if (!runtime.pendingPermissions) { + runtime.pendingPermissions = [] + } + + const existingIndex = runtime.pendingPermissions.findIndex( + p => p.toolCallId === permission.toolCallId && p.messageId === permission.messageId + ) + if (existingIndex >= 0) { + runtime.pendingPermissions[existingIndex] = permission } else { - // 5. 拒绝权限 - await this.continueAfterPermissionDenied(messageId) + runtime.pendingPermissions.push(permission) + } + runtime.pendingPermission = runtime.pendingPermissions[0] +} + +// 从队列移除 +removePendingPermission(conversationId: string, messageId: string, toolCallId: string): void { + const runtime = this.getRuntime(agentId) + if (runtime.pendingPermissions) { + runtime.pendingPermissions = runtime.pendingPermissions.filter( + p => !(p.toolCallId === toolCallId && p.messageId === messageId) + ) + runtime.pendingPermission = runtime.pendingPermissions[0] } } ``` diff --git a/docs/architecture/tool-system.md b/docs/architecture/tool-system.md index f73c17213..9886a72f1 100644 --- a/docs/architecture/tool-system.md +++ b/docs/architecture/tool-system.md @@ -648,6 +648,75 @@ YoBrowser 提供基于 Chrome DevTools Protocol (CDP) 的最小工具集,在 a | `all` | 全部权限 | 授予读写权限 | | `command` | 命令执行 | bash 命令(需要额外审批) | +### 权限状态机 + +```mermaid +stateDiagram-v2 + [*] --> IDLE: 初始化 + IDLE --> REQUESTING: 开始权限检查 + REQUESTING --> AWAITING_USER: 需要用户确认 + REQUESTING --> GRANTED: autoApprove 匹配 + AWAITING_USER --> GRANTED: 用户批准 + AWAITING_USER --> DENIED: 用户拒绝 + GRANTED --> COMPLETED: 工具执行完成 + DENIED --> COMPLETED: 返回错误响应 + COMPLETED --> [*] + + note right of REQUESTING + 检查 autoApprove 配置 + 检查已授予权限层级 + end note + + note right of AWAITING_USER + pendingPermissions 队列管理 + 支持多个并发权限请求 + end note +``` + +### 权限层级与批量更新 + +```typescript +// 权限层级:all > write > read > command +const PERMISSION_LEVELS: Record = { + all: 3, + write: 2, + read: 1, + command: 0 // command 只匹配 command(需要精确匹配) +} + +function isPermissionSufficient(granted: string, required: string): boolean { + if (granted === 'command' || required === 'command') { + return granted === required + } + return (PERMISSION_LEVELS[granted] || 0) >= (PERMISSION_LEVELS[required] || 0) +} + +// 批量更新条件 +function canBatchUpdate( + targetPermission: AssistantMessageBlock, + grantedPermission: AssistantMessageBlock, + grantedPermissionType: string +): boolean { + if (targetPermission.status !== 'pending') return false + if (targetPermission.action_type !== 'tool_call_permission') return false + + const targetServerName = targetPermission.extra?.serverName + const grantedServerName = grantedPermission.extra?.serverName + + // 必须是相同的 server + if (targetServerName !== grantedServerName) return false + + // CRITICAL: 必须是相同的 tool_call.id + if (targetPermission.tool_call?.id !== grantedPermission.tool_call?.id) return false + + // 检查权限层级 + const targetPermissionType = targetPermission.extra?.permissionType || 'read' + if (!isPermissionSufficient(grantedPermissionType, targetPermissionType)) return false + + return true +} +``` + ### MCP 服务器权限配置 ```typescript @@ -670,43 +739,94 @@ interface MCPServerConfig { ```mermaid sequenceDiagram + participant AgentLoop as agentLoopHandler + participant ToolProc as toolCallProcessor participant ToolP as ToolPresenter participant ToolMgr as ToolManager participant McpP as McpPresenter + participant PermHandler as permissionHandler participant User as 用户 - ToolP->>McpP: callTool(request) - McpP->>ToolMgr: checkToolPermission(serverName, toolName) + Note over AgentLoop: 工具调用前 + AgentLoop->>ToolProc: process(toolCalls) - ToolMgr->>ToolMgr: 检查 autoApprove 配置 + Note over ToolProc: Step 1: 批量预检查权限 + ToolProc->>ToolProc: batchPreCheckPermissions() - alt 权限在 autoApprove 中 - Note over ToolMgr: 权限在 autoApprove 中 - ToolMgr-->>McpP: granted: true - else 需要权限请求 - ToolMgr->>ToolMgr: 查找最高权限类型 - ToolMgr-->>McpP: granted: false, permissionType: 'read'|'write' + loop 遍历每个 toolCall + ToolProc->>ToolP: callTool(request) + ToolP->>McpP: callTool(request) + McpP->>ToolMgr: checkToolPermission(serverName, toolName) + ToolMgr->>ToolMgr: 检查 autoApprove 和已授予权限 + + alt 需要权限请求 + ToolMgr-->>McpP: granted: false, permissionType + McpP-->>ToolP: requiresPermission: true + ToolP-->>ToolProc: permission required + + Note over ToolProc: 添加到 pendingPermissions + ToolProc->>PermHandler: 发送 permission-required 事件 + PermHandler->>User: 显示权限请求 UI + else 权限已授予 + ToolMgr-->>McpP: granted: true + McpP->>McpP: 执行工具 + McpP-->>ToolP: toolResult + ToolP-->>ToolProc: toolResult + end end - alt granted == false - McpP-->>ToolP: requiresPermission: true - ToolP->>User: 显示权限请求 UI - User->>ToolP: 批准/拒绝 - - alt 批准 - ToolP->>ToolMgr: 记录用户选择(remember?) - ToolP->>McpP: grantPermission(serverName, permissionType, remember) - ToolMgr->>ToolMgr: 更新权限缓存 - ToolP->>ToolP: 重试 callTool - else 拒绝 - ToolP->>ToolP: 返回错误 - end - else granted == true - McpP->>McpP: 执行工具 - McpP-->>ToolP: toolResult + alt 有权限请求 + Note over ToolProc: 暂停执行,等待用户响应 + User->>PermHandler: 批准/拒绝权限 + PermHandler->>PermHandler: batch update 权限块 + PermHandler->>ToolProc: resumeToolExecution() + Note over ToolProc: SYNCHRONOUS FLUSH + ToolProc->>ToolProc: 执行已授权的工具 end ``` +### 工具输出保护机制 + +```typescript +// 1. 输出截断(防止上下文溢出) +const MAX_TOOL_OUTPUT_LENGTH = 4500 + +function truncateOutput(output: string): string { + if (output.length <= MAX_TOOL_OUTPUT_LENGTH) return output + return output.substring(0, MAX_TOOL_OUTPUT_LENGTH) + + `\n\n... [截断:输出超过 ${MAX_TOOL_OUTPUT_LENGTH} 字符]` +} + +// 2. 目录树深度限制(防止循环引用导致无限输出) +const DIRECTORY_TREE_MAX_DEPTH = 3 + +async function getDirectoryTree(dirPath: string, currentDepth = 0): Promise { + if (currentDepth >= DIRECTORY_TREE_MAX_DEPTH) { + return { name: path.basename(dirPath), type: 'directory', truncated: true } + } + // ... 递归获取子目录 +} + +// 3. 大输出卸载到文件 +const OFFLOAD_THRESHOLD = 10000 + +async function handleLargeOutput(output: string, toolName: string): Promise { + if (output.length > OFFLOAD_THRESHOLD) { + const tempFile = await writeToTempFile(output) + return { + content: `输出已保存到文件: ${tempFile}\n\n预览(前 500 字符):\n${output.substring(0, 500)}...`, + offloaded: true, + offloadedFile: tempFile + } + } + return { content: output } +} + +// 4. 流式输出刷新(确保 UI 状态同步) +// 在工具执行前同步刷新所有待处理的 UI 更新 +await llmEventHandler.flushStreamUpdates(messageId) +``` + ## 📊 工具调用事件流 ### 流中发送的工具事件 diff --git a/docs/specs/agent-provider-simplification/plan.md b/docs/specs/agent-provider-simplification/plan.md deleted file mode 100644 index 1820f65a5..000000000 --- a/docs/specs/agent-provider-simplification/plan.md +++ /dev/null @@ -1,68 +0,0 @@ -# Plan: Agent Provider Simplification (ACP-only) - -## Summary - -Replace the “agent provider” abstraction and detection logic with a single explicit rule: **ACP is the only agent provider and is identified by `providerId === 'acp'`.** - -## Current Call Flow (relevant parts) - -- Main: - - `ProviderInstanceManager.createProviderInstance()` already special-cases `provider.id === 'acp'`. - - `ProviderInstanceManager.isAgentProvider()` uses `instanceof BaseAgentProvider` and (if instance not created) a constructor prototype check (`isAgentConstructor`). - - `LLMProviderPresenter.isAgentProvider()` exposes this to the renderer via `ILlmProviderPresenter`. -- Renderer: - - `src/renderer/src/stores/modelStore.ts` calls `llmproviderPresenter.isAgentProvider(providerId)` over IPC to choose between: - - `agentModelStore.refreshAgentModels(providerId)` (ACP path) - - `refreshStandardModels + refreshCustomModels` (standard path) - - Other renderer logic already treats ACP as special via `provider.id === 'acp'`. - -## Proposed Changes - -### 1) Remove agent-provider classification API - -- Remove `isAgentProvider(providerId: string)` from: - - `src/shared/types/presenters/llmprovider.presenter.d.ts` - - `src/shared/types/presenters/legacy.presenters.d.ts` - - `src/main/presenter/llmProviderPresenter/index.ts` - - `src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts` - -Rationale: It is only used by the renderer for ACP gating, and ACP can be identified locally by ID. - -### 2) Replace renderer gating with an explicit ACP check - -- In `src/renderer/src/stores/modelStore.ts`: - - Remove the async IPC call `llmP.isAgentProvider(providerId)`. - - Replace with a local predicate: `providerId === 'acp'`. - - Keep the existing ACP refresh path using `agentModelStore.refreshAgentModels('acp')` (no behavioral change). - -### 3) Remove `BaseAgentProvider` (optional but preferred) - -Because `BaseAgentProvider` is only used by `AcpProvider`, delete the base class and: - -- Make `AcpProvider` extend `BaseLLMProvider` directly. -- Move `cleanup()` logic into `AcpProvider` (or delegate to `AcpSessionManager` / `AcpProcessManager`). -- Ensure `cleanup()` is safe to call multiple times and during shutdown. - -Notes: -- `acpCleanupHook` currently awaits `cleanup()` even though `BaseAgentProvider.cleanup()` is `void`. Consider standardizing ACP cleanup to `Promise` to match usage. - -## Compatibility / Migration - -- No user data migration. -- Provider ID `acp` remains unchanged and is treated as a stable internal contract. -- Any internal IPC typing generation must be updated to reflect removal of `isAgentProvider`. - -## Test Strategy - -Add minimal tests focusing on the only behavioral dependency (renderer model refresh selection): - -- Renderer unit test for `modelStore.refreshProviderModels()`: - - When `providerId === 'acp'`, it uses `agentModelStore.refreshAgentModels`. - - When `providerId !== 'acp'`, it uses standard refresh path. - -Main-process unit tests are optional; the change is mostly removal and ACP-id checks. - -## Rollout - -Single PR is acceptable if changes stay localized (types + modelStore + ACP provider base class cleanup). - diff --git a/docs/specs/agent-provider-simplification/tasks.md b/docs/specs/agent-provider-simplification/tasks.md deleted file mode 100644 index 9b6918842..000000000 --- a/docs/specs/agent-provider-simplification/tasks.md +++ /dev/null @@ -1,26 +0,0 @@ -# Tasks: Agent Provider Simplification (ACP-only) - -1. Update renderer to stop using IPC for agent-provider detection - - Remove `llmproviderPresenter.isAgentProvider` usage from `src/renderer/src/stores/modelStore.ts`. - - Gate ACP behavior by `providerId === 'acp'`. - -2. Remove `isAgentProvider` from the presenter contract - - Remove from `src/shared/types/presenters/llmprovider.presenter.d.ts`. - - Remove from `src/shared/types/presenters/legacy.presenters.d.ts`. - - Remove implementation from `src/main/presenter/llmProviderPresenter/index.ts`. - -3. Remove main-side agent-provider classification implementation - - Delete `ProviderInstanceManager.isAgentProvider()` and `isAgentConstructor()` in `src/main/presenter/llmProviderPresenter/managers/providerInstanceManager.ts`. - - Ensure no other code path depends on `BaseAgentProvider` type checks. - -4. Remove `BaseAgentProvider` abstraction (preferred) - - Delete `src/main/presenter/llmProviderPresenter/baseAgentProvider.ts`. - - Update `src/main/presenter/llmProviderPresenter/providers/acpProvider.ts` to extend `BaseLLMProvider` directly. - - Keep/adjust ACP cleanup semantics (safe shutdown, provider disable, app quit). - -5. Add/adjust tests - - Add a Vitest suite under `test/renderer/**` validating model refresh selection for ACP vs non-ACP. - -6. Quality gates - - Run `pnpm run format`, `pnpm run lint`, `pnpm run typecheck`, and `pnpm test`. - diff --git a/docs/specs/agent-tooling-v2/plan.md b/docs/specs/agent-tooling-v2/plan.md new file mode 100644 index 000000000..4bd1ea682 --- /dev/null +++ b/docs/specs/agent-tooling-v2/plan.md @@ -0,0 +1,395 @@ +# Agent Tooling V2 实施计划(Main Loop 优先) + +## 1. 当前实现基线 + +### 1.1 工具路由 + +主路由为: + +1. `ToolPresenter` 统一汇总和路由工具 + `src/main/presenter/toolPresenter/index.ts` +2. Agent 本地工具由 `AgentToolManager` 管理 + `src/main/presenter/agentPresenter/acp/agentToolManager.ts` +3. 文件能力由 `AgentFileSystemHandler` 执行 + `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` + +### 1.2 Loop 与事件 + +1. 生成与工具调度:`AgentLoopHandler` + `ToolCallProcessor` + `src/main/presenter/agentPresenter/loop/agentLoopHandler.ts` + `src/main/presenter/agentPresenter/loop/toolCallProcessor.ts` +2. 主事件类型:`LLMAgentEventData` + `src/shared/types/core/agent-events.ts` +3. 对 renderer 推送由 `StreamUpdateScheduler` 聚合 + `src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts` + +### 1.3 Skills + +1. active skills 与 allowedTools 来源:`SkillPresenter` + `src/main/presenter/skillPresenter/index.ts` +2. 当前 allowedTools 未做统一 canonical 归一化。 + +### 1.4 实施边界(补充) + +本计划仅涉及: + +1. `src/main/presenter/agentPresenter/acp/agentToolManager.ts` +2. `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` +3. `src/main/presenter/agentPresenter/loop/toolCallProcessor.ts` +4. `src/main/presenter/skillPresenter/index.ts`(仅 allowedTools 归一化接入) +5. `src/main/presenter/toolPresenter/index.ts`(tool prompt 与路由提示) +6. `src/main/presenter/agentPresenter/message/messageBuilder.ts`(system prompt 拼接) +7. `src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts`(skills allowedTools 接入) +8. `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`(新增 env prompt 生成) +9. 与上述模块直接相关的测试与文档 + +明确不改动: + +1. `src/main/presenter/browser/**` +2. `src/main/presenter/agentPresenter/acp/chatSettingsTools.ts` +3. `src/main/presenter/agentPresenter/tools/questionTool.ts` +4. `src/main/presenter/mcpPresenter/**` + +## 2. 设计决策 + +### 2.1 工具命名策略 + +决策:使用固定 canonical 工具名,不保留旧名兼容调用。 + +canonical: + +1. `read` +2. `write` +3. `edit` +4. `find` +5. `grep` +6. `ls` +7. `exec` +8. `process` + +实现点: + +1. `AgentToolManager.fileSystemSchemas` 直接改名。 +2. `isFileSystemTool()`、`callFileSystemTool()`、`collectWriteTargets()` 全量切换新名。 +3. `ToolCallProcessor.TOOLS_REQUIRING_OFFLOAD` 同步新名。 + +旧工具删除清单(本次落地必须删除): + +1. `read_file` +2. `write_file` +3. `list_directory` +4. `create_directory` +5. `move_files` +6. `edit_text` +7. `glob_search` +8. `directory_tree` +9. `get_file_info` +10. `grep_search` +11. `text_replace` +12. `edit_file` +13. `execute_command` + +### 2.1.1 Canonical 参数收敛 + +决策:参数名统一,删除旧参数别名;一处校验,多处复用。 + +参数标准: + +1. `read`: `path`, `offset?`, `limit?` +2. `write`: `path`, `content` +3. `edit`: `path`, `oldText`, `newText`, `replaceAll?` +4. `find`: `pattern`, `path?`, `maxResults?`, `exclude?` +5. `grep`: `pattern`, `path?`, `filePattern?`, `caseSensitive?`, `contextLines?`, `maxResults?` +6. `ls`: `path`, `depth?` +7. `exec`: `command`, `cwd?`, `timeoutMs?`, `background?`, `yieldMs?` +8. `process`: `action`, `sessionId?`, `offset?`, `limit?`, `data?`, `eof?` + +落地原则: + +1. schema 校验失败直接返回 `INVALID_ARGUMENT`,不执行工具。 +2. 不再接受 `old_string/new_string` 等 alias。 +3. skills 映射只做工具名映射,不改写工具参数。 +4. 不引入 `allowParallel` 等并行开关参数,避免工具语义分裂。 + +### 2.1.2 工具返回 envelope + +决策:工具返回统一为“可读摘要 + 结构化数据”。 + +约定: + +1. `content`: 给模型看的短摘要。 +2. `rawData.toolResult`: 结构化对象,至少包含 `ok`。 +3. 查询类工具(`read/find/grep/ls/process(log)`)补充 `meta`(截断、分页)。 +4. 写入类工具(`write/edit/exec/process(write|kill...)`)补充 `affectedPaths` 或 `sessionId` 等执行结果元信息。 + +### 2.2 Skills 工具映射层 + +决策:新增“skills allowedTools canonicalizer”,在 skills 到运行时工具过滤的边界做归一化。 + +建议新增模块: + +`src/main/presenter/skillPresenter/toolNameMapping.ts` + +提供: + +1. `normalizeSkillToolName(toolName: string): { canonical: string; mapped: boolean }` +2. `normalizeSkillAllowedTools(tools: string[]): { tools: string[]; warnings: string[] }` + +首批映射(Claude Code 优先): + +1. `Read -> read` +2. `Write -> write` +3. `Edit -> edit` +4. `MultiEdit -> edit` +5. `Glob -> find` +6. `Grep -> grep` +7. `LS -> ls` +8. `Bash -> exec` + +接入点: + +1. `SkillPresenter.getActiveSkillsAllowedTools()` 返回前归一化。 +2. 归一化 warning 通过日志输出,避免静默丢失。 + +### 2.3 rg 增强策略 + +决策:`find/grep` 均采用 “rg 优先,fallback 次级实现”。 + +1. `find` + - 优先:`rg --files` + `-g` include/exclude + - 回退:`glob` +2. `grep` + - 优先:现有 `runRipgrepSearch` 路径继续强化结构化输出 + - 回退:现有 JS grep 路径 + +约束: + +1. 保持 `maxResults` 生效,返回截断信息。 +2. 统一默认 ignore 集。 +3. 当 `rg` 调用异常时必须记录告警并稳定回退。 + +### 2.4 事件与消息格式定版(main 导出) + +决策:不在本阶段改事件通道,但冻结字段约束。 + +事件: + +1. `stream:response` +2. `stream:error` +3. `stream:end` + +`stream:response` 内 tool 事件规范: + +1. 公共字段:`eventId`, `stream_kind`, `seq` +2. tool 事件必备: + - `tool_call` + - `tool_call_id` + - `tool_call_name` +3. permission 事件附加: + - `permission_request.toolName` + - `permission_request.serverName` + - `permission_request.permissionType` + - `permission_request.description` + +字段样例: + +1. 文本增量: + +```json +{ + "eventId": "evt_123", + "stream_kind": "delta", + "seq": 7, + "content": "partial text" +} +``` + +2. 工具执行中: + +```json +{ + "eventId": "evt_123", + "tool_call": "running", + "tool_call_id": "call_1", + "tool_call_name": "grep", + "tool_call_params": "{\"pattern\":\"TODO\",\"path\":\"src\"}", + "tool_call_server_name": "agent-filesystem" +} +``` + +3. 工具执行结束: + +```json +{ + "eventId": "evt_123", + "tool_call": "end", + "tool_call_id": "call_1", + "tool_call_name": "grep", + "tool_call_response": "Found 3 matches in 2 files", + "tool_call_response_raw": { + "toolResult": { + "ok": true, + "summary": "Found 3 matches in 2 files", + "data": { + "matches": [] + }, + "meta": { + "truncated": false + } + } + } +} +``` + +4. 权限请求: + +```json +{ + "eventId": "evt_123", + "tool_call": "permission-required", + "tool_call_id": "call_2", + "tool_call_name": "write", + "permission_request": { + "toolName": "write", + "serverName": "agent-filesystem", + "permissionType": "write", + "description": "Write access requires approval." + } +} +``` + +5. 提问请求: + +```json +{ + "eventId": "evt_123", + "tool_call": "question-required", + "tool_call_id": "call_3", + "tool_call_name": "deepchat_question", + "question_request": { + "question": "Select environment", + "choices": ["dev", "prod"] + } +} +``` + +说明:renderer 暂不改,仅作为后续改造输入契约。 + +### 2.5 Prompt 管线与调用策略更新(main) + +决策:固定 system prompt 管线,避免动态状态碎片化与缓存抖动。 + +改动点: + +1. `src/main/presenter/toolPresenter/index.ts` + - `buildToolSystemPrompt()` 增加 canonical 工具清单与“意图到工具”选择规则。 +2. `src/main/presenter/agentPresenter/message/messageBuilder.ts` + - 保证 system prompt 固定顺序拼接。 +3. `src/main/presenter/agentPresenter/message/skillsPromptBuilder.ts` + - `getSkillsAllowedTools()` 使用 canonicalized allowed tools。 +4. `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`(新增) + - 统一生成 env prompt(模型、系统、仓库、AGENTS.md)。 + +拼接顺序(固定): + +1. conversation `systemPrompt` +2. Runtime 简要说明段(YoBrowser/后台进程能力说明,静态) +3. Skills Prompt(metadata + active skills) +4. Env Prompt(模型名/模型 ID/工作目录/git/platform/date/AGENTS.md 全文) +5. Tooling Prompt(canonical 规则) + +约束: + +1. 不在 system prompt 注入 YoBrowser 当前 tab 或后台进程实时列表。 +2. `enhanceSystemPromptWithDateTime` 的运行态信息迁移至统一 env prompt。 +3. Tooling Prompt 保留独立段,不并入 env prompt。 + +### 2.6 Loop 执行顺序收敛 + +统一流程: + +1. LLM 产出 tool calls +2. `batchPreCheckPermissions()` 批量权限预检 +3. 发出 `permission-required`(若有)并暂停 loop +4. 逐个 tool 执行,发 `running -> end/error` +5. 大输出按 offload 规则写入 session 文件并返回 stub +6. 继续下一轮推理或结束 + +## 3. 实施阶段 + +### Phase 1:工具面切换(无兼容) + +1. 重命名并收敛 tool schemas + definitions。 +2. 切换 handler 分派逻辑。 +3. 删除旧工具名引用(包括测试、文档、skills 默认示例)。 +4. 回归确认 browser/skills/settings/question 工具定义与行为无变更。 + +### Phase 2:Skills 映射 + +1. 新增映射模块与单元测试。 +2. 接入 `getActiveSkillsAllowedTools()`。 +3. 在 skill sync 适配器层保持原始值,运行时归一化。 + +### Phase 3:rg 增强 + +1. `find` 引入 rg 分支(若尚未实现)。 +2. `grep` 完善 rg 结果结构化(命中文件/行号/上下文/截断)。 +3. 完善 fallback 与错误日志。 + +### Phase 4:Prompt 与协议导出 + +1. 新增统一 env prompt builder 并接入 messageBuilder。 +2. 调整 system prompt 固定拼接顺序(含 runtime 静态说明与 skills)。 +3. 更新 main 侧 tool prompt(仅 canonical + 调用规则)。 +4. 增加消息/事件契约测试,确保字段不回归。 +5. 回退 `allowParallel` 相关参数/逻辑与测试预期。 + +## 4. 数据与配置影响 + +1. **破坏性变化(明确)**:旧工具名不可再调用。 +2. 不涉及数据库 schema 迁移。 +3. Skills 的 `allowedTools` 原始存储不强制改写,仅在运行时归一化。 + +## 5. 测试策略 + +### 5.1 单元测试 + +1. `AgentToolManager`: + - 工具定义仅包含 canonical 名。 + - 旧工具名调用报错。 + - canonical 参数 schema 校验正确。 +2. Skills 映射: + - Claude Code 常见工具名映射正确。 + - 未知工具产生 warning。 +3. `AgentFileSystemHandler`: + - `find/grep` 在 rg 可用与不可用分支均可运行。 +4. Prompt 组装: + - tool prompt 仅包含 canonical 工具名。 + - 不包含旧工具名提示。 + - system prompt 顺序固定且可断言。 + - env prompt 包含 AGENTS.md 全文与关键环境字段。 + +### 5.2 集成测试 + +1. loop 工具调用事件序列:start/running/end。 +2. permission-required 负载完整性。 +3. offload 与大输出行为在新工具名下仍生效。 +4. toolResult envelope 字段(`ok/summary/data/meta`)在关键工具路径可观测。 + +## 6. 风险与缓解 + +1. 风险:旧 prompt 或 skill 仍调用旧工具名导致失败。 + 缓解:在系统 prompt 与 skills metadata prompt 中明确仅 canonical 名。 + +2. 风险:不同平台 rg 参数兼容性差异。 + 缓解:统一封装 rg 参数构造,Windows/Linux/macOS 加测试样例。 + +3. 风险:skills 映射冲突导致过度归并。 + 缓解:映射表版本化,保留原值告警,必要时支持精细映射策略。 + +## 7. 质量门槛(DoD) + +1. `pnpm run format` +2. `pnpm run lint` +3. `pnpm run typecheck` +4. 关键 main 测试通过(tool/loop/permission/skills 映射相关) diff --git a/docs/specs/agent-tooling-v2/spec.md b/docs/specs/agent-tooling-v2/spec.md new file mode 100644 index 000000000..b07a71863 --- /dev/null +++ b/docs/specs/agent-tooling-v2/spec.md @@ -0,0 +1,297 @@ +# Agent Tooling V2(Main Loop 优先) + +## 背景 + +当前 main 层 agent 工具体系存在以下问题: + +1. 工具命名与主流 Agent 生态不一致(大量下划线命名、语义重叠)。 +2. 文件工具数量偏多且参数风格不统一,模型选工具与组参成本高。 +3. Skills 来源多样(尤其 Claude Code),`allowedTools` 与 DeepChat 当前工具名不直接兼容。 +4. 文件匹配/检索能力未充分利用 `rg`,在大型仓库下性能和结果质量不稳定。 +5. loop 已基本可运行,但“对 renderer 输出的消息/事件格式”尚未形成明确、稳定的主协议。 + +本规格聚焦 main 层:先让 loop + tool 协议稳定、清晰、可推理,再推进 renderer 适配。 + +## 目标 + +1. **工具面收敛**:仅保留 8 个主工具(不兼容旧名)。 +2. **Skills 工具映射**:引入标准映射层,重点支持 Claude Code 工具名。 +3. **文件匹配增强**:`find/grep` 优先使用 `rg`,大仓库性能可预期。 +4. **协议清晰**:导出 main 层稳定的消息格式与事件格式,供 renderer 后续接入。 +5. **Prompt 管线稳定**:system prompt 拼接顺序固定,避免随意动态段影响缓存命中。 +6. **环境信息统一**:模型/系统/仓库/AGENTS.md 信息统一由 `env prompt` 生成。 + +## 非目标 + +1. 本阶段不改 renderer 逻辑与 UI 交互。 +2. 不保留旧工具名的后向兼容(不做 alias call)。 +3. 不重构 MCP 协议与第三方 provider 的底层实现。 +4. 不引入新的权限类型(仍为 `read|write|all|command`)。 +5. 不在 system prompt 中注入 YoBrowser 当前 tab 明细或后台进程实时列表。 + +## 范围边界(补充) + +本次优化仅覆盖: + +1. 文件操作工具(fs) +2. runtime 工具(命令执行与后台进程管理) +3. 上述工具在 main loop 中的调用、权限与事件输出 + +本次**不改动**: + +1. browser 相关工具(如 `yo_browser_*`) +2. skills 管理工具(如 `skill_list`、`skill_control`) +3. DeepChat 设置类工具(如 `deepchat_settings_*`) +4. 提问工具(`deepchat_question`) +5. MCP 工具本体及其服务端协议 + +## 工具集合(V2 Canonical) + +固定为以下 8 个工具: + +1. `read` +2. `write` +3. `edit` +4. `find` +5. `grep` +6. `ls` +7. `exec` +8. `process` + +## Canonical 参数定义(V2) + +### A. 文件工具(fs) + +| 工具 | 必填参数 | 可选参数 | 说明 | +|---|---|---|---| +| `read` | `path: string` | `offset?: number`, `limit?: number` | 单文件读取;大文件分页读取。 | +| `write` | `path: string`, `content: string` | 无 | 创建或覆盖文件。 | +| `edit` | `path: string`, `oldText: string`, `newText: string` | `replaceAll?: boolean` | 精确文本替换;仅接受 canonical 参数名。 | +| `find` | `pattern: string` | `path?: string`, `maxResults?: number`, `exclude?: string[]` | 文件匹配,优先 `rg --files`。 | +| `grep` | `pattern: string` | `path?: string`, `filePattern?: string`, `caseSensitive?: boolean`, `contextLines?: number`, `maxResults?: number` | 内容搜索,优先 `rg`。 | +| `ls` | `path: string` | `depth?: number` | 列目录(默认浅层)。 | + +### B. 运行时工具(runtime) + +| 工具 | 必填参数 | 可选参数 | 说明 | +|---|---|---|---| +| `exec` | `command: string` | `cwd?: string`, `timeoutMs?: number`, `background?: boolean`, `yieldMs?: number` | 命令执行;长任务建议后台。 | +| `process` | `action: enum` | `sessionId?: string`, `offset?: number`, `limit?: number`, `data?: string`, `eof?: boolean` | 后台会话管理(list/poll/log/write/kill/clear/remove)。 | + +约束: + +1. 不再接受旧参数别名(如 `old_string/new_string`),只保留 canonical 参数名。 +2. 参数校验失败必须返回结构化错误,不进入工具执行。 +3. `exec` 不引入 `allowParallel` 参数。 + +## 工具返回格式(统一) + +所有 canonical 工具返回统一 envelope(`content` 可读摘要 + `rawData.toolResult` 结构化数据): + +```json +{ + "ok": true, + "summary": "Found 12 matches in 3 files", + "data": {}, + "meta": { + "truncated": false + } +} +``` + +错误格式: + +```json +{ + "ok": false, + "error": { + "code": "INVALID_ARGUMENT", + "message": "path is required" + } +} +``` + +说明: + +1. `content` 供模型快速理解,`rawData.toolResult` 供 loop/renderer 稳定消费。 +2. `find/grep/read` 需包含分页或截断信息(如 `returned`, `total`, `nextOffset`)。 + +## 待删除旧工具清单(用于边界确认) + +以下旧工具将从 agent 文件/运行时工具定义中移除: + +1. `read_file` +2. `write_file` +3. `list_directory` +4. `create_directory` +5. `move_files` +6. `edit_text` +7. `glob_search` +8. `directory_tree` +9. `get_file_info` +10. `grep_search` +11. `text_replace` +12. `edit_file` +13. `execute_command` + +说明: + +1. `process` 不删除,保留并归入 runtime canonical 集合。 +2. 上述删除仅针对本地 Agent 工具定义层,不影响外部 MCP server 自带同名工具。 + +## Prompt 约束(main) + +目标:降低模型选错工具/组参错误率,减少多余调用。 + +1. 系统提示中只暴露 canonical 工具名与参数摘要,不出现旧名。 +2. 增加工具选择规则(按意图): + - 定位文件:`find` / `ls` + - 搜索内容:`grep` + - 读取内容:`read` + - 精确修改:`edit` + - 整体写入:`write` + - 执行命令:`exec` + `process` +3. 增加调用策略: + - 先查找再读取,再修改(`find/grep -> read -> edit/write`) + - 优先小步调用,避免一次返回超大输出 +4. 对无效工具名统一返回:`Unknown Agent tool: `,不做自动别名纠正。 + +## System Prompt 组装顺序(V2.1) + +`conversation.settings.systemPrompt` 之后固定拼接顺序: + +1. **Runtime 简要说明段**(静态说明) + - 仅说明 YoBrowser 能力和后台进程能力。 + - 不注入当前 tab 列表、当前 active tab、当前运行进程明细等动态快照。 +2. **Skills Prompt 段** + - 含 skills metadata + active skills 内容。 +3. **Env Prompt 段** + - 统一封装环境信息,格式稳定。 + - 包含:模型名、模型 ID、工作目录、是否 git 仓库、平台、日期。 + - 包含 `AGENTS.md` 的具体内容(全文)。 +4. **Tooling Prompt 段** + - 继续保留独立工具调用规则段(canonical 工具名 + 推荐调用顺序)。 + +说明: + +1. Env Prompt 统一由独立 builder 生成,不在多个模块分散拼接。 +2. 运行态动态信息不进 system prompt,避免提示词频繁变化影响缓存。 + +Env Prompt 参考格式: + +```text +You are powered by the model named . +The exact model ID is / +Here is some useful information about the environment you are running in: + +Working directory: +Is directory a git repo: yes|no +Platform: +Today's date: + + + + +Instructions from: /AGENTS.md + +``` + +## 用户故事 + +### US-1:模型能更稳定选对工具 +作为 agent 用户,我希望模型面对文件任务时优先在 6 个文件工具中做确定选择,而不是在多个重叠工具之间摇摆。 + +### US-2:Claude Code Skills 可直接复用 +作为多工具用户,我希望导入 Claude Code skills 后,`allowed-tools` 能自动映射到 DeepChat 的 canonical 工具,不需要手工改名。 + +### US-3:大型仓库下检索稳定 +作为 agent 用户,我希望 `find/grep` 在大仓库下保持高性能和一致结果,优先走 `rg`。 + +### US-4:事件协议可作为 renderer 改造输入 +作为开发者,我希望 main 层先产出稳定的事件与消息格式,后续 renderer 可按协议接入,不再反复追 main 内部状态细节。 + +## Skills 工具映射(重点:Claude Code) + +定义 canonical 映射(首批): + +| 外部工具名(Claude Code 常见) | DeepChat Canonical | +|---|---| +| `Read` | `read` | +| `Write` | `write` | +| `Edit` | `edit` | +| `MultiEdit` | `edit` | +| `Glob` | `find` | +| `Grep` | `grep` | +| `LS` | `ls` | +| `Bash` | `exec` | + +说明: + +1. 该映射用于 `skills allowedTools` 归一化,不影响 MCP 原生工具名。 +2. 无法映射的工具名保留原值并标记 warning(不静默丢失)。 +3. 映射后再参与“工具可用性过滤”。 + +## 文件匹配增强(rg 优先) + +1. `find`:优先 `rg --files` + `-g` 模式过滤(含排除规则),不可用时回退 `glob`。 +2. `grep`:优先 `rg`(支持行号、上下文、max results),不可用时回退 JS 扫描。 +3. 输出必须包含可消费的结构化元信息(命中数、文件数、截断标识)。 +4. 默认忽略目录保持统一:`.git`、`node_modules`、`dist`、`build`、`.next`(可扩展)。 + +## 消息与事件格式(main 导出) + +本阶段定义并冻结 main 输出契约(renderer 暂不改): + +1. 仍通过 `STREAM_EVENTS.RESPONSE/ERROR/END` 发送。 +2. `tool_call` 状态集合保持:`start|running|update|end|error|permission-required|question-required`。 +3. 明确字段稳定性: + - 必带:`eventId` + - tool 事件必带:`tool_call_id`, `tool_call_name` + - 权限事件必带:`permission_request.toolName/serverName/permissionType/description` +4. 在 `docs/specs/agent-tooling-v2/plan.md` 给出字段级 schema 约束与 JSON 样例(text/reasoning/tool/permission/question/end)。 + +## 约束 + +1. 安全边界:文件访问必须受 workspace/approved paths/conversation session 限制。 +2. 权限门闩:遇到 permission-required 必须暂停,遵循已落地的 permission stabilization 语义。 +3. 输出控制:工具大输出继续遵循 offload guardrails,不向模型直接注入超大文本。 + +## 验收标准 + +### A. 工具面 + +1. agent 工具定义列表只包含 8 个 canonical 工具名(加业务工具如 `deepchat_question`、skills 管理工具不在本条约束内)。 +2. 旧文件工具名(如 `read_file/write_file/edit_file/...`)不再出现在工具定义中。 +3. 调用旧工具名返回明确错误:`Unknown Agent tool`。 +4. `yo_browser_*`、`skill_*`、`deepchat_settings_*` 的可见性与行为保持不变。 + +### B. Skills 映射 + +1. Claude Code `allowed-tools` 输入可映射到 canonical 工具。 +2. `MultiEdit -> edit`、`Bash -> exec`、`Glob -> find` 等关键映射可通过测试验证。 +3. 未知工具名不会被静默吞掉,存在可观测 warning。 + +### C. rg 增强 + +1. `find` 在可用 `rg` 时走 `rg` 分支;`rg` 不可用时自动回退 `glob`。 +2. `grep` 在可用 `rg` 时走 `rg` 分支;`rg` 不可用时自动回退 JS。 +3. 大仓库场景下 `find/grep` 都有 `maxResults` 截断行为并返回截断信息。 + +### D. 协议 + +1. `STREAM_EVENTS.RESPONSE` 中 tool 相关字段满足 plan 中定义的 schema。 +2. `permission-required` 事件负载完整且可用于恢复执行链路。 +3. 主流程可导出标准消息样例(text/reasoning/tool/permission/question/end)。 + +### E. 参数与 Prompt + +1. 所有 canonical 工具参数满足本 spec 的统一定义。 +2. 系统提示仅出现 canonical 工具名,不出现旧工具名或旧参数别名。 +3. 工具参数校验失败可观测(明确错误码/错误信息),且不执行工具副作用操作。 +4. system prompt 拼接顺序严格符合“V2.1 顺序”。 +5. system prompt 中不包含 YoBrowser/后台进程动态状态快照。 +6. env prompt 中包含模型名/模型 ID/工作目录/git 检测/platform/date/AGENTS.md 全文。 + +## 开放问题 + +无。 diff --git a/docs/specs/agent-tooling-v2/tasks.md b/docs/specs/agent-tooling-v2/tasks.md new file mode 100644 index 000000000..a87f549ae --- /dev/null +++ b/docs/specs/agent-tooling-v2/tasks.md @@ -0,0 +1,57 @@ +# Agent Tooling V2 Tasks(Prompt 管线与 Env Prompt) + +## T0 文档先行 + +- [x] 更新 `spec.md`:增加 V2.1 system prompt 固定顺序与 env prompt 约束。 +- [x] 更新 `plan.md`:补充实现边界、顺序、回退 `allowParallel` 要求。 +- [x] 新建 `tasks.md`:形成可执行任务清单。 + +## T1 回退并清理 `allowParallel` + +- [ ] 从 `src/main/presenter/agentPresenter/acp/agentToolManager.ts` 的 `exec` schema 删除 `allowParallel`。 +- [ ] 删除 `exec` description 中关于 `allowParallel` 的文案。 +- [ ] 删除 `exec` 前置并行守卫逻辑和相关辅助方法。 +- [ ] 清理/更新相关测试断言(若涉及)。 + +## T2 新增统一 Env Prompt Builder + +- [ ] 新增 `src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts`。 +- [ ] 实现 `buildSystemEnvPrompt(...)`,输出固定格式: + - 模型名 + 模型 ID + - ``:workdir、git repo、platform、date + - `` 空块 + - `Instructions from: /AGENTS.md` + - AGENTS.md 全文(读取失败时输出可观测 fallback) +- [ ] 实现 runtime 静态能力说明 builder(YoBrowser + terminal background)。 + +## T3 调整 Message Builder 拼接顺序 + +- [ ] 在 `src/main/presenter/agentPresenter/message/messageBuilder.ts` 按固定顺序拼接: + 1. conversation `systemPrompt` + 2. runtime 静态能力说明 + 3. skills prompt + 4. env prompt + 5. tooling prompt +- [ ] 移除 YoBrowser 动态状态注入(tab/active tab 实时快照)。 +- [ ] 确认不注入后台进程动态列表。 + +## T4 收敛 Prompt Enhancer 责任 + +- [ ] 更新 `src/main/presenter/agentPresenter/utility/promptEnhancer.ts`,避免重复拼接 runtime/date/platform/workdir 信息。 +- [ ] runtime 环境信息统一由 `systemEnvPromptBuilder` 提供。 + +## T5 测试 + +- [ ] 新增/更新 `messageBuilder` 测试:断言 system prompt 段落顺序。 +- [ ] 新增 `systemEnvPromptBuilder` 测试: + - git yes/no + - AGENTS.md 存在/不存在 + - model name/model id 回退逻辑 +- [ ] 更新 `agentToolManager` 测试:确保 `allowParallel` 不可用。 + +## T6 验证 + +- [ ] `pnpm run format` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 跑关键 main 测试集并记录结果 diff --git a/docs/specs/chat-settings-control/plan.md b/docs/specs/chat-settings-control/plan.md deleted file mode 100644 index d76665b0c..000000000 --- a/docs/specs/chat-settings-control/plan.md +++ /dev/null @@ -1,122 +0,0 @@ -# Plan: Control Settings via Chat - -## Key Decision: Skill-Based Context Control - -This feature MUST be described and delivered as a DeepChat skill so that additional instructions/context are only injected when the user actually requests to change DeepChat settings. - -- Skill Name (suggested): `deepchat-settings` -- Activation: Activated via `skill_control` **ONLY** when the user request involves DeepChat settings/preferences. -- Deactivation: Call `skill_control` after completing the setting change to keep context lean. - -## Tool Injection Control (No Skill, No Tools) - -Configuration-related tools MUST NOT appear in the LLM tool list (and MUST NOT be mentioned in the system prompt) unless the `deepchat-settings` skill is active. - -Implementation intent: - -- Define dedicated tools (MCP-format function definitions): - - `deepchat_settings_toggle` - - `deepchat_settings_set_language` - - `deepchat_settings_set_theme` - - `deepchat_settings_set_font_size` - - `deepchat_settings_open` -- **DO NOT** expose them through MCP server/tool list UI (avoid being auto-enabled into `enabledMcpTools`). -- Only inject these tool definitions when: - - `deepchat-settings` is enabled for the current conversation, AND - - The skill's pre-metadata `allowedTools` includes the tool name. - -This requires conversation-scoped tool definition construction: - -- Extend tool definition construction context to include `conversationId`. -- Retrieve `skillsAllowedTools` for that conversation (via `SkillPresenter.getActiveSkillsAllowedTools`). -- Only conditionally append `deepchat_settings_*` tool definitions when allowed. - -## Step 1: Safe Settings Application API (Main Process) - -### Entry Point - -Implement a narrow, validated application surface in the main process (presenter method or agent tool handler) for: - -- Accepting `unknown` input and validating it (Zod-style, similar to `AgentFileSystemHandler`). -- Using an allowlist of setting IDs. -- Applying changes by calling existing `ConfigPresenter` methods so existing event broadcasts remain correct. -- Returning structured results to render confirmation/error messages. - -### Allowlisted Settings and Mapping - -Toggle settings: - -- `soundEnabled` -> `ConfigPresenter.setSoundEnabled(boolean)` (broadcasts: `CONFIG_EVENTS.SOUND_ENABLED_CHANGED`) -- `copyWithCotEnabled` -> `ConfigPresenter.setCopyWithCotEnabled(boolean)` (broadcasts: `CONFIG_EVENTS.COPY_WITH_COT_CHANGED`) - -Enum settings: - -- `language` -> `ConfigPresenter.setLanguage(locale)` (broadcasts: `CONFIG_EVENTS.LANGUAGE_CHANGED`) -- `theme` -> `ConfigPresenter.setTheme('dark' | 'light' | 'system')` (broadcasts: `CONFIG_EVENTS.THEME_CHANGED`) -- `fontSizeLevel` -> `ConfigPresenter.setSetting('fontSizeLevel', level)` (broadcasts `CONFIG_EVENTS.FONT_SIZE_CHANGED` via special case) - -### Validation Rules - -- Strict allowlist; reject unknown IDs. -- No implicit type conversion in Step 1. -- Validation per setting: - - Booleans: must be boolean type - - Enum values: must match allowed set - - `fontSizeLevel`: must be integer within supported range (source of truth TBD; may align with `uiSettingsStore` constants) - - `language`: must be one of supported locales (reuse support list from config) - -### Defense in Depth: Require Skill Activity - -Even with controlled tool injection, maintain runtime checks: - -- If `deepchat-settings` is **NOT** enabled for the conversation, reject application and return error telling the model/user to activate it. -- This ensures settings don't accidentally change due to unrelated agent behavior. - -## Step 2: Skill Definition (Natural Language Behavior) - -### Built-in Skill Artifact - -Add `resources/skills/deepchat-settings/SKILL.md`: - -- Pre-metadata `description` MUST explicitly state: - - This is ONLY for changing DeepChat application settings. - - Activate ONLY when user requests setting changes (settings/preferences/theme/language/font/sound/copy COT). - - Do NOT activate for OS settings or programming/code settings. -- Body MUST define: - - Supported settings (allowlist) and canonical values. - - How to ask clarifying questions when ambiguous. - - When to refuse and instead open settings. - - Always deactivate after completing setting tasks. - -### Disallowed Settings -> Open Settings - -For requests involving MCP configuration, prompts, providers, API keys, etc.: - -- Do NOT apply via tools. -- Provide precise instructions telling user where to change them. -- Open settings window and navigate to relevant section if possible. - -Implementation options for opening/navigating settings: - -- Use `presenter.windowPresenter.createSettingsWindow()`. -- Optionally `executeJavaScript` to set localStorage navigation hint that UI can read. -- Or add dedicated IPC channel from main process -> settings renderer to navigate to tab/section. - -## Data Model - -Introduce shared request/response types (for Step 1 entry point + tools): - -- `ChatSettingId` (union of allowlisted IDs) -- `ApplyChatSettingRequest` (discriminated union `{ id, value }`) -- `ApplyChatSettingResult` - - `{ ok: true; id; value; previousValue?; appliedAt }` - - `{ ok: false; errorCode; message; details? }` - -## Testing Strategy - -- Main process (Vitest): - - Allowlist + validation (reject invalid values, no writes) - - Each supported setting maps to correct `ConfigPresenter` method - - Skill requirement enforcement works (tool rejects when skill inactive) -- Renderer/UI (if any navigation hints added): - - Settings page navigation handler tests (optional) diff --git a/docs/specs/chat-settings-control/tasks.md b/docs/specs/chat-settings-control/tasks.md deleted file mode 100644 index cb7c35b74..000000000 --- a/docs/specs/chat-settings-control/tasks.md +++ /dev/null @@ -1,31 +0,0 @@ -# Tasks: Control Settings via Chat - -## Step 0 - Skill-First Design (Context Control) - -1. Draft built-in skill: `resources/skills/deepchat-settings/SKILL.md`. -2. Ensure pre-metadata `description` explicitly restricts activation to only DeepChat setting changes. -3. Ensure skill body lists allowlisted settings + safe handling + self-deactivation guidance. - -## Step 1 - Safe Settings Application API (Main Process) - -1. Add shared types for settings application request/result. -2. Implement validated application entry point (Zod-style `unknown` parsing). -3. Implement allowlist mapping to existing `ConfigPresenter` methods: - - `soundEnabled` - - `copyWithCotEnabled` - - `language` - - `theme` - - `fontSizeLevel` -4. Implement tool injection control: only include `deepchat_settings_toggle`/`deepchat_settings_set_language`/`deepchat_settings_set_theme`/`deepchat_settings_set_font_size`/`deepchat_settings_open` in tool definitions when `deepchat-settings` is active AND allowed. -5. Add defense-in-depth control: reject application if `deepchat-settings`` skill is not active for conversation. -6. Add "open settings" helper/tool for unsupported settings (MCP/prompts, etc.), including best-eff-mn navigation. -7. Add main process tests: - - Validation and mapping - - Tool definitions only exist when skill active - - Skill control enforces rejection when inactive - -## Step 2 - UX Behavior (LLM + Skill) - -1. Verify skill metadata prompt list clearly enough lists `deepchat-settings` for model to select it. -2. Ensure skill instructs: activate only when user asks; deactivate after completion. -3. Add examples of Chinese/English user phrasing in SKILL.md. diff --git a/docs/specs/edit-file-tool/plan.md b/docs/specs/edit-file-tool/plan.md deleted file mode 100644 index 929fd428e..000000000 --- a/docs/specs/edit-file-tool/plan.md +++ /dev/null @@ -1,228 +0,0 @@ -# edit_file Tool Implementation Plan - -## Architecture Overview - -The `edit_file` tool will be integrated into the existing agent filesystem tool infrastructure, following the established three-layer architecture: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Layer 1: Tool Definition (agentToolManager.ts) │ -│ - Zod schema for parameter validation │ -│ - Tool metadata (name, description, parameters) │ -│ - Registration in filesystem tool registry │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Layer 2: Tool Routing (agentToolManager.ts) │ -│ - Parameter normalization (alias handling) │ -│ - Permission checks (assertWritePermission) │ -│ - Dispatch to filesystem handler │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Layer 3: File Operation (agentFileSystemHandler.ts) │ -│ - Path validation and resolution │ -│ - File content reading │ -│ - Exact text replacement │ -│ - Diff generation and response formatting │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Design Decisions - -### 1. Parameter Alias Handling - -**Decision**: Normalize parameter names at the routing layer (agentToolManager.callFileSystemTool) rather than in Zod schema. - -**Rationale**: -- Zod does not natively support parameter aliases -- Normalizing early allows schema to use canonical names -- Keeps handler implementation clean - -**Implementation**: -```typescript -// Normalize parameter aliases before schema validation -if (toolName === 'edit_file') { - args = { - ...args, - path: args.path ?? args.file_path, - oldText: args.oldText ?? args.old_string, - newText: args.newText ?? args.new_string, - base_directory: args.base_directory, - } -} -``` - -### 2. Text Matching Strategy - -**Decision**: Case-sensitive exact string matching, replace ALL occurrences. - -**Rationale**: -- Consistent with `edit_text` tool's `edit_lines` operation -- Simple mental model for LLMs: "find this exact text, replace with that" -- Replacing all occurrences prevents partial updates which could leave code in inconsistent state - -### 3. Response Format - -**Decision**: JSON response with diff preview, matching existing filesystem tools. - -**Structure**: -```typescript -interface EditFileSuccessResponse { - success: true - originalCode: string // Truncated for large files - updatedCode: string // Truncated for large files - language: string // Detected from file extension - replacements: number // Number of replacements made -} - -interface EditFileErrorResponse { - success: false - error: string -} -``` - -## File Changes - -### Modified Files - -| File | Purpose | Lines Added/Modified | -|------|---------|----------------------| -| `src/main/presenter/agentPresenter/acp/agentToolManager.ts` | Schema, definition, routing | ~80 lines | -| `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` | Handler implementation | ~50 lines | - -### No New Files Required - -The implementation integrates into existing infrastructure without creating new files. - -## Implementation Details - -### Schema Definition (agentToolManager.ts) - -```typescript -edit_file: z.object({ - path: z.string().describe('Path to the file to edit'), - oldText: z - .string() - .max(10000) - .describe('The exact text to find and replace (case-sensitive)'), - newText: z.string().max(10000).describe('The replacement text'), - base_directory: z - .string() - .optional() - .describe('Base directory for resolving relative paths') -}) -``` - -### Tool Definition - -```typescript -{ - type: 'function', - function: { - name: 'edit_file', - description: - 'Make precise edits to files by replacing exact text strings. Use this for simple text replacements when you know the exact content to replace. For regex or complex operations, use edit_text instead.', - parameters: zodToJsonSchema(schemas.edit_file) - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } -} -``` - -### Handler Implementation (agentFileSystemHandler.ts) - -```typescript -async editFile(args: unknown, baseDirectory?: string): Promise { - const parsed = EditFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments: ${parsed.error}`) - } - - const { path: filePath, oldText, newText } = parsed.data - const validPath = await this.validatePath(filePath, baseDirectory) - - const content = await fs.readFile(validPath, 'utf-8') - - if (!content.includes(oldText)) { - throw new Error(`Cannot find the specified text to replace. The exact text was not found in the file.`) - } - - let replacementCount = 0 - const modifiedContent = content.replaceAll(oldText, () => { - replacementCount++ - return newText - }) - - await fs.writeFile(validPath, modifiedContent, 'utf-8') - - const { originalCode, updatedCode } = this.buildTruncatedDiff(content, modifiedContent, 3) - const language = getLanguageFromFilename(validPath) - - return JSON.stringify({ - success: true, - originalCode, - updatedCode, - language, - replacements: replacementCount - }) -} -``` - -## Test Strategy - -### Unit Tests (test/main/presenter/agentPresenter/acp/) - -Create `agentFileSystemHandler.editFile.test.ts`: - -- **Happy Path**: Replace single occurrence, replace multiple occurrences -- **Error Cases**: File not found, oldText not found, path outside allowed directories -- **Edge Cases**: Empty oldText, empty newText, large text content - -### Integration Tests - -- Verify tool registration and routing -- Verify permission system integration -- Verify response format consistency - -## Security Considerations - -- Path traversal prevention via existing `validatePath()` method -- Write permission enforcement via `assertWritePermission()` -- Allowed directory restriction via `isPathAllowed()` check -- Maximum text length limits (10,000 chars for oldText/newText) - -## Migration & Compatibility - -- No breaking changes to existing tools -- New tool is additive only -- No database or configuration migrations required -- No impact on existing agent workflows - -## Rollback Plan - -If issues are discovered: -1. Remove tool from `isFileSystemTool()` list to disable -2. Remove tool definition from `getFileSystemToolDefinitions()` -3. No data changes to revert (file changes are atomic writes) - -## Performance Considerations - -- File reads are limited by available memory -- `replaceAll()` is O(n*m) where n=content length, m=pattern length -- For very large files (>1MB), consider warning or limiting -- Diff truncation limits output size for large changes - -## Success Metrics - -- Tool is available in agent tool list -- Tool accepts all parameter variants (camelCase and snake_case) -- Tool successfully edits files with exact text matching -- Tool returns proper error messages for invalid inputs -- All tests pass -- Lint and typecheck pass diff --git a/docs/specs/edit-file-tool/tasks.md b/docs/specs/edit-file-tool/tasks.md deleted file mode 100644 index c5f11fdb6..000000000 --- a/docs/specs/edit-file-tool/tasks.md +++ /dev/null @@ -1,118 +0,0 @@ -# edit_file Tool Implementation Tasks - -## Task List - -### Phase 1: Schema and Definition - -- [ ] **Task 1.1**: Add `edit_file` schema to `fileSystemSchemas` in `agentToolManager.ts` - - Location: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` (line ~69-214) - - Add after `text_replace` schema - - Include: path, oldText, newText, base_directory fields - - Add max length validation (10000 chars) for oldText/newText - -- [ ] **Task 1.2**: Add `edit_file` tool definition to `getFileSystemToolDefinitions()` - - Location: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` (line ~411-630) - - Add after `text_replace` definition - - Description: "Make precise edits to files by replacing exact text strings" - - Icon: 📁 (same as other filesystem tools) - -- [ ] **Task 1.3**: Add `'edit_file'` to `isFileSystemTool()` method - - Location: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` (line ~655-671) - - Add to filesystemTools array - -### Phase 2: Routing and Parameter Normalization - -- [ ] **Task 2.1**: Add parameter normalization for `edit_file` in `callFileSystemTool()` - - Location: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` (line ~673+) - - After schema validation, before switch statement - - Normalize: file_path → path, old_string → oldText, new_string → newText - -- [ ] **Task 2.2**: Add `edit_file` case to switch statement in `callFileSystemTool()` - - Location: `src/main/presenter/agentPresenter/acp/agentToolManager.ts` (line ~716-785) - - Add after `text_replace` case - - Include `assertWritePermission()` check - - Call `fileSystemHandler.editFile()` - -### Phase 3: Handler Implementation - -- [ ] **Task 3.1**: Add `EditFileArgsSchema` to `agentFileSystemHandler.ts` - - Location: `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` - - Add after `TextReplaceArgsSchema` (~line 107) - - Define: path, oldText, newText, base_directory - -- [ ] **Task 3.2**: Implement `editFile()` method in `AgentFileSystemHandler` - - Location: `src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts` - - Add after `textReplace()` method - - Steps: - 1. Parse and validate arguments - 2. Resolve and validate path - 3. Read file content - 4. Check if oldText exists - 5. Replace all occurrences using replaceAll() - 6. Write modified content - 7. Generate diff and return JSON response - -### Phase 4: Quality Assurance - -- [ ] **Task 4.1**: Run type checking - ```bash - pnpm run typecheck - ``` - -- [ ] **Task 4.2**: Run linting - ```bash - pnpm run lint - ``` - -- [ ] **Task 4.3**: Run formatting - ```bash - pnpm run format - ``` - -- [ ] **Task 4.4**: Run tests - ```bash - pnpm test - ``` - -## Verification Checklist - -### Manual Testing - -- [ ] Tool appears in agent tool list -- [ ] Tool accepts `path`, `oldText`, `newText` parameters -- [ ] Tool accepts `file_path`, `old_string`, `new_string` aliases -- [ ] Tool successfully replaces text in a file -- [ ] Tool replaces ALL occurrences when multiple matches exist -- [ ] Tool returns proper error when oldText not found -- [ ] Tool returns proper error when file not found -- [ ] Tool respects write permissions -- [ ] Tool respects path validation (outside allowed directories) - -### Code Review Checklist - -- [ ] Schema uses appropriate validation (max length, required fields) -- [ ] Parameter aliases are normalized before schema validation -- [ ] Error messages are user-friendly -- [ ] Response format matches other filesystem tools -- [ ] Code follows existing style conventions -- [ ] No unnecessary comments or dead code - -## Commit Suggestion - -``` -feat(agent): add edit_file tool for precise text editing - -Add new filesystem tool that enables AI agents to make precise -text-based edits using exact string matching. - -Features: -- Exact text replacement with case-sensitive matching -- Support for parameter aliases (path/file_path, oldText/old_string, newText/new_string) -- Replace all occurrences of matching text -- JSON diff response with language detection -- Write permission enforcement - -Files modified: -- src/main/presenter/agentPresenter/acp/agentToolManager.ts -- src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts -``` diff --git a/docs/specs/hooks-notifications/plan.md b/docs/specs/hooks-notifications/plan.md deleted file mode 100644 index 5c006e9a1..000000000 --- a/docs/specs/hooks-notifications/plan.md +++ /dev/null @@ -1,72 +0,0 @@ -# Plan: Hooks 与 Webhook 通知(DeepChat) - -## 范围与原则 - -- **仅通知**:不会阻断/中止 DeepChat 的生成、工具与权限流程(hook 失败也不影响主链路)。 -- **仅 Settings 配置**:所有配置都由 Settings 管理,不读取/合并任何外部配置文件。 -- **Webhook-only**:Telegram/Discord 只做向外发送消息(HTTP 请求),不做双向交互/按钮/回调;Confirmo 走本地 hook。 - -## 交付拆分(建议) - -为降低回归与 UI 复杂度,分两步交付: - -- **Step 1(可用)**:Settings 页面 + 配置模型 + Test(Telegram/Discord/Confirmo/每个事件 command test)+ 基础日志 -- **Step 2(完整)**:生命周期事件注入 + 真实触发 + 队列/限流/截断/脱敏 + 单元/集成测试 - -## Step 1:Settings + Test 能力(不接入真实生命周期) - -1. 定义数据模型与校验 - - 新增 shared types:`HookEventName`、settings config、event payload、执行/发送结果 - - 用 `zod` 做 settings schema 校验(容错:未知字段忽略,但记录 warning) -2. 配置存储与读取(main) - - 在现有 config store 中新增 `hooksNotifications` 配置树(默认全关闭) - - 提供 getter/setter + IPC 通道(renderer 仅通过 IPC 读写,避免在 renderer 暴露 secret) -3. Settings UI(renderer) - - 新增设置页面(或新增一个 section),布局要求: - - 顶部:Telegram 卡片(Enable + 参数 + Test + 事件勾选) - - 其次:Discord 卡片(Enable + 参数 + Test + 事件勾选) - - 其次:Confirmo 卡片(Enable + Test;默认全部事件;需检测 hook 文件存在) - - 下方:Hooks Commands 卡片(Enable + 每个生命周期:Switch + 单个 command 输入框 + 右侧 Test) - - UI 风格参考知识库配置:卡片/折叠 + Switch 控制启用 -4. Test 逻辑(main) - - `testTelegram()`:发送一条 `type="test"` 的通知文本到配置目标 - - `testDiscord()`:同上 - - `testConfirmo()`:执行本地 Confirmo hook(stdin JSON) - - `testHookCommand(eventName)`:构造一个最小模拟 payload,通过 stdin JSON 执行对应 command - - Test 结果返回 renderer:success/错误信息 + 状态码 + 用时 + stdout/stderr 摘要 -5. i18n - - 新增 settings 文案 key(zh-CN/en-US),不硬编码中文 - -## Step 2:接入生命周期 + 可靠性 - -1. 生命周期事件注入(main) - - `SessionStart`/`SessionEnd`:每次一次完整生成链路开始/结束 - - `UserPromptSubmit`:用户提交消息后、调用 LLM 前 - - `PreToolUse`:工具调用实际执行前(含 tool name/id/params) - - `PostToolUse`:工具调用成功返回后 - - `PostToolUseFailure`:工具调用失败(error) - - `PermissionRequest`:触发权限请求时(含 tool/permission meta) - - `Stop`:生成停止(含 stop_reason、userStop) -2. Dispatcher(非阻塞) - - 根据配置把事件分发到: - - command hook runner(按 event 的 switch+command) - - Telegram notifier(按 channel enabled + event 勾选) - - Discord notifier(同上) - - Confirmo hook runner(同上) - - 所有分发均异步执行、不可阻断主流程;失败仅记录日志与 diagnostics -3. 队列/限流/截断/脱敏 - - per-channel 串行队列,保持顺序并降低触发限流概率 - - 自动截断:Telegram `sendMessage` 文本 4096;Discord webhook `content` 2000 - - 处理 429:按 `Retry-After`/`retry_after` 等信息退避重试(上限次数) - - 脱敏:复用 main 侧 `redact.ts`(token、webhook URL、Authorization、apiKey 等) -4. Tests - - payload builder、截断、脱敏、队列顺序、429 退避、配置 schema - - 可选:本地 mock server 验证 Telegram/Discord 200/429/500 行为 - -## 里程碑验收(Definition of Done) - -- Settings 可配置 Telegram/Discord(启用/禁用 + 参数 + 事件勾选)并能 Test 成功/失败可见 -- Settings 可配置 Confirmo(检测 hook 可用性 + 事件勾选)并能 Test 成功/失败可见 -- 每个生命周期事件均可配置单个 command(启用/禁用)并能 Test 执行(展示 exit code/stdout/stderr 摘要) -- 生命周期触发后可按配置向 Telegram/Discord/Confirmo 发送消息(失败不影响主流程,日志可追踪) -- 不读取任何外部配置文件;默认关闭;不影响现有系统通知与聊天主流程 diff --git a/docs/specs/permission-flow-stabilization/plan.md b/docs/specs/permission-flow-stabilization/plan.md deleted file mode 100644 index ebf54d0be..000000000 --- a/docs/specs/permission-flow-stabilization/plan.md +++ /dev/null @@ -1,107 +0,0 @@ -# Plan: 权限流程稳定性(多工具/批量)(v2) - -## 范围与原则 - -- **批次语义**:一次 assistant message 产出的 tool call 视为同一批次;permission 与恢复执行必须绑定该批次(messageId)。 -- **强暂停**:出现 permission-required 后,停止执行后续工具与继续对话,直到用户完成该批次内全部权限决策。 -- **顺序与幂等**:恢复后按原始 tool call 顺序执行;同一批次的恢复链路最多触发一次(互斥 + 幂等)。 -- **最小改动**:优先复用 message blocks 作为“执行事实”,session runtime 只做 UI 计数与互斥锁。 - -## 现状问题与根因(结合日志) - -- **批准后仍反复 tool_use / 不继续执行**:恢复执行后的 tool end/result 仅在内存或等待 `StreamUpdateScheduler` 异步落库(600ms),但继续生成会从 DB 重新构建上下文(`prepareConversationContext()`),读到旧 message content,模型看不到 tool 结果而再次 tool_use。 -- **重复启动 loop / 顺序更乱**:恢复链路中存在释放 resume lock 的窗口,导致同一 `messageId` 可能被重复恢复(重复 start loop / start stream)。 -- **预检查不统一/载荷丢失**:agent 工具的 pre-check 被跳过或 permission_request payload 不保真(如 paths/commandInfo),导致执行期才触发 permission-required,破坏批次顺序与可控暂停。 -- **pendingPermissions 状态残留**:空数组/指针未清理会干扰“是否还有 pending”的判断与前端展示。 - -## 目标行为(v2) - -### 状态机(概念) - -``` -generating - └─(permission-required)→ waiting_permission - └─(all permissions resolved)→ generating (resume tools) - └─(tools done + persisted)→ generating (continue model) - └─(end)→ idle -``` - -关键约束: -- `waiting_permission` 期间不允许启动新的 LLM stream,也不允许执行任何后续 tool call。 -- `resume tools` 阶段必须 single-flight(同一 messageId 只能有一个恢复链路在跑)。 -- `continue model` 前必须保证 tool 结果对 DB 读路径可见。 - -### 批次定义与事实来源 - -- **批次 ID**:`assistantMessageId(eventId/messageId)`。 -- **批次事实**:message content 中的 tool_call / permission blocks(顺序与状态)。 -- **session runtime**:仅用于: - - `pendingPermissions[]`(UI/状态栏/下一条 pending 提示) - - `permissionResumeLock`(互斥恢复) - -## 关键实现策略 - -### 1) 恢复链路 single-flight(临界区互斥) - -- `permissionResumeLock` 的作用域:覆盖 **执行工具 -> 同步落库 -> 启动继续生成** 的整个临界区。 -- 恢复过程中不在“每个 tool”之间释放 lock;仅在以下情况释放: - - 执行期再次触发 requiresPermission:立刻停止,释放 lock,回到 `waiting_permission` - - 全部工具执行完成并已同步落库,且已触发继续生成 -- 防重:对同一 `messageId` 的重复 `handlePermissionResponse` 只能有一次进入恢复临界区。 - -### 2) 工具结果可见性(同步落库) - -- 在继续生成前,必须把 `state.message.content` 同步写入 DB: - - 使用 `messageManager.editMessageSilently(messageId, JSON.stringify(state.message.content))` -- 不依赖 `StreamUpdateScheduler` 的定时 DB flush(600ms)作为一致性保障。 -- 写入必须发生在 `startStreamCompletion(conversationId, messageId)` 之前。 - -### 3) 统一 pre-check + payload 保真(MCP + agent) - -- `ToolPresenter.preCheckToolPermission` 对 agent 工具不再直接跳过。 -- `AgentToolManager` 增加 `preCheckToolPermission(toolName, args, conversationId)`: - - 只判断权限需求,不执行工具 - - write 类工具输出 `paths`;execute_command 输出 `commandInfo/commandSignature` -- `ToolCallProcessor.batchPreCheckPermissions`: - - 支持 `permissionType: 'read'|'write'|'all'|'command'` - - 保留并合并 tool 层提供的 `permissionRequest` payload,禁止丢字段 - -### 4) execute_command background 权限一致性 - -- `execute_command` 的 `background: true` 也必须先走 command permission check,禁止绕过。 - -## 工作项拆解(按优先级) - -1) **PermissionHandler:恢复临界区 + 同步落库** -- 文件:`src/main/presenter/agentPresenter/permission/permissionHandler.ts` -- 调整 `resumeToolExecutionAfterPermissions(...)`:lock 不在每个 tool 之间释放,避免重入窗口 -- 调整 `continueAfterToolsExecuted(...)`:`startStreamCompletion` 前 `editMessageSilently` - -2) **SessionManager:pendingPermissions 清理** -- 文件:`src/main/presenter/agentPresenter/session/sessionManager.ts` -- `removePendingPermission(...)`:filter 后 length=0 时把 `pendingPermission/pendingPermissions` 置空 - -3) **统一 pre-check + payload 保真** -- 文件:`src/main/presenter/toolPresenter/index.ts`(agent 工具 pre-check 不再跳过) -- 文件:`src/main/presenter/agentPresenter/acp/agentToolManager.ts`(新增 `preCheckToolPermission`) -- 文件:`src/main/presenter/agentPresenter/loop/toolCallProcessor.ts`(batch pre-check 合并 payload + command union) - -4) **background 命令权限** -- 文件:`src/main/presenter/agentPresenter/acp/agentBashHandler.ts` -- 将 command permission check 提前到 background 分支之前 - -5) **测试(必须覆盖“批准后继续执行”回归)** -- `test/main/presenter/sessionPresenter/permissionHandler.test.ts` - - 断言:恢复后会执行工具、并在继续生成前写 DB(mock `editMessageSilently`/顺序) - - 断言:同 messageId 重复触发只会进入一次恢复链路 -- `test/main/presenter/toolPresenter/toolPresenter.test.ts` - - 断言:agent 工具 pre-check 会被调用并返回 payload -- 新增:`test/main/presenter/agentPresenter/toolCallProcessor.batchPrecheck.test.ts` - - 断言:permission_request payload 不丢失(paths/commandInfo) - -## 手动验收脚本(v2) - -- 单工具:`execute_command`(非白名单/危险命令)-> 批准 -> 只执行一次 -> 继续生成(不再二次 permission-required) -- 多工具 batch:两个 tool 都需要权限 -> 逐个批准 -> 批次恢复按顺序执行 -> 继续生成 -- 部分拒绝:拒绝其中一个 -> 该 tool 产生一致 error tool result,其它照常执行并继续生成 -- background:`execute_command` + `background: true` -> 也必须弹权限 diff --git a/docs/specs/permission-flow-stabilization/tasks.md b/docs/specs/permission-flow-stabilization/tasks.md deleted file mode 100644 index 8c9f5dc14..000000000 --- a/docs/specs/permission-flow-stabilization/tasks.md +++ /dev/null @@ -1,130 +0,0 @@ -# Tasks: 权限流程稳定性(多工具/批量) - -## Phase 1: Session runtime 与类型 - -### Task 1.1: 支持多条 pending permissions + 恢复互斥锁 -**Files:** -- `src/main/presenter/agentPresenter/session/sessionContext.ts` -- `src/main/presenter/agentPresenter/session/sessionManager.ts` - -**Subtasks:** -- [ ] 新增 `runtime.pendingPermissions: Array<{ messageId; toolCallId; permissionType; payload }>` -- [ ] 新增 `runtime.permissionResumeLock: { messageId; startedAt } | undefined` -- [ ] 保留 `runtime.pendingPermission` 作为兼容字段(从 `pendingPermissions[0]` 派生或镜像) -- [ ] `startLoop()`/清理逻辑同步重置新增字段 - -**Acceptance:** -- 能稳定表达同一条消息内的多条 permission 请求,不再被覆盖 - ---- - -## Phase 2: permission-required 事件落盘与暂停 - -### Task 2.1: permission-required 不覆盖、只追加/更新 -**File:** `src/main/presenter/agentPresenter/streaming/llmEventHandler.ts` - -**Subtasks:** -- [ ] `permission-required` 时把请求写入 `runtime.pendingPermissions`(按 `messageId+toolCallId` 去重) -- [ ] session 状态稳定进入 `waiting_permission`(不在未决策时回到 generating/idle) - -**Acceptance:** -- 同批次多 permission 时 UI/状态不会错乱、不会只显示最后一条 - ---- - -## Phase 3: PermissionHandler(决策与恢复) - -### Task 3.1: 决策阶段只更新,不提前恢复 -**File:** `src/main/presenter/agentPresenter/permission/permissionHandler.ts` - -**Subtasks:** -- [ ] 拆分“更新 permission blocks 状态”与“尝试恢复执行”两个步骤 -- [ ] 仅当该 message 内不存在 `tool_call_permission.status=pending` 时才允许进入恢复逻辑 -- [ ] 落盘后同步更新 generatingMessages 内的快照(避免 renderer 看到旧块) -- [ ] `command/agent-filesystem/deepchat-settings` 按精确 scope 处理,不做 serverName 一刀切批量 -- [ ] 仅在安全条件满足时才批量更新(同 serverName 且 permission 层级满足且无额外 scope) - -**Acceptance:** -- 多 permission 场景下,用户确认第 1 条后不会触发 resume/工具执行 - ---- - -### Task 3.2: 恢复执行按 tool_call 顺序、且幂等 -**File:** `src/main/presenter/agentPresenter/permission/permissionHandler.ts` - -**Subtasks:** -- [ ] 引入 `permissionResumeLock`,同一 `conversationId+messageId` 只允许一次恢复链路 -- [ ] 以 `tool_call.status=loading` blocks 作为“待执行队列”事实来源(保持原始顺序) -- [ ] 对被拒绝的 tool call:生成一致的 tool error 回填(不执行 tool) -- [ ] 对允许/无需 permission 的 tool call:串行执行并闭合 tool_call blocks -- [ ] 执行中若再次 `requiresPermission`:立刻暂停并回到 `waiting_permission`(不丢队列) -- [ ] 全部工具完成后,仅继续一次模型生成(避免重复 startLoop/重复 stream) - -**Acceptance:** -- 恢复后执行顺序正确、只恢复一次、不会漏执行“未触发 permission 的 tool call” - ---- - -## Phase 4: 权限层级(all > write > read) - -### Task 4.1: MCP session 权限检查按层级判断 -**File:** `src/main/presenter/mcpPresenter/toolManager.ts` - -**Subtasks:** -- [ ] 实现统一的 permission 比较/包含关系 -- [ ] `checkSessionPermission()` 按层级返回(不再 “任意权限=全部通过”) -- [ ] 持久化 `autoApprove` 逻辑保持一致(`all` 覆盖一切,`write` 覆盖 `read`) - -**Acceptance:** -- 已授予 `write` 时,不应再次因为 `read` 弹窗;但也不会把 `read` 误当成 `write` - ---- - -## Phase 5: Renderer(command permission 交互修正) - -### Task 5.1: “允许一次 / 允许本次会话”区分生效 -**File:** `src/renderer/src/components/message/MessageBlockPermissionRequest.vue` - -**Subtasks:** -- [ ] `Allow once` → `remember=false` -- [ ] `Allow for session` → `remember=true` - -**Acceptance:** -- command permission 可真正按“一次/会话”两种粒度授权 - ---- - -## Phase 6: Tests 与质量门禁 - -### Task 6.1: 主链路单元测试覆盖多 permission/幂等/顺序 -**Files:** -- `test/main/presenter/sessionPresenter/permissionHandler.test.ts` -- `test/main/presenter/mcpPresenter/toolManager.permission.test.ts`(建议新增) - -**Subtasks:** -- [ ] 多 permission:确认第 1 条不恢复、全部 resolved 才恢复 -- [ ] 恢复幂等:重复触发响应只执行一次恢复链路 -- [ ] 顺序:恢复后的执行顺序与 tool_call blocks 一致 -- [ ] 层级:`all > write > read` 覆盖关系正确 -- [ ] 校验并修正历史测试中与现实现不一致的断言(例如“permission block removal”类用例) - ---- - -### Task 6.2: 回归自测清单 -**Subtasks:** -- [ ] 同一轮 2+ 个 MCP tool:其中 1 个需要 permission -- [ ] 同一轮 2+ 个 MCP tool:2 个都需要 permission(同 server / 不同 permissionType) -- [ ] 混合 allow/deny:允许 1 个、拒绝 1 个,能继续回答 -- [ ] command permission:Allow once/Allow for session 都生效 - ---- - -### Task 6.3: 质量门禁 -**Commands:** -```bash -pnpm run format -pnpm run lint -pnpm run typecheck -pnpm test -``` - diff --git a/docs/specs/process-tool/plan.md b/docs/specs/process-tool/plan.md deleted file mode 100644 index 00ddce79d..000000000 --- a/docs/specs/process-tool/plan.md +++ /dev/null @@ -1,211 +0,0 @@ -# Process Tool Implementation Plan - -## Architecture - -### Component Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ AgentToolManager │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ fileSystem │ │ process │ │ question │ │ -│ │ tools │ │ tool (NEW) │ │ tool │ │ -│ └────────┬────────┘ └────────┬────────┘ └─────────────────┘ │ -│ │ │ │ -│ ┌────────▼────────────────────▼────────┐ │ -│ │ AgentBashHandler (modified) │ │ -│ │ - executeCommand (foreground) │ │ -│ │ - executeCommandBackground (NEW) │ │ -│ └────────┬─────────────────────────────┘ │ -│ │ │ -│ ┌────────▼──────────────────────────────────────────┐ │ -│ │ BackgroundExecSessionManager (NEW) │ │ -│ │ - Map> │ │ -│ │ - spawn processes with stdio pipes │ │ -│ │ - poll/log/write/kill/clear/remove operations │ │ -│ │ - TTL cleanup timer │ │ -│ │ - offload large outputs to files │ │ -│ └───────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Data Model - -```typescript -interface BackgroundSession { - sessionId: string - conversationId: string - command: string - child: ChildProcess - status: 'running' | 'done' | 'error' | 'killed' - exitCode?: number - createdAt: number - lastAccessedAt: number - outputBuffer: string // In-memory buffer (<10KB) - outputFilePath: string|null // Offloaded content (>10KB) - totalOutputLength: number -} - -interface SessionMeta { - sessionId: string - command: string - status: 'running' | 'done' | 'error' | 'killed' - createdAt: number - lastAccessedAt: number - pid?: number - exitCode?: number - outputLength: number - offloaded: boolean -} -``` - -### Tool Schema - -```typescript -// process tool -{ - action: z.enum(['list', 'poll', 'log', 'write', 'kill', 'clear', 'remove']), - sessionId: z.string().optional(), - offset: z.number().int().min(0).optional(), // log only - limit: z.number().int().min(1).optional(), // log only - data: z.string().optional(), // write only - eof: z.boolean().optional() // write only -} - -// execute_command modification -{ - command: z.string().min(1), - timeout: z.number().min(100).optional(), - description: z.string().min(5).max(100), - background: z.boolean().optional(), // NEW - yieldMs: z.number().min(100).optional() // NEW -} -``` - -## Event Flow - -### Start Background Session - -``` -1. LLM calls execute_command with background=true -2. AgentToolManager routes to AgentBashHandler -3. AgentBashHandler calls BackgroundExecSessionManager.start() -4. Spawn child process with stdio pipes -5. Store session in Map -6. Return {status: "running", sessionId} -``` - -### Poll Output - -``` -1. LLM calls process with action="poll" -2. AgentToolManager routes to process tool handler -3. BackgroundExecSessionManager.poll() returns recent output -4. If offloaded, read last N chars from file -5. Return {status, output, exitCode?, offloaded?} -``` - -### Write Input - -``` -1. LLM calls process with action="write", data="..." -2. BackgroundExecSessionManager.write() writes to child.stdin -3. Optionally close stdin with eof=true -``` - -### Kill Session - -``` -1. LLM calls process with action="kill" -2. BackgroundExecSessionManager.kill() sends SIGTERM -3. Wait 2s, then SIGKILL if still running -4. Update session status -``` - -## File Structure - -``` -src/main/presenter/agentPresenter/acp/ -├── backgroundExecSessionManager.ts # NEW - Session manager -├── agentBashHandler.ts # MODIFY - Add background support -├── agentToolManager.ts # MODIFY - Add process tool -└── index.ts # MODIFY - Export new module - -src/main/presenter/sessionPresenter/ -└── sessionPaths.ts # EXISTING - Used for offload paths -``` - -## Offload Strategy - -``` -Output Collection: -1. Write to memory buffer initially -2. When buffer > 10KB threshold: - - Write buffer to file: ~/.deepchat/sessions//bgexec_.log - - Clear memory buffer -3. Subsequent output appended directly to file - -Poll Response: -- If offloaded: return last 500 chars from file -- Else: return last 500 chars from buffer - -Log Response: -- If offloaded: read from file with offset/limit -- Else: slice from buffer with offset/limit -``` - -## Security Considerations - -1. **Session Isolation**: Map key is `conversationId`, prevents cross-agent access -2. **Path Security**: Offload files use resolved session directory -3. **Resource Limits**: TTL cleanup prevents resource exhaustion -4. **Kill Safety**: SIGTERM before SIGKILL, timeout handling - -## Error Handling - -| Scenario | Response | -|----------|----------| -| Session not found | Error: "Session X not found" | -| Write to non-running session | Error: "Session X is not running" | -| Kill already dead session | No-op (idempotent) | -| Remove with cleanup failure | Log warning, continue removal | -| File read error | Return empty string, log warning | - -## Testing Strategy - -### Unit Tests (BackgroundExecSessionManager) - -- `start()`: Verify session creation, process spawn -- `poll()`: Verify output retrieval, offloading behavior -- `log()`: Verify pagination with offset/limit -- `write()`: Verify stdin writing -- `kill()`: Verify process termination -- `clear()`: Verify buffer/file clearing -- `remove()`: Verify complete cleanup -- `cleanup timer`: Verify TTL expiration - -### Integration Tests - -- End-to-end: execute_command (background) → process:poll → process:kill -- Offload flow: Large output → file creation → file reading -- Error scenarios: Invalid sessionId, terminated process - -## i18n Strings - -```json -{ - "tools": { - "process": { - "sessionNotFound": "Session {sessionId} not found", - "sessionNotRunning": "Session {sessionId} is not running", - "stdinNotAvailable": "Session {sessionId} stdin is not available" - } - } -} -``` - -## Migration Notes - -- No breaking changes to existing execute_command -- New parameters are optional with sensible defaults -- process tool is additive only diff --git a/docs/specs/process-tool/tasks.md b/docs/specs/process-tool/tasks.md deleted file mode 100644 index de5c18019..000000000 --- a/docs/specs/process-tool/tasks.md +++ /dev/null @@ -1,143 +0,0 @@ -# Process Tool Implementation Tasks - -## Phase 1: Core Infrastructure - -### Task 1.1: Create BackgroundExecSessionManager -**File:** `src/main/presenter/agentPresenter/acp/backgroundExecSessionManager.ts` - -**Subtasks:** -- [ ] Define BackgroundSession and SessionMeta interfaces -- [ ] Implement session storage Map structure -- [ ] Implement `start()` method with process spawning -- [ ] Implement output handling (buffer → file offload) -- [ ] Implement `poll()` method (recent output only) -- [ ] Implement `log()` method (pagination support) -- [ ] Implement `write()` method (stdin writing) -- [ ] Implement `kill()` method (SIGTERM → SIGKILL) -- [ ] Implement `clear()` method (buffer/file clearing) -- [ ] Implement `remove()` method (complete cleanup) -- [ ] Implement `list()` method (session metadata) -- [ ] Implement TTL cleanup timer -- [ ] Add environment variable config support -- [ ] Export singleton instance - -**Acceptance:** -- Can start a background process -- Can poll output -- Can kill process -- Cleanup works correctly - ---- - -### Task 1.2: Export from acp/index.ts -**File:** `src/main/presenter/agentPresenter/acp/index.ts` - -**Subtasks:** -- [ ] Add export for BackgroundExecSessionManager -- [ ] Add export for backgroundExecSessionManager singleton - ---- - -## Phase 2: Tool Integration - -### Task 2.1: Modify AgentBashHandler -**File:** `src/main/presenter/agentPresenter/acp/agentBashHandler.ts` - -**Subtasks:** -- [ ] Update ExecuteCommandArgsSchema with `background` and `yieldMs` params -- [ ] Modify `executeCommand()` to detect background mode -- [ ] Add `executeCommandBackground()` method -- [ ] Import and use BackgroundExecSessionManager -- [ ] Return `{status: "running", sessionId}` for background mode - -**Acceptance:** -- `execute_command` with `background: true` returns sessionId -- Foreground mode unchanged - ---- - -### Task 2.2: Add Process Tool to AgentToolManager -**File:** `src/main/presenter/agentPresenter/acp/agentToolManager.ts` - -**Subtasks:** -- [ ] Add process tool schema definition -- [ ] Add process tool to `getAllToolDefinitions()` -- [ ] Add `isProcessTool()` helper -- [ ] Add `callProcessTool()` method -- [ ] Route process tool in `callTool()` switch -- [ ] Import BackgroundExecSessionManager - -**Process Actions to Implement:** -- [ ] `list` - return session list -- [ ] `poll` - return recent output -- [ ] `log` - return paginated output -- [ ] `write` - write to stdin -- [ ] `kill` - terminate process -- [ ] `clear` - clear output -- [ ] `remove` - delete session - -**Acceptance:** -- process tool appears in tool list -- All actions work correctly -- Proper error handling - ---- - -## Phase 3: Polish & Quality - -### Task 3.1: Add i18n Strings -**Files:** -- `src/renderer/src/i18n/en-US.json` -- `src/renderer/src/i18n/zh-CN.json` - -**Subtasks:** -- [ ] Add error message keys for process tool -- [ ] Add tool description strings - ---- - -### Task 3.2: Write Tests -**File:** `test/main/presenter/agentPresenter/acp/backgroundExecSessionManager.test.ts` - -**Subtasks:** -- [ ] Test session start -- [ ] Test poll output -- [ ] Test log pagination -- [ ] Test write to stdin -- [ ] Test kill process -- [ ] Test clear/remove -- [ ] Test list sessions -- [ ] Test TTL cleanup -- [ ] Test offload behavior - ---- - -### Task 3.3: Code Quality -**Command:** -```bash -pnpm run format -pnpm run lint -pnpm run typecheck -``` - ---- - -## Task Dependencies - -``` -Task 1.1 ──┬──→ Task 2.1 ──┬──→ Task 2.2 - │ │ - └──→ Task 1.2 ──┘ - -Task 2.2 ──┬──→ Task 3.1 - ├──→ Task 3.2 - └──→ Task 3.3 -``` - -## Definition of Done - -- [ ] All tasks complete -- [ ] Tests passing -- [ ] Lint/format/typecheck passing -- [ ] Spec/plan documents complete -- [ ] Manual testing verified diff --git a/docs/specs/skills-system/tasks.md b/docs/specs/skills-system/tasks.md deleted file mode 100644 index d254e3e04..000000000 --- a/docs/specs/skills-system/tasks.md +++ /dev/null @@ -1,255 +0,0 @@ -# DeepChat Skills 系统开发任务清单 - -## 概述 - -本文档基于 [design.md](./design.md) 和 [ui-design.md](./ui-design.md) 制定,用于开发跟踪。 - -**预计工作量**:中等复杂度功能,涉及 Main/Renderer 双端开发 - ---- - -## Phase 1: 核心基础设施 - -### 1.1 数据模型与类型定义 - -- [x] **1.1.1** 在 `src/shared/` 中定义 Skills 相关类型 - ```typescript - interface SkillMetadata { - name: string - description: string - path: string - skillRoot: string - allowedTools?: string[] - } - - interface SkillContent { - name: string - content: string - } - ``` - -- [x] **1.1.2** 扩展 Conversation 类型,添加 `activeSkills?: string[]` 字段 - -### 1.2 数据库 Schema 扩展 - -- [x] **1.2.1** 修改 `chat.db` 的 conversations 表,添加 `active_skills` 列(JSON 序列化) -- [x] **1.2.2** 添加数据库迁移脚本 - -### 1.3 配置系统扩展 - -- [x] **1.3.1** 在 ConfigPresenter 中添加 Skills 配置项 - - `skillsPath`: Skills 目录路径 (`~/.deepchat/skills/`) - - `enableSkills`: 全局开关(默认 true) - -### 1.4 SkillPresenter 实现 - -- [x] **1.4.1** 创建 `src/main/presenter/skillPresenter/index.ts` -- [x] **1.4.2** 实现 `getSkillsDir()` - 获取 Skills 根目录路径 -- [x] **1.4.3** 实现 `discoverSkills()` - 扫描目录,解析 SKILL.md frontmatter -- [x] **1.4.4** 实现 `getMetadataList()` - 返回所有 Skill 的 Metadata -- [x] **1.4.5** 实现 `loadSkillContent(name)` - 读取完整 SKILL.md,替换路径变量 -- [x] **1.4.6** 实现 `getMetadataPrompt()` - 生成注入 Context 的 Metadata 文本 - -### 1.5 安装与卸载功能 - -- [x] **1.5.1** 实现 `installBuiltinSkills()` - 首次启动时安装内置 Skills -- [x] **1.5.2** 实现 `installFromFolder(folderPath)` - 从本地文件夹安装 -- [x] **1.5.3** 实现 `installFromZip(zipPath)` - 从 ZIP 文件安装 -- [x] **1.5.4** 实现 `installFromUrl(url)` - 从 URL 下载安装 -- [x] **1.5.5** 实现 `uninstallSkill(name)` - 卸载(删除文件夹) -- [x] **1.5.6** 实现 name 与目录名一致性验证(自动重命名) -- [x] **1.5.7** 实现同名冲突检测与覆盖逻辑 - -### 1.6 热加载机制 - -- [x] **1.6.1** 实现 `watchSkillFiles()` - 使用 chokidar 监控 skills 目录 -- [x] **1.6.2** 文件变化时重新解析 Metadata -- [x] **1.6.3** 发送 `SKILL_EVENTS.METADATA_UPDATED` 事件 - -### 1.7 会话状态管理 - -- [x] **1.7.1** 实现 `getActiveSkills(conversationId)` - 从数据库加载激活状态 -- [x] **1.7.2** 实现 `setActiveSkills(conversationId, skills)` - 持久化激活状态 -- [x] **1.7.3** 实现 `validateSkillNames(names)` - 过滤已不存在的 Skill - ---- - -## Phase 2: Agent Loop 集成 - -### 2.1 工具定义与注册 - -- [x] **2.1.1** 创建 `src/main/presenter/skillPresenter/skillTools.ts` -- [x] **2.1.2** 实现 `skill_list` 工具 - 列出可用 Skills 及激活状态 -- [x] **2.1.3** 实现 `skill_control` 工具 - 激活/停用 Skill -- [x] **2.1.4** 在 ToolPresenter 中注册 Skills 工具(仅当 enableSkills = true) - -### 2.2 Context 构建集成 - -- [x] **2.2.1** 修改 AgentLoopHandler,添加 enableSkills 检查 -- [x] **2.2.2** 在系统提示中注入 Metadata 列表(含 Skills 根目录路径) -- [x] **2.2.3** 检测激活 Skills,加载完整内容注入系统提示 -- [x] **2.2.4** 实现路径变量替换(`${SKILL_ROOT}`, `${SKILLS_DIR}`) - -### 2.3 工具列表合并 - -- [x] **2.3.1** 实现 `getActiveSkillsAllowedTools(conversationId)` -- [x] **2.3.2** 在构建 LLM 工具列表时合并 allowedTools(并集) - -### 2.4 事件系统 - -- [x] **2.4.1** 在 `src/main/events.ts` 中定义 SKILL_EVENTS 常量 - ```typescript - const SKILL_EVENTS = { - DISCOVERED: 'skill:discovered', - METADATA_UPDATED: 'skill:metadata-updated', - INSTALLED: 'skill:installed', - UNINSTALLED: 'skill:uninstalled', - ACTIVATED: 'skill:activated', - DEACTIVATED: 'skill:deactivated' - } - ``` -- [x] **2.4.2** 在相应操作时发送事件 - ---- - -## Phase 3: UI 实现 - -### 3.1 路由与导航 - -- [x] **3.1.1** 在 Settings 路由配置中添加 `/skills` 路由 -- [~] **3.1.2** 添加导航菜单项(图标/位置与 spec 不一致) - -### 3.2 Pinia Store - -- [x] **3.2.1** 创建 `src/renderer/src/stores/skills.ts`(已存在 `skillsStore.ts`) -- [x] **3.2.2** 实现 state: `skills`, `loading`, `error` -- [x] **3.2.3** 实现 actions: `loadSkills`, `installFromFolder`, `installFromZip`, `installFromUrl`, `uninstall`, `updateSkill` - -### 3.3 主页面组件 - -- [x] **3.3.1** 创建 `src/renderer/settings/components/skills/SkillsSettings.vue` -- [x] **3.3.2** 实现页面整体布局(Header + ScrollArea + Footer) -- [x] **3.3.3** 实现空状态展示 -- [x] **3.3.4** 实现卡片网格布局(`grid grid-cols-1 md:grid-cols-2`) -- [x] **3.3.5** 监听 SKILL_EVENTS 实时更新 - -### 3.4 Header 组件 - -- [x] **3.4.1** 创建 `SkillsHeader.vue` -- [x] **3.4.2** 实现搜索输入框 -- [~] **3.4.3** 实现导入下拉菜单(文件夹/ZIP/URL)(通过 Dialog Tab 实现) -- [x] **3.4.4** 实现安装按钮 - -### 3.5 Skill 卡片组件 - -- [x] **3.5.1** 创建 `SkillCard.vue` -- [x] **3.5.2** 实现卡片展示(名称、描述、allowedTools) -- [x] **3.5.3** 实现编辑/删除操作按钮 -- [x] **3.5.4** 实现 hover 效果 - -### 3.6 编辑侧边栏 - -- [x] **3.6.1** 创建 `SkillEditorSheet.vue`(独立组件) -- [x] **3.6.2** 实现 frontmatter 字段编辑(name, description, allowedTools) -- [x] **3.6.3** 实现 Markdown 内容编辑 -- [x] **3.6.4** 实现文件夹结构展示(只读) -- [x] **3.6.5** 实现保存逻辑(写回 SKILL.md) - -### 3.7 安装对话框 - -- [x] **3.7.1** 创建 `SkillInstallDialog.vue`(独立组件) -- [x] **3.7.2** 实现 Tab 切换(文件夹/ZIP/URL) -- [~] **3.7.3** 实现文件夹选择(支持拖拽)(仅选择,拖拽待实现) -- [~] **3.7.4** 实现 ZIP 文件选择(支持拖拽)(仅选择,拖拽待实现) -- [x] **3.7.5** 实现 URL 输入 -- [x] **3.7.6** 实现安装流程与进度提示 -- [x] **3.7.7** 实现冲突确认对话框 - -### 3.8 文件夹树组件 - -- [x] **3.8.1** 创建 `SkillFolderTree.vue` 和 `SkillFolderTreeNode.vue` -- [x] **3.8.2** 实现 `getSkillFolderTree(name)` Presenter 方法 -- [x] **3.8.3** 实现树形结构展示 - -### 3.9 删除确认 - -- [x] **3.9.1** 实现删除确认 AlertDialog -- [x] **3.9.2** 实现删除后 Toast 提示 - ---- - -## Phase 4: 国际化与完善 - -### 4.1 i18n - -- [x] **4.1.1** 添加中文 i18n keys (`zh-CN`) -- [x] **4.1.2** 添加英文 i18n keys (`en-US`) -- [x] **4.1.3** 运行 `pnpm run i18n` 检查完整性 - -### 4.2 内置 Skills - -- [x] **4.2.1** 设计并编写 1-2 个内置 Skill 示例 - - `code-review`: 代码审查助手 - - `git-commit`: Git 提交信息生成助手 -- [x] **4.2.2** 打包内置 Skills 到应用资源(electron-builder.yml extraResources) -- [x] **4.2.3** 首次启动时自动安装(已在 SkillPresenter.initialize() 中实现) - -### 4.3 测试 - -- [x] **4.3.1** SkillPresenter 单元测试 -- [x] **4.3.2** skill_list / skill_control 工具测试 -- [x] **4.3.3** 安装/卸载流程测试 -- [ ] **4.3.4** UI 组件测试(可选) - -### 4.4 文档与清理 - -- [ ] **4.4.1** 更新 README 或用户文档 -- [ ] **4.4.2** 代码审查与清理 -- [x] **4.4.3** 运行 `pnpm run format && pnpm run lint && pnpm run typecheck` - ---- - -## 依赖关系 - -``` -Phase 1 (基础设施) - │ - ├── 1.1-1.3 类型/数据库/配置 ─┐ - │ │ - ├── 1.4 SkillPresenter ──────┼── Phase 2 (Agent Loop) - │ │ │ - ├── 1.5 安装/卸载 ───────────┤ ├── 2.1-2.2 工具/Context - │ │ │ - ├── 1.6 热加载 ──────────────┤ └── 2.3-2.4 工具合并/事件 - │ │ - └── 1.7 会话状态 ────────────┘ - │ - ▼ - Phase 3 (UI) - │ - ├── 3.1-3.2 路由/Store - │ - └── 3.3-3.9 组件 - │ - ▼ - Phase 4 (完善) -``` - ---- - -## 里程碑 - -| 里程碑 | 完成标准 | -|--------|----------| -| **M1: 核心功能** | SkillPresenter 完成,能发现、安装、卸载 Skills | -| **M2: Agent 集成** | skill_list/skill_control 工具可用,激活后内容注入 Context | -| **M3: UI 完成** | Settings 页面可用,支持全部安装方式 | -| **M4: 发布就绪** | i18n 完成,测试通过,代码审查完成 | - ---- - -## 备注 - -- 开发过程中如有设计变更,及时更新 design.md 和 ui-design.md -- 每个任务完成后在本文档标记 `[x]` -- 建议按 Phase 顺序推进,Phase 内可并行 diff --git a/docs/specs/tool-output-guardrails/plan.md b/docs/specs/tool-output-guardrails/plan.md deleted file mode 100644 index 638435e29..000000000 --- a/docs/specs/tool-output-guardrails/plan.md +++ /dev/null @@ -1,87 +0,0 @@ -# 实施计划 - -## 现状梳理 - -- 真正的工具路由在 `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/tasks.md b/docs/specs/tool-output-guardrails/tasks.md deleted file mode 100644 index 4896ff3b5..000000000 --- a/docs/specs/tool-output-guardrails/tasks.md +++ /dev/null @@ -1,12 +0,0 @@ -# 任务拆分 - -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/docs/specs/yobrowser-optimization/plan.md b/docs/specs/yobrowser-optimization/plan.md deleted file mode 100644 index 37bfac326..000000000 --- a/docs/specs/yobrowser-optimization/plan.md +++ /dev/null @@ -1,83 +0,0 @@ -# YoBrowser Optimization:实施方案(Plan) - -## 现状盘点(基于代码) - -- Renderer:`src/renderer/src/components/workspace/WorkspaceView.vue` 在 `agent` 模式下渲染 `WorkspaceBrowserTabs`,但不关心是否存在 tabs。 -- Renderer:`src/renderer/src/stores/yoBrowser.ts` 已维护 tabs 与 `tabCount`(由 IPC 事件更新)。 -- Main:YoBrowser 通过 `YoBrowserToolHandler` + `YoBrowserToolDefinitions` 暴露 `yo_browser_*` 工具,当前有 skill gating 逻辑(需要激活 `yo-browser-cdp` skill)。 -- Agent loop:`src/main/presenter/agentPresenter/loop/toolCallProcessor.ts` 中 `TOOLS_REQUIRING_OFFLOAD` 包含 `yo_browser_cdp_send`。 - -## 总体设计 - -1) UI:Browser Tabs 分区只在 `tabCount > 0` 时出现。 -2) YoBrowser 工具直接注入:agent 模式下直接提供 `yo_browser_*` 工具,不依赖 skills 体系。 -3) 工具实现保持 CDP 方式:`yo_browser_cdp_send` + tab 管理,参数 schema 按 CDP 定义。 - -> 约束:不做任何 system prompt / browser context 缩减。 - ---- - -## 1) UI:Browser Tabs 分区仅在 `tabCount > 0` 时渲染 - -- 修改 `WorkspaceView.vue`: - - 引入 `useYoBrowserStore()`。 - - 将 `showBrowserTabs` 改为:`chatMode.currentMode.value === 'agent' && yoBrowserStore.tabCount > 0`。 - -说明: -- `yoBrowserStore.tabCount` 已存在且由 tabs 数组计算。 -- tabs 更新依赖现有 `YO_BROWSER_EVENTS.*`(TAB_CREATED/TAB_CLOSED/TAB_COUNT_CHANGED 等),无需新增事件。 - ---- - -## 2) YoBrowser 工具直接注入(agent 模式,不依赖 skills) - -### 2.1 移除 tool definitions 的 skill gating - -- `src/main/presenter/browser/YoBrowserToolHandler.ts` - - 删除 `getActiveSkills()` 方法或不再使用。 - - `getToolDefinitions()` 直接返回 `getYoBrowserToolDefinitions()`(不再受 `activeSkills` 控制)。 - -### 2.2 同步更新 AgentToolManager 注入逻辑 - -- `src/main/presenter/agentPresenter/acp/agentToolManager.ts` - - `getAllToolDefinitions()` 中,在 agent 模式下直接追加 `yoBrowserPresenter.toolHandler.getToolDefinitions()`(不再传递/依赖 conversationId 做 gating)。 - - `callTool()` 中,`toolName.startsWith('yo_browser_')` 分支保持不变(继续路由到 YoBrowser handler)。 - -### 2.3 移除 skill 文档与残留引用 - -- 删除 `resources/skills/yo-browser-cdp/` 整个目录。 -- `docs/architecture/tool-system.md`: - - 删除或改写“YoBrowser CDP 工具仅在 `yo-browser-cdp` skill 激活时可用”的描述。 - - 改为:“YoBrowser CDP 工具在 agent 模式下直接可用”。 -- 全局搜索 `yo-browser-cdp` / `allowedTools` / `skill gated`,确保没有残留引用(代码、文档、测试)。 - ---- - -## 3) 工具实现:CDP 方式 + 参数定义(保持现状) - -### 3.1 工具集合(无需改动) - -- `yo_browser_tab_list` -- `yo_browser_tab_new` -- `yo_browser_tab_activate` -- `yo_browser_tab_close` -- `yo_browser_cdp_send` - -### 3.2 参数 schema(保持现状,无需改动) - -- `src/main/presenter/browser/YoBrowserToolDefinitions.ts`: - - `cdp_send` 参数:`{ tabId?: string, method: string, params?: object }`。 - - 其他 tab 管理工具参数保持不变。 - -### 3.3 安全边界(保持现状) - -- `src/main/presenter/browser/BrowserTab.ensureSession()`: - - 检查 `currentUrl.startsWith('local://')`,若为真则抛出错误(禁止 CDP attach)。 - ---- - -## 不在本计划内 - -- system prompt / browser context 的缩减或重写。 -- 任何对 YoBrowser UI 行为(窗口位置/大小等)的调整。 -- skills 体系(YoBrowser 不再使用 skills 来控制工具可见性)。 diff --git a/docs/specs/yobrowser-optimization/tasks.md b/docs/specs/yobrowser-optimization/tasks.md deleted file mode 100644 index 25b972712..000000000 --- a/docs/specs/yobrowser-optimization/tasks.md +++ /dev/null @@ -1,61 +0,0 @@ -# YoBrowser Optimization:任务拆分(Tasks) - -## Phase 1:UI(Workspace 侧边栏) - -1. 调整调整 Browser Tabs 分区显示条件 -- 文件:`src/renderer/src/components/workspace/WorkspaceView.vue` -- 改动:`WorkspaceBrowserTabs` 仅在 `chatMode === 'agent' && yoBrowserStore.tabCount > 0` 时渲染。 -- 验收:无 tabs 时不出现分区;有 tabs 时出现并能点击切换。 - -2.(可选)补 renderer 单测 -- 文件:`test/renderer/**`(按现有测试组织落位) -- 用例:tabCount=0/1 下的条件渲染。 - ---- - -## Phase 2:移除 YoBrowser skill gating - -3. 移除 YoBrowser tool definitions 的 skill gating -- 文件:`src/main/presenter/browser/YoBrowserToolHandler.ts` -- 改动:删除 `getActiveSkills()` 方法或不再使用;`getToolDefinitions()` 直接返回 `getYoBrowserToolDefinitions()`。 -- 验收:不再依赖 `activeSkills`。 - -4. 调整 AgentToolManager 注入逻辑(不再依赖 conversationId 做 gating) -- 文件:`src/main/presenter/agentPresenter/acp/agentToolManager.ts` -- 改动:`getAllToolDefinitions()` 中,agent 模式下直接追加 `yoBrowserPresenter.toolHandler.getToolDefinitions()`(可不传 conversationId)。 -- 验收:tool definitions 包含 `yo_browser_*`。 - -5. 删除 skill 文档与残留引用 -- 删除 `resources/skills/yo-browser-cdp/` 整个目录。 -- 文件:`docs/architecture/tool-system.md`(以及搜索到的其他文档) -- 改动:删除或改写“仅在 `yo-browser-cdp` skill 激活时可用”的描述;改为“agent 模式下直接可用”。 -- 全局搜索:确认没有残留的 `yo-browser-cdp` / `skill gated` 引用。 - ---- - -## Phase 3:验证工具实现(保持 CDP 方式) - -6. 验证工具参数定义 -- 文件:`src/main/presenter/browser/YoBrowserToolDefinitions.ts` -- 验收:`yo_browser_cdp_send` 参数为 `{ tabId?: string, method: string, params?: object }`。 - -7. 验证安全边界 -- 文件:`src/main/presenter/browser/BrowserTab.ts` -- 验收:`ensureSession()` 中有 `local://` URL 检查。 - -8.(可选)补 main 单测 -- 验证: - - agent 模式下 tool definitions 包含 `yo_browser_*`。 - - `callTool()` 正确路由到 YoBrowser handler。 - ---- - -## Phase 4:验收与质量门禁 - -9. 手工验收 -- Agent 模式下:无 tabs 时 Workspace 不显示 Browser Tabs;创建 tab 后显示。 -- Agent 模式下:不激活任何 skill,`yo_browser_*` 工具直接可用。 - -10. 质量门禁 -- `pnpm run format && pnpm run lint && pnpm run typecheck` -- `pnpm test` diff --git a/src/main/presenter/agentPresenter/acp/agentBashHandler.ts b/src/main/presenter/agentPresenter/acp/agentBashHandler.ts index 2a780c5dc..c5fca9712 100644 --- a/src/main/presenter/agentPresenter/acp/agentBashHandler.ts +++ b/src/main/presenter/agentPresenter/acp/agentBashHandler.ts @@ -23,6 +23,7 @@ const ExecuteCommandArgsSchema = z.object({ command: z.string().min(1), timeout: z.number().min(100).optional(), description: z.string().min(5).max(100), + cwd: z.string().optional(), background: z.boolean().optional().default(false), yieldMs: z.number().min(100).optional() }) @@ -55,11 +56,12 @@ export class AgentBashHandler { throw new Error(`Invalid arguments: ${parsed.error}`) } - const { command, timeout, background } = parsed.data + const { command, timeout, background, cwd: requestedCwd } = parsed.data + const cwd = this.resolveWorkingDirectory(requestedCwd) // Handle background execution if (background) { - return this.executeCommandBackground(command, timeout, options) + return this.executeCommandBackground(command, timeout, cwd, options) } if (this.commandPermissionHandler) { @@ -72,7 +74,7 @@ export class AgentBashHandler { const responseContent = 'components.messageBlockPermissionRequest.description.commandWithRisk' throw new CommandPermissionRequiredError(responseContent, { - toolName: 'execute_command', + toolName: 'exec', serverName: 'agent-filesystem', permissionType: 'command', description: 'Execute command requires approval.', @@ -84,7 +86,6 @@ export class AgentBashHandler { } } - const cwd = this.allowedDirectories[0] const startedAt = Date.now() const snippetId = options.snippetId ?? nanoid() @@ -181,6 +182,39 @@ export class AgentBashHandler { return path.normalize(p) } + private normalizeForComparison(inputPath: string): string { + const normalized = this.normalizePath(path.resolve(inputPath)) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized + } + + private isPathAllowed(targetPath: string): boolean { + const normalizedTarget = this.normalizeForComparison(targetPath) + return this.allowedDirectories.some((allowedDirectory) => { + const normalizedAllowed = this.normalizeForComparison(allowedDirectory) + const relative = path.relative(normalizedAllowed, normalizedTarget) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) + }) + } + + private resolveWorkingDirectory(requestedCwd?: string): string { + const defaultCwd = this.allowedDirectories[0] + const normalizedInput = requestedCwd?.trim() + if (!normalizedInput) { + return defaultCwd + } + + const expanded = this.expandHome(normalizedInput) + const resolved = path.isAbsolute(expanded) + ? this.normalizePath(path.resolve(expanded)) + : this.normalizePath(path.resolve(defaultCwd, expanded)) + + if (!this.isPathAllowed(resolved)) { + throw new Error(`Working directory is not allowed: ${requestedCwd}`) + } + + return resolved + } + private expandHome(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)) @@ -298,9 +332,9 @@ export class AgentBashHandler { private async executeCommandBackground( command: string, timeout: number | undefined, + cwd: string, options: ExecuteCommandOptions ): Promise<{ status: 'running'; sessionId: string }> { - const cwd = this.allowedDirectories[0] const conversationId = options.conversationId if (!conversationId) { @@ -314,7 +348,7 @@ export class AgentBashHandler { throw new CommandPermissionRequiredError( 'components.messageBlockPermissionRequest.description.commandWithRisk', { - toolName: 'execute_command', + toolName: 'exec', serverName: 'agent-filesystem', permissionType: 'command', description: 'Execute command requires approval.', diff --git a/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts b/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts index 5554edc7c..6fde9a2b9 100644 --- a/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts +++ b/src/main/presenter/agentPresenter/acp/agentFileSystemHandler.ts @@ -12,7 +12,7 @@ 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 +// Auto-truncate threshold for read to avoid triggering tool output offload const READ_FILE_AUTO_TRUNCATE_THRESHOLD = 4500 const ReadFileArgsSchema = z.object({ diff --git a/src/main/presenter/agentPresenter/acp/agentToolManager.ts b/src/main/presenter/agentPresenter/acp/agentToolManager.ts index 8d7cdcae3..a2b9e8287 100644 --- a/src/main/presenter/agentPresenter/acp/agentToolManager.ts +++ b/src/main/presenter/agentPresenter/acp/agentToolManager.ts @@ -68,22 +68,15 @@ export class AgentToolManager { private chatSettingsHandler: ChatSettingsToolHandler | null = null 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'), + read: z.object({ + path: z.string(), + offset: z.number().int().min(0).optional().describe('Starting character offset (0-based)'), limit: z .number() .int() .positive() .optional() - .describe( - 'Maximum characters to read per file. Large files are auto-truncated if not specified' - ), + .describe('Maximum characters to read. Large files are auto-truncated if not specified'), base_directory: z .string() .optional() @@ -91,7 +84,7 @@ export class AgentToolManager { "Base directory for resolving relative paths. Required when using skills with relative paths. For skill-based operations, provide the skill's root directory path." ) }), - write_file: z.object({ + write: z.object({ path: z.string(), content: z.string(), base_directory: z @@ -101,119 +94,53 @@ export class AgentToolManager { 'Base directory for resolving relative paths. Required when using skills with relative paths.' ) }), - list_directory: z.object({ - path: z.string(), - showDetails: z.boolean().default(false), - sortBy: z.enum(['name', 'size', 'modified']).default('name'), - base_directory: z.string().optional().describe('Base directory for resolving relative paths.') - }), - create_directory: z.object({ + ls: z.object({ path: z.string(), + depth: z.number().int().min(0).max(3).default(1), base_directory: z.string().optional().describe('Base directory for resolving relative paths.') }), - move_files: z.object({ - sources: z.array(z.string()).min(1), - destination: z.string(), - base_directory: z.string().optional().describe('Base directory for resolving relative paths.') - }), - edit_text: z.object({ + edit: z.object({ path: z.string(), - operation: z.enum(['replace_pattern', 'edit_lines']), - pattern: z + oldText: z .string() - .max(1000) - .describe( - 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS). Required when operation is "replace_pattern"' - ) - .optional(), - replacement: z.string().optional(), - global: z.boolean().default(true), - caseSensitive: z.boolean().default(false), - edits: z - .array( - z.object({ - oldText: z.string(), - newText: z.string() - }) - ) - .optional(), - dryRun: z.boolean().default(false), + .max(10000) + .describe('The exact text to find and replace (case-sensitive)'), + newText: z.string().max(10000).describe('The replacement text'), + replaceAll: z.boolean().default(true), base_directory: z.string().optional().describe('Base directory for resolving relative paths.') }), - glob_search: z.object({ + find: z.object({ pattern: z.string().describe('Glob pattern (e.g., **/*.ts, src/**/*.js)'), - root: z + path: z .string() .optional() .describe('Root directory for search (defaults to workspace root)'), - excludePatterns: z + exclude: z .array(z.string()) .optional() .default([]) .describe('Patterns to exclude (e.g., ["node_modules", ".git"])'), maxResults: z.number().default(1000).describe('Maximum number of results to return'), - sortBy: z - .enum(['name', 'modified']) - .default('name') - .describe('Sort results by name or modification time') + base_directory: z.string().optional().describe('Base directory for resolving relative paths.') }), - grep_search: z.object({ - path: z.string(), + grep: z.object({ pattern: z .string() .max(1000) .describe( 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' ), + path: z.string().optional().default('.'), filePattern: z.string().optional(), recursive: z.boolean().default(true), caseSensitive: z.boolean().default(false), - includeLineNumbers: z.boolean().default(true), contextLines: z.number().default(0), maxResults: z.number().default(100), base_directory: z.string().optional().describe('Base directory for resolving relative paths.') }), - text_replace: z.object({ - path: z.string(), - pattern: z - .string() - .max(1000) - .describe( - 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' - ), - replacement: z.string(), - global: z.boolean().default(true), - caseSensitive: z.boolean().default(false), - dryRun: z.boolean().default(false), - base_directory: z.string().optional().describe('Base directory for resolving relative paths.') - }), - edit_file: z.object({ - path: z.string().describe('Path to the file to edit'), - oldText: z - .string() - .max(10000) - .describe('The exact text to find and replace (case-sensitive)'), - newText: z.string().max(10000).describe('The replacement text'), - base_directory: z.string().optional().describe('Base directory for resolving relative paths.') - }), - 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({ - path: z.string(), - base_directory: z.string().optional().describe('Base directory for resolving relative paths.') - }), - execute_command: z.object({ + exec: z.object({ command: z.string().min(1).describe('The shell command to execute'), - timeout: z + timeoutMs: z .number() .min(100) .max(600000) @@ -223,9 +150,11 @@ export class AgentToolManager { .string() .min(5) .max(100) + .optional() .describe( 'Brief description of what the command does (e.g., "Install dependencies", "Start dev server")' ), + cwd: z.string().optional().describe('Optional working directory for command execution.'), background: z .boolean() .optional() @@ -468,10 +397,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'read_file', + name: 'read', description: - "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 { + "Read the contents of a file. 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) as { type: string properties: Record required?: string[] @@ -486,28 +415,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'write_file', + name: 'write', description: "Write content to a file. For skill files, provide base_directory as the skill's root directory.", - parameters: zodToJsonSchema(schemas.write_file) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'list_directory', - description: - 'List files and directories in a path. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.list_directory) as { + parameters: zodToJsonSchema(schemas.write) as { type: string properties: Record required?: string[] @@ -522,9 +433,9 @@ export class AgentToolManager { { type: 'function', function: { - name: 'create_directory', - description: 'Create a directory. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.create_directory) as { + name: 'ls', + description: 'List files and directories in a path.', + parameters: zodToJsonSchema(schemas.ls) as { type: string properties: Record required?: string[] @@ -539,10 +450,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'move_files', + name: 'edit', description: - 'Move or rename files and directories. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.move_files) as { + 'Make precise edits to a file by replacing exact text strings. Set replaceAll=false to only replace the first match.', + parameters: zodToJsonSchema(schemas.edit) as { type: string properties: Record required?: string[] @@ -557,46 +468,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'edit_text', - description: - 'Edit text files using pattern replacement or line-based editing. When using "replace_pattern" operation, the pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.edit_text) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'glob_search', + name: 'find', description: 'Search for files using glob patterns (e.g., **/*.ts, src/**/*.js). Automatically excludes common directories like node_modules and .git.', - parameters: zodToJsonSchema(schemas.glob_search) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'directory_tree', - description: - '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 { + parameters: zodToJsonSchema(schemas.find) as { type: string properties: Record required?: string[] @@ -611,10 +486,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'get_file_info', + name: 'grep', description: - 'Get detailed metadata about a file or directory. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.get_file_info) as { + 'Search file contents using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', + parameters: zodToJsonSchema(schemas.grep) as { type: string properties: Record required?: string[] @@ -629,64 +504,10 @@ export class AgentToolManager { { type: 'function', function: { - name: 'grep_search', + name: 'exec', description: - 'Search file contents using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.grep_search) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'text_replace', - description: - 'Replace text in a file using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks. Provide base_directory for skill-relative paths.', - parameters: zodToJsonSchema(schemas.text_replace) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'edit_file', - description: - 'Make precise edits to files by replacing exact text strings. Use this for simple text replacements when you know the exact content to replace. For regex or complex operations, use edit_text instead.', - parameters: zodToJsonSchema(schemas.edit_file) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'agent-filesystem', - icons: '📁', - description: 'Agent FileSystem tools' - } - }, - { - type: 'function', - function: { - name: 'execute_command', - description: - 'Execute a shell command in the workspace directory. For long-running commands (builds, tests, servers, installations), use background: true to run asynchronously and get a session ID. Then use the process tool to poll output, send input, or manage the session. For quick commands that complete within seconds, run without background mode.', - parameters: zodToJsonSchema(schemas.execute_command) as { + 'Execute a shell command in the workspace directory. For long-running commands (builds, tests, servers, installations), use background: true to run asynchronously and get a session ID. Then use the process tool to poll output, send input, or manage the session.', + parameters: zodToJsonSchema(schemas.exec) as { type: string properties: Record required?: string[] @@ -703,7 +524,7 @@ export class AgentToolManager { function: { name: 'process', description: - 'Manage background exec sessions created by execute_command with background: true. Use poll to check output and status, log to get full output with pagination, write to send input to stdin, kill to terminate, and remove to clean up completed sessions.', + 'Manage background exec sessions created by exec with background: true. Use poll to check output and status, log to get full output with pagination, write to send input to stdin, kill to terminate, and remove to clean up completed sessions.', parameters: zodToJsonSchema(schemas.process) as { type: string properties: Record @@ -744,22 +565,7 @@ export class AgentToolManager { } private isFileSystemTool(toolName: string): boolean { - const filesystemTools = [ - 'read_file', - 'write_file', - 'list_directory', - 'create_directory', - 'move_files', - 'edit_text', - 'glob_search', - 'directory_tree', - 'get_file_info', - 'grep_search', - 'text_replace', - 'edit_file', - 'execute_command', - 'process' - ] + const filesystemTools = ['read', 'write', 'ls', 'edit', 'find', 'grep', 'exec', 'process'] return filesystemTools.includes(toolName) } @@ -877,17 +683,6 @@ export class AgentToolManager { throw new Error(`No schema found for FileSystem tool: ${toolName}`) } - // Normalize parameter aliases for edit_file tool - if (toolName === 'edit_file') { - args = { - ...args, - path: args.path ?? args.file_path, - oldText: args.oldText ?? args.old_string, - newText: args.newText ?? args.new_string, - base_directory: args.base_directory - } - } - const validationResult = schema.safeParse(args) if (!validationResult.success) { throw new Error(`Invalid arguments for ${toolName}: ${validationResult.error.message}`) @@ -918,9 +713,24 @@ export class AgentToolManager { try { switch (toolName) { - case 'read_file': - return { content: await fileSystemHandler.readFile(parsedArgs, baseDirectory) } - case 'write_file': + case 'read': { + const readArgs = parsedArgs as { + path: string + offset?: number + limit?: number + } + return { + content: await fileSystemHandler.readFile( + { + paths: [readArgs.path], + offset: readArgs.offset, + limit: readArgs.limit + }, + baseDirectory + ) + } + } + case 'write': this.assertWritePermission( toolName, parsedArgs, @@ -929,55 +739,27 @@ export class AgentToolManager { conversationId ) return { content: await fileSystemHandler.writeFile(parsedArgs, baseDirectory) } - case 'list_directory': - return { content: await fileSystemHandler.listDirectory(parsedArgs, baseDirectory) } - case 'create_directory': - this.assertWritePermission( - toolName, - parsedArgs, - baseDirectory, - fileSystemHandler, - conversationId - ) + case 'ls': { + const lsArgs = parsedArgs as { + path: string + depth?: number + } + if ((lsArgs.depth ?? 1) > 1) { + return { + content: await fileSystemHandler.directoryTree( + { path: lsArgs.path, depth: lsArgs.depth }, + baseDirectory + ) + } + } return { - content: await fileSystemHandler.createDirectory(parsedArgs, baseDirectory) + content: await fileSystemHandler.listDirectory( + { path: lsArgs.path, showDetails: false, sortBy: 'name' }, + baseDirectory + ) } - case 'move_files': - this.assertWritePermission( - toolName, - parsedArgs, - baseDirectory, - fileSystemHandler, - conversationId - ) - return { content: await fileSystemHandler.moveFiles(parsedArgs, baseDirectory) } - case 'edit_text': - this.assertWritePermission( - toolName, - parsedArgs, - baseDirectory, - fileSystemHandler, - conversationId - ) - return { content: await fileSystemHandler.editText(parsedArgs, baseDirectory) } - case 'glob_search': - return { content: await fileSystemHandler.globSearch(parsedArgs, baseDirectory) } - case 'directory_tree': - return { content: await fileSystemHandler.directoryTree(parsedArgs, baseDirectory) } - case 'get_file_info': - return { content: await fileSystemHandler.getFileInfo(parsedArgs, baseDirectory) } - case 'grep_search': - return { content: await fileSystemHandler.grepSearch(parsedArgs, baseDirectory) } - case 'text_replace': - this.assertWritePermission( - toolName, - parsedArgs, - baseDirectory, - fileSystemHandler, - conversationId - ) - return { content: await fileSystemHandler.textReplace(parsedArgs, baseDirectory) } - case 'edit_file': + } + case 'edit': { this.assertWritePermission( toolName, parsedArgs, @@ -985,18 +767,112 @@ export class AgentToolManager { fileSystemHandler, conversationId ) - return { content: await fileSystemHandler.editFile(parsedArgs, baseDirectory) } - case 'execute_command': + const editArgs = parsedArgs as { + path: string + oldText: string + newText: string + replaceAll?: boolean + } + if (editArgs.replaceAll === false) { + return { + content: await fileSystemHandler.editText( + { + path: editArgs.path, + operation: 'edit_lines', + edits: [{ oldText: editArgs.oldText, newText: editArgs.newText }], + dryRun: false + }, + baseDirectory + ) + } + } + return { + content: await fileSystemHandler.editFile( + { + path: editArgs.path, + oldText: editArgs.oldText, + newText: editArgs.newText + }, + baseDirectory + ) + } + } + case 'find': { + const findArgs = parsedArgs as { + pattern: string + path?: string + exclude?: string[] + maxResults?: number + } + return { + content: await fileSystemHandler.globSearch( + { + pattern: findArgs.pattern, + root: findArgs.path, + excludePatterns: findArgs.exclude, + maxResults: findArgs.maxResults, + sortBy: 'name' + }, + baseDirectory + ) + } + } + case 'grep': { + const grepArgs = parsedArgs as { + pattern: string + path?: string + filePattern?: string + recursive?: boolean + caseSensitive?: boolean + contextLines?: number + maxResults?: number + } + return { + content: await fileSystemHandler.grepSearch( + { + path: grepArgs.path ?? '.', + pattern: grepArgs.pattern, + filePattern: grepArgs.filePattern, + recursive: grepArgs.recursive ?? true, + caseSensitive: grepArgs.caseSensitive ?? false, + includeLineNumbers: true, + contextLines: grepArgs.contextLines ?? 0, + maxResults: grepArgs.maxResults ?? 100 + }, + baseDirectory + ) + } + } + case 'exec': { if (!this.bashHandler) { - throw new Error('Bash handler not initialized for execute_command tool') + throw new Error('Bash handler not initialized for exec tool') } - const commandResult = await this.bashHandler.executeCommand(parsedArgs, { - conversationId - }) + const execArgs = parsedArgs as { + command: string + timeoutMs?: number + description?: string + cwd?: string + background?: boolean + yieldMs?: number + } + const commandResult = await this.bashHandler.executeCommand( + { + command: execArgs.command, + timeout: execArgs.timeoutMs, + description: execArgs.description ?? 'Execute command', + cwd: execArgs.cwd, + background: execArgs.background, + yieldMs: execArgs.yieldMs + }, + { + conversationId + } + ) return { content: typeof commandResult === 'string' ? commandResult : JSON.stringify(commandResult) } + } default: throw new Error(`Unknown FileSystem tool: ${toolName}`) } @@ -1088,31 +964,11 @@ export class AgentToolManager { private collectWriteTargets(toolName: string, args: Record): string[] { switch (toolName) { - case 'write_file': { - const pathArg = args.path - return typeof pathArg === 'string' ? [pathArg] : [] - } - case 'create_directory': { - const pathArg = args.path - return typeof pathArg === 'string' ? [pathArg] : [] - } - case 'edit_text': { + case 'write': + case 'edit': { const pathArg = args.path return typeof pathArg === 'string' ? [pathArg] : [] } - case 'text_replace': { - const pathArg = args.path - return typeof pathArg === 'string' ? [pathArg] : [] - } - case 'move_files': { - const sources = Array.isArray(args.sources) - ? args.sources.filter((source): source is string => typeof source === 'string') - : [] - const destination = typeof args.destination === 'string' ? args.destination : undefined - if (!destination) return sources - const destinations = sources.map((source) => path.join(destination, path.basename(source))) - return [...sources, ...destinations] - } default: return [] } @@ -1236,21 +1092,8 @@ export class AgentToolManager { conversationId?: string } | null> { // Only file system write operations and command execution need pre-check - const writeTools = [ - 'write_file', - 'create_directory', - 'move_files', - 'edit_text', - 'text_replace', - 'edit_file' - ] - const readTools = [ - 'read_file', - 'list_directory', - 'directory_tree', - 'glob_search', - 'grep_search' - ] + const writeTools = ['write', 'edit'] + const readTools = ['read', 'ls', 'find', 'grep'] // Check for file system write operations if (this.isFileSystemTool(toolName)) { @@ -1259,7 +1102,7 @@ export class AgentToolManager { } // Handle command tools separately (they use command permission service) - if (toolName === 'execute_command') { + if (toolName === 'exec') { if (!this.bashHandler) { return null } @@ -1323,8 +1166,7 @@ export class AgentToolManager { // Collect target paths const targets = this.collectWriteTargets(toolName, args) if (targets.length === 0 && isWriteOperation) { - // Check for path in read operations too - const pathArg = (args.path as string) || (args.paths as string[])?.[0] + const pathArg = args.path as string | undefined if (pathArg) { targets.push(pathArg) } diff --git a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts index dbc7ed339..97a561ee7 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallProcessor.ts @@ -93,16 +93,8 @@ const TOOL_OUTPUT_PREVIEW_LENGTH = 1024 const QUESTION_ERROR_KEY = 'common.error.invalidQuestionRequest' // 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', - 'yo_browser_cdp_send' -]) +// Tools not in this list will never trigger offload (e.g., read has its own pagination) +const TOOLS_REQUIRING_OFFLOAD = new Set(['exec', 'ls', 'find', 'grep', 'yo_browser_cdp_send']) export class ToolCallProcessor { constructor(private readonly options: ToolCallProcessorOptions) {} diff --git a/src/main/presenter/agentPresenter/message/index.ts b/src/main/presenter/agentPresenter/message/index.ts index c17b59b34..4be94eb85 100644 --- a/src/main/presenter/agentPresenter/message/index.ts +++ b/src/main/presenter/agentPresenter/message/index.ts @@ -2,3 +2,4 @@ export * from './messageBuilder' export * from './messageCompressor' export * from './messageFormatter' export * from './messageTruncator' +export * from './systemEnvPromptBuilder' diff --git a/src/main/presenter/agentPresenter/message/messageBuilder.ts b/src/main/presenter/agentPresenter/message/messageBuilder.ts index 9cf6d5899..2600ef3bf 100644 --- a/src/main/presenter/agentPresenter/message/messageBuilder.ts +++ b/src/main/presenter/agentPresenter/message/messageBuilder.ts @@ -16,13 +16,13 @@ import { formatMessagesForCompletion, mergeConsecutiveMessages } from './messageFormatter' -import { BrowserContextBuilder } from '../../browser/BrowserContextBuilder' import { selectContextMessages } from './messageTruncator' import { buildSkillsMetadataPrompt, buildSkillsPrompt, getSkillsAllowedTools } from './skillsPromptBuilder' +import { buildRuntimeCapabilitiesPrompt, buildSystemEnvPrompt } from './systemEnvPromptBuilder' export type PendingToolCall = { id: string @@ -83,6 +83,25 @@ function appendPromptSection(base: string, section: string): string { return `${base}\n\n${trimmedSection}` } +export interface AgentSystemPromptSections { + basePrompt: string + runtimePrompt?: string + skillsMetadataPrompt?: string + skillsPrompt?: string + envPrompt?: string + toolingPrompt?: string +} + +export function composeAgentSystemPromptSections(sections: AgentSystemPromptSections): string { + let composed = sections.basePrompt?.trim() ?? '' + composed = appendPromptSection(composed, sections.runtimePrompt ?? '') + composed = appendPromptSection(composed, sections.skillsMetadataPrompt ?? '') + composed = appendPromptSection(composed, sections.skillsPrompt ?? '') + composed = appendPromptSection(composed, sections.envPrompt ?? '') + composed = appendPromptSection(composed, sections.toolingPrompt ?? '') + return composed +} + export async function preparePromptContent({ conversation, userContent, @@ -142,45 +161,46 @@ export async function preparePromptContent({ } } - let finalSystemPromptWithExtras = finalSystemPrompt + let runtimePrompt = '' + let skillsMetadataPrompt = '' + let skillsPrompt = '' + let envPrompt = '' + let toolingPrompt = '' if (!isImageGeneration && isAgentMode) { + runtimePrompt = buildRuntimeCapabilitiesPrompt() try { - const browserContext = await presenter.yoBrowserPresenter.getBrowserContext() - const browserContextPrompt = BrowserContextBuilder.buildSystemPrompt( - browserContext.tabs, - browserContext.activeTabId - ) - finalSystemPromptWithExtras = appendPromptSection( - finalSystemPromptWithExtras, - browserContextPrompt - ) + skillsMetadataPrompt = await buildSkillsMetadataPrompt() + skillsPrompt = await buildSkillsPrompt(conversation.id) } catch (error) { - console.warn('AgentPresenter: Failed to load Yo Browser context/tools', error) + console.warn('AgentPresenter: Failed to build skills prompt', error) + } + + try { + envPrompt = await buildSystemEnvPrompt({ + providerId, + modelId, + workdir: conversation.settings.agentWorkspacePath?.trim() || null + }) + } catch (error) { + console.warn('AgentPresenter: Failed to build system env prompt', error) } } if (!isImageGeneration && isToolPromptMode && toolDefinitions.length > 0) { - const toolPrompt = toolCallCenter.buildToolSystemPrompt({ + toolingPrompt = toolCallCenter.buildToolSystemPrompt({ conversationId: conversation.id }) - finalSystemPromptWithExtras = appendPromptSection(finalSystemPromptWithExtras, toolPrompt) } - if (!isImageGeneration && isAgentMode) { - try { - const skillsMetadataPrompt = await buildSkillsMetadataPrompt() - finalSystemPromptWithExtras = appendPromptSection( - finalSystemPromptWithExtras, - skillsMetadataPrompt - ) - - const skillsPrompt = await buildSkillsPrompt(conversation.id) - finalSystemPromptWithExtras = appendPromptSection(finalSystemPromptWithExtras, skillsPrompt) - } catch (error) { - console.warn('AgentPresenter: Failed to build skills prompt', error) - } - } + const finalSystemPromptWithExtras = composeAgentSystemPromptSections({ + basePrompt: finalSystemPrompt, + runtimePrompt, + skillsMetadataPrompt, + skillsPrompt, + envPrompt, + toolingPrompt + }) const systemPromptTokens = !isImageGeneration && finalSystemPromptWithExtras diff --git a/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts b/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts new file mode 100644 index 000000000..fdba156b3 --- /dev/null +++ b/src/main/presenter/agentPresenter/message/systemEnvPromptBuilder.ts @@ -0,0 +1,126 @@ +import * as fs from 'node:fs' +import path from 'node:path' +import { presenter } from '@/presenter' +import logger from '@shared/logger' + +export interface BuildSystemEnvPromptOptions { + providerId?: string + modelId?: string + workdir?: string | null + platform?: NodeJS.Platform + now?: Date + agentsFilePath?: string +} + +function resolveModelDisplayName(providerId: string, modelId: string): string | undefined { + try { + const models = presenter.configPresenter?.getProviderModels?.(providerId) || [] + const match = models.find((model) => model.id === modelId) + if (match?.name) { + return match.name + } + + const customModels = presenter.configPresenter?.getCustomModels?.(providerId) || [] + const customMatch = customModels.find((model) => model.id === modelId) + if (customMatch?.name) { + return customMatch.name + } + } catch (error) { + console.warn( + `[SystemEnvPromptBuilder] Failed to resolve model display name for ${providerId}/${modelId}:`, + error + ) + } + + return undefined +} + +function resolveModelIdentity( + providerId?: string, + modelId?: string +): { + modelName: string + exactModelId: string +} { + const trimmedProviderId = providerId?.trim() || 'unknown-provider' + const trimmedModelId = modelId?.trim() || 'unknown-model' + const displayName = resolveModelDisplayName(trimmedProviderId, trimmedModelId) + + return { + modelName: displayName || trimmedModelId, + exactModelId: `${trimmedProviderId}/${trimmedModelId}` + } +} + +function resolveWorkdir(workdir?: string | null): string { + const normalized = workdir?.trim() + if (normalized) { + return path.resolve(normalized) + } + return process.cwd() +} + +function isGitRepository(workdir: string): boolean { + let current = path.resolve(workdir) + while (true) { + if (fs.existsSync(path.join(current, '.git'))) { + return true + } + const parent = path.dirname(current) + if (parent === current) { + return false + } + current = parent + } +} + +async function readAgentsInstructions(sourcePath: string): Promise { + try { + return await fs.promises.readFile(sourcePath, 'utf8') + } catch (error) { + logger.warn('[SystemEnvPromptBuilder] Failed to read AGENTS.md', { + sourcePath, + error + }) + return '' + } +} + +export function buildRuntimeCapabilitiesPrompt(): string { + return [ + '## Runtime Capabilities', + '- YoBrowser tools are available for browser automation when needed.', + '- Use exec(background: true) to start long-running terminal commands.', + '- Use process(list|poll|log|write|kill|remove) to manage background terminal sessions.', + '- Before launching another long-running command, prefer process action "list" to inspect existing sessions.' + ].join('\n') +} + +export async function buildSystemEnvPrompt( + options: BuildSystemEnvPromptOptions = {} +): Promise { + const now = options.now ?? new Date() + const platform = options.platform ?? process.platform + const workdir = resolveWorkdir(options.workdir) + const agentsFilePath = options.agentsFilePath + ? path.resolve(options.agentsFilePath) + : path.join(workdir, 'AGENTS.md') + const agentsContent = await readAgentsInstructions(agentsFilePath) + const { modelName, exactModelId } = resolveModelIdentity(options.providerId, options.modelId) + + const promptLines = [ + `You are powered by the model named ${modelName}.`, + `The exact model ID is ${exactModelId}`, + `## Here is some useful information about the environment you are running in:`, + `Working directory: ${workdir}`, + `Is directory a git repo: ${isGitRepository(workdir) ? 'yes' : 'no'}`, + `Platform: ${platform}`, + `Today's date: ${now.toDateString()}` + ] + + if (agentsContent.trim().length > 0) { + promptLines.push(`Instructions from: ${agentsFilePath}\n`, agentsContent) + } + + return promptLines.join('\n') +} diff --git a/src/main/presenter/agentPresenter/utility/promptEnhancer.ts b/src/main/presenter/agentPresenter/utility/promptEnhancer.ts index a26ba8d5e..835f6f4f4 100644 --- a/src/main/presenter/agentPresenter/utility/promptEnhancer.ts +++ b/src/main/presenter/agentPresenter/utility/promptEnhancer.ts @@ -1,25 +1,3 @@ -type PlatformName = 'macOS' | 'Windows' | 'Linux' | 'Unknown' - -function formatCurrentDateTime(): string { - return new Date().toLocaleString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - hour12: false - }) -} - -function formatPlatformName(platform: NodeJS.Platform): PlatformName { - if (platform === 'darwin') return 'macOS' - if (platform === 'win32') return 'Windows' - if (platform === 'linux') return 'Linux' - return 'Unknown' -} - interface EnhanceOptions { isImageGeneration?: boolean isAgentMode?: boolean @@ -31,31 +9,9 @@ export function enhanceSystemPromptWithDateTime( systemPrompt: string, options: EnhanceOptions = {} ): string { - const { - isImageGeneration = false, - isAgentMode = false, - agentWorkspacePath, - platform = process.platform - } = options + const { isImageGeneration = false } = options if (isImageGeneration) return systemPrompt - const trimmedPrompt = systemPrompt?.trim() ?? '' - - const runtimeLines: string[] = [`## Runtime Context - Today is ${formatCurrentDateTime()}`] - const platformName = formatPlatformName(platform) - if (platformName !== 'Unknown') { - runtimeLines.push(`- You are running on ${platformName}`) - } - - const normalizedWorkspace = agentWorkspacePath?.trim() - if (isAgentMode && normalizedWorkspace) { - runtimeLines.push( - `- Current working directory: ${normalizedWorkspace} (All file operations and shell commands will be executed relative to this directory)` - ) - } - - const runtimeBlock = runtimeLines.join('\n') - - return trimmedPrompt ? `${trimmedPrompt}\n${runtimeBlock}` : runtimeBlock + return systemPrompt?.trim() ?? '' } diff --git a/src/main/presenter/skillPresenter/index.ts b/src/main/presenter/skillPresenter/index.ts index b946e2011..19b062fea 100644 --- a/src/main/presenter/skillPresenter/index.ts +++ b/src/main/presenter/skillPresenter/index.ts @@ -16,6 +16,8 @@ import { import { eventBus, SendTarget } from '@/eventbus' import { SKILL_EVENTS } from '@/events' import { presenter } from '@/presenter' +import logger from '@shared/logger' +import { normalizeSkillAllowedTools } from './toolNameMapping' /** * Skill system configuration constants @@ -66,10 +68,26 @@ export class SkillPresenter implements ISkillPresenter { private resolveSkillsDir(): string { const configuredPath = this.configPresenter.getSkillsPath() const normalized = configuredPath?.trim() - if (normalized) { - return path.resolve(normalized) + const homePath = app.getPath('home') + const homeDir = homePath ? path.resolve(homePath) : path.resolve('.') + const fallbackDir = path.join(homeDir, '.deepchat', 'skills') + const resolved = normalized ? path.resolve(normalized) : fallbackDir + + // Repair malformed paths like: C:\Users\name.deepchat\skills + const brokenPrefix = `${homeDir}.deepchat` + const compareResolved = process.platform === 'win32' ? resolved.toLowerCase() : resolved + const compareBrokenPrefix = + process.platform === 'win32' ? brokenPrefix.toLowerCase() : brokenPrefix + const hasBrokenPrefix = compareResolved.startsWith(compareBrokenPrefix) + const nextChar = compareResolved.charAt(compareBrokenPrefix.length) + const hasBoundaryAfterPrefix = + compareResolved.length === compareBrokenPrefix.length || nextChar === '/' || nextChar === '\\' + if (hasBrokenPrefix && hasBoundaryAfterPrefix) { + const suffix = resolved.slice(brokenPrefix.length).replace(/^[\\/]+/, '') + return path.join(homeDir, '.deepchat', suffix) } - return path.join(app.getPath('home'), '.deepchat', 'skills') + + return resolved } /** @@ -196,7 +214,7 @@ export class SkillPresenter implements ISkillPresenter { async getMetadataPrompt(): Promise { const skills = await this.getMetadataList() const header = '# Available Skills' - const dirLine = `Skills directory: ${this.skillsDir}` + const dirLine = `Skills directory: \`${this.skillsDir}\`` if (skills.length === 0) { return `${header}\n\n${dirLine}\nNo skills are currently installed.` @@ -780,7 +798,11 @@ export class SkillPresenter implements ISkillPresenter { } } - return Array.from(allowedTools) + const result = normalizeSkillAllowedTools(Array.from(allowedTools)) + for (const warning of result.warnings) { + logger.warn(warning, { conversationId }) + } + return result.tools } /** diff --git a/src/main/presenter/skillPresenter/toolNameMapping.ts b/src/main/presenter/skillPresenter/toolNameMapping.ts new file mode 100644 index 000000000..b2d033653 --- /dev/null +++ b/src/main/presenter/skillPresenter/toolNameMapping.ts @@ -0,0 +1,96 @@ +const CANONICAL_TOOL_NAMES = new Set([ + 'read', + 'write', + 'edit', + 'find', + 'grep', + 'ls', + 'exec', + 'process' +]) + +const TOOL_NAME_MAPPING: Record = { + // Canonical names + read: 'read', + write: 'write', + edit: 'edit', + find: 'find', + grep: 'grep', + ls: 'ls', + exec: 'exec', + process: 'process', + + // Claude Code common names + multiedit: 'edit', + glob: 'find', + bash: 'exec', + + // Legacy DeepChat names + read_file: 'read', + write_file: 'write', + list_directory: 'ls', + glob_search: 'find', + grep_search: 'grep', + edit_file: 'edit', + execute_command: 'exec' +} + +export interface NormalizeSkillToolNameResult { + canonical: string + mapped: boolean +} + +export function normalizeSkillToolName(toolName: string): NormalizeSkillToolNameResult { + const normalizedInput = toolName.trim() + if (!normalizedInput) { + return { canonical: normalizedInput, mapped: false } + } + + const mapped = TOOL_NAME_MAPPING[normalizedInput.toLowerCase()] + if (!mapped) { + return { canonical: normalizedInput, mapped: false } + } + + return { + canonical: mapped, + mapped: mapped !== normalizedInput + } +} + +export interface NormalizeSkillAllowedToolsResult { + tools: string[] + warnings: string[] +} + +export function normalizeSkillAllowedTools(tools: string[]): NormalizeSkillAllowedToolsResult { + const normalized: string[] = [] + const warnings: string[] = [] + const seen = new Set() + + for (const originalToolName of tools) { + if (typeof originalToolName !== 'string') { + continue + } + + const { canonical, mapped } = normalizeSkillToolName(originalToolName) + if (!canonical) { + continue + } + + const isCanonical = CANONICAL_TOOL_NAMES.has(canonical) + if (!isCanonical && !mapped) { + warnings.push(`[SkillTools] Unknown allowedTools entry kept as-is: ${originalToolName}`) + } + if (mapped && canonical !== originalToolName) { + warnings.push(`[SkillTools] Mapped allowedTools entry: ${originalToolName} -> ${canonical}`) + } + + if (seen.has(canonical)) { + continue + } + seen.add(canonical) + normalized.push(canonical) + } + + return { tools: normalized, warnings } +} diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 73b5ec2f5..49f4695f8 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -259,6 +259,9 @@ export class ToolPresenter implements IToolPresenter { '~/.deepchat/sessions//tool_.offload' return [ + 'Use canonical Agent tool names only: read, write, edit, find, grep, ls, exec, process.', + 'Legacy tool names are not available and will fail with Unknown Agent tool.', + 'Recommended sequence for code tasks: find/grep -> read -> edit/write.', '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.', diff --git a/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts b/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts index 5b5bd9a5a..15668d05a 100644 --- a/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts +++ b/test/main/presenter/agentPresenter/loop/toolCallProcessor.test.ts @@ -26,7 +26,7 @@ describe('ToolCallProcessor tool output offload', () => { const toolDefinition = { type: 'function', function: { - name: 'execute_command', + name: 'exec', description: 'execute command', parameters: { type: 'object', @@ -65,7 +65,7 @@ describe('ToolCallProcessor tool output offload', () => { const events: any[] = [] for await (const event of processor.process({ eventId: 'event-1', - toolCalls: [{ id: 'tool-1', name: 'execute_command', arguments: '{}' }], + toolCalls: [{ id: 'tool-1', name: 'exec', arguments: '{}' }], enabledMcpTools: [], conversationMessages, modelConfig, @@ -124,7 +124,7 @@ describe('ToolCallProcessor question tool', () => { const executeCommandDef = { type: 'function', function: { - name: 'execute_command', + name: 'exec', description: 'execute command', parameters: { type: 'object', @@ -198,7 +198,7 @@ describe('ToolCallProcessor question tool', () => { eventId: 'event-question-2', toolCalls: [ { id: 'tool-q1', name: 'deepchat_question', arguments: basicQuestionArgs }, - { id: 'tool-2', name: 'execute_command', arguments: '{}' } + { id: 'tool-2', name: 'exec', arguments: '{}' } ], enabledMcpTools: [], conversationMessages, @@ -234,7 +234,7 @@ describe('ToolCallProcessor batch permission pre-check', () => { const toolDefinition = { type: 'function', function: { - name: 'edit_file', + name: 'edit', description: 'edit file', parameters: { type: 'object', @@ -254,7 +254,7 @@ describe('ToolCallProcessor batch permission pre-check', () => { })) const preCheckToolPermission = vi.fn(async () => ({ needsPermission: true as const, - toolName: 'edit_file', + toolName: 'edit', serverName: 'agent-filesystem', permissionType: 'write' as const, description: 'Write access requires approval', @@ -273,7 +273,7 @@ describe('ToolCallProcessor batch permission pre-check', () => { const conversationMessages: ChatMessage[] = [{ role: 'assistant', content: 'hello' }] const iterator = processor.process({ eventId: 'event-batch-permission', - toolCalls: [{ id: 'tool-1', name: 'edit_file', arguments: '{"path":"src/main.ts"}' }], + toolCalls: [{ id: 'tool-1', name: 'edit', arguments: '{"path":"src/main.ts"}' }], enabledMcpTools: [], conversationMessages, modelConfig: { functionCall: true } as ModelConfig, diff --git a/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts b/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts new file mode 100644 index 000000000..d2a25a0cd --- /dev/null +++ b/test/main/presenter/agentPresenter/message/systemEnvPromptBuilder.test.ts @@ -0,0 +1,122 @@ +import path from 'node:path' +import * as fs from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { presenter } from '@/presenter' +import logger from '@shared/logger' +import { + buildRuntimeCapabilitiesPrompt, + buildSystemEnvPrompt +} from '@/presenter/agentPresenter/message/systemEnvPromptBuilder' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + existsSync: vi.fn(), + promises: { + ...actual.promises, + readFile: vi.fn() + } + } +}) + +vi.mock('@/presenter', () => ({ + presenter: { + configPresenter: { + getProviderModels: vi.fn(), + getCustomModels: vi.fn() + } + } +})) + +vi.mock('@shared/logger', () => ({ + default: { + warn: vi.fn() + } +})) + +describe('systemEnvPromptBuilder', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(presenter.configPresenter.getProviderModels as ReturnType).mockReturnValue([]) + ;(presenter.configPresenter.getCustomModels as ReturnType).mockReturnValue([]) + ;(fs.existsSync as unknown as ReturnType).mockReturnValue(false) + ;(fs.promises.readFile as unknown as ReturnType).mockRejectedValue( + new Error('ENOENT') + ) + }) + + it('builds env prompt with git=yes and AGENTS instructions', async () => { + const workdir = path.resolve(path.sep, 'workspace', 'deepchat') + const gitPath = path.join(workdir, '.git') + const agentsPath = path.join(workdir, 'AGENTS.md') + + ;(fs.existsSync as unknown as ReturnType).mockImplementation( + (targetPath: string) => targetPath === gitPath + ) + ;(fs.promises.readFile as unknown as ReturnType).mockResolvedValue( + '# Repository Guidelines\nLine 2' + ) + ;(presenter.configPresenter.getProviderModels as ReturnType).mockReturnValue([ + { id: 'model-a', name: 'Model A' } + ]) + + const prompt = await buildSystemEnvPrompt({ + providerId: 'provider-x', + modelId: 'model-a', + workdir, + platform: 'win32', + now: new Date('2026-02-11T12:00:00.000Z') + }) + + expect(prompt).toContain('You are powered by the model named Model A.') + expect(prompt).toContain('The exact model ID is provider-x/model-a') + expect(prompt).toContain(`Working directory: ${workdir}`) + expect(prompt).toContain('Is directory a git repo: yes') + expect(prompt).toContain('Platform: win32') + expect(prompt).toContain("Today's date: Wed Feb 11 2026") + expect(prompt).toContain(`Instructions from: ${agentsPath}`) + expect(prompt).toContain('# Repository Guidelines\nLine 2') + }) + + it('falls back when AGENTS.md is missing and git=no', async () => { + const workdir = path.resolve(path.sep, 'workspace', 'deepchat') + const prompt = await buildSystemEnvPrompt({ + providerId: 'provider-y', + modelId: 'model-b', + workdir, + platform: 'linux', + now: new Date('2026-02-11T12:00:00.000Z') + }) + + expect(prompt).toContain('You are powered by the model named model-b.') + expect(prompt).toContain('The exact model ID is provider-y/model-b') + expect(prompt).toContain('Is directory a git repo: no') + expect(prompt).not.toContain('Instructions from:') + expect(prompt).not.toContain('[SystemEnvPromptBuilder] AGENTS.md not available') + expect(logger.warn).toHaveBeenCalledWith( + '[SystemEnvPromptBuilder] Failed to read AGENTS.md', + expect.objectContaining({ sourcePath: path.join(workdir, 'AGENTS.md') }) + ) + }) + + it('builds stable runtime capabilities prompt', () => { + const prompt = buildRuntimeCapabilitiesPrompt() + expect(prompt).toContain('## Runtime Capabilities') + expect(prompt).toContain('YoBrowser') + expect(prompt).toContain('process(list|poll|log|write|kill|remove)') + }) + + it('falls back to unknown provider/model identity', async () => { + const workdir = path.resolve(path.sep, 'workspace', 'deepchat') + const prompt = await buildSystemEnvPrompt({ + providerId: ' ', + modelId: '', + workdir, + now: new Date('2026-02-11T12:00:00.000Z') + }) + + expect(prompt).toContain('You are powered by the model named unknown-model.') + expect(prompt).toContain('The exact model ID is unknown-provider/unknown-model') + }) +}) diff --git a/test/main/presenter/agentPresenter/messageBuilder.test.ts b/test/main/presenter/agentPresenter/messageBuilder.test.ts index 99eed1294..0648c15e7 100644 --- a/test/main/presenter/agentPresenter/messageBuilder.test.ts +++ b/test/main/presenter/agentPresenter/messageBuilder.test.ts @@ -4,6 +4,24 @@ import { describe, expect, it, vi } from 'vitest' // pure message-building utilities without initializing Electron/main presenters. vi.mock('@/presenter', () => ({ presenter: {} })) +describe('messageBuilder.composeAgentSystemPromptSections', () => { + it('keeps fixed V2.1 section order', async () => { + const { composeAgentSystemPromptSections } = + await import('@/presenter/agentPresenter/message/messageBuilder') + + const composed = composeAgentSystemPromptSections({ + basePrompt: 'BASE', + runtimePrompt: 'RUNTIME', + skillsMetadataPrompt: 'SKILLS_META', + skillsPrompt: 'SKILLS_ACTIVE', + envPrompt: 'ENV', + toolingPrompt: 'TOOLING' + }) + + expect(composed).toBe('BASE\n\nRUNTIME\n\nSKILLS_META\n\nSKILLS_ACTIVE\n\nENV\n\nTOOLING') + }) +}) + describe('messageBuilder.buildPostToolExecutionContext', () => { it('emits assistant(tool_calls) -> tool(tool_call_id) pairing when functionCall is enabled', async () => { const { buildPostToolExecutionContext } = @@ -19,7 +37,7 @@ describe('messageBuilder.buildPostToolExecutionContext', () => { } as any, completedToolCall: { id: 'call_1', - name: 'execute_command', + name: 'exec', params: '{"command":"pwd"}', response: '/tmp' }, @@ -45,7 +63,7 @@ describe('messageBuilder.buildPostToolExecutionContext', () => { } as any, completedToolCall: { id: '', - name: 'execute_command', + name: 'exec', params: '{"command":"pwd"}', response: '/tmp' }, @@ -72,7 +90,7 @@ describe('messageBuilder.buildPostToolExecutionContext', () => { } as any, completedToolCall: { id: 'call_1', - name: 'execute_command', + name: 'exec', params: '{"command":"pwd"}', response: '/tmp' }, diff --git a/test/main/presenter/agentPresenter/permission/permissionHandler.resume.test.ts b/test/main/presenter/agentPresenter/permission/permissionHandler.resume.test.ts index 7958b821b..0b3c36f6c 100644 --- a/test/main/presenter/agentPresenter/permission/permissionHandler.resume.test.ts +++ b/test/main/presenter/agentPresenter/permission/permissionHandler.resume.test.ts @@ -42,7 +42,7 @@ describe('PermissionHandler resume behavior', () => { type: 'tool_call', status: 'loading', timestamp: Date.now(), - tool_call: { id: 'tool-1', name: 'read_file', params: '{}' } + tool_call: { id: 'tool-1', name: 'read', params: '{}' } }, { type: 'action', @@ -50,13 +50,13 @@ describe('PermissionHandler resume behavior', () => { status: 'pending', timestamp: Date.now(), content: 'Permission required', - tool_call: { id: 'tool-1', name: 'read_file', params: '{}' }, + tool_call: { id: 'tool-1', name: 'read', params: '{}' }, extra: { needsUserAction: true, serverName: 'agent-filesystem', permissionType: 'read', permissionRequest: JSON.stringify({ - toolName: 'read_file', + toolName: 'read', serverName: 'agent-filesystem', permissionType: 'read', description: 'Read access requires approval', @@ -68,7 +68,7 @@ describe('PermissionHandler resume behavior', () => { type: 'tool_call', status: 'loading', timestamp: Date.now(), - tool_call: { id: 'tool-2', name: 'read_file', params: '{}' } + tool_call: { id: 'tool-2', name: 'read', params: '{}' } }, { type: 'action', @@ -76,13 +76,13 @@ describe('PermissionHandler resume behavior', () => { status: 'pending', timestamp: Date.now(), content: 'Permission required', - tool_call: { id: 'tool-2', name: 'read_file', params: '{}' }, + tool_call: { id: 'tool-2', name: 'read', params: '{}' }, extra: { needsUserAction: true, serverName: 'agent-filesystem', permissionType: 'read', permissionRequest: JSON.stringify({ - toolName: 'read_file', + toolName: 'read', serverName: 'agent-filesystem', permissionType: 'read', description: 'Read access requires approval', diff --git a/test/main/presenter/skillPresenter/skillPresenter.test.ts b/test/main/presenter/skillPresenter/skillPresenter.test.ts index 5a54e21d4..a087fd4f0 100644 --- a/test/main/presenter/skillPresenter/skillPresenter.test.ts +++ b/test/main/presenter/skillPresenter/skillPresenter.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi, Mock, afterEach } from 'vitest' import type { IConfigPresenter } from '../../../../src/shared/presenter' import type { SkillMetadata } from '../../../../src/shared/types/skill' +import { app } from 'electron' // Mock external dependencies vi.mock('electron', () => ({ @@ -28,6 +29,10 @@ vi.mock('fs', () => ({ rmSync: vi.fn(), copyFileSync: vi.fn(), renameSync: vi.fn(), + statSync: vi.fn().mockReturnValue({ + isFile: () => true, + size: 1024 + }), mkdtempSync: vi.fn().mockReturnValue('/mock/temp/deepchat-skill-123') } })) @@ -122,6 +127,10 @@ describe('SkillPresenter', () => { ;(fs.existsSync as Mock).mockReturnValue(true) ;(fs.mkdirSync as Mock).mockReturnValue(undefined) ;(fs.readdirSync as Mock).mockReturnValue([]) + ;(fs.statSync as Mock).mockReturnValue({ + isFile: () => true, + size: 1024 + }) ;(matter as unknown as Mock).mockReturnValue({ data: { name: 'test-skill', description: 'Test skill' }, content: '# Test content' @@ -154,6 +163,19 @@ describe('SkillPresenter', () => { expect(fs.mkdirSync).toHaveBeenCalledWith(expect.any(String), { recursive: true }) presenter.destroy() }) + + it('should repair malformed .deepchat path segments', async () => { + ;(mockConfigPresenter.getSkillsPath as Mock).mockReturnValue('/mock/home.deepchat/skills') + ;(app.getPath as Mock).mockImplementation((name: string) => { + if (name === 'home') return '/mock/home' + if (name === 'temp') return '/mock/temp' + return '/mock/' + name + }) + + const presenter = new SkillPresenter(mockConfigPresenter) + await expect(presenter.getSkillsDir()).resolves.toBe('/mock/home/.deepchat/skills') + presenter.destroy() + }) }) describe('getSkillsDir', () => { @@ -269,6 +291,7 @@ describe('SkillPresenter', () => { const prompt = await skillPresenter.getMetadataPrompt() expect(prompt).toContain('# Available Skills') + expect(prompt).toContain('Skills directory: `') expect(prompt).toContain('No skills are currently installed') }) @@ -774,8 +797,8 @@ describe('SkillPresenter', () => { const tools = await skillPresenter.getActiveSkillsAllowedTools('conv-123') - expect(tools).toContain('read_file') - expect(tools).toContain('write_file') + expect(tools).toContain('read') + expect(tools).toContain('write') }) it('should return empty array when no active skills', async () => { diff --git a/test/main/presenter/skillPresenter/toolNameMapping.test.ts b/test/main/presenter/skillPresenter/toolNameMapping.test.ts new file mode 100644 index 000000000..26527e8ca --- /dev/null +++ b/test/main/presenter/skillPresenter/toolNameMapping.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeSkillAllowedTools, + normalizeSkillToolName +} from '@/presenter/skillPresenter/toolNameMapping' + +describe('toolNameMapping', () => { + it('maps Claude Code tool names to canonical names', () => { + expect(normalizeSkillToolName('Read')).toEqual({ canonical: 'read', mapped: true }) + expect(normalizeSkillToolName('MultiEdit')).toEqual({ canonical: 'edit', mapped: true }) + expect(normalizeSkillToolName('Glob')).toEqual({ canonical: 'find', mapped: true }) + expect(normalizeSkillToolName('Bash')).toEqual({ canonical: 'exec', mapped: true }) + }) + + it('maps legacy DeepChat names to canonical names', () => { + expect(normalizeSkillToolName('read_file')).toEqual({ canonical: 'read', mapped: true }) + expect(normalizeSkillToolName('write_file')).toEqual({ canonical: 'write', mapped: true }) + expect(normalizeSkillToolName('execute_command')).toEqual({ + canonical: 'exec', + mapped: true + }) + }) + + it('keeps unknown names and emits warnings', () => { + const result = normalizeSkillAllowedTools(['read_file', 'custom_tool', 'Read']) + expect(result.tools).toEqual(['read', 'custom_tool']) + expect(result.warnings.some((msg) => msg.includes('read_file -> read'))).toBe(true) + expect(result.warnings.some((msg) => msg.includes('Unknown allowedTools entry'))).toBe(true) + }) +})