feat(core): 添加后台 Shell 管理功能

- 新增 ShellManager 管理后台 Shell 进程生命周期
- bash 工具支持 run_in_background 参数在后台运行命令
- 新增 kill_shell 工具用于终止后台 Shell
- task_output 工具同时支持获取 Agent 和 Shell 任务输出
- 支持超时控制、输出限制和优雅终止
This commit is contained in:
2025-12-17 14:36:38 +08:00
parent 72120b72c8
commit 5a6925aef3
7 changed files with 593 additions and 89 deletions
@@ -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
@@ -9,6 +9,7 @@ const __dirname = path.dirname(__filename);
const TOOL_CATEGORY_MAP: Record<string, string> = {
// shell
bash: 'shell',
kill_shell: 'shell',
// filesystem
read_file: 'filesystem',
write_file: 'filesystem',
+60 -18
View File
@@ -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<string, unknown>): Promise<ToolResult> => {
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 输出限制
});
+3
View File
@@ -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';
@@ -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<string, unknown>): Promise<ToolResult> => {
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}`,
};
},
};
+305
View File
@@ -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<string, BackgroundShell>();
private completionCallbacks = new Map<string, Array<() => 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<BackgroundShell | null> {
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<BackgroundShell | null> {
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;
}
+153 -71
View File
@@ -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.`,
};
},
};