🤖 从 0 到 1 打造 Coding Agent------mini cursor(终篇):装上命令行手脚,造一个会"干活"的 AI 程序员
📖 前情提要:
- 第一篇 学了 Tool 系统------
tool()定义工具、bindTools()绑定、tool_calls怎么来的 🧰- 第二篇 学了 Agent 循环------while 循环 + Promise.all 并发 + ToolMessage 对号入座 🔄
🎯 本篇目标 :把所有知识串起来,给 Agent 装上命令行手脚,造出一个能写代码、改代码、跑代码的完整 Coding Agent 🚀 这也是本系列的收官之作!
📑 目录
- [🎯 目标与项目全景](#🎯 目标与项目全景 "#-%E7%9B%AE%E6%A0%87%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A8%E6%99%AF")
- [⚙️ 一、命令行执行工具(node-exec.mjs)](#⚙️ 一、命令行执行工具(node-exec.mjs) "#%EF%B8%8F-%E4%B8%80%E5%91%BD%E4%BB%A4%E8%A1%8C%E6%89%A7%E8%A1%8C%E5%B7%A5%E5%85%B7node-execmjs")
- [🖥️ 二、跨平台兼容:Windows vs Linux](#🖥️ 二、跨平台兼容:Windows vs Linux "#%EF%B8%8F-%E4%BA%8C%E8%B7%A8%E5%B9%B3%E5%8F%B0%E5%85%BC%E5%AE%B9windows-vs-linux")
- [🧰 三、Agent 工具集(all-tools.mjs)](#🧰 三、Agent 工具集(all-tools.mjs) "#-%E4%B8%89agent-%E5%B7%A5%E5%85%B7%E9%9B%86all-toolsmjs")
- [🚀 四、mini-cursor.mjs 入口](#🚀 四、mini-cursor.mjs 入口 "#-%E5%9B%9Bmini-cursormjs-%E5%85%A5%E5%8F%A3")
- [📝 五、关键技术点总结](#📝 五、关键技术点总结 "#-%E4%BA%94%E5%85%B3%E9%94%AE%E6%8A%80%E6%9C%AF%E7%82%B9%E6%80%BB%E7%BB%93")
- [💬 写在最后](#💬 写在最后 "#-%E5%86%99%E5%9C%A8%E6%9C%80%E5%90%8E")
🎯 目标与项目全景
本篇的终极目标------让 Agent 自动完成这个任务:
"用 Vite 创建一个 React 的 TodoList 项目,并把它运行起来"
想象一下:你只需要说一句话,AI 就会自动打开终端、敲命令、写代码、装依赖、启动项目......全程不需要你动一根手指。这就是 Coding Agent 的魅力 ✨
📂 从单工具到多工具
来看看整个项目的文件结构。tool.mjs 只是第一阶段的文件,现在要扩展成完整项目:
python
hello-langchain/
├── .env # 🔑 密钥(API Key 等,不提交 git)
├── package.json # 📦 项目依赖配置
├── node_modules/ # 📚 安装的依赖包
├── tool.mjs # 📖 readme1+2 的主角:单工具 Agent(演示 ReAct 循环)
└── src/
├── all-tool.mjs # 🧰 多工具定义(read_file + write_file + list_directory + execute_command)
├── mini-cursor.mjs # 🤖 Agent 入口(组装所有工具 + LLM)
└── node-exec.mjs # 🧪 子进程执行命令的独立脚本(验证 spawn 用)
文件之间的关系:
scss
📖 tool.mjs(学习用)
└── 只有一个 readFileTool,演示 ReAct 循环原理
🧰 src/all-tool.mjs(生产用)
└── 4 个工具通过 tool() 定义,真正的工具库
🤖 src/mini-cursor.mjs(生产用)
└── 从 all-tool.mjs 导入工具,组装成完整 Agent
🧪 src/node-exec.mjs(实验用)
└── 独立的子进程实验脚本,验证 spawn 怎么用
📊 工具对照表
tool.mjs 只有一个 readFileTool,但 Coding Agent 至少需要这四个工具:
| 工具 | 做的事 | 实现方式 | 位于 |
|---|---|---|---|
📖 read_file |
读文件 | fs.readFile() |
all-tool.mjs |
✍️ write_file |
写文件(要先建目录) | path.dirname() + fs.mkdir() + fs.writeFile() |
all-tool.mjs |
📂 list_directory |
列目录 | fs.readdir() |
all-tool.mjs |
💻 execute_command |
执行终端命令 | spawn() 开子进程 |
all-tool.mjs |
💡 前三个用
fs模块直接搞定,唯独execute_command不一样 ------它需要用child_process.spawn开子进程执行命令。这就是本篇要从子进程讲起的原因。
⚙️ 一、命令行执行工具(node-exec.mjs)
🤔 为什么需要子进程?
- Node.js 主进程是 JS 单线程,一次只能干一件事
- 耗时的命令行任务(如
npm install、vite build)会阻塞主进程 - 解决方案:用
child_process模块把命令行任务分离到 独立的子进程 中执行 - 子进程完成后通过 IPC(进程间通信) 把结果传回主进程
核心流程图:
scss
用户指令 📝
↓
Agent 主进程 🧠(大脑/调度)
↓ spawn()
子进程 ⚡(手脚/执行命令)
↓ IPC
主进程拿到结果 ✅ → 返回给用户
1.1 spawn 基本用法
js
import { spawn } from 'node:child_process';
// 命令和参数必须分开传
spawn('ls', ['-al']);
// ❌ 不能写成 spawn('ls -al'),会把整个字符串当成命令名
1.2 拆分命令字符串 vs shell: true
js
// ❌ 手动拆分(不推荐,引号参数会出错)
const command = 'ls -al';
const [cmd, ...args] = command.split(' ');
// cmd = 'ls'
// args = ['-al']
📌
...args是 rest 运算符,把数组剩余元素打包成一个新数组。
什么时候需要手动拆分? 当 shell: false(默认),必须手动拆分参数。但如果设了 shell: true,shell 会自动解析命令 ,不需要手动 split。手动拆反而会出问题------比如 git commit -m "hello world" 的引号会被拆坏。
✅ 实际项目中的做法 :设了
shell: true,直接把整条command字符串传给spawn,不拆。
1.3 spawn 的关键配置项
js
const client = spawn(cmd, args, {
cwd: process.cwd(), // 📂 子进程的工作目录(cwd = Current Working Directory)
stdio: 'inherit', // 📡 子进程复用主进程的 stdin/stdout/stderr
shell: true, // 💻 在 shell 中执行命令(Windows 下用 cmd.exe)
});
| 配置项 | 作用 | 说明 |
|---|---|---|
cwd |
指定子进程在哪个目录干活 | pwd 是命令(问"我在哪"),cwd 是配置("你去哪干") |
stdio |
控制子进程输入输出怎么处理 | 'inherit' 直接显示在终端;'pipe' 通过代码获取;'ignore' 丢弃 |
shell |
是否在 shell 中执行 | true 才能用管道、重定向等 shell 特性;Windows 默认用 cmd.exe,Linux 默认用 /bin/sh;也可以指定具体 shell 路径 |
stdio 三种模式的适用场景:
| 模式 | 数据流向 | 适合场景 |
|---|---|---|
'pipe' |
子进程输出 → 代码捕获(client.stdout.on('data')) |
Agent Tool:执行结果要返回给 LLM 分析 |
'inherit' |
子进程输出 → 直接刷到用户终端 | 用户自己手动跑命令、node-exec 实验脚本 |
'ignore' |
子进程输出 → 丢弃 | 不需要输出的后台任务 |
⚠️ Agent 场景用
inherit会出问题 :npm install的日志直接喷到终端,但 LLM 收不到------LLM 根本不知道安装成功了没有。必须用pipe捕获输出,再塞进 ToolMessage 喂给 LLM。 本项目用['pipe', 'pipe', 'pipe']同时做两件事:process.stdout.write(data)实时刷到终端让用户看到,stdout += data收集完整输出最后通过resolve返回给 LLM。
1.4 子进程状态监听(实验版)
⚠️ 这是
node-exec.mjs实验脚本的写法,process.exit()会杀主进程。实际 Agent 中不能这样用,§1.5 会改进。
js
// 监听错误
client.on('error', (err) => {
errorMsg = err.message;
});
// 监听子进程结束
client.on('close', (code) => {
if (code == 0) {
// 退出码 0 = 一切正常
process.exit(0);
} else {
// 非 0 = 出错了
process.exit(code || 1); // || 1 是兜底:如果 code 是 null,默认用 1
}
});
退出码规则 :0 表示成功,非 0 表示失败。code || 1 防止子进程被杀(code = null)时主进程以"成功"状态退出。
1.5 从实验脚本到 Agent Tool:spawn Promise 化
node-exec.mjs 是验证脚本 ------验证 spawn 能不能跑通命令。但它有两个致命问题,不能直接嵌入 Agent:
| 问题 | node-exec.mjs 的做法 | 为什么不适合 Agent |
|---|---|---|
🔴 process.exit() 杀主进程 |
client.on('close', code => process.exit(code)) |
Agent 主进程一死,整个程序没了,后续对话全丢 |
| 🔴 输出无法获取 | stdio: 'inherit' |
输出直接刷到终端,LLM 拿不到,不知道命令成功还是失败 |
🔴 无法 await |
基于事件回调 | Agent 的 tool 是 async 函数,需要 await 等结果 |
✅ 正确的做法:用 new Promise 把 spawn 包装成可 await 的 Promise
核心思路就两点:
spawn()返回一个 child 对象,通过事件.on('close')知道子进程结束了- 用
new Promise把"事件回调"变成"await 等结果"------子进程结束 →resolve()→await拿到结果
js
// ✅ Agent Tool 中 execute_command 的正确写法
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
return new Promise((resolve) => { // ← 关键:把 spawn 包进 Promise
const child = spawn(command, { // shell:true 自动解析命令
cwd,
stdio: ['inherit'],
shell: true
});
let errorMsg = '';
child.on('error', (err) => { errorMsg = err.message; });
child.on('close', (code) => { // ← 子进程结束了
if (code === 0) {
resolve(`命令行成功执行 ${command}`); // 成功 → Promise 完成
} else {
resolve(`命令执行失败,退出码${code}\n 错误:${errorMsg}`);
}
});
});
},
// ... name, description, schema ...
);
💡 怎么读这段代码 :
new Promise((resolve) => { ... })把整个 spawn 过程包住 →resolve(结果)就是"Promise 完成了,这是结果" → 外层await tool.invoke()就拿到这个结果。
对比总结:
arduino
node-exec.mjs(实验) → Agent Tool(生产)
─────────────────────────────────────────────────────
process.exit(code) → resolve
stdio: 'inherit' → stdio: ['pipe'] + process.stdout.write 实时刷 + resolve 返回完整输出
split(' ') 手动拆分 → 不拆,shell:true 自动解析
无法 await → await tool.invoke()
输出在终端但无法返回给 LLM → 既刷终端又返回给 LLM
⚠️
process.exit()会直接杀掉 Node.js 主进程------在 Agent 里绝对不能用。正确做法是resolve退出码,让 Agent 自己决定下一步。
1.6 子进程超时处理
长时间运行的命令(如 npm install 卡在网络问题)会永远挂起,Agent 也跟着卡死。加个超时保护:
js
const runCommandWithTimeout = (command, cwd, timeoutMs = 60000) => {
return Promise.race([
runCommand(command, cwd),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`命令超时(${timeoutMs / 1000} 秒):${command}`)), timeoutMs)
),
]);
};
💡
Promise.race让"命令完成"和"超时计时器"竞速,谁先到用谁的结果。
📌 本节小结 :spawn开子进程执行命令;shell: true让 shell 自动解析,不用手动 split;new Promise把事件回调变成 async/await 能用的 Promise。
🖥️ 二、跨平台兼容:Windows vs Linux
2.1 命令差异
| 功能 | Linux/Mac | Windows (cmd) | Windows (PowerShell) |
|---|---|---|---|
| 列出文件 | ls -al |
dir /a |
Get-ChildItem |
| 创建目录 | mkdir -p |
mkdir(自动递归) |
mkdir(自动递归) |
2.2 Git Bash 方案
- Git Bash 内置了一套精简的 Linux 命令行环境(bash.exe + 基础命令)
- 在 Windows 上安装 Git 后,可以直接运行
ls、grep、cat等 Linux 命令 - 代码中指定 Git Bash 路径即可跨平台:
js
spawn('ls', ['-al'], {
shell: 'C:\\Program Files\\Git\\bin\\bash.exe'
});
2.3 Git Bash 路径注意事项
| 写法 | 结果 |
|---|---|
src/node-exec.mjs |
✅ 相对路径,正确 |
/src/node-exec.mjs |
❌ 被当成绝对路径,跑到 Git 安装目录去了 |
src\node-exec.mjs |
❌ \ 在 Linux 里是转义符,不是路径分隔符 |
💡 记住 :Git Bash 用的是 Linux 规则,路径用
/,不加/开头。
📌 本节小结 :Windows 上用 Git Bash 的 shell 路径指定 bash.exe,就能写跨平台的 Linux 命令。路径用/分隔。
🧰 三、Agent 工具集(all-tools.mjs)
📚 前置知识:fs 模块的两种导入方式
| 导入方式 | 特点 | 示例 |
|---|---|---|
fs from 'fs/promises' |
返回 Promise ,可以用 await |
await fs.readFile('a.txt', 'utf-8') |
fs from 'node:fs' |
回调风格,需要传 callback | fs.readFile('a.txt', 'utf-8', (err, data) => {}) |
✅ 项目中
all-tools.mjs用的是fs/promises,因为工具函数是async的,直接await更简洁。
Agent 通过 工具(Tool) 与文件系统和命令行交互:
3.1 📖 读文件工具
js
const content = await fs.readFile(filePath, 'utf-8');
- 读取文件内容,返回字符串
- 每次调用打印日志,让用户知道 Agent 在干什么
3.2 ✍️ 写文件工具 --- path 模块和 fs.mkdir
path 模块 是 Node.js 内置的路径处理工具:
js
import path from 'node:path';
path.dirname('src/all-tools.mjs'); // → 'src'(拿到目录部分)
path.basename('src/all-tools.mjs'); // → 'all-tools.mjs'(拿到文件名部分)
path.join('src', 'utils', 'a.js'); // → 'src/utils/a.js'(拼接路径,自动处理分隔符)
path.resolve('./src'); // → 绝对路径(如 'D:/project/src')
写文件工具的完整逻辑:
js
const dir = path.dirname(filePath); // 1. 用 path 提取文件所在目录
await fs.mkdir(dir, { recursive: true }); // 2. 确保目录存在(递归创建)
await fs.writeFile(filePath, content, 'utf-8'); // 3. 写入文件
recursive: true 的作用:
| 场景 | 不加 recursive | 加了 recursive |
|---|---|---|
| 目录不存在 | 只建最后一层,父级不存在就报错 | 自动创建所有父级目录 |
| 目录已存在 | 报错(EEXIST) | 静默跳过,不报错 |
💡 "递归创建"的含义:逐层向上查找,哪层不存在就从哪层开始创建,直到目标目录。比如
a/b/c三层全不存在,它会先建a,再建a/b,最后建a/b/c。
3.3 📂 列出目录工具
js
const files = await fs.readdir(directoryPath);
// 返回字符串数组:['note.txt', 'photo.png', 'subFolder']
⚠️ 注意 :
fs.readdir()默认返回字符串数组 (只有文件名),不是对象数组。如果需要.name属性,要加{ withFileTypes: true }才会返回Dirent对象。
3.4 💻 执行命令工具(完整实现)
executeCommandTool 是 Agent 最核心的工具。实现原理和 §1.5 完全一致(spawn + new Promise),这里额外加了输出捕获 和日志:
💡 核心思路 :
stdio: ['pipe', 'pipe', 'pipe']让 Node.js 能拿到子进程的输出 →child.stdout.on('data')实时收集 → 一边process.stdout.write(data)刷到终端给用户看,一边stdout += data攒起来 → 子进程结束后通过resolve把完整输出返回给 LLM。
js
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[工具调用] execute_command(${command})
工作目录:${cwd}`);
return new Promise((resolve) => {
// shell:true 自动解析命令,不需要手动 split
const child = spawn(command, {
cwd,
stdio: ['pipe', 'pipe', 'pipe'], // ★ 三根管道:stdin/stdout/stderr 都走 pipe
shell: true,
});
let stdout = ''; // 攒标准输出(正常内容)
let stderr = ''; // 攒标准错误(报错信息)
let errorMsg = '';
// ===== 一边刷终端,一边攒内容 =====
child.stdout.on('data', (data) => {
stdout += data.toString(); // 攒起来,最后给 LLM
process.stdout.write(data); // 实时刷给用户看
});
child.stderr.on('data', (data) => {
stderr += data.toString();
process.stderr.write(data);
});
// ===== 子进程出错(命令本身不存在等) =====
child.on('error', (err) => {
errorMsg = err.message;
resolve(`命令执行出错:${err.message}\n\n已输出内容:\n${stdout || '(无输出)'}`);
});
// ===== 子进程结束 =====
child.on('close', (code) => {
const cwdInfo = workingDirectory
? `\n\n重要提示:命令在目录 "${workingDirectory}" 执行`
: '';
if (code === 0) {
console.log(`[工具调用] execute_command(${command}) 成功执行`);
resolve(`命令执行成功:${command}${cwdInfo}\n\n输出结果:\n${stdout || '(无输出)'}`);
} else {
console.log(`[工具调用] execute_command(${command}) 退出码:${code}`);
resolve(`命令执行失败,退出码:${code}\n错误信息:${errorMsg || stderr || '未知错误'}\n\n输出结果:\n${stdout || '(无输出)'}`);
}
});
});
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录、实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令(如 "npm run dev")'),
workingDirectory: z.string().describe('工作目录(推荐指定)')
})
}
);
💡 怎么读这段代码 :
spawn生出子进程 → 两个.on('data')同时在攒内容 和刷终端 →.on('close')触发时resolve把完整输出返回 → 外层await拿到结果塞进 ToolMessage 喂给 LLM。LLM 能看到命令的完整输出,而不只是一个"成功/失败"的摘要。
和 §1.5 的基础版相比:
| 新增内容 | 作用 |
|---|---|
stdio: ['pipe', 'pipe', 'pipe'] |
让 Node 能捕获子进程输出(§1.5 用 inherit 只看不存) |
stdout += data + process.stdout.write(data) |
一边攒给 LLM,一边刷给用户(两不误) |
stderr 捕获 |
错误信息也收集起来,LLM 能看到报错内容自我纠错 |
外层 console.log(...) |
让用户在终端看到 Agent 在干什么 |
📌 本节小结 :四个工具覆盖了 Agent 的完整能力------读、写、看目录用
fs/promises,执行命令用spawn+new Promise。写好工具定义后 import 到入口组装即可。
🚀 四、mini-cursor.mjs 入口
4.1 🔧 模型初始化
js
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages';
import { executeCommandTool, readFileTool, writeFileTool, listDirectoryTool } from './all-tools.mjs';
import chalk from 'chalk'; // 🎨 终端文字加颜色(pnpm i chalk)
// 模型配置同 readme1,详见那篇的 ChatOpenAI 四个配置项
const model = new ChatOpenAI({
modelName: 'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.DEEPSEEK_BASE_URL }
});
const tools = [executeCommandTool, readFileTool, writeFileTool, listDirectoryTool];
const modelWithTools = model.bindTools(tools);
4.2 📋 SystemMessage --- 给 AI 写的"使用说明书"
SystemMessage 告诉 AI:你有哪些工具、每个工具怎么用、有什么注意事项。
📌 下面模板字符串里的缩进是给 LLM 看的排版,不影响代码运行------别被缩进吓到。
js
new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录:${process.cwd()}
工具:
1.read_file: 读取文件
2.write_file: 写入文件
3.list_directory: 列出目录下的所有子目录
4.execute_command: 执行命令行任务(支持 workingDirectory 参数)
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
这是错误的!因为 workingDirectory 已经在 react-todo-app 目录了,再 cd 会找不到目录
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可
`);
⚠️ 注意:SystemMessage 中提到的参数名必须是
workingDirectory,和工具 schema 中定义的参数名一致。
4.3 🔄 ReAct 循环
Agent 的核心执行流程------思考 → 行动 → 观察 → 再思考...直到任务完成。
💡 这里用
for和for...of而不是 readme2 里的while和Promise.all,是两个不同的选择:
for代替while:加了maxIterations = 30安全阀,防止 LLM 陷入无限循环for...of代替Promise.all:这里为了代码简单用了串行执行。实际项目中建议换回Promise.all并发跑,和 readme2 教的一样
js
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(...), // 第1条:系统角色设定(见 §4.2)
new HumanMessage(query), // 第2条:用户任务
];
// ReAct 循环:最多跑 maxIterations 轮
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.blue(`\n[第 ${i + 1}/${maxIterations} 次] AI 思考中...`));
const response = await modelWithTools.invoke(messages);
messages.push(response); // ★ 把 AI 回复 push 进对话历史
// 没有 tool_calls → LLM 认为任务完成,返回最终答案
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(chalk.green(`\n✅ AI 最终回复:\n${response.content}\n`));
return response.content;
}
// 遍历每个工具调用,串行执行
for (const tool_call of response.tool_calls) {
const foundTool = tools.find(tool => tool.name === tool_call.name);
if (foundTool) {
try {
// ===== 执行工具 =====
console.log(chalk.cyan(` 🔧 调用工具: ${tool_call.name}`));
const toolResult = await foundTool.invoke(tool_call.args);
// ===== 结果喂回 messages =====
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: tool_call.id,
}));
// ===== 打印结果摘要(太长就截断) =====
const resultPreview = toolResult.length > 100
? toolResult.substring(0, 100) + '...'
: toolResult;
console.log(chalk.gray(` 📦 工具返回: ${resultPreview}`));
} catch (err) {
// 工具执行抛异常也不炸循环,把错误信息当结果喂给 LLM
console.log(chalk.red(` ❌ 工具执行失败: ${err.message}`));
messages.push(new ToolMessage({
content: `工具执行失败:${err.message}`,
tool_call_id: tool_call.id,
}));
}
} else {
console.log(chalk.yellow(` ❌ 未找到工具:${tool_call.name}`));
}
}
}
// 超过最大迭代次数 → 兜底返回最后一次 AI 回复
console.log(chalk.yellow(`\n⚠️ 达到最大迭代次数 (${maxIterations}),返回最后结果`));
return { content: messages[messages.length - 1].content, messages };
}
代码里加了什么(相比 readme2 的纯 while 循环):
| 新增 | 作用 |
|---|---|
chalk.cyan / green / red / gray |
终端彩色日志,一眼看出 Agent 在干什么 🎨 |
try/catch 包住 foundTool.invoke() |
工具执行出错不炸循环,错误当 ToolMessage 喂给 LLM 自行纠错 🛡️ |
resultPreview(前 100 字符截断) |
工具返回值太长时只打印摘要,终端不刷屏 📄 |
maxIterations 兜底 |
循环超过 30 轮强制退出,返回最后的 AI 回复 ⏱️ |
chalk.yellow 兜底提示 |
超限时告诉开发者"不太对劲,但给你最后的结果" ⚠️ |
4.4 🏁 入口:超时保护与主程序
Agent 执行可能卡住(网络超时、LLM 循环死锁),所以用 Promise.race 加一层超时保护------"Agent 跑任务"和"超时计时器"竞速,谁先完成用谁的结果:
js
// ==================== 超时保护 ====================
const TIMEOUT_MS = 1000000; // 100万毫秒 ≈ 16.7 分钟
// 超时 Promise ------ 时间到了就 reject
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`⏰ 执行超时(${TIMEOUT_MS / 1000 / 60} 分钟),强制退出`));
}, TIMEOUT_MS);
});
// ==================== 主程序执行 ====================
async function main() {
try {
console.log(chalk.blue('\n🚀 启动 Agent...\n'));
// Promise.race:Agent 任务 vs 超时计时器,谁先到用谁
const result = await Promise.race([
runAgentWithTools(case1),
timeoutPromise,
]);
console.log(chalk.green('\n✅ Agent 执行完成'));
return result;
} catch (error) {
console.error(chalk.red(`\n❌ 错误:${error.message}`));
// 超时 → 强制退出
if (error.message.includes('超时')) {
console.log(chalk.red('⏰ 强制退出进程...'));
process.exit(1);
}
throw error;
}
}
// 启动
main().catch((error) => {
console.error(chalk.red(`\n💥 未捕获的错误:${error.message}`));
process.exit(1);
});
// 兜底:万一上面的 Promise.race 没拦住,硬超时 5 秒后强制杀进程
setTimeout(() => {
console.log(chalk.red('⏰ 超时兜底强制退出进程'));
process.exit(0);
}, TIMEOUT_MS + 5000); // 比主超时多 5 秒
💡 怎么读这段代码:
Promise.race([Agent任务, 超时计时器])--- 两个 Promise 同时跑,谁先完成await就拿谁的结果- Agent 正常完成 →
main()正常结束,Node 进程退出- 超时先触发 →
reject抛异常 →catch拿到 →process.exit(1)强制退出- 兜底
setTimeout--- 万一Promise.race的 reject 没被正确捕获,5 秒后硬杀进程,绝不挂死
📌 本节小结 :把工具 import 进来 → bindTools 绑定 → SystemMessage 写使用说明书 → ReAct 循环跑起来。Promise.race超时保护防止 Agent 卡死,兜底setTimeout确保进程一定退出。
📝 五、关键技术点总结
📌 下面这张表是本篇所有新概念的速查,忘了就回来看。
| 概念 | 一句话理解 |
|---|---|
spawn |
生出一个子进程去执行命令 |
stdio: ['pipe', 'pipe', 'pipe'] |
三根管道捕获子进程的 stdin/stdout/stderr,代码能拿到输出内容 |
process.stdout.write(data) |
在 pipe 模式下把子进程输出实时刷到终端,用户看得到 |
shell: true |
命令通过 shell 执行,不需要手动 split,支持管道、引号等 |
cwd |
指定子进程在哪个目录下干活 |
recursive: true |
创建目录时自动建好所有不存在的父级,已存在就跳过 |
fs.readdir() |
列出目录内容,返回文件名数组(不是对象,不要用 .name) |
退出码 code |
0 = 成功,非 0 = 失败 |
| `code | |
new Promise(resolve) |
把事件驱动的 spawn 包装成可 await 的 Promise |
Promise.race([a, b]) |
多个 Promise 竞速,谁先完成用谁(超时保护核心) |
tool_calls |
AI 模型自动生成的工具调用请求数组,含 {name, args, id} |
ToolMessage |
工具执行结果,必须有 tool_call_id 与 tool_calls 对应 |
chalk.cyan/.green/.red/.gray |
给终端输出加颜色,让 Agent 每一步状态一目了然 |
| Git Bash | Windows 上的精简 Linux 环境,让 Linux 命令能在 Windows 跑 |
Zod describe() |
描述工具参数,供 Agent 理解参数含义 |
🚨 常见踩坑速查
正文已覆盖大部分踩坑的详细讲解,这里做快速索引:
| 坑 | 现象 | 详见正文 |
|---|---|---|
| Git Bash 路径 | \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs |
[§2.3](#坑 现象 详见正文 Git Bash 路径 \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs §2.3 路径注意事项 spawn 命令格式 spawn('ls -al') 报错 §1.1 + §1.2(用 shell:true 避免拆分) fs.readdir 返回值 file.name 是 undefined §3.3 注意标注 mkdir 目录已存在 不加 recursive 会报 EEXIST §3.2 recursive 对比表 "#23-git-bash-%E8%B7%AF%E5%BE%84%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9") 路径注意事项 |
spawn 命令格式 |
spawn('ls -al') 报错 |
[§1.1](#坑 现象 详见正文 Git Bash 路径 \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs §2.3 路径注意事项 spawn 命令格式 spawn('ls -al') 报错 §1.1 + §1.2(用 shell:true 避免拆分) fs.readdir 返回值 file.name 是 undefined §3.3 注意标注 mkdir 目录已存在 不加 recursive 会报 EEXIST §3.2 recursive 对比表 "#11-spawn-%E5%9F%BA%E6%9C%AC%E7%94%A8%E6%B3%95") + [§1.2](#坑 现象 详见正文 Git Bash 路径 \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs §2.3 路径注意事项 spawn 命令格式 spawn('ls -al') 报错 §1.1 + §1.2(用 shell:true 避免拆分) fs.readdir 返回值 file.name 是 undefined §3.3 注意标注 mkdir 目录已存在 不加 recursive 会报 EEXIST §3.2 recursive 对比表 "#12-%E6%8B%86%E5%88%86%E5%91%BD%E4%BB%A4%E5%AD%97%E7%AC%A6%E4%B8%B2-vs-shell-true")(用 shell:true 避免拆分) |
fs.readdir 返回值 |
file.name 是 undefined |
[§3.3](#坑 现象 详见正文 Git Bash 路径 \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs §2.3 路径注意事项 spawn 命令格式 spawn('ls -al') 报错 §1.1 + §1.2(用 shell:true 避免拆分) fs.readdir 返回值 file.name 是 undefined §3.3 注意标注 mkdir 目录已存在 不加 recursive 会报 EEXIST §3.2 recursive 对比表 "#33-%E5%88%97%E5%87%BA%E7%9B%AE%E5%BD%95%E5%B7%A5%E5%85%B7") 注意标注 |
mkdir 目录已存在 |
不加 recursive 会报 EEXIST |
[§3.2](#坑 现象 详见正文 Git Bash 路径 \n 变成换行符,src\node-exec.mjs 变成 src + ode-exec.mjs §2.3 路径注意事项 spawn 命令格式 spawn('ls -al') 报错 §1.1 + §1.2(用 shell:true 避免拆分) fs.readdir 返回值 file.name 是 undefined §3.3 注意标注 mkdir 目录已存在 不加 recursive 会报 EEXIST §3.2 recursive 对比表 "#32-%E5%86%99%E6%96%87%E4%BB%B6%E5%B7%A5%E5%85%B7--path-%E6%A8%A1%E5%9D%97%E5%92%8C-fsmkdir") recursive 对比表 |
💬 写在最后
🎉 恭喜你!从第一篇的 Tool 系统,到第二篇的 ReAct 循环,再到本篇的命令行手脚------你已经完整地走完了 Coding Agent 从 0 到 1 的全过程。
回顾一下这三篇走过的路:
| 篇章 | 核心收获 | 关键产出 |
|---|---|---|
| 第一篇 🧰 | Tool 定义 + bindTools + tool_calls | 学会了给 LLM 装"手脚" |
| 第二篇 🔄 | ReAct 循环 + Promise.all + ToolMessage | 学会了让 Agent"边想边干" |
| 第三篇(本篇) 🚀 | spawn 子进程 + 跨平台 + 4 工具组装 | 造出了一个能写代码、改代码、跑代码的 Agent |
你现在拥有的,是一个真正的 Coding Agent------它能听懂你的自然语言指令,能读写文件、列目录、执行终端命令,能自己规划任务、调用工具、观察结果、调整策略,直到任务完成。
这套"思考 → 行动 → 观察 → 再思考"的 ReAct 范式,不仅仅是 Coding Agent 的基础,也是所有 AI Agent 系统的通用模式。无论以后是做客服机器人、数据分析助手还是自动化运维,底层逻辑都是相通的。