diff --git a/packages/core/src/tools/descriptions/shell/kill_shell.txt b/packages/core/src/tools/descriptions/shell/kill_shell.txt new file mode 100644 index 0000000..c0ce2cc --- /dev/null +++ b/packages/core/src/tools/descriptions/shell/kill_shell.txt @@ -0,0 +1,5 @@ +- Kills a running background bash shell by its ID +- Takes a shell_id parameter identifying the shell to kill +- Returns a success or failure status +- Use this tool when you need to terminate a long-running shell +- Shell IDs can be found using the /tasks command diff --git a/packages/core/src/tools/load_description.ts b/packages/core/src/tools/load_description.ts index 4330d82..dab8978 100644 --- a/packages/core/src/tools/load_description.ts +++ b/packages/core/src/tools/load_description.ts @@ -9,6 +9,7 @@ const __dirname = path.dirname(__filename); const TOOL_CATEGORY_MAP: Record = { // shell bash: 'shell', + kill_shell: 'shell', // filesystem read_file: 'filesystem', write_file: 'filesystem', diff --git a/packages/core/src/tools/shell/bash.ts b/packages/core/src/tools/shell/bash.ts index 7908f37..b049518 100644 --- a/packages/core/src/tools/shell/bash.ts +++ b/packages/core/src/tools/shell/bash.ts @@ -4,6 +4,7 @@ import type { ToolResult } from '../../types/index.js'; import type { ToolWithMetadata } from '../types.js'; import { loadDescription } from '../load_description.js'; import { getPermissionManager } from '../../permission/index.js'; +import { getShellManager } from './manager.js'; const execAsync = promisify(exec); @@ -20,47 +21,88 @@ export const bashTool: ToolWithMetadata = { parameters: { command: { type: 'string', - description: '要执行的 bash 命令', + description: 'The command to execute', required: true, }, - cwd: { + description: { type: 'string', - description: '工作目录(可选,默认为当前目录)', + description: 'Clear, concise description of what this command does in 5-10 words, in active voice', + required: false, + }, + timeout: { + type: 'number', + description: 'Optional timeout in milliseconds (max 600000)', + required: false, + maximum: 120000, + }, + run_in_background: { + type: 'boolean', + description: 'Set to true to run this command in the background. Use TaskOutput to read the output later.', + required: false, + }, + dangerouslyDisableSandbox: { + type: 'boolean', + description: 'Set this to true to dangerously override sandbox mode and run commands without sandboxing.', required: false, }, }, execute: async (params: Record): Promise => { const command = params.command as string; - const cwd = (params.cwd as string) || process.cwd(); + const timeout = (params.timeout as number) || 120000; // 默认 2 分钟超时 + const runInBackground = params.run_in_background as boolean; + const dangerouslyDisableSandbox = params.dangerouslyDisableSandbox as boolean; + const cwd = process.cwd(); - // 权限检查 - const permissionManager = getPermissionManager(); - const permResult = await permissionManager.checkBashPermission({ - command, - workdir: cwd, - }); + // 权限检查(除非 dangerouslyDisableSandbox 为 true) + if (!dangerouslyDisableSandbox) { + const permissionManager = getPermissionManager(); + const permResult = await permissionManager.checkBashPermission({ + command, + workdir: cwd, + }); + + if (!permResult.allowed) { + // 如果需要用户确认但没有设置回调,返回等待确认的状态 + if (permResult.needsConfirmation) { + return { + success: false, + output: '', + error: `需要用户确认: ${command}\n原因: ${permResult.reason || '需要权限确认'}\n模式: ${permResult.patterns?.join(', ') || ''}`, + }; + } - if (!permResult.allowed) { - // 如果需要用户确认但没有设置回调,返回等待确认的状态 - if (permResult.needsConfirmation) { return { success: false, output: '', - error: `需要用户确认: ${command}\n原因: ${permResult.reason || '需要权限确认'}\n模式: ${permResult.patterns?.join(', ') || ''}`, + error: `权限被拒绝: ${permResult.reason || '命令不被允许执行'}`, }; } + } + + // 后台运行模式 + if (runInBackground) { + const shellManager = getShellManager(); + const description = params.description as string | undefined; + const shellId = shellManager.runInBackground(command, { + description, + cwd, + timeout: Math.min(timeout, 600000), + }); return { - success: false, - output: '', - error: `权限被拒绝: ${permResult.reason || '命令不被允许执行'}`, + success: true, + output: `Command started in background with shell_id: ${shellId}\nUse TaskOutput tool with task_id="${shellId}" to retrieve results.`, + metadata: { + shellId, + type: 'background_shell', + }, }; } try { const { stdout, stderr } = await execAsync(command, { cwd, - timeout: 60000, // 60 秒超时 + timeout: Math.min(timeout, 600000), // 最大 10 分钟 maxBuffer: 1024 * 1024 * 10, // 10MB 输出限制 }); diff --git a/packages/core/src/tools/shell/index.ts b/packages/core/src/tools/shell/index.ts index 0a4aad8..8df73f7 100644 --- a/packages/core/src/tools/shell/index.ts +++ b/packages/core/src/tools/shell/index.ts @@ -1 +1,4 @@ export { bashTool } from './bash.js'; +export { killShellTool } from './kill_shell.js'; +export { getShellManager, resetShellManager } from './manager.js'; +export type { BackgroundShell, BackgroundShellStatus } from './manager.js'; diff --git a/packages/core/src/tools/shell/kill_shell.ts b/packages/core/src/tools/shell/kill_shell.ts new file mode 100644 index 0000000..9dab0b5 --- /dev/null +++ b/packages/core/src/tools/shell/kill_shell.ts @@ -0,0 +1,66 @@ +import type { ToolResult } from '../../types/index.js'; +import type { ToolWithMetadata } from '../types.js'; +import { loadDescription } from '../load_description.js'; +import { getShellManager } from './manager.js'; + +export const killShellTool: ToolWithMetadata = { + name: 'kill_shell', + description: loadDescription('kill_shell'), + metadata: { + name: 'kill_shell', + category: 'shell', + description: '终止后台运行的 shell', + keywords: ['kill', 'shell', 'terminate', 'stop', 'background', '终止', '停止', '后台'], + deferLoading: false, + }, + parameters: { + shell_id: { + type: 'string', + description: 'The ID of the background shell to kill', + required: true, + }, + }, + execute: async (params: Record): Promise => { + const shellId = params.shell_id as string; + const shellManager = getShellManager(); + + // 先检查 shell 是否存在 + const shell = shellManager.getShell(shellId); + if (!shell) { + return { + success: false, + output: '', + error: `Shell ${shellId} not found. Use /tasks command to list available shells.`, + }; + } + + // 检查状态 + if (shell.status !== 'running') { + return { + success: false, + output: '', + error: `Shell ${shellId} is not running (status: ${shell.status})`, + }; + } + + // 尝试终止 + const killed = shellManager.killShell(shellId); + if (killed) { + return { + success: true, + output: `Shell ${shellId} has been terminated.\nCommand: ${shell.command}`, + metadata: { + shellId, + command: shell.command, + status: 'killed', + }, + }; + } + + return { + success: false, + output: '', + error: `Failed to kill shell ${shellId}`, + }; + }, +}; diff --git a/packages/core/src/tools/shell/manager.ts b/packages/core/src/tools/shell/manager.ts new file mode 100644 index 0000000..b1af5de --- /dev/null +++ b/packages/core/src/tools/shell/manager.ts @@ -0,0 +1,305 @@ +import { spawn, ChildProcess } from 'child_process'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * 后台 Shell 状态 + */ +export type BackgroundShellStatus = 'running' | 'completed' | 'failed' | 'killed'; + +/** + * 后台 Shell 信息 + */ +export interface BackgroundShell { + /** Shell 唯一 ID */ + id: string; + /** 执行的命令 */ + command: string; + /** 命令描述 */ + description?: string; + /** 执行状态 */ + status: BackgroundShellStatus; + /** 开始时间 */ + startedAt: Date; + /** 完成时间 */ + completedAt?: Date; + /** 标准输出 */ + stdout: string; + /** 标准错误 */ + stderr: string; + /** 退出码 */ + exitCode?: number; + /** 错误信息 */ + error?: string; + /** 子进程引用 */ + process?: ChildProcess; + /** 工作目录 */ + cwd: string; +} + +/** + * Shell 管理器 + * 负责管理后台 Shell 进程的生命周期 + */ +export class ShellManager { + private backgroundShells = new Map(); + private completionCallbacks = new Map void>>(); + + /** + * 启动后台 Shell 命令 + */ + runInBackground( + command: string, + options: { + description?: string; + cwd?: string; + timeout?: number; + } = {} + ): string { + const shellId = uuidv4().substring(0, 8); // 短 ID + const cwd = options.cwd || process.cwd(); + const timeout = options.timeout || 600000; // 默认 10 分钟 + + // 创建后台 Shell 记录 + const shell: BackgroundShell = { + id: shellId, + command, + description: options.description, + status: 'running', + startedAt: new Date(), + stdout: '', + stderr: '', + cwd, + }; + + this.backgroundShells.set(shellId, shell); + + // 启动子进程 + const childProcess = spawn(command, { + shell: true, + cwd, + env: process.env, + }); + + shell.process = childProcess; + + // 收集输出 + childProcess.stdout?.on('data', (data: Buffer) => { + shell.stdout += data.toString(); + // 限制输出大小,防止内存溢出 + if (shell.stdout.length > 10 * 1024 * 1024) { + shell.stdout = shell.stdout.slice(-5 * 1024 * 1024); + } + }); + + childProcess.stderr?.on('data', (data: Buffer) => { + shell.stderr += data.toString(); + // 限制输出大小 + if (shell.stderr.length > 10 * 1024 * 1024) { + shell.stderr = shell.stderr.slice(-5 * 1024 * 1024); + } + }); + + // 处理进程结束 + childProcess.on('close', (code: number | null) => { + shell.status = code === 0 ? 'completed' : 'failed'; + shell.completedAt = new Date(); + shell.exitCode = code ?? undefined; + shell.process = undefined; // 清理进程引用 + this.notifyCompletion(shellId); + }); + + childProcess.on('error', (err: Error) => { + shell.status = 'failed'; + shell.completedAt = new Date(); + shell.error = err.message; + shell.process = undefined; + this.notifyCompletion(shellId); + }); + + // 设置超时 + setTimeout(() => { + if (shell.status === 'running') { + this.killShell(shellId); + shell.error = `Command timed out after ${timeout}ms`; + } + }, timeout); + + return shellId; + } + + /** + * 终止后台 Shell + */ + killShell(shellId: string): boolean { + const shell = this.backgroundShells.get(shellId); + if (!shell) { + return false; + } + + if (shell.status !== 'running' || !shell.process) { + return false; + } + + try { + // 尝试优雅终止 + shell.process.kill('SIGTERM'); + + // 如果 3 秒后还未终止,强制杀死 + setTimeout(() => { + if (shell.status === 'running' && shell.process) { + shell.process.kill('SIGKILL'); + } + }, 3000); + + shell.status = 'killed'; + shell.completedAt = new Date(); + shell.error = 'Process killed by user'; + this.notifyCompletion(shellId); + + return true; + } catch { + return false; + } + } + + /** + * 获取后台 Shell 状态 + */ + getShell(shellId: string): BackgroundShell | null { + return this.backgroundShells.get(shellId) || null; + } + + /** + * 获取 Shell 输出(支持阻塞等待) + */ + async getShellOutput( + shellId: string, + block: boolean = true, + timeoutMs: number = 30000 + ): Promise { + const shell = this.backgroundShells.get(shellId); + if (!shell) { + return null; + } + + // 如果已完成或不需要阻塞,直接返回 + if (shell.status !== 'running' || !block) { + return shell; + } + + // 阻塞等待完成 + return this.waitForCompletion(shellId, timeoutMs); + } + + /** + * 等待 Shell 完成 + */ + private waitForCompletion( + shellId: string, + timeoutMs: number + ): Promise { + return new Promise((resolve) => { + const shell = this.backgroundShells.get(shellId); + if (!shell || shell.status !== 'running') { + resolve(shell || null); + return; + } + + // 设置超时 + const timeoutId = setTimeout(() => { + // 移除回调 + const callbacks = this.completionCallbacks.get(shellId); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + resolve(this.backgroundShells.get(shellId) || null); + }, timeoutMs); + + // 注册完成回调 + const callback = () => { + clearTimeout(timeoutId); + resolve(this.backgroundShells.get(shellId) || null); + }; + + if (!this.completionCallbacks.has(shellId)) { + this.completionCallbacks.set(shellId, []); + } + this.completionCallbacks.get(shellId)!.push(callback); + }); + } + + /** + * 通知等待者任务已完成 + */ + private notifyCompletion(shellId: string): void { + const callbacks = this.completionCallbacks.get(shellId); + if (callbacks) { + for (const callback of callbacks) { + callback(); + } + this.completionCallbacks.delete(shellId); + } + } + + /** + * 列出所有后台 Shell + */ + listShells(): BackgroundShell[] { + return Array.from(this.backgroundShells.values()).map((shell) => ({ + ...shell, + process: undefined, // 不暴露进程引用 + })); + } + + /** + * 列出运行中的 Shell + */ + listRunningShells(): BackgroundShell[] { + return this.listShells().filter((s) => s.status === 'running'); + } + + /** + * 清理已完成的 Shell 记录 + */ + cleanup(maxAge: number = 3600000): void { + const now = Date.now(); + for (const [id, shell] of this.backgroundShells) { + if ( + shell.status !== 'running' && + shell.completedAt && + now - shell.completedAt.getTime() > maxAge + ) { + this.backgroundShells.delete(id); + } + } + } +} + +// 单例实例 +let shellManager: ShellManager | null = null; + +/** + * 获取 ShellManager 单例 + */ +export function getShellManager(): ShellManager { + if (!shellManager) { + shellManager = new ShellManager(); + } + return shellManager; +} + +/** + * 重置 ShellManager(测试用) + */ +export function resetShellManager(): void { + // 先清理所有运行中的进程 + if (shellManager) { + for (const shell of shellManager.listRunningShells()) { + shellManager.killShell(shell.id); + } + } + shellManager = null; +} diff --git a/packages/core/src/tools/task/task_output.ts b/packages/core/src/tools/task/task_output.ts index da32573..1a97ae8 100644 --- a/packages/core/src/tools/task/task_output.ts +++ b/packages/core/src/tools/task/task_output.ts @@ -1,10 +1,146 @@ import type { ToolWithMetadata } from '../types.js'; +import type { ToolResult } from '../../types/index.js'; +import type { BackgroundAgent } from '../../agent/manager.js'; +import type { BackgroundShell } from '../shell/manager.js'; import { getAgentManager } from '../../agent/manager.js'; +import { getShellManager } from '../shell/manager.js'; import { loadDescription } from '../load_description.js'; +/** + * 格式化 Shell 输出 + */ +function formatShellOutput(taskId: string, shell: BackgroundShell): ToolResult { + const duration = shell.completedAt + ? Math.round((shell.completedAt.getTime() - shell.startedAt.getTime()) / 1000) + : Math.round((Date.now() - shell.startedAt.getTime()) / 1000); + + if (shell.status === 'running') { + return { + success: true, + output: `Shell ${taskId} is still running...\n` + + `- Command: ${shell.command}\n` + + `- Running for: ${duration} seconds\n\n` + + `Current output:\n${shell.stdout || '(no output yet)'}\n` + + (shell.stderr ? `\nSTDERR:\n${shell.stderr}` : ''), + metadata: { + taskId, + type: 'shell', + status: 'running', + command: shell.command, + runningTime: duration, + }, + }; + } + + if (shell.status === 'failed' || shell.status === 'killed') { + return { + success: false, + output: shell.stdout || '', + error: `Shell ${taskId} ${shell.status}:\n` + + `- Command: ${shell.command}\n` + + `- Duration: ${duration} seconds\n` + + `- Exit code: ${shell.exitCode ?? 'N/A'}\n` + + `- Error: ${shell.error || shell.stderr || 'Unknown error'}`, + metadata: { + taskId, + type: 'shell', + status: shell.status, + command: shell.command, + duration, + exitCode: shell.exitCode, + }, + }; + } + + // completed + const output = shell.stdout + (shell.stderr ? `\nSTDERR:\n${shell.stderr}` : ''); + return { + success: true, + output: `## Shell ${taskId} completed\n\n` + + `- Command: ${shell.command}\n` + + `- Duration: ${duration} seconds\n` + + `- Exit code: ${shell.exitCode ?? 0}\n\n` + + `### Output\n\n${output || '(no output)'}`, + metadata: { + taskId, + type: 'shell', + status: 'completed', + command: shell.command, + duration, + exitCode: shell.exitCode, + }, + }; +} + +/** + * 格式化 Agent 输出 + */ +function formatAgentOutput(taskId: string, agent: BackgroundAgent): ToolResult { + if (agent.status === 'running') { + const runningTime = Math.round((Date.now() - agent.startedAt.getTime()) / 1000); + return { + success: true, + output: `Agent ${taskId} is still running...\n` + + `- Type: ${agent.agentName}\n` + + `- Task: ${agent.description}\n` + + `- Running for: ${runningTime} seconds\n\n` + + `Use task_output again later or set block: true to wait for completion.`, + metadata: { + taskId, + type: 'agent', + status: 'running', + agentName: agent.agentName, + runningTime, + }, + }; + } + + const duration = agent.completedAt + ? Math.round((agent.completedAt.getTime() - agent.startedAt.getTime()) / 1000) + : 0; + + if (agent.status === 'failed') { + return { + success: false, + output: '', + error: `Agent ${taskId} failed:\n` + + `- Type: ${agent.agentName}\n` + + `- Task: ${agent.description}\n` + + `- Duration: ${duration} seconds\n` + + `- Error: ${agent.error || 'Unknown error'}`, + metadata: { + taskId, + type: 'agent', + status: 'failed', + agentName: agent.agentName, + duration, + }, + }; + } + + // completed + return { + success: true, + output: `## Agent ${taskId} completed\n\n` + + `- Type: ${agent.agentName}\n` + + `- Task: ${agent.description}\n` + + `- Duration: ${duration} seconds\n` + + `- Steps: ${agent.steps || 0}\n\n` + + `### Result\n\n${agent.result || '(no output)'}`, + metadata: { + taskId, + type: 'agent', + status: 'completed', + agentName: agent.agentName, + duration, + steps: agent.steps, + }, + }; +} + /** * TaskOutput 工具 - * 用于获取后台 Agent 的执行结果 + * 用于获取后台 Agent 或 Shell 的执行结果 */ export const taskOutputTool: ToolWithMetadata = { name: 'task_output', @@ -48,83 +184,29 @@ export const taskOutputTool: ToolWithMetadata = { timeout?: number; }; - const agentManager = getAgentManager(); - - // 验证超时参数(毫秒),转换为秒传给 agentManager const timeoutMs = Math.min(Math.max(timeout, 0), 600000); - const timeoutSec = Math.ceil(timeoutMs / 1000); - // 获取 Agent 输出 + // 先尝试查找 Shell 任务 + const shellManager = getShellManager(); + const shell = await shellManager.getShellOutput(task_id, block, timeoutMs); + + if (shell) { + return formatShellOutput(task_id, shell); + } + + // 再尝试查找 Agent 任务 + const agentManager = getAgentManager(); + const timeoutSec = Math.ceil(timeoutMs / 1000); const agent = await agentManager.getAgentOutput(task_id, block, timeoutSec); - if (!agent) { - return { - success: false, - output: '', - error: `Agent ${task_id} 不存在。请检查 task_id 是否正确。`, - }; + if (agent) { + return formatAgentOutput(task_id, agent); } - // 根据状态返回不同结果 - if (agent.status === 'running') { - const runningTime = Math.round((Date.now() - agent.startedAt.getTime()) / 1000); - return { - success: true, - output: `Agent ${task_id} 仍在运行中...\n` + - `- 类型: ${agent.agentName}\n` + - `- 任务: ${agent.description}\n` + - `- 已运行: ${runningTime} 秒\n\n` + - `稍后再次调用 task_output 查询结果,或使用 block: true 等待完成。`, - metadata: { - agentId: agent.id, - status: 'running', - agentName: agent.agentName, - runningTime, - }, - }; - } - - if (agent.status === 'failed') { - const duration = agent.completedAt - ? Math.round((agent.completedAt.getTime() - agent.startedAt.getTime()) / 1000) - : 0; - return { - success: false, - output: '', - error: `Agent ${task_id} 执行失败:\n` + - `- 类型: ${agent.agentName}\n` + - `- 任务: ${agent.description}\n` + - `- 耗时: ${duration} 秒\n` + - `- 错误: ${agent.error || '未知错误'}`, - metadata: { - agentId: agent.id, - status: 'failed', - agentName: agent.agentName, - duration, - }, - }; - } - - // completed - const duration = agent.completedAt - ? Math.round((agent.completedAt.getTime() - agent.startedAt.getTime()) / 1000) - : 0; - return { - success: true, - output: `## Agent ${task_id} 执行完成\n\n` + - `- 类型: ${agent.agentName}\n` + - `- 任务: ${agent.description}\n` + - `- 耗时: ${duration} 秒\n` + - `- 步数: ${agent.steps || 0}\n\n` + - `### 结果\n\n${agent.result || '(无输出)'}`, - metadata: { - agentId: agent.id, - status: 'completed', - agentName: agent.agentName, - duration, - steps: agent.steps, - }, + success: false, + output: '', + error: `Task ${task_id} not found. Please check the task_id is correct.`, }; }, };