手写一个 Mini Cursor:让 Agent 读写文件、执行命令并创建项目

0 阅读7分钟

上一篇我们讲了 ReAct:

Reason:模型根据当前消息判断下一步
Act:Runtime 执行模型选择的工具
Observe:工具结果写回消息历史
Loop:模型根据新结果继续判断

天气查询 Demo 已经跑通了最小循环,但它只能调用一个简单函数。

如果希望 Agent 像 Cursor 一样处理编程任务,它还需要真正接触项目环境:

读取文件
写入文件
查看目录
执行命令

这篇直接使用课程项目 hello-langchain 中的代码,看看同一套 ReAct 循环接入这些工具以后,如何变成一个最小编程 Agent。

这次要完成什么任务?

项目给 Agent 的任务是创建一个 React TodoList:

创建 Vite + React + TypeScript 项目
实现 TodoList 的添加、删除和完成状态切换
增加全部、进行中、已完成筛选
增加任务统计和 localStorage 持久化
补充渐变背景、卡片样式和过渡动画
安装依赖并启动开发服务器

这些步骤没有被逐条写成固定的 JavaScript 调用。

程序只给模型提供工具和任务,模型在每一轮根据当前消息决定接下来调用哪个工具。

Mini Cursor 的核心结构

mini-cursor-agent@2x.png 这张图表达的是分层关系,而不是固定执行顺序。

四个工具彼此独立,模型可能先执行命令创建项目,也可能先查看已有目录;拿到真实结果后,再决定下一步。

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

{
  "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

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() 读取文件,并把内容作为工具结果返回。

工具二:写入文件

项目中的写文件工具会先取得父目录,再递归创建目录并写入内容:

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 用来查看指定目录:

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() 启动命令子进程:

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 中注册的是项目刚才定义的四个工具:

const tools = [
    readFileTool,
    writeFileTool,
    listDirectoryTool,
    executeCommandTool,
]

const modelWithTools = model.bindTools(tools);

bindTools() 会把工具的名称、描述和 Schema 转换成模型能理解的工具定义。

但它不会直接执行工具。

真正的调用仍然需要后面的 Agent Loop 读取 response.tool_calls

课程项目给模型的任务

下面是 mini-cursor.mjs 中的原始任务:

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 启动服务器
`

这段任务同时给出了结果要求和部分执行约束。

模型仍然需要自己决定:

什么时候创建项目
什么时候读取 App.tsx
应该向文件写入什么内容
依赖安装在哪个工作目录执行
完成后是否需要再次查看目录

SystemMessage 约束命令工作目录

项目中的 SystemMessage 明确告诉模型有哪些工具,并强调 workingDirectory 的使用规则:

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 结构:

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() 返回的文件名字符串:

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 的共同基础:模型并不直接操作电脑,而是通过受控工具不断行动、观察和修正。