手写Mini Cursor:Agent文件读写、命令执行与项目创建
时间:2026-07-13 16:15
基于ReAct循环构建的MiniCursorAgent,通过Node jsRuntime提供读文件、写文件、查看目录、执行命令四个工具,在本地工作区自主完成创建ReactTodoList项目、实现功能、添加样式动画等编程任务,模型根据工具结果循环决策。
上回我们探讨了 ReAct 循环的核心机制:模型根据当前信息判断下一步行动,Runtime 执行模型选中的工具,并将结果写回消息历史,模型再依据新结果继续判断,如此循环往复。我们曾通过天气查询的 Demo 实现了最小闭环,但那个示例仅能调用一个简单函数。
若要让 Agent 像 Cursor 那样处理编程任务,它必须真正融入项目环境——能够读取文件、写入文件、查看目录、执行命令。本文直接以课程项目 `hello-langchain` 中的代码为例,演示同一套 ReAct 循环在集成这些工具后,如何演变为一个最小编程 Agent。
这次要完成什么任务?
项目为 Agent 设定的任务是创建一个 React TodoList:
- 创建 Vite + React + TypeScript 项目
- 实现 TodoList 的添加、删除和完成状态切换
- 增加全部、进行中、已完成三种筛选视图
- 集成任务统计与 localStorage 持久化
- 补充渐变背景、卡片样式和过渡动画
- 安装依赖并启动开发服务器
这些步骤并未被逐条写成固定的 Ja vaScript 调用。程序只向模型提供工具和任务,模型在每一轮根据当前消息决定下一步调用哪个工具。
Mini Cursor 的核心结构
此图展示了分层关系,而非固定执行顺序。四个工具相互独立,模型可能先执行命令创建项目,也可能先查看已有目录;拿到真实结果后,再决定下一步。
- **LLM**:理解任务并产生 tool_calls
- **Runtime**:匹配工具、执行函数、维护 messages
- **Tools**:通过 Node.js 操作文件和子进程
- **本地工作区**:保存代码、目录和命令执行结果
模型并未直接获得电脑权限。真正读写磁盘和执行命令的,仍然是 Node.js Runtime 中已注册好的工具。
项目结构
课程项目位于:
```
hello-langchain/
├── package.json
├── .env
├── react-todo-app/
└── src/
├── all-tools.mjs
├── mini-cursor.mjs
├── node-exec.mjs
└── tool.mjs
```
本文重点关注两个文件:`all-tools.mjs`(定义文件和命令工具)与 `mini-cursor.mjs`(绑定工具并运行 Agent Loop)。项目依赖来自当前 `package.json`:
```json
{
"dependencies": {
"@langchain/core": "^1.2.1",
"@langchain/openai": "^1.5.3",
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"zod": "^4.4.3"
}
}
```
工具一:读取文件
下面是项目 `all-tools.mjs` 中的 `readFileTool`:
```ja vascript
const readFileTool = tool(async({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
console.log(`[工具调用] read_file(${filePath})成功读取 ${content.length} 字节`)
return content;
},{
name: 'read_file',
description: `用此工具来读取文件内容,当用户要求读取文件、查看代码、分析文件内容时,调用此工具。输入文件路径(可以是相对路径或绝对路径)`,
schema: z.object({
filePath: z.string().describe('要读取的文件路径')
})
})
```
一个 LangChain Tool 包含两个部分:功能函数(真正执行 `fs.readFile()`)和描述对象(告知模型工具名称、用途和参数结构)。模型选择 `read_file` 后,Runtime 才会调用 `fs.readFile()` 读取文件,并把内容作为工具结果返回。
工具二:写入文件
项目中的写文件工具会先取得父目录,再递归创建目录并写入内容:
```ja vascript
const writeFileTool = tool(async({ filePath, content}) => {
try {
const dir = path.dirname(filePath);
console.log(`目录:${dir}`);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, 'utf-8');
console.log(`[工具调用] write_file(${filePath})成功写入 ${content.length} 字节`)
return `成功写入 ${filePath}`
} catch (err) {
console.log(`[工具调用] write_file(${filePath})文件写入失败:${err.message}`)
return `文件写入失败:${err.message}`
}
},{
name: 'write_file',
description: `向指定路径写入文件内容,自动创建目录`,
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入文件的内容')
})
})
```
关键代码是 `await fs.mkdir(dir, { recursive: true })`。如果 `src/components/todo` 这样的多级目录尚不存在,`recursive: true` 会递归创建父目录,然后再写入文件。工具执行成功后返回 `成功写入 文件路径`,这段文本随后进入 `ToolMessage`,让模型获悉写入操作是否成功。
工具三:查看目录
项目中的 `listDirectoryTool` 用于查看指定目录:
```ja vascript
const listDirectoryTool = tool(async ({ directoryPath }) => {
try{
const files = await fs.readdir(directoryPath);
console.log(`[工具调用] list_directory(${directoryPath})成功列出 ${files.length} 个文件和文件夹`)
return `目录内容:\n ${files.join('\n')}`
} catch (err) {
console.log(`[工具调用] list_directory(${directoryPath})目录读取失败:${err.message}`)
return `目录读取失败:${err.message}`
}
},{
name: 'list_directory',
description: `列出指定目录下的所有文件和文件夹`,
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
})
```
编程 Agent 需要了解当前项目里有哪些文件,才能判断下一步该读取或修改什么。例如 Vite 项目创建完成后,模型可调用目录工具确认 `package.json`、`src/`、`public/`、`vite.config.ts` 等。目录结果会成为下一轮模型决策的上下文,而非仅打印给用户。
工具四:执行命令
项目通过 Node.js 的 `child_process.spawn()` 启动命令子进程:
```ja vascript
const executeCommandTool = tool(async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[工具调用] execute_command(${command})工作目录:${cwd}`);
return new Promise((resolve, reject) => {
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})成功执行`)
const cwdInfo = cwd?`\n\n重要提示:命令在目录“${cwd}”执行`:'';
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('工作目录(推荐指定)')
})
})
```
`spawn()` 创建子进程执行命令,`stdio: 'inherit'` 表示继承当前终端的输入输出,因此安装依赖、构建项目时,执行日志会直接显示。命令结束后触发 `close` 事件,Runtime 根据退出码组织工具结果:`code === 0` 表示命令成功,`code !== 0` 表示失败,并返回退出码和错误信息。这样模型下一轮才能知晓命令是否真正执行成功。
把四个工具交给模型
`mini-cursor.mjs` 中注册的是项目刚才定义的四个工具:
```ja vascript
const tools = [
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool,
]
const modelWithTools = model.bindTools(tools);
```
`bindTools()` 会将工具的名称、描述和 Schema 转换成模型能理解的工具定义,但它不会直接执行工具。真正的调用仍需要后续的 Agent Loop 读取 `response.tool_calls`。
课程项目给模型的任务
下面是 `mini-cursor.mjs` 中的原始任务:
```ja vascript
const case1 = `创建一个功能丰富的react todolist 应用:
1. 创建项目:echo -e "\n\n\n" | 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 启动服务器`
```
这段任务同时给出了结果要求和部分执行约束。模型仍需自己决定:何时创建项目、何时读取 App.tsx、应向文件写入什么内容、依赖安装在哪个工作目录执行、完成后是否需要再次查看目录。
SystemMessage 约束命令工作目录
项目中的 SystemMessage 明确告知模型有哪些工具,并强调 `workingDirectory` 的使用规则:
```ja vascript
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 react-todo-app 会找不到目录
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可
回复要简洁,只说做了什么`)
```
如果工具已将工作目录设为 `react-todo-app`,命令中就不必再次执行 `cd react-todo-app`,否则可能进入不存在的嵌套目录。
Mini Cursor 的 ReAct 循环
项目里的 `runAgentWithTools()` 延续了上一篇的 ReAct 结构:
```ja vascript
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
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 react-todo-app 会找不到目录
- 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
这样就对了!workingDirectory 已经切换到 react-todo-app,直接执行命令即可
回复要简洁,只说做了什么`),
new HumanMessage(query),
];
for(let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`正在等待AI 第 ${i+1} 次思考...`))
const response = await modelWithTools.invoke(messages);
messages.push(response);
if(!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n: AI 最终回复\n: ${response.content}`);
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;
}
```
每轮执行可拆分为四步:
1. `modelWithTools.invoke(messages)`:模型决定下一步操作
2. `response.tool_calls`:读取工具调用请求
3. `foundTool.invoke(toolCall.args)`:Runtime 执行工具
4. `ToolMessage`:将执行结果写回上下文
当模型不再返回 `tool_calls` 时,说明它认为任务已完成,函数返回最终内容。`maxIterations = 30` 则为 Agent 设定了停止边界,避免无限循环。
这个项目实际生成了什么?
当前课程项目中已存在 Agent 生成的 `react-todo-app`:
```
react-todo-app/
├── package.json
├── vite.config.ts
├── src/
│ ├── App.tsx
│ ├── App.css
│ └── index.css
└── ...
```
`App.tsx` 中已实现:
- 添加与删除任务
- 切换完成状态
- 全部、进行中、已完成筛选
- 总数与状态统计
- localStorage 数据持久化
- 添加和删除过渡状态
这表明 Agent 不仅输出了建议,还通过文件工具将结果真正写入了项目工作区。
代码细节
工具名称、导出名称和 Schema 参数需要在不同文件中保持一致:`listDirectoriesTool` → `listDirectoryTool`,`executeCommndTool` → `executeCommandTool`,`directoryPath` → `workingDirectory`。目录工具也改为直接输出 `fs.readdir()` 返回的文件名字符串:
```ja vascript
const files = await fs.readdir(directoryPath);
return files.join('\n');
```
这样模型生成的参数才能被工具函数正确接收,`mini-cursor.mjs` 也能导入对应工具。
仍需注意:开发服务器不会主动退出
命令工具通过 `close` 事件等待子进程结束,但 `pnpm run dev` 通常会持续运行。执行到启动服务器这一步时,工具可能一直等待,需要结合课堂实际运行方式确认是否另开了终端或还有进程管理代码。这不是课程知识缺失,而是后续做进程管理时需要继续处理的工程问题。
Mini Cursor 还缺少哪些生产能力?
Demo 已跑通核心工作流,但成熟 Coding Agent 还需要更多工程边界:
工作区限制
文件工具应只允许访问指定项目,不能让模型任意读取用户其他目录。
命令确认
删除文件、修改 Git 历史、上传内容等高风险命令,需要禁止或在执行前征得用户确认。
Patch 修改
覆盖整个文件容易影响已有代码。成熟工具通常使用结构化 Patch,仅修改必要片段。
构建与测试
“写入成功”不代表功能正确。Agent 还需通过构建、测试或浏览器检查来验证结果。
进程管理
开发服务器等长期进程需要查看日志、检查端口和主动停止,不能只负责启动。
总结
这次的 Mini Cursor 没有改变上一篇 ReAct 的基本循环,只是将工具能力从天气查询扩展到了本地开发环境:`read_file` 读取代码,`write_file` 创建和修改文件,`list_directory` 查看项目结构,`execute_command` 执行安装、构建和启动命令。模型负责根据任务与观察结果选择下一步,Runtime 负责执行工具并维护消息,本地工作区负责提供真实的文件和命令结果。
**LLM 决策 + ReAct Loop + 文件与命令工具 = 最小编程 Agent**
理解这条链路后,就能看清 Cursor、Claude Code 和 Codex 这类 Coding Agent 的共同基础:模型并不直接操作电脑,而是通过受控工具不断行动、观察和修正。
来源:https://juejin.cn/post/7661030491453538304
Mini
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。