从 0 到 1 打造 Coding Agent——mini cursor(终篇):装上命令行手脚,造一个会"干活"的 AI 程序员

🤖 从 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 installvite 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']

📌 ...argsrest 运算符,把数组剩余元素打包成一个新数组。

什么时候需要手动拆分?shell: false(默认),必须手动拆分参数。但如果设了 shell: trueshell 会自动解析命令 ,不需要手动 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 Promisespawn 包装成可 await 的 Promise

核心思路就两点:

  1. spawn() 返回一个 child 对象,通过事件 .on('close') 知道子进程结束了
  2. 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 后,可以直接运行 lsgrepcat 等 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 的核心执行流程------思考 → 行动 → 观察 → 再思考...直到任务完成。

💡 这里用 forfor...of 而不是 readme2 里的 whilePromise.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 秒

💡 怎么读这段代码

  1. Promise.race([Agent任务, 超时计时器]) --- 两个 Promise 同时跑,谁先完成 await 就拿谁的结果
  2. Agent 正常完成 → main() 正常结束,Node 进程退出
  3. 超时先触发 → reject 抛异常 → catch 拿到 → process.exit(1) 强制退出
  4. 兜底 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_idtool_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.nameundefined [§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 系统的通用模式。无论以后是做客服机器人、数据分析助手还是自动化运维,底层逻辑都是相通的。

相关推荐
leeyi1 小时前
让 Agent 说中文:Prompt 模板怎么管理(第47篇-E33)
aigc·agent·ai编程
FantasticPerson1 小时前
03 · Claude Code 如何工作
ai编程
西贝爱学习1 小时前
【Reasonix桌面端】介绍与安装
windows·ai编程
不好听6131 小时前
让 Agent 开口说话:用 Chalk 实现可视化终端
agent
不好听6131 小时前
AI Agent 的四肢:Node.js 内置模块实战指南
agent
学渣超1 小时前
微服务日志智能诊断系统(七) 日志 RAG 引擎——蒸馏、索引与精准检索
java·后端·agent
掉鱼的猫1 小时前
代码审查 Agent Harness 实战:AI 自动 Code Review
java·llm·agent
BraveWang1 小时前
【LangChain 1.x】03、Models 模型层简介与 init_chat_model 模型统一初始化
langchain
学渣超1 小时前
微服务日志智能诊断系统(六) Fast Path vs Slow Path——双路径日志诊断策略
java·后端·agent