手写 mini-cursor 完整版:从 Promise 子进程到 ReAct 循环,30 轮迭代完成 TodoList 全流程
上一版我们搭好了 mini-cursor 的骨架,但 executeCommandTool 还是空的。这一版一切就绪------executeCommandTool 用 Promise 封装 spawn 完整实现,workingDirectory 参数告别"cd 进目录再 cd 出来"的混乱,30 轮 ReAct 循环配合超时兜底,最终让 AI 自主完成一个带渐变背景、动画效果、localStorage 持久化的 React TodoList 应用全流程。
前言
如果你还在纠结"AI 编程 Agent 到底是怎么工作的",展开这个问题------它需要:
- 创建一个 Vite + React 项目
- 写入完整的 TodoList 组件代码(增删改查、分类筛选、localStorage)
- 添加渐变背景、卡片阴影、过渡动画
- 安装依赖并启动开发服务器
这已经不是"一句话生成代码"了,这是一个多步骤工程任务 。而 Claude Code、Cursor Agent 能做到的,我们现在用 120 行 Node.js 代码也能做到------一个完整的 ReAct 循环 + 四个工具函数。
本文是 mini-cursor 系列的完整版,核心升级:
executeCommandTool完整实现,Promise 包装 spawn 子进程workingDirectory参数替代错误的cd模式- 30 轮 ReAct 循环,支持复杂多步骤任务
setTimeout超时兜底,防止进程僵死- 完整的 React TodoList 项目实战案例
一、executeCommandTool 完整实现:Promise 包装 spawn
1.1 为什么需要 Promise 包装?
spawn 启动的子进程是事件驱动 的,不是异步函数。Agent 的 tool() 函数要求返回一个值(或 Promise),所以需要用 Promise 包装 spawn 的 close 事件:
javascript
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
return new Promise((resolve, reject) => {
// 切分命令为 cmd 和 args
const [cmd, ...args] = command.split(' ');
// 启动子进程
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit', // 实时输出,不缓存
shell: true, // 支持管道、重定向
});
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message;
});
child.on('close', (code) => {
if (code === 0) {
console.log(`[工具调用] execute_command(${command}) 成功执行`);
// 重要提示:告知 AI 命令在哪个目录执行
const cwdInfo = workingDirectory
? `\n\n重要提示:命令在目录 "${workingDirectory}" 执行`
: '';
resolve(`命令行成功执行 ${command}${cwdInfo}`);
} else {
console.log(`[工具调用] execute_command(${command}) 退出码:${code}`);
resolve(`命令执行失败,退出码:${code}\n错误:${errorMsg}`);
}
});
});
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().describe('工作目录(推荐指定)')
})
}
);
1.2 Promise 包装的工作流
php
Agent 调用 execute_command
↓
执行 spawn(cmd, args, { cwd, stdio: 'inherit', shell: true })
↓
子进程独立运行(不阻塞主进程)
↓
监听 close 事件:子进程结束
↓
resolve(结果) → Promise 完成 → 返回给 Agent
↓
Agent 继续下一步
1.3 参数名变化:directoryPath → workingDirectory
对比上一版的关键变化:
| 参数 | 旧版 | 新版 | 原因 |
|---|---|---|---|
| 参数名 | directoryPath |
workingDirectory |
语义更清晰,表明这是"工作目录"而非"目录路径" |
| 描述 | 工作目录(推荐指定) | 工作目录(推荐指定) | 不变 |
| 行为 | 自动切换 | 自动切换 | 不变 |
二、workingDirectory 的正确用法:告别 cd 混乱
2.1 错误模式:手动 cd
很多初学 Agent 的人在写 Prompt 时,会这样写命令:
php
execute_command({
command: "cd react-todo-app && pnpm install",
workingDirectory: "react-todo-app"
})
问题在哪?
bash
workingDirectory 已经将工作目录切到了 react-todo-app,
再执行 cd react-todo-app 会出错!
流程:
父进程在 /home/user/projects
↓
spawn 设置 cwd = /home/user/projects/react-todo-app
↓
子进程执行:cd react-todo-app && pnpm install
↓
cd react-todo-app 找不到目录(已经在 react-todo-app 里了)
→ 命令失败
2.2 正确模式:利用 workingDirectory
bash
execute_command({
command: "pnpm install",
workingDirectory: "react-todo-app"
})
流程:
父进程在 /home/user/projects
↓
spawn 设置 cwd = /home/user/projects/react-todo-app
↓
子进程执行:pnpm install
↓
✅ 直接在 react-todo-app 目录下执行
2.3 SystemMessage 中的规则约束
为了让 LLM 理解这个规则,SystemMessage 中明确写了:
bash
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
这是错误的!因为 workingDirectory 已经在 react-todo-app 目录了,再 cd react-todo-app 会找不到目录
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可
这就是 Prompt Engineering + Harness Engineering 的结合------在 Prompt 中注入规则约束,引导 LLM 正确使用工具。
三、完整 ReAct 循环:30 轮迭代 + 超时兜底
3.1 主循环代码
javascript
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
工具:
1. read_file: 读取文件
2. write_file: 写入文件
3. execute_command: 执行命令(支持 workingDirectory 参数)
4. list_directory: 列出目录
...`),
new HumanMessage(query)
];
// ReAct 循环
for (let i = 0; i < maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
// 没有 tool_calls → 任务完成
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n🤖 AI 最终回复:\n${response.content}\n`);
return response.content;
}
// 执行工具调用
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
const toolResult = await foundTool.invoke(toolCall.args);
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}));
}
}
}
return messages[messages.length - 1].content;
}
3.2 循环迭代可视化
php
第 1 轮:AI 收到任务 → 思考 → 需要创建项目
→ execute_command({ command: "pnpm create vite ...", workingDirectory: "." })
→ 项目创建成功
第 2 轮:AI 思考 → 需要查看生成的目录结构
→ list_directory({ directoryPath: "react-todo-app/src" })
→ 看到 src/App.tsx 等文件
第 3 轮:AI 思考 → 需要写入 TodoList 组件代码
→ write_file({ filePath: "react-todo-app/src/App.tsx", content: "..." })
→ 写入成功
第 4 轮:AI 思考 → 需要安装依赖
→ execute_command({ command: "pnpm install", workingDirectory: "react-todo-app" })
→ 安装成功
第 5 轮:AI 思考 → 需要启动项目
→ execute_command({ command: "pnpm run dev", workingDirectory: "react-todo-app" })
→ 项目运行在 http://localhost:5173
✅ 任务完成!
3.3 超时兜底机制
javascript
try {
await runAgentWithTools(case1);
} catch (err) {
console.error(`\n❌ 错误:${err.message}`);
}
// 超时兜底:1000 秒后强制退出
setTimeout(() => {
console.log("⏰ 超时兜底强制退出进程");
process.exit(0);
}, 1000000);
为什么需要超时兜底?
| 场景 | 问题 | 兜底方案 |
|---|---|---|
| Agent 陷入死循环 | 无限轮询,不退出 | maxIterations = 30 终止 |
| 子进程挂起 | npm install 卡住不动 | setTimeout 强制退出主进程 |
| API 调用超时 | LLM 不响应 | 兜底 process.exit(0) |
setTimeout 的 1000000ms(约 16 分钟):这是一个非常宽松的超时限制,给复杂项目留足时间,但防止进程无限运行。
四、实战:React TodoList 完整项目
4.1 任务描述
javascript
const case1 = `
创建一个功能丰富的 React TodoList 应用:
1. 创建项目:
echo -e "n\nn" | pnpm create vite react-todo-app --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList:
- 添加、删除、标记完成
- 分类筛选(全部/进行中/已完成)
- 统计信息显示
- localStorage 数据持久化
3. 添加复杂样式
- 渐变背景(蓝到紫)
- 卡片阴影,圆角
- 悬停效果
4. 添加动画:
- 添加/删除时的过渡动画
- 使用 css transitions
5. 列出目录确定
注意:使用 pnpm,功能要完整,样式要美观,要有动画效果
之后 react-todo-app 项目中:
1. 使用 pnpm install 安装依赖
2. 使用 pnpm run dev 启动服务器
`
4.2 任务拆解
less
任务分解(AI 的 ReAct 推理过程):
Round 1: 创建项目
Reason: 需要先创建 Vite + React 项目
Act: execute_command({ command: "echo -e \"n\nn\" | pnpm create vite react-todo-app --template react-ts", workingDirectory: "." })
Observe: 项目创建成功
Round 2: 查看目录结构
Reason: 需要知道生成的文件结构,以便修改正确的文件
Act: list_directory({ directoryPath: "react-todo-app/src" })
Observe: 看到 App.tsx、main.tsx、index.css 等文件
Round 3: 读取当前代码
Reason: 需要了解当前 App.tsx 的内容,以便修改
Act: read_file({ filePath: "react-todo-app/src/App.tsx" })
Observe: 看到默认模板代码
Round 4: 写入 TodoList 完整代码
Reason: 需要替换为完整的 TodoList 组件
Act: write_file({ filePath: "react-todo-app/src/App.tsx", content: "..." })
Observe: 写入成功
Round 5: 写入样式文件
Reason: 需要添加渐变背景、卡片阴影、动画
Act: write_file({ filePath: "react-todo-app/src/App.css", content: "..." })
Observe: 写入成功
Round 6: 安装依赖
Reason: 需要安装项目依赖
Act: execute_command({ command: "pnpm install", workingDirectory: "react-todo-app" })
Observe: 依赖安装完成
Round 7: 启动项目
Reason: 需要启动开发服务器
Act: execute_command({ command: "pnpm run dev", workingDirectory: "react-todo-app" })
Observe: 项目运行在 http://localhost:5173
✅ 任务完成!
4.3 完整的 TodoList 代码(App.tsx)
当 AI 写入 App.tsx 时,它应该生成类似这样的代码:
tsx
import { useState, useEffect } from 'react';
import './App.css';
interface Todo {
id: number;
text: string;
completed: boolean;
}
type Filter = 'all' | 'active' | 'completed';
function App() {
const [todos, setTodos] = useState<Todo[]>(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
const [input, setInput] = useState('');
const [filter, setFilter] = useState<Filter>('all');
// 持久化到 localStorage
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
const addTodo = () => {
if (!input.trim()) return;
setTodos([...todos, { id: Date.now(), text: input, completed: false }]);
setInput('');
};
const toggleTodo = (id: number) => {
setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(t => t.id !== id));
};
const filteredTodos = todos.filter(t => {
if (filter === 'active') return !t.completed;
if (filter === 'completed') return t.completed;
return true;
});
const stats = {
total: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length,
};
return (
<div className="app">
<h1>📋 TodoList</h1>
<div className="input-area">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="添加新任务..."
/>
<button onClick={addTodo}>添加</button>
</div>
<div className="filters">
{(['all', 'active', 'completed'] as Filter[]).map(f => (
<button
key={f}
className={filter === f ? 'active' : ''}
onClick={() => setFilter(f)}
>
{f === 'all' ? '全部' : f === 'active' ? '进行中' : '已完成'}
</button>
))}
</div>
<ul className="todo-list">
{filteredTodos.map(todo => (
<li key={todo.id} className={`todo-item ${todo.completed ? 'done' : ''}`}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
<span className="text">{todo.text}</span>
<button className="delete" onClick={() => deleteTodo(todo.id)}>✕</button>
</li>
))}
</ul>
<div className="stats">
<span>总计: {stats.total}</span>
<span>进行中: {stats.active}</span>
<span>已完成: {stats.completed}</span>
</div>
</div>
);
}
export default App;
对应的 App.css:
css
.app {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: white;
}
h1 { text-align: center; font-size: 2.5rem; }
.input-area {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.input-area input {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-size: 1rem;
}
.input-area button {
padding: 0.75rem 1.5rem;
background: #4CAF50;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.input-area button:hover { transform: scale(1.05); }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.filters button {
padding: 0.5rem 1rem;
border: 1px solid rgba(255,255,255,0.3);
background: transparent;
color: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.filters button.active { background: white; color: #667eea; }
.todo-list { list-style: none; padding: 0; }
.todo-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(255,255,255,0.15);
border-radius: 8px;
margin-bottom: 0.5rem;
transition: all 0.3s;
animation: slideIn 0.3s ease;
}
.todo-item:hover { transform: translateX(5px); background: rgba(255,255,255,0.25); }
.todo-item.done .text { text-decoration: line-through; opacity: 0.6; }
.todo-item .text { flex: 1; }
.todo-item .delete {
background: none;
border: none;
color: #ff6b6b;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.2s;
}
.todo-item .delete:hover { transform: scale(1.2); }
.stats {
display: flex;
justify-content: space-around;
margin-top: 1.5rem;
padding: 1rem;
background: rgba(255,255,255,0.1);
border-radius: 8px;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
五、完整架构图
scss
┌──────────────────────────────────────────────────────────────┐
│ mini-cursor 完整版架构 │
├──────────────────────────────────────────────────────────────┤
│ mini-cursor.mjs │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ runAgentWithTools(query, maxIterations = 30) │ │
│ │ ┌────────────────────────────────────────────────┐ │ │
│ │ │ SystemMessage(规则约束) │ │ │
│ │ │ + HumanMessage(用户任务) │ │ │
│ │ │ ↓ │ │ │
│ │ │ ReAct 循环 (for i < maxIterations) │ │ │
│ │ │ ├── modelWithTools.invoke → 推理 │ │ │
│ │ │ ├── 检测 tool_calls │ │ │
│ │ │ ├── 遍历执行工具 → ToolMessage 加入历史 │ │ │
│ │ │ └── 无 tool_calls → 返回结果 ✅ │ │ │
│ │ └────────────────────────────────────────────────┘ │ │
│ │ setTimeout(1000000ms) → 超时兜底 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ bindTools │
│ all-tools.mjs │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ read_file → fs.readFile │ │
│ │ write_file → fs.mkdir + fs.writeFile │ │
│ │ list_directory → fs.readdir │ │
│ │ execute_command → spawn + Promise 包装 │ │
│ │ ├── cwd → workingDirectory │ │
│ │ ├── stdio: 'inherit' │ │
│ │ └── shell: true │ │
│ └──────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ 模型: deepseek-v4-pro(temperature: 0) │
│ 框架: LangChain(ChatOpenAI + bindTools) │
└──────────────────────────────────────────────────────────────┘
知识树
arduino
手写 mini-cursor 完整版
├── executeCommandTool(Promise 包装 spawn)
│ ├── command.split(' ') → [cmd, ...args]
│ ├── cwd = workingDirectory || process.cwd()
│ ├── stdio: 'inherit'(实时输出)
│ ├── shell: true(管道/重定向)
│ └── Promise(resolve on close)
├── workingDirectory 正确用法
│ ├── ❌ 错误:command 中写 cd
│ └── ✅ 正确:workingDirectory 自动切换目录
├── ReAct 循环(30 轮迭代)
│ ├── invoke → tool_calls 检测
│ ├── tools.find → tool.invoke
│ ├── ToolMessage 加入历史
│ └── setTimeout 超时兜底
├── 实战:React TodoList 全流程
│ ├── pnpm create vite → 创建项目
│ ├── write_file → 写入 App.tsx + App.css
│ ├── pnpm install → 安装依赖
│ └── pnpm run dev → 启动服务
└── 审查纠正
├── fs.readdir 返回值陷阱
├── AIMessage 导入说明
├── chalk 注释原因
└── echo -e "n\nn" 技巧
结语
从"骨架版"到"完整版",mini-cursor 的变化体现了 Agent 工程化的演进路径:
- 工具要完整 :
executeCommandTool从空壳到完整实现,Agent 才能真正干活 - 参数要合理 :
workingDirectory替代cd模式,减少 Agent 犯错 - 循环要有兜底 :
maxIterations+setTimeout,防止 Agent 无限执行 - Prompt 要有规则:SystemMessage 中注入约束,引导 LLM 正确使用工具
现在你的 mini-cursor 已经可以完成"创建项目 → 写入代码 → 安装依赖 → 启动服务"的完整流程了。虽然不如 Claude Code 那样强大,但核心原理完全一致------LLM 负责推理,工具负责执行,循环负责推进。
100 行代码,一个能自己干活的编程 Agent。这就是 ReAct 循环 + 工具集的力量。
参考与拓展阅读:
- Node.js 官方文档:child_process.spawn
- LangChain 官方文档:bindTools 和 Messages
- 《ReAct 框架深度解析》------ Reason + Act + Observe 循环
- 《手写 mini-cursor 骨架版》------ 从零搭建三层架构
- 《从零手写 AI Agent》------ LangChain + Tool 入门
如果本文帮你理解了编程 Agent 从骨架到完整的演进过程,欢迎点赞 + 收藏。动手跑一下你的 mini-cursor,看看它能不能完成 TodoList 项目,欢迎在评论区分享你的执行结果 👇
#AIAgent #Node.js #child_process #编程Agent #LangChain #掘金技术社区