导航菜单

  • 1.claudecode
  • 2.claudecode
  • readline
  • iconv-lite
  • 1. 使用 readline 搭建交互式行输入
    • 1.1 claude.js
    • 1.2 时序图
  • 2. 接入 DeepSeek:用 OpenAI SDK 完成终端对话
    • 2.1 安装依赖
    • 2.2 claude.js
    • 2.3 .env
    • 2.4 completion
      • 2.4.1 示例代码
      • 2.4.2 输出信息
      • 2.4.3 数据结构
        • 2.4.3.1 ChatCompletion对象
        • 2.4.3.2 choices 数组
        • 2.4.3.3 message 对象
        • 2.4.3.4 usage 对象
        • 2.4.3.5 补充说明
    • 2.5 时序图
  • 3. 受控工作区与 readFile / writeFile 工具骨架
    • 3.1 claude.js
  • 4. 工具调用循环:把 readFile / writeFile 接到 API
    • 4.1 claude.js
    • 4.2 时序图
    • 4.3 测试数据
  • 5. 扩展工具:editFile 就地替换与 listDir 目录浏览
    • 5.1 claude.js
    • 5.2 时序图
    • 5.3 msg.txt
    • 5.4 测试
  • 6. 在工作区内执行 Shell 命令(runCommand)
    • 6.1 安装依赖
    • 6.2 claude.js
    • 6.3 时序图
    • 6.4 测试
  • 7. 长驻命令后台运行:spawn 与启动段输出采集
    • 7.1 server1.js
    • 7.2 server2.js
    • 7.3 claude.js
    • 7.4 时序图
    • 7.5 测试
  • 8. 后台任务登记与 task_list 管理
    • 8.1 claude.js
    • 8.2 时序图
    • 8.3 测试
  • 9. task_logs / task_stop
    • 9.1 claude.js
    • 9.2 时序图
    • 9.3 测试数据
  • 10. 含 runCommand 时同轮工具顺序执行
    • 10.1 a.txt
    • 10.2 claude.js
    • 10.3 时序图
    • 10.4 测试

1. 使用 readline 搭建交互式行输入 #

程序在做什么: 启动后会在控制台显示提示符 ›,等待你输入一行文字;回车后会把内容原样回显为「你说:…」。空行会被忽略;输入 q 并回车则结束程序。

核心 API:

  • readline.createInterface({ input, output, terminal }):把可读流(一般是 process.stdin)和可写流(一般是 process.stdout)绑在一起,形成「一问一答」的交互环境。terminal: true 表示按终端语义处理(光标、行编辑等),适合在真正的控制台里运行。
  • rl.question(prompt, callback):在 prompt 后等待用户输入一整行,以回车结束;callback 收到去掉末尾换行的字符串。示例里用 new Promise 把它包成 await askLine(),便于在 async function main 里用顺序写法组织逻辑。
  • rl.close():用完接口后应关闭,避免句柄与监听器一直占用。
// 根据进程名结束所有 node.exe 进程(不带强制参数,如果有交互会提示)
taskkill /IM node.exe
// 根据进程名强制结束所有 node.exe 进程(/F 表示“强制”)
taskkill /F /IM node.exe
// 根据进程号(PID)结束指定进程(这里 1234 是举例的 PID)
taskkill /PID 1234

1.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");

// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步主函数 main
async function main() {
  // 无限循环,持续读取用户输入
  for (;;) {
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 输出用户输入的内容,前面加上提示文字
    console.log("你说:", line);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

1.2 时序图 #

sequenceDiagram autonumber participant Top as 脚本入口 participant Main as main() participant Ask as askLine() participant RL as readline (rl) participant Out as stdout participant User as 用户/终端 Top->>Main: main() loop 直到输入为 "q" Main->>Ask: await askLine() Ask->>Ask: new Promise(resolve => ...) Ask->>RL: question("› ", resolve) RL->>Out: 写入提示符 "› " RL-->>User: 等待一行(stdin,回车结束) User->>RL: 输入一行 + Enter RL->>Ask: resolve(line) Ask-->>Main: Promise 兑现,line alt 仅空白 Note over Main: continue,不打印 else 内容为 q Main->>Main: break 跳出循环 else 其它非空 Main->>Out: console.log("你说:", line) end end Main->>RL: rl.close()

2. 接入 DeepSeek:用 OpenAI SDK 完成终端对话 #

在第 1 节「读一行、回显一行」的基础上,本节为 claude.js 增加大模型回复:仍用 readline 在终端读入,但不再原样打印,而是把用户输入发给 DeepSeek 的 Chat Completions API,并把助手回复打印到控制台。

程序在做什么: 启动时从 .env 读取 DEEPSEEK_API_KEY;每轮在 › 后读入一行。空行忽略;输入 q 退出。其它内容会带上固定的 system 角色说明,以 user 消息发给模型,模型返回后以 Assistant 标题打印回复。

  • require("dotenv").config({ override: true }):加载项目根目录 .env 中的环境变量;override: true 表示用文件里的值覆盖进程里已有的同名变量,便于本地开发统一配置密钥。
  • OpenAI 客户端 + baseURL: "https://api.deepseek.com":DeepSeek 提供与 OpenAI 兼容的 HTTP API,因此可直接使用官方 openai npm 包,只需改 baseURL 和 apiKey,无需换 SDK。
  • AGENT_SYSTEM_INSTRUCTION:写入 messages 首条的 role: "system",约束助手身份与行为。
  • openaiClient.chat.completions.create({ model, messages }):异步请求聊天补全;model 使用 deepseek-v4-pro;从 completion.choices[0].message 取出助手文本并打印。

关于对话上下文: 当前实现每轮循环都会新建 messages = [system],因此每一轮独立,不会把上一轮的 user / assistant 带入下一轮 API 请求;循环末尾 messages.push(assistant) 主要为单轮结构完整,若要做多轮记忆,应把 messages 提到 for 循环外并在多轮间复用。

运行前准备: 在项目根目录配置 .env(例如 DEEPSEEK_API_KEY=你的密钥),安装依赖后执行 node claude.js。需能访问 https://api.deepseek.com;密钥勿提交到版本库。

2.1 安装依赖 #

npm install openai dotenv --save

2.2 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
+// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
+require("dotenv").config({ override: true });
+// 从 openai 包中引入 OpenAI 类
+const { OpenAI } = require("openai");
+// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
+const openaiClient = new OpenAI({
+ apiKey: process.env.DEEPSEEK_API_KEY,
+ baseURL: "https://api.deepseek.com",
+});
+// 定义代理的系统指令
+const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步主函数 main
async function main() {
  // 无限循环,持续读取用户输入
+ for (; ;) {
+   // 创建一个消息列表,包含系统指令
+   const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
+   // 将用户输入加入对话消息列表
+   messages.push({ role: "user", content: line });
+   // 调用 OpenAI API,发送所有消息,生成聊天回复
+   const completion = await openaiClient.chat.completions.create({
+     model: "deepseek-v4-pro",
+     messages: messages,
+   });
+   // 获取助手回复内容
+   const reply = completion.choices[0].message;
+   // 打印助手的回复到控制台
+   console.log("\nAssistant\n", reply.content, "\n");
+   // 将助手回复也加入消息列表,以便对话上下文保持同步
+   messages.push({ role: "assistant", content: reply.content });
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

2.3 .env #

DEEPSEEK_API_KEY=sk-fcfe6462dcd24095ab2f7e238e74143f

2.4 completion #

2.4.1 示例代码 #

// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
async function main() {
    const completion = await openaiClient.chat.completions.create({
    model: "deepseek-v4-pro",
    messages: [
      { role: "user", content: "1+1=?" },
    ]
  });
    console.log(JSON.stringify(completion, null, 2));  
    // 获取模型的文本回答
    const assistantMessage = completion.choices[0].message.content;
    console.log(assistantMessage);
    // 获取本次请求的Token消耗
    const tokensUsed = completion.usage.total_tokens;
    console.log(`本次请求共消耗 ${tokensUsed} 个Token`);
}
main();

2.4.2 输出信息 #

{
  "id": "5e3d5c3d-5d36-493b-9eb8-3066ef854d58", // 本次会话的唯一标识
  "object": "chat.completion", // 返回对象类型,固定为 chat.completion
  "created": 1778910161, // 响应生成的UNIX时间戳
  "model": "deepseek-v4-pro", // 所使用的模型名称
  "choices": [ // 多个候选答案的列表,通常只有一个
    {
      "index": 0, // 当前choice在choices数组中的索引
      "message": { // assistant生成的消息对象
        "role": "assistant", // 消息角色,这里是assistant(助手)
        "content": "1 + 1 = 2", // assistant的回复内容
        "reasoning_content": "We are asked: \"1+1=?\" This is a very simple arithmetic question. The answer is 2. But let's consider if there is any trick. The user might be testing for a basic response. I'll just answer directly." // assistant推理与分析过程
      },
      "logprobs": null, // 置信概率信息,通常为null
      "finish_reason": "stop" // 回答完成的原因(如stop表示正常结束)
    }
  ],
  "usage": { // token消耗统计
    "prompt_tokens": 8, // 用户输入的token数
    "completion_tokens": 58, // assistant回复的token数
    "total_tokens": 66, // 总token数(prompt_tokens + completion_tokens)
    "prompt_tokens_details": { // 提示词token细节
      "cached_tokens": 0 // 缓存命中的token数
    },
    "completion_tokens_details": { // 回复token细节
      "reasoning_tokens": 50 // assistant推理内容的token数
    },
    "prompt_cache_hit_tokens": 0, // prompt部分缓存命中token数
    "prompt_cache_miss_tokens": 8 // prompt部分没有命中的token数
  },
  "system_fingerprint": "fp_9954b31ca7_prod0820_fp8_kvcache_20260402" // 系统指纹标识
}

2.4.3 数据结构 #

completion 对象遵循 OpenAI 的 API 标准格式。对于你的 create 调用(非流式),它的结构是一个顶层对象,主要包含 id, object, created, model, choices, 和 usage 等字段。

下面是其核心数据结构分解:

2.4.3.1 ChatCompletion对象 #

这个顶层对象是响应的主体,为你提供本次对话生成的全局信息。

字段 类型 描述 示例
id string 本次对话补全的唯一标识符。 "chatcmpl-abc123"
object string 对象类型,对于非流式响应,其值固定为 chat.completion。 "chat.completion"
created integer 对话补全创建时的 Unix 时间戳(秒)。 1677652288
model string 实际生成补全所使用的模型名称。 "deepseek-v4-pro"
choices array 一个或多个生成的对话补全选择。这是你获取模型回答的主要入口。 见下文详细说明
usage object 本次请求的 Token 用量统计,用于成本计算。 见下文详细说明
2.4.3.2 choices 数组 #

choices 数组包含了模型实际生成的回答。如果你在请求中设置了参数 n(例如 n: 2),这个数组里会包含多个选项,每个选项是一个 Choice 对象,并通过 index 字段来标识序号。

一个 Choice 对象的结构如下:

字段 类型 描述 示例
index integer 该选项在 choices 数组中的索引。 0
message object 模型生成的完整回答消息。这是你需要关注的核心数据。 见下文详细说明
finish_reason string 模型停止生成的原因。可能是 "stop" (自然结束)、"length" (达到最大token限制)、"content_filter" (内容被过滤) 等。 "stop"
2.4.3.3 message 对象 #

这是 choices[*].message 字段的内容,代表了模型生成的一条完整消息。

字段 类型 描述 示例
role string 此消息的作者角色,通常是 "assistant"。 "assistant"
content string \ null 模型生成的文本回复内容。 "Python是一种高级编程语言..."
2.4.3.4 usage 对象 #

此对象用于统计本次 API 调用所消耗的 Token 数量,方便你进行成本控制和额度管理。

字段 类型 描述 示例
prompt_tokens integer 用户输入的 messages 参数中所有内容的 Token 数量。 32
completion_tokens integer 模型生成的 content 所包含的 Token 数量。 86
total_tokens integer prompt_tokens 与 completion_tokens 的总和。 118
2.4.3.5 补充说明 #
  • 兼容性:这个数据结构是通用的,当你使用 openai 库调用其他兼容 OpenAI 格式的 API 时(包括 deepseek-v4-pro),返回的结构通常是相同的。
  • 流式响应:如果你在请求时设置了 stream: true,API 会返回一个数据流,其中每个数据块是一个 ChatCompletionChunk 对象,结构与上述的完整 ChatCompletion 对象不同。你通常需要逐步处理这些 delta 内容。

2.5 时序图 #

sequenceDiagram autonumber participant Top as 脚本入口 participant Env as dotenv participant Main as main() participant Ask as askLine() participant RL as readline (rl) participant Out as stdout participant User as 用户/终端 participant Client as openaiClient participant API as DeepSeek API Top->>Env: config({ override: true }) Env-->>Top: 加载 DEEPSEEK_API_KEY 等 Note over Top,Client: 模块加载时创建 OpenAI 客户端(baseURL + apiKey) Top->>Main: main() loop 直到输入为 "q" Main->>Main: messages = [system] Main->>Ask: await askLine() Ask->>RL: question("› ", resolve) RL->>Out: 写入提示符 "› " RL-->>User: 等待一行(stdin) User->>RL: 输入一行 + Enter RL->>Ask: resolve(line) Ask-->>Main: line alt 仅空白 Note over Main: continue else 内容为 q Main->>Main: break else 其它非空 Main->>Main: messages.push(user) Main->>Client: chat.completions.create({ model, messages }) Client->>API: POST /chat/completions API-->>Client: completion (choices[0].message) Client-->>Main: completion Main->>Out: console.log("Assistant", reply.content) Main->>Main: messages.push(assistant) Note over Main: 下轮循环重建 messages,不保留上轮对话 end end Main->>RL: rl.close()

3. 受控工作区与 readFile / writeFile 工具骨架 #

在第 2 节「终端对话 + 大模型回复」之上,本节为助手增加可读写文件的沙箱能力:所有文件操作限定在项目下的 workspace/ 目录内,并通过 JSON Schema 向模型声明 readFile、writeFile 两个函数工具。

程序在做什么: 启动时把 WORKSPACE_ROOT 固定为 process.cwd()/workspace;模型(或本地测试)若调用 readFile / writeFile,会先经 resolvePathInsideWorkspace 把相对路径解析为绝对路径并校验不越界,再由 ReadText / WriteText 用 UTF-8 读写字面量文本。写入前会对父目录 mkdir(..., { recursive: true }),不存在则自动创建。

核心设计:

  • isDescendantOrSameDirectory + resolvePathInsideWorkspace:用 path.resolve 规范化路径,再用 path.relative 判断目标是否落在 WORKSPACE_ROOT 之下(拒绝以 .. 逃逸、拒绝跨盘符等「相对结果为绝对路径」的情况),避免模型通过 ../../../ 读写工作区外文件。
  • ReadText / WriteText:各实现一个 async run(args),与 OpenAI「function calling」里工具 handler 的形态一致;错误以字符串返回(如 ENOENT →「找不到文件」),便于后续把结果塞回 role: "tool" 消息。
  • MODEL_TOOL_DEFINITIONS:符合 Chat Completions tools 参数的 schema,描述工具名、说明与 parameters(readFile 仅需 path,writeFile 需 path + content)。
  • toolHandlersByName:工具名到实例的映射,便于在收到 tool_calls 时按 function.name 分发执行。

3.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
+// 引入 Node.js 的 path 模块,用于处理和转换文件路径
+const path = require("path");
+// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
+const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
+// 定义工作区根目录,位于当前进程的 workspace 文件夹内
+const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

+// 判断目标路径是否是根目录的子目录或根目录本身
+function isDescendantOrSameDirectory(candidatePath) {
+ // 解析 candidatePath 为绝对路径
+ const targetAbs = path.resolve(candidatePath);
+ // 如果目标路径和工作区根目录完全相同,则返回 true
+ if (WORKSPACE_ROOT === targetAbs) return true;
+ // 计算目标路径相对于工作区根目录的相对路径
+ const relative = path.relative(WORKSPACE_ROOT, targetAbs);
+ // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
+}

+// 在工作区内解析路径,并校验是否越界
+function resolvePathInsideWorkspace(relativePath) {
+ // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
+ const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
+ // 如果解析后的路径不在工作区内,则抛出异常
+ if (!isDescendantOrSameDirectory(candidatePath))
+   throw new Error(`路径越界:${candidatePath}`);
+ // 返回校验通过的绝对路径
+ return candidatePath;
+}

+// 文本读取工具实现
+class ReadText {
+ // 执行读取操作,参数为 { path }
+ async run({ path: relativePath }) {
+   try {
+     // 调用文件系统 API 读取文件内容
+     return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
+   } catch (err) {
+     // 如果找不到文件,返回自定义提示;否则返回异常原因
+     return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
+   }
+ }
+}

+// 文本写入工具实现
+class WriteText {
+ // 执行写入操作,参数为 { path, content }
+ async run({ path: relativePath, content }) {
+   try {
+     // 解析目标文件的绝对路径
+     const absolute = resolvePathInsideWorkspace(relativePath);
+     // 如果父级目录不存在则递归创建
+     await fsp.mkdir(path.dirname(absolute), { recursive: true });
+     // 写入内容到文件,使用 UTF-8 编码
+     await fsp.writeFile(absolute, content, "utf8");
+     // 返回写入结果以及写入字节数
+     return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
+   } catch (err) {
+     // 返回写入过程中的异常信息
+     return `写入异常:${err.message}`;
+   }
+ }
+}

+// 工具的 Schema 定义,描述每个工具的用途和参数结构
+const MODEL_TOOL_DEFINITIONS = [
+ {
+   // 声明工具类型为函数
+   type: "function",
+   function: {
+     // 工具名称为 readFile
+     name: "readFile",
+     // 工具功能描述
+     description: "以 UTF-8 读取工作区内文本文件。",
+     // 工具参数定义
+     parameters: {
+       // 参数类型为对象
+       type: "object",
+       // 参数属性为 path,字符串类型
+       properties: { path: { type: "string" } },
+       // path 字段为必需参数
+       required: ["path"],
+     },
+   },
+ },
+ {
+   // 声明写入工具类型为函数
+   type: "function",
+   function: {
+     // 工具名称为 writeFile
+     name: "writeFile",
+     // 工具描述:新建或覆盖写入工作区内文件
+     description: "在工作区内新建或覆盖写入文件。",
+     // 工具参数定义
+     parameters: {
+       type: "object",
+       properties: {
+         // path 为文件路径,字符串类型
+         path: { type: "string" },
+         // content 为要写入的文本内容,字符串类型
+         content: { type: "string" },
+       },
+       // path 和 content 都必需
+       required: ["path", "content"],
+     },
+   },
+ },
+];

+// 工具名称到对应实例的映射表
+const toolHandlersByName = {
+ // 读取文件工具
+ readFile: new ReadText(),
+ // 写入文件工具
+ writeFile: new WriteText(),
+};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步主函数 main
async function main() {
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const completion = await openaiClient.chat.completions.create({
      model: "deepseek-v4-pro",
      messages: messages,
    });
    // 获取助手回复内容
    const reply = completion.choices[0].message;
    // 打印助手的回复到控制台
    console.log("\nAssistant\n", reply.content, "\n");
    // 将助手回复也加入消息列表,以便对话上下文保持同步
    messages.push({ role: "assistant", content: reply.content });
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

4. 工具调用循环:把 readFile / writeFile 接到 API #

在第 3 节已具备「工作区沙箱 + 工具 Schema + handler」的基础上,本节把 Function Calling 真正接到对话主循环:模型可自动发起 readFile / writeFile,本地执行后把结果以 role: "tool" 回传,直到模型给出最终文字回复或达到步数上限。

程序在做什么: 用户每输入一行(非空且非 q),main 构造 messages 后调用 runAgentUntilReplyOrMaxSteps;该函数在 agentMaxSteps(默认 100)内反复请求 API(带 tools + tool_choice: "auto")。若助手消息含 tool_calls,则并发执行 executeSingleToolCall,把工具结果追加进 messages 并再次请求;若无 tool_calls,返回该条 assistant 消息,由 main 打印 reply.content。启动时还会 mkdir(WORKSPACE_ROOT),并监听 SIGINT 优雅退出。

新增点:

  • executeSingleToolCall:解析 function.arguments(JSON 字符串 → 对象)、按名查 toolHandlersByName、执行 handler.run,构造 { role: "tool", tool_call_id, name, content } 供下一轮 API 使用。
  • runAgentUntilReplyOrMaxSteps:实现「模型 → 工具 → 模型」的 ReAct 式多步循环;同一轮用户输入内,messages 会累积 assistant(含 tool_calls)与多条 tool,直到模型不再调工具。
  • CONFIG:agentMaxSteps 防止工具调用死循环;

与第 2 节类似的限制: 外层 for 每轮仍新建 messages = [system],跨用户输入的多轮记忆仍未保留;单轮用户输入内部则可通过工具循环保留完整上下文。

4.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}

// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
];

// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

+// 定义常量配置对象
+const CONFIG = {
+ // 智能体允许的最大步数
+ agentMaxSteps: 100,
+};

+// 定义异步函数,用于执行一次工具调用
+async function executeSingleToolCall(toolCallPayload) {
+ // 获取本次工具调用的名字
+ const name = toolCallPayload.function.name;
+ // 声明解析参数变量
+ let parsedArgs;
+ try {
+   // 尝试将工具调用的参数字符串解析为对象
+   parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
+ } catch {
+   // 如果解析失败,则设为空对象
+   parsedArgs = {};
+ }

+ // 控制台输出工具调用信息
+ console.log(`\n工具 ${name} 被调用`);
+ // 控制台输出工具的入参
+ console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

+ // 根据工具名获取对应处理器
+ const handler = toolHandlersByName[name];
+ // 判断是否有对应处理器,如有则执行,否则返回未实现提示
+ const textResult = handler
+   ? await handler.run(parsedArgs)
+   : `✖ 未实现的工具:${name}`;

+ // 控制台输出工具调用的结果
+ console.log(`返回:${textResult}`);

+ // 构造并返回本次工具调用的完整回复对象
+ return {
+   // 设置角色为 tool
+   role: "tool",
+   // 工具调用对应的 id
+   tool_call_id: toolCallPayload.id,
+   // 工具名称
+   name,
+   // 工具返回的内容
+   content: textResult,
+ };
+}

+// 定义异步函数,驱动智能体连续推理直到文字回复或超步
+async function runAgentUntilReplyOrMaxSteps(messages) {
+ // 初始化步数计数器
+ let step = 0;
+ // 循环,直到步数超过最大限制
+ while (step < CONFIG.agentMaxSteps) {
+   // 步数递增
+   step++;
+   // 控制台显示正在请求模型
+   console.log("\n请求模型中...");

+   // 向模型 API 发送消息请求回复
+   const completion = await openaiClient.chat.completions.create({
+     // 模型名称
+     model: "deepseek-v4-pro",
+     // 消息参数
+     messages: messages,
+     // 工具函数定义
+     tools: MODEL_TOOL_DEFINITIONS,
+     // 工具选择方式
+     tool_choice: "auto",
+   });

+   // 获取模型助手返回的消息对象
+   const assistantMessage = completion.choices[0].message;
+   // 控制台输出助手消息
+   console.log(JSON.stringify(assistantMessage, null, 2));
+   // 将助手消息加入对话消息历史
+   messages.push(assistantMessage);
+   // 获取本轮助手消息产生的工具调用
+   const calls = assistantMessage.tool_calls;
+   // 判断是否有工具调用,无则直接返回助手回复
+   if (!calls || calls.length === 0) {
+     // 返回助手文字回复
+     return assistantMessage;
+   }
+   // 并发执行每个工具调用,等待所有结果
+   const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
+   // 将所有工具回复添加到消息历史
+   for (const row of toolResponses) 
+     messages.push(row);
+ }
+ // 若达到最大步数仍未获得直接回复,则返回警告信息
+ return {
+   role: "assistant",
+   content: "⚠ 对话步数已达上限。",
+ };
+}

// 定义异步主函数 main
async function main() {
+ //创建工作区目录
+ await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
+   const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
+   if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

4.2 时序图 #

下图描述单次用户输入内,runAgentUntilReplyOrMaxSteps 与工具执行的交互(若模型连续多轮调工具,则 loop 单轮 agent 步 会重复)。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Handler as toolHandlersByName participant FS as workspace 文件系统 Main->>Main: mkdir(WORKSPACE_ROOT) loop 直到用户输入 q Main->>Main: messages = [system] User->>Main: 一行输入 Main->>Main: messages.push(user) Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) loop 单轮 agent 步(step < agentMaxSteps) Agent->>Client: create({ messages, tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Client: assistantMessage Client-->>Agent: assistantMessage Agent->>Agent: messages.push(assistantMessage) alt 无 tool_calls Agent-->>Main: assistantMessage(最终文字) else 有 tool_calls par 并发执行各 tool_call Agent->>Exec: executeSingleToolCall(call) Exec->>Exec: JSON.parse(arguments) Exec->>Handler: handler.run(parsedArgs) Handler->>FS: readFile / writeFile(经路径校验) FS-->>Handler: 文本结果 Handler-->>Exec: textResult Exec-->>Agent: { role: tool, tool_call_id, content } end Agent->>Agent: messages.push(各 tool 消息) Note over Agent: 继续 while,再次请求模型 end end alt 超过 agentMaxSteps Agent-->>Main: "⚠ 对话步数已达上限。"(字符串) end Main->>User: console.log(Assistant, reply.content) end

4.3 测试数据 #

› 把I love you写入msg.txt

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants to write \"I love you\" to a file named msg.txt.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_72JFPzLd2yo2WbQSCtIJ7049",
      "type": "function",
      "function": {
        "name": "writeFile",
        "arguments": "{\"path\": \"msg.txt\", \"content\": \"I love you\"}"
      }
    }
  ]
}

工具 writeFile 被调用
入参:{
  "path": "msg.txt",
  "content": "I love you"
}
返回:已落盘 10 字节 → msg.txt

请求模型中...
{
  "role": "assistant",
  "content": "已写入 `msg.txt`。",
  "reasoning_content": "The file has been written successfully."
}

Assistant
已写入 `msg.txt`。

› 读取msg.txt文件的内容

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "用户想要读取msg.txt文件的内容。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_gZSYt35Zv7poCriwwDTC3801",
      "type": "function",
      "function": {
        "name": "readFile",
        "arguments": "{\"path\": \"msg.txt\"}"
      }
    }
  ]
}

工具 readFile 被调用
入参:{
  "path": "msg.txt"
}
返回:I love you

请求模型中...
{
  "role": "assistant",
  "content": "文件 `msg.txt` 的内容是:\n\n```\nI love you\n```\n\n这是一个简短的消息。需要我帮你做其他操作吗?",
  "reasoning_content": "文件内容已读取,是\"I love you\"。"
}

Assistant
文件 `msg.txt` 的内容是:

I love you

5. 扩展工具:editFile 就地替换与 listDir 目录浏览 #

在第 4 节「工具调用循环」已能驱动 readFile / writeFile 的基础上,本节为工作区再增加两类常用能力:局部改文件与看目录结构

程序在做什么: 工具集从 2 个扩为 4 个。模型可先 listDir 摸清 workspace 布局,用 readFile 读内容,用 writeFile 整文件覆盖,或用 editFile 在已有文本上做一次精确子串替换(oldText 必须与文件内容完全一致,且只替换第一次匹配)。用户说「把某文件里的 A 换成 B」时,典型路径是 readFile → editFile → 最终文字回复(见 5.4 测试)。

相对第 4 节的新增与调整:

  • EditFile:readFile 读入 → includes(oldText) 校验 → before.replace(oldText, newText) 只改第一处 → 写回。若 oldText 未出现或出现多次但模型只给了片段,可能替换失败或只改一处;与「整文件重写」相比更适合小改动。
  • ListDir:readdir + withFileTypes,用 ▣(目录)/ ▢(文件)格式化列表,按名称排序,便于模型探索 workspace。
  • MODEL_TOOL_DEFINITIONS / toolHandlersByName:注册 editFile、listDir 的 schema 与 handler。

5.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
+// 定义 EditFile 类,用于精确修改文件内容
+class EditFile {
+ // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
+ async run({ path: relativePath, oldText, newText }) {
+   try {
+     // 解析出目标文件的绝对路径,确保在工作区内
+     const absolute = resolvePathInsideWorkspace(relativePath);
+     // 以 UTF-8 编码读取文件内容
+     const before = await fsp.readFile(absolute, "utf8");
+     // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
+     if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
+     // 将第一次出现的 oldText 替换为 newText 并写回原文件
+     await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
+     // 返回替换成功的提示
+     return `✔ 已就地替换一处:${relativePath}`;
+   } catch (err) {
+     // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
+     return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
+   }
+ }
+}

+// 定义 ListDir 类,用于列出目录内容
+class ListDir {
+ // 定义异步运行方法,参数为目录的相对路径
+ async run({ path: relativePath }) {
+   try {
+     // 解析出目标目录的绝对路径,确保在工作区内
+     const absolute = resolvePathInsideWorkspace(relativePath);
+     // 获取目标路径的文件状态信息
+     const stat = await fsp.stat(absolute);
+     // 判断目标路径是否为目录,如果不是则返回提示
+     if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
+     // 读取目录下的所有条目,获取类型信息
+     const entries = await fsp.readdir(absolute, { withFileTypes: true });
+     // 根据名称进行字典序排序
+     entries.sort((a, b) => a.name.localeCompare(b.name));
+     // 格式化每条目录项,区分文件与子目录
+     const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
+     // 如果有内容则按行拼接返回,否则返回空目录提示
+     return rows.length ? rows.join("\n") : "(空目录)";
+   } catch (err) {
+     // 异常处理:目录不存在单独提示,否则返回错误信息
+     return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
+   }
+ }
+}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
+ // 定义一个函数工具,用于精确子串替换现有文件内容
+ {
+   // 工具类型为 function
+   type: "function",
+   // function 字段,定义具体工具内容
+   function: {
+     // 工具名称为 editFile
+     name: "editFile",
+     // 工具描述,说明功能为仅替换一次完全匹配的原文
+     description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
+     // parameters 字段,定义参数信息
+     parameters: {
+       // 参数类型为对象
+       type: "object",
+       // properties 字段,定义具体参数
+       properties: {
+         // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
+         path: { type: "string", description: "待修改文件路径" },
+         // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
+         oldText: {
+           type: "string",
+           description: "要被替换掉的原文(唯一一次匹配)",
+         },
+         // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
+         newText: { type: "string", description: "替换后的新文本" },
+       },
+       // 这三个参数都是必需的
+       required: ["path", "oldText", "newText"],
+     },
+   },
+ },
+ // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
+ {
+   // 工具类型为 function
+   type: "function",
+   // function 字段,定义具体工具内容
+   function: {
+     // 工具名称为 listDir
+     name: "listDir",
+     // 工具描述,说明功能为罗列目录结构,可区分文件夹
+     description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
+     // parameters 字段,定义参数信息
+     parameters: {
+       // 参数类型为对象
+       type: "object",
+       // properties 字段,定义具体参数
+       properties: {
+         // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
+         path: { type: "string", description: "目录路径(相对 workspace)" },
+       },
+       // path 是必需参数
+       required: ["path"],
+     },
+   },
+ },
];

// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
+ // 替换文件工具
+ editFile: new EditFile(),
+ // 枚举目录工具
+ listDir: new ListDir(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义常量配置对象
const CONFIG = {
  // 智能体允许的最大步数
  agentMaxSteps: 100,
};

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
    // 并发执行每个工具调用,等待所有结果
    const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

5.2 时序图 #

下图以 5.4 测试(先读后改 msg.txt)为例,展示单次用户输入内多步工具链;若模型改用 listDir 或 writeFile,Handler 分支会换成对应工具,整体仍走第 4 节的 agent 循环。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Handler as toolHandlersByName participant FS as workspace 文件系统 User->>Main: 把 msg.txt 里的 you 替换为 her Main->>Main: messages = [system, user] Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) Note over Agent,API: 第 1 步:模型选择先读取 Agent->>Client: create({ tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Agent: tool_calls: readFile(msg.txt) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall(readFile) Exec->>Handler: ReadText.run Handler->>FS: readFile → "I love you" FS-->>Handler: 文件内容 Handler-->>Agent: role: tool, content: I love you Agent->>Agent: messages.push(tool) Note over Agent,API: 第 2 步:模型根据内容调用 editFile Agent->>Client: create(...) API-->>Agent: tool_calls: editFile(oldText, newText) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall(editFile) Exec->>Handler: EditFile.run Handler->>FS: read → replace 第一处 → write FS-->>Handler: I love her Handler-->>Agent: ✔ 已就地替换一处 Agent->>Agent: messages.push(tool) Note over Agent,API: 第 3 步:无 tool_calls,返回文字 Agent->>Client: create(...) API-->>Agent: content: 已完成替换… Agent-->>Main: assistantMessage Main->>User: Assistant 打印 reply.content alt 达到 agentMaxSteps Agent-->>Main: { role: assistant, content: ⚠ 已达上限 } end

5.3 msg.txt #

workspace/msg.txt

I love her

5.4 测试 #

› 把msg.txt文件里的you替换为her

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "用户想把 msg.txt 文件中的 \"you\" 替换为 \"her\"。让我先读取这个文件。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_9UylBi8MJAXgwH2lFcS33443",
      "type": "function",
      "function": {
        "name": "readFile",
        "arguments": "{\"path\": \"msg.txt\"}"
      }
    }
  ]
}

工具 readFile 被调用
入参:{
  "path": "msg.txt"
}
返回:I love you

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The file contains \"I love you\". I need to replace \"you\" with \"her\". That would make it \"I love her\".",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_rl1M5O569v8hkU4kqSeD9719",
      "type": "function",
      "function": {
        "name": "editFile",
        "arguments": "{\"path\": \"msg.txt\", \"oldText\": \"I love you\", \"newText\": \"I love her\"}"
      }
    }
  ]
}

工具 editFile 被调用
入参:{
  "path": "msg.txt",
  "oldText": "I love you",
  "newText": "I love her"
}
返回:✔ 已就地替换一处:msg.txt

请求模型中...
{
  "role": "assistant",
  "content": "已完成替换。`msg.txt` 的内容从 **\"I love you\"** 变成了 **\"I love her\"**。",
  "reasoning_content": "Done. The file now reads \"I love her\"."
}

Assistant
已完成替换。`msg.txt` 的内容从 **"I love you"** 变成了 **"I love her"**。

6. 在工作区内执行 Shell 命令(runCommand) #

在第 5 节四类文件工具之上,本节增加 runCommand:模型可在 workspace/ 目录下通过系统 shell 同步执行一条命令行(如 Windows 的 dir、npm test),并把 stdout/stderr 汇总成字符串回传给对话,再由模型组织自然语言答复。

程序在做什么: 用户输入「执行 dir 命令」等意图后,模型发起 runCommand({ commandLine: "dir" });RunCommandLine 用 spawnSync 在 cwd: WORKSPACE_ROOT 下、shell: true 阻塞执行,将输出 Buffer 经 decodeResultBuffer 转成字符串(Windows 用 cp936,其它平台用 utf8),拼上退出码后作为 role: "tool" 的 content 返回。

相对第 5 节的新增点:

  • child_process.spawnSync:encoding: "buffer" 保留原始字节,便于按系统代码页解码;maxBuffer: 10 * 1024 * 1024 限制单次输出体积。
  • decodeResultBuffer + iconv-lite:解决 Windows 中文控制台 GBK 输出被误当 UTF-8 解码产生乱码的问题。
  • RunCommandLine:当前仅实现同步阻塞执行(run → runBlockingCommandLine);diff 中引入了 spawn 但尚未用于长驻/异步进程。
  • MODEL_TOOL_DEFINITIONS / toolHandlersByName:注册 runCommand 及参数 commandLine。
  • ListDir 展示文案:条目前缀由 ▣/▢ 改为「目录:」「文件:」(与第 5 节 diff 中的次要调整一致)。

运行前准备: 需安装依赖 npm install iconv-lite --save(若尚未安装)。命令在 workspace 为当前工作目录 下执行

6.1 安装依赖 #

npm install iconv-lite --save

6.2 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
+// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
+const { spawn, spawnSync } = require("child_process");
+// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
+const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
  // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
  async run({ path: relativePath, oldText, newText }) {
    try {
      // 解析出目标文件的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 以 UTF-8 编码读取文件内容
      const before = await fsp.readFile(absolute, "utf8");
      // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
      if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
      // 将第一次出现的 oldText 替换为 newText 并写回原文件
      await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
      // 返回替换成功的提示
      return `✔ 已就地替换一处:${relativePath}`;
    } catch (err) {
      // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
    }
  }
}

// 定义 ListDir 类,用于列出目录内容
class ListDir {
  // 定义异步运行方法,参数为目录的相对路径
  async run({ path: relativePath }) {
    try {
      // 解析出目标目录的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 获取目标路径的文件状态信息
      const stat = await fsp.stat(absolute);
      // 判断目标路径是否为目录,如果不是则返回提示
      if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
      // 读取目录下的所有条目,获取类型信息
      const entries = await fsp.readdir(absolute, { withFileTypes: true });
      // 根据名称进行字典序排序
      entries.sort((a, b) => a.name.localeCompare(b.name));
      // 格式化每条目录项,区分文件与子目录
      const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
      // 如果有内容则按行拼接返回,否则返回空目录提示
      return rows.length ? rows.join("\n") : "(空目录)";
    } catch (err) {
      // 异常处理:目录不存在单独提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
    }
  }
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
  // 定义一个函数工具,用于精确子串替换现有文件内容
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 editFile
      name: "editFile",
      // 工具描述,说明功能为仅替换一次完全匹配的原文
      description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
          path: { type: "string", description: "待修改文件路径" },
          // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
          oldText: {
            type: "string",
            description: "要被替换掉的原文(唯一一次匹配)",
          },
          // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
          newText: { type: "string", description: "替换后的新文本" },
        },
        // 这三个参数都是必需的
        required: ["path", "oldText", "newText"],
      },
    },
  },
  // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 listDir
      name: "listDir",
      // 工具描述,说明功能为罗列目录结构,可区分文件夹
      description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
          path: { type: "string", description: "目录路径(相对 workspace)" },
        },
        // path 是必需参数
        required: ["path"],
      },
    },
  },
+ // 定义工具对象,表示类型为 function
+ {
+   // 设置 type 字段为 "function",表示此为函数型工具
+   type: "function",
+   // function 字段,定义具体的函数内容
+   function: {
+     // name 字段,工具名称为 runCommand
+     name: "runCommand",
+     // description 字段,对工具功能做简要描述
+     description:
+       // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
+       "在 shell 中执行命令;短命令阻塞至结束\n" +
+       "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n",
+     // parameters 字段,定义参数类型和要求
+     parameters: {
+       // type 指定参数为对象形式
+       type: "object",
+       // properties 字段,详细定义各参数
+       properties: {
+         // commandLine 参数,定义为字符串类型
+         commandLine: {
+           // type 指明参数为字符串
+           type: "string",
+           // description 说明 commandLine 的用途
+           description: "Shell 命令行(例如:npm install)",
+         }
+       },
+       // required 字段,列出必传参数
+       required: ["commandLine"],
+     },
+   },
+ },
];
+// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
+function decodeResultBuffer(buf) {
+ // 若 Buffer 为空或长度为0,直接返回空字符串
+ if (!buf || buf.length === 0) return "";
+ // 若当前系统是 Windows,则以 cp936 编码解码
+ if (process.platform === "win32") return iconv.decode(buf, "cp936");
+ // 其他系统则以 utf8 解码
+ return buf.toString("utf8");
+}

+// 工具类:用于在工作空间目录中执行 commandLine 命令
+class RunCommandLine {
+ // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
+ async run({ commandLine }) {
+   // 调用同步(阻塞)方式运行 commandLine 命令的方法
+   return this.runBlockingCommandLine(commandLine)
+ }
+ // 同步运行 commandLine 命令,并返回命令执行的文本结果
+ runBlockingCommandLine(commandLine) {
+   // 使用 spawnSync 同步执行命令行
+   const result = spawnSync(commandLine, {
+     // 指定使用 shell 运行 commandLine
+     shell: true,
+     // 指定工作目录为工作空间根目录
+     cwd: WORKSPACE_ROOT,
+     // 输出按二进制 Buffer 格式返回
+     encoding: "buffer",
+     // 最大缓冲区 10MB
+     maxBuffer: 10 * 1024 * 1024,
+   });
+   // 命令执行超时,则返回超时提示
+   if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
+   // 若 stdout 有内容则解码,否则返回“stdout 为空”
+   let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
+   // 若 stderr 有内容,则拼接 stderr 的解码结果
+   if (result.stderr?.length)
+     merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
+   // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
+   return result.status !== 0
+     ? `退出码 ${result.status}\n${merged}`
+     : `命令完成\n${merged}`;
+ }
+}
// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
  // 替换文件工具
  editFile: new EditFile(),
  // 枚举目录工具
  listDir: new ListDir(),
+ // 执行命令工具
+ runCommand: new RunCommandLine(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义常量配置对象
const CONFIG = {
  // 智能体允许的最大步数
  agentMaxSteps: 100,
};

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
    // 并发执行每个工具调用,等待所有结果
    const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

6.3 时序图 #

下图以 6.4 测试(执行 dir)为例:单次用户输入内,模型一次 runCommand 后给出文字总结。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Run as RunCommandLine participant Shell as shell (spawnSync) participant Decode as decodeResultBuffer User->>Main: 执行 dir 命令 Main->>Main: messages = [system, user] Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) Agent->>Client: create({ tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Agent: tool_calls: runCommand(dir) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall Exec->>Run: run({ commandLine: "dir" }) Run->>Shell: spawnSync(dir, { cwd: WORKSPACE_ROOT, shell: true }) Shell-->>Run: stdout/stderr Buffer, status Run->>Decode: decodeResultBuffer (win32→cp936) Decode-->>Run: 可读文本 Run-->>Exec: 命令完成 + 输出 / 或 退出码 + 输出 Exec-->>Agent: role: tool, content: 汇总结果 Agent->>Agent: messages.push(tool) Agent->>Client: create(...) 再次请求 API-->>Agent: content: 目录说明(无 tool_calls) Agent-->>Main: assistantMessage Main->>User: Assistant 打印 reply.content

6.4 测试 #

› 执行dir命令

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "用户想让我执行 `dir` 命令(Windows 中列出目录内容的命令)。让我执行它。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_bu3oknLgzw5ySoKJc3eh4502",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"dir\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "dir"
}
返回:✔ 命令完成
 驱动器 D 中的卷是 软件
 卷的序列号是 8001-E599

 D:\aprepare\testcode\workspace 的目录

2026-05-16  14:28    <DIR>          .
2026-05-16  14:28    <DIR>          ..
2026-05-16  15:34                10 msg.txt
               1 个文件             10 字节
               2 个目录 98,774,122,496 可用字节


请求模型中...
{
  "role": "assistant",
  "content": "`dir` 命令已执行。当前工作区 `D:\\aprepare\\testcode\\workspace` 下只有:\n\n- **文件**:`msg.txt`(10 字 节)\n- **可用磁盘空间**:约 98.8 GB\n\n除此之外没有其他文件或子目录。需要我进一步查看 `msg.txt` 的内容吗?",
  "reasoning_content": "`dir` 命令已执行。工作区目录 `D:\\aprepare\\testcode\\workspace` 下目前只有一个文件 `msg.txt`(10 字节),以及 `.` 和 `..` 两个目录项。可用空间约 98.8 GB。"
}

Assistant
`dir` 命令已执行。当前工作区 `D:\aprepare\testcode\workspace` 下只有:

- **文件**:`msg.txt`(10 字节)
- **可用磁盘空间**:约 98.8 GB

7. 长驻命令后台运行:spawn 与启动段输出采集 #

第 6 节的 runCommand 一律用 spawnSync 阻塞到命令结束,适合 dir、npm test 等短命令;若执行 node server.js 这类不会自行退出的 HTTP 服务,同步调用会一直卡住。本节在 workspace/ 中放入两个最小 HTTP 示例(server1.js / server2.js),并改造 RunCommandLine:按命令行启发式判断是否走后台分支。

程序在做什么: looksLikeLongRunningCommand 检查命令是否包含 server、dev、start、watch 等关键字(见 LONG_RUNNING_HINTS)。命中则 spawn 子进程(stdio 管道、cwd: workspace),用 bindStreamToBuffers 收集启动阶段的 stdout/stderr,等待 backgroundWarmupMs(默认 8 秒) 后立刻向模型返回 PID 与「启动段输出」,主进程不等待服务退出。未命中仍走第 6 节的 spawnSync 同步路径。

配套示例文件:

  • server1.js:监听 1000,响应正文 "1000"。
  • server2.js:监听 2000,响应正文 "2000"。

相对第 6 节的新增点:

  • looksLikeLongRunningCommand + LONG_RUNNING_HINTS:用子串匹配区分「可能常驻」与「一次性」命令(启发式,有误判可能,例如普通命令里碰巧含 start)。
  • runBackgroundCommandLine:spawn 非阻塞拉起;delay(CONFIG.backgroundWarmupMs) 给服务留出监听端口、打印日志的时间;返回字符串含 PID,便于用户后续自行结束进程。
  • bindStreamToBuffers:流式追加 chunk,end 时压入 null 标记(当前汇总用 Buffer.concat(buffers).toString("utf8")

7.1 server1.js #

workspace/server1.js

// 引入 Node.js 内置的 http 模块
const http = require('http');

// 定义服务器监听的端口号为 1000
const PORT = 1000;

// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
  // 设置响应状态码为 200(成功)
  res.statusCode = 200;
  // 设置响应头,指定内容类型为纯文本
  res.setHeader('Content-Type', 'text/plain');
  // 结束响应并返回消息内容
  res.end('1000');
});

// 启动服务器,监听指定端口
server.listen(PORT, () => {
  // 服务器启动后在控制台输出提示信息
  console.log(`Server running at http://localhost:${PORT}/`);
});

7.2 server2.js #

workspace/server2.js

// 引入 Node.js 内置的 http 模块
const http = require('http');

// 定义服务器监听的端口号为 2000
const PORT = 2000;

// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
  // 设置响应状态码为 200(成功)
  res.statusCode = 200;
  // 设置响应头,指定内容类型为纯文本
  res.setHeader('Content-Type', 'text/plain');
  // 结束响应并返回消息内容
  res.end('2000');
});

// 启动服务器,监听指定端口
server.listen(PORT, () => {
  // 服务器启动后在控制台输出提示信息
  console.log(`Server running at http://localhost:${PORT}/`);
});

7.3 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
  // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
  async run({ path: relativePath, oldText, newText }) {
    try {
      // 解析出目标文件的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 以 UTF-8 编码读取文件内容
      const before = await fsp.readFile(absolute, "utf8");
      // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
      if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
      // 将第一次出现的 oldText 替换为 newText 并写回原文件
      await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
      // 返回替换成功的提示
      return `✔ 已就地替换一处:${relativePath}`;
    } catch (err) {
      // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
    }
  }
}

// 定义 ListDir 类,用于列出目录内容
class ListDir {
  // 定义异步运行方法,参数为目录的相对路径
  async run({ path: relativePath }) {
    try {
      // 解析出目标目录的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 获取目标路径的文件状态信息
      const stat = await fsp.stat(absolute);
      // 判断目标路径是否为目录,如果不是则返回提示
      if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
      // 读取目录下的所有条目,获取类型信息
      const entries = await fsp.readdir(absolute, { withFileTypes: true });
      // 根据名称进行字典序排序
      entries.sort((a, b) => a.name.localeCompare(b.name));
      // 格式化每条目录项,区分文件与子目录
      const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
      // 如果有内容则按行拼接返回,否则返回空目录提示
      return rows.length ? rows.join("\n") : "(空目录)";
    } catch (err) {
      // 异常处理:目录不存在单独提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
    }
  }
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
  // 定义一个函数工具,用于精确子串替换现有文件内容
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 editFile
      name: "editFile",
      // 工具描述,说明功能为仅替换一次完全匹配的原文
      description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
          path: { type: "string", description: "待修改文件路径" },
          // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
          oldText: {
            type: "string",
            description: "要被替换掉的原文(唯一一次匹配)",
          },
          // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
          newText: { type: "string", description: "替换后的新文本" },
        },
        // 这三个参数都是必需的
        required: ["path", "oldText", "newText"],
      },
    },
  },
  // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 listDir
      name: "listDir",
      // 工具描述,说明功能为罗列目录结构,可区分文件夹
      description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
          path: { type: "string", description: "目录路径(相对 workspace)" },
        },
        // path 是必需参数
        required: ["path"],
      },
    },
  },
  // 定义工具对象,表示类型为 function
  {
    // 设置 type 字段为 "function",表示此为函数型工具
    type: "function",
    // function 字段,定义具体的函数内容
    function: {
      // name 字段,工具名称为 runCommand
      name: "runCommand",
      // description 字段,对工具功能做简要描述
      description:
        // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
        "在 shell 中执行命令;短命令阻塞至结束\n" +
        "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n",
      // parameters 字段,定义参数类型和要求
      parameters: {
        // type 指定参数为对象形式
        type: "object",
        // properties 字段,详细定义各参数
        properties: {
          // commandLine 参数,定义为字符串类型
          commandLine: {
            // type 指明参数为字符串
            type: "string",
            // description 说明 commandLine 的用途
            description: "Shell 命令行(例如:npm install)",
          }
        },
        // required 字段,列出必传参数
        required: ["commandLine"],
      },
    },
  },
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
  // 若 Buffer 为空或长度为0,直接返回空字符串
  if (!buf || buf.length === 0) return "";
  // 若当前系统是 Windows,则以 cp936 编码解码
  if (process.platform === "win32") return iconv.decode(buf, "cp936");
  // 其他系统则以 utf8 解码
  return buf.toString("utf8");
}

+// 定义常量配置对象
+const CONFIG = {
+ // 智能体允许的最大步数
+ agentMaxSteps: 100,
+ // 后台进程预热时间 8秒
+ backgroundWarmupMs: 8000,
+};

+// 定义 delay 函数,实现异步延迟
+function delay(ms) {
+ // 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
+ return new Promise((resolve) => setTimeout(resolve, ms));
+// 定义一个异步延迟函数,参数为毫秒数
+}

+// 包含了会让开发环境长时间运行的命令字符串
+const LONG_RUNNING_HINTS = [
+ // "dev" 表示开发模式
+ "dev",           
+ // "start" 表示启动服务
+ "start",         
+ // "serve" 表示启动服务器
+ "serve",         
+ // "server" 表示启动开发服务器
+ "server",        
+ // "watch" 表示监听文件变更
+ "watch",         
+ // "run server" 表示运行服务器
+ "run server",    
+ // "runserver" 另一种写法,运行服务器
+ "runserver",     
+ // "preview" 表示预览模式
+ "preview",       
+ // "nodemon" 表示自动重启开发服务器
+ "nodemon",       
+ // "uvicorn" 表示 Python asgi 服务器
+ "uvicorn",       
+ // "gunicorn" 表示 Python wsgi 服务器
+ "gunicorn",      
+ // "flask run" 表示 Flask 的运行命令
+ "flask run",     
+ // "vite" 表示前端构建工具
+ "vite",          
+ // "webpack" 表示前端构建工具
+ "webpack",       
+ // "--watch" 表示监听参数
+ "--watch",       
+ // "--hot" 表示热更新参数
+ "--hot"          
+];
+// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
+function looksLikeLongRunningCommand(commandLine) {
+ // 去除命令行字符串首尾空白,并转为小写,标准化处理
+ const normalized = commandLine.trim().toLowerCase();
+ // 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
+ return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
+}
+// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
+function bindStreamToBuffers(readableStream, buffers) {
+ // 监听 "data" 事件,每收到一段数据时被调用
+ readableStream.on("data", (chunk) => {
+   // 将收到的数据块 chunk 压入缓冲区 buffers
+   buffers.push(chunk);
+ });
+ // 监听 "end" 事件,当数据流结束时被调用
+ readableStream.on("end", () => {
+   // 将 null 压入缓冲区 buffers,表示流已结束
+   buffers.push(null);
+ });
+}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
  // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
  async run({ commandLine }) {
+   // 判断命令行是否看起来像是长时间运行的命令
+   if (looksLikeLongRunningCommand(commandLine)) {
+     // 如果是,则调用异步方法在后台运行命令行
+     return this.runBackgroundCommandLine(commandLine);
+   }
+   // 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
    return this.runBlockingCommandLine(commandLine)
  }
  // 同步运行 commandLine 命令,并返回命令执行的文本结果
  runBlockingCommandLine(commandLine) {
    // 使用 spawnSync 同步执行命令行
    const result = spawnSync(commandLine, {
      // 指定使用 shell 运行 commandLine
      shell: true,
      // 指定工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 输出按二进制 Buffer 格式返回
      encoding: "buffer",
      // 最大缓冲区 10MB
      maxBuffer: 10 * 1024 * 1024,
    });
    // 命令执行超时,则返回超时提示
    if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
    // 若 stdout 有内容则解码,否则返回“stdout 为空”
    let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
    // 若 stderr 有内容,则拼接 stderr 的解码结果
    if (result.stderr?.length)
      merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
    // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
    return result.status !== 0
      ? `退出码 ${result.status}\n${merged}`
      : `命令完成\n${merged}`;
  }
+ // 定义一个异步方法,用于在后台启动 commandLine 命令
+ async runBackgroundCommandLine(commandLine) {
+   // 创建一个用于存储子进程输出的缓冲区数组
+   const buffers = [];
+   // 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
+   const child = spawn(commandLine, {
+     // 指定使用 shell 运行 commandLine 命令
+     shell: true,
+     // 指定子进程的工作目录为工作空间根目录
+     cwd: WORKSPACE_ROOT,
+     // 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
+     stdio: ["ignore", "pipe", "pipe"],
+   });
+   // 绑定子进程的标准输出流到缓冲区数组
+   bindStreamToBuffers(child.stdout, buffers);
+   // 绑定子进程的标准错误流到缓冲区数组
+   bindStreamToBuffers(child.stderr, buffers);
+   // 获取子进程的进程ID(PID)
+   const pid = child.pid;
+   // 等待后台进程初始化,延迟指定的时间(用于预热/启动)
+   await delay(CONFIG.backgroundWarmupMs);
+   // 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
+   return `已在后台拉起子进程\n  PID:${pid}\n  命令:${commandLine}\n  启动段输出:\n${Buffer.concat(buffers).toString("utf8") || "(尚无输出)"}`;
+ }
}
// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
  // 替换文件工具
  editFile: new EditFile(),
  // 枚举目录工具
  listDir: new ListDir(),
  // 执行命令工具
  runCommand: new RunCommandLine(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
    // 并发执行每个工具调用,等待所有结果
    const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

7.4 时序图 #

下图以 7.5 测试 中 node server1.js 为例:命令因含 server 走后台分支;dir 等短命令仍走第 6 节同步路径(图中未画出)。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant API as DeepSeek API participant Exec as executeSingleToolCall participant Run as RunCommandLine participant Hint as looksLikeLongRunningCommand participant Child as 子进程 (spawn) participant S1 as server1.js :1000 User->>Main: 执行 node server1.js Main->>Agent: runAgentUntilReplyOrMaxSteps Agent->>API: create → tool_calls: runCommand Agent->>Exec: executeSingleToolCall Exec->>Run: run({ commandLine }) Run->>Hint: 是否含 server 等关键字 Hint-->>Run: true → 后台分支 Run->>Child: spawn(shell, cwd=WORKSPACE_ROOT) Child->>S1: node server1.js S1-->>Child: stdout: Server running at… Run->>Run: bindStreamToBuffers 收集 chunk Run->>Run: await delay(8000ms) Run-->>Exec: PID + 启动段输出 Exec-->>Agent: role: tool Agent->>API: 再次 create(无 tool_calls) API-->>Agent: 服务已启动 localhost:1000 Agent-->>Main: assistantMessage Main->>User: 打印 Assistant Note over Child,S1: 子进程继续运行,<br/>不随工具返回而结束

7.5 测试 #

› 执行命令 node server1.js

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants me to run `node server1.js`. Let me execute that command.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_eBwOMAUuh91Z6YYmsbnR0848",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"node server1.js\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "node server1.js"
}
返回:已在后台拉起子进程
  PID:22976
  命令:node server1.js
  启动段输出:
Server running at http://localhost:1000/


请求模型中...
{
  "role": "assistant",
  "content": "服务已成功启动,运行在 `http://localhost:1000/`,PID 为 22976。",
  "reasoning_content": "The server has started successfully. It's running at http://localhost:1000/. This is a background process with PID 22976."
}

Assistant
服务已成功启动,运行在 `http://localhost:1000/`,PID 为 22976。

› 执行命令 node server2.js

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "用户想要执行 `node server2.js` 命令。让我在受控 workspace 内执行这个命令。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_c45sYEanJGXJZZuEMBHb9630",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"node server2.js\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "node server2.js"
}
返回:已在后台拉起子进程
  PID:14512
  命令:node server2.js
  启动段输出:
Server running at http://localhost:2000/


请求模型中...
{
  "role": "assistant",
  "content": "命令 `node server2.js` 已在后台成功启动:\n\n- **PID**:14512\n- **地址**:http://localhost:2000/\n\n服务 器正在监听 2000 端口,可以正常访问了。",
  "reasoning_content": "命令已成功在后台启动。服务器正在 http://localhost:2000/ 运行。"
}

Assistant
命令 `node server2.js` 已在后台成功启动:

- **PID**:14512
- **地址**:http://localhost:2000/

服务器正在监听 2000 端口,可以正常访问了。

8. 后台任务登记与 task_list 管理 #

第 7 节用 spawn 拉起长驻进程后,子进程 PID 只出现在当次工具返回里,进程表未集中保存,用户难以追问「现在还有哪些服务在跑」。本节引入全局 backgroundProcesses(Map<pid, meta>),在后台拉起时登记 commandLine、child、buffers,进程 exit 时自动移除;并通过 runCommand 的伪子命令 task_list 列出当前任务。

程序在做什么: runBackgroundCommandLine 在 spawn 成功后执行 backgroundProcesses.set(pid, { commandLine, child, buffers }),并 child.once("exit", () => delete)。RunCommandLine.run 入口处先调用 handleBackgroundTaskControlCommand:若 commandLine 匹配 task_list(正则,不区分大小写),则不启动 shell,直接遍历 Map 返回「活动中 / 已结束」与对应命令。工具描述中已写明 task_logs、task_stop(第 9 节才实现);本节 diff 仅实现 task_list。见 8.3 测试:先后台启动 server1 / server2,再 task_list 看到两个 PID。

相对第 7 节的新增点:

  • backgroundProcesses:进程级「任务台账」,供控制类伪命令查询;buffers 为后续 task_logs 预留。
  • handleBackgroundTaskControlCommand:在 looksLikeLongRunningCommand 之前拦截,避免把 task_list 当成普通 shell 命令或误判为长驻命令。
  • 工具 Schema 文案更新:向模型说明同步命令、后台常驻命令与 task_* 子命令的用法,减少误用。

8.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
+// 存储所有后台拉起的子进程信息
+const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
  // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
  async run({ path: relativePath, oldText, newText }) {
    try {
      // 解析出目标文件的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 以 UTF-8 编码读取文件内容
      const before = await fsp.readFile(absolute, "utf8");
      // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
      if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
      // 将第一次出现的 oldText 替换为 newText 并写回原文件
      await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
      // 返回替换成功的提示
      return `✔ 已就地替换一处:${relativePath}`;
    } catch (err) {
      // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
    }
  }
}

// 定义 ListDir 类,用于列出目录内容
class ListDir {
  // 定义异步运行方法,参数为目录的相对路径
  async run({ path: relativePath }) {
    try {
      // 解析出目标目录的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 获取目标路径的文件状态信息
      const stat = await fsp.stat(absolute);
      // 判断目标路径是否为目录,如果不是则返回提示
      if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
      // 读取目录下的所有条目,获取类型信息
      const entries = await fsp.readdir(absolute, { withFileTypes: true });
      // 根据名称进行字典序排序
      entries.sort((a, b) => a.name.localeCompare(b.name));
      // 格式化每条目录项,区分文件与子目录
      const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
      // 如果有内容则按行拼接返回,否则返回空目录提示
      return rows.length ? rows.join("\n") : "(空目录)";
    } catch (err) {
      // 异常处理:目录不存在单独提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
    }
  }
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
  // 定义一个函数工具,用于精确子串替换现有文件内容
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 editFile
      name: "editFile",
      // 工具描述,说明功能为仅替换一次完全匹配的原文
      description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
          path: { type: "string", description: "待修改文件路径" },
          // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
          oldText: {
            type: "string",
            description: "要被替换掉的原文(唯一一次匹配)",
          },
          // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
          newText: { type: "string", description: "替换后的新文本" },
        },
        // 这三个参数都是必需的
        required: ["path", "oldText", "newText"],
      },
    },
  },
  // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 listDir
      name: "listDir",
      // 工具描述,说明功能为罗列目录结构,可区分文件夹
      description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
          path: { type: "string", description: "目录路径(相对 workspace)" },
        },
        // path 是必需参数
        required: ["path"],
      },
    },
  },
  // 定义工具对象,表示类型为 function
  {
    // 设置 type 字段为 "function",表示此为函数型工具
    type: "function",
    // function 字段,定义具体的函数内容
    function: {
      // name 字段,工具名称为 runCommand
      name: "runCommand",
      // description 字段,对工具功能做简要描述
      description:
        // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
+       "在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
+       "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
+       "常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
+       "后台子命令:\n" +
+       "  task_list         列出已登记的后台任务\n" +
+       "  task_logs <pid>   拉取该 PID 最近若干行合并输出\n" +
+       "  task_stop <pid>   结束该后台任务",
      // parameters 字段,定义参数类型和要求
      parameters: {
        // type 指定参数为对象形式
        type: "object",
        // properties 字段,详细定义各参数
        properties: {
          // commandLine 参数,定义为字符串类型
          commandLine: {
            // type 指明参数为字符串
            type: "string",
            // description 说明 commandLine 的用途
            description: "Shell 命令行(例如:npm install)",
          }
        },
        // required 字段,列出必传参数
        required: ["commandLine"],
      },
    },
  },
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
  // 若 Buffer 为空或长度为0,直接返回空字符串
  if (!buf || buf.length === 0) return "";
  // 若当前系统是 Windows,则以 cp936 编码解码
  if (process.platform === "win32") return iconv.decode(buf, "cp936");
  // 其他系统则以 utf8 解码
  return buf.toString("utf8");
}

// 定义常量配置对象
const CONFIG = {
  // 智能体允许的最大步数
  agentMaxSteps: 100,
  // 后台进程预热时间 8秒
  backgroundWarmupMs: 8000,
};

// 定义 delay 函数,实现异步延迟
function delay(ms) {
  // 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
  return new Promise((resolve) => setTimeout(resolve, ms));
+ // 定义一个异步延迟函数,参数为毫秒数
}

// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
  // "dev" 表示开发模式
+ "dev",
  // "start" 表示启动服务
+ "start",
  // "serve" 表示启动服务器
+ "serve",
  // "server" 表示启动开发服务器
+ "server",
  // "watch" 表示监听文件变更
+ "watch",
  // "run server" 表示运行服务器
+ "run server",
  // "runserver" 另一种写法,运行服务器
+ "runserver",
  // "preview" 表示预览模式
+ "preview",
  // "nodemon" 表示自动重启开发服务器
+ "nodemon",
  // "uvicorn" 表示 Python asgi 服务器
+ "uvicorn",
  // "gunicorn" 表示 Python wsgi 服务器
+ "gunicorn",
  // "flask run" 表示 Flask 的运行命令
+ "flask run",
  // "vite" 表示前端构建工具
+ "vite",
  // "webpack" 表示前端构建工具
+ "webpack",
  // "--watch" 表示监听参数
+ "--watch",
  // "--hot" 表示热更新参数
+ "--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
  // 去除命令行字符串首尾空白,并转为小写,标准化处理
  const normalized = commandLine.trim().toLowerCase();
  // 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
  return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
  // 监听 "data" 事件,每收到一段数据时被调用
  readableStream.on("data", (chunk) => {
    // 将收到的数据块 chunk 压入缓冲区 buffers
    buffers.push(chunk);
  });
  // 监听 "end" 事件,当数据流结束时被调用
  readableStream.on("end", () => {
    // 将 null 压入缓冲区 buffers,表示流已结束
    buffers.push(null);
  });
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
+ // 检查并处理后台任务控制命令,比如 task_list
+ handleBackgroundTaskControlCommand(commandLine) {
+   // 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
+   if (/^task_list(\s|$)/i.test(commandLine)) {
+     if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
+     return [
+       "【后台任务一览】",
+       ...[...backgroundProcesses].map(([pid, meta]) =>
+         `  · ${pid}  ${meta.child.exitCode === null ? "活动中" : "已结束"}  |  ${meta.commandLine}`
+       )
+     ].join("\n");
+   }
+   return null;
+ }
  // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
  async run({ commandLine }) {
+   // 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
+   const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
+   // 如果解析出控制回复,不再继续后续命令执行,直接返回
+   if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
    // 判断命令行是否看起来像是长时间运行的命令
    if (looksLikeLongRunningCommand(commandLine)) {
      // 如果是,则调用异步方法在后台运行命令行
      return this.runBackgroundCommandLine(commandLine);
    }
    // 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
    return this.runBlockingCommandLine(commandLine)
  }
  // 同步运行 commandLine 命令,并返回命令执行的文本结果
  runBlockingCommandLine(commandLine) {
    // 使用 spawnSync 同步执行命令行
    const result = spawnSync(commandLine, {
      // 指定使用 shell 运行 commandLine
      shell: true,
      // 指定工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 输出按二进制 Buffer 格式返回
      encoding: "buffer",
      // 最大缓冲区 10MB
      maxBuffer: 10 * 1024 * 1024,
    });
    // 命令执行超时,则返回超时提示
    if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
    // 若 stdout 有内容则解码,否则返回“stdout 为空”
    let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
    // 若 stderr 有内容,则拼接 stderr 的解码结果
    if (result.stderr?.length)
      merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
    // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
    return result.status !== 0
      ? `退出码 ${result.status}\n${merged}`
      : `命令完成\n${merged}`;
  }
  // 定义一个异步方法,用于在后台启动 commandLine 命令
  async runBackgroundCommandLine(commandLine) {
    // 创建一个用于存储子进程输出的缓冲区数组
    const buffers = [];
    // 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
    const child = spawn(commandLine, {
      // 指定使用 shell 运行 commandLine 命令
      shell: true,
      // 指定子进程的工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
      stdio: ["ignore", "pipe", "pipe"],
    });
    // 绑定子进程的标准输出流到缓冲区数组
    bindStreamToBuffers(child.stdout, buffers);
    // 绑定子进程的标准错误流到缓冲区数组
    bindStreamToBuffers(child.stderr, buffers);
    // 获取子进程的进程ID(PID)
    const pid = child.pid;
+   // 将当前子进程的信息存储到 backgroundProcesses 映射中
+   backgroundProcesses.set(pid, {
+     // 记录启动该子进程使用的命令行字符串
+     commandLine,
+     // 记录子进程对象本身
+     child,
+     // 记录用于收集子进程输出的缓冲区数组
+     buffers
+   });
+   // 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
+   child.once("exit", () => backgroundProcesses.delete(pid));
    // 等待后台进程初始化,延迟指定的时间(用于预热/启动)
    await delay(CONFIG.backgroundWarmupMs);
    // 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
    return `已在后台拉起子进程\n  PID:${pid}\n  命令:${commandLine}\n  启动段输出:\n${Buffer.concat(buffers).toString("utf8") || "(尚无输出)"}`;
  }
}
// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
  // 替换文件工具
  editFile: new EditFile(),
  // 枚举目录工具
  listDir: new ListDir(),
  // 执行命令工具
  runCommand: new RunCommandLine(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
    // 并发执行每个工具调用,等待所有结果
    const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

8.2 时序图 #

下图综合 8.3 测试:先登记两个后台服务,再通过 task_list 查询(task_list 不经过 shell,也不触发 spawn)。

sequenceDiagram autonumber participant User as 用户/终端 participant Agent as runAgentUntilReplyOrMaxSteps participant Run as RunCommandLine participant Map as backgroundProcesses participant Child as 子进程 Note over User,Child: 阶段 A:后台启动(与第 7 节相同,但多登记) User->>Agent: runCommand(node server1.js) Agent->>Run: run → runBackgroundCommandLine Run->>Child: spawn Run->>Map: set(pid, { commandLine, child, buffers }) Child-->>Run: stdout 启动日志 Run-->>Agent: PID + 启动段输出 Note over Map: server2 同理再 set 一条 Note over User,Map: 阶段 B:task_list 查询 User->>Agent: task_list(或 runCommand task_list) Agent->>Run: run({ commandLine: task_list }) Run->>Run: handleBackgroundTaskControlCommand Run->>Map: 遍历 entries Map-->>Run: pid + 状态 + commandLine Run-->>Agent: 【后台任务一览】文本 Note over Run: 不调用 spawn / spawnSync Child-->>Map: exit 时 delete(pid)

8.3 测试 #

› 执行命令 node server1.js

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "用户想要我执行 `node server1.js`。让我先看看这个文件是否存在以及它的内容。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_b3BGRYqx3NBXLSseK2Gf3780",
      "type": "function",
      "function": {
        "name": "readFile",
        "arguments": "{\"path\": \"server1.js\"}"
      }
    }
  ]
}

工具 readFile 被调用
入参:{
  "path": "server1.js"
}
返回:// 引入 Node.js 内置的 http 模块
const http = require('http');

// 定义服务器监听的端口号为 1000
const PORT = 1000;

// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
  // 设置响应状态码为 200(成功)
  res.statusCode = 200;
  // 设置响应头,指定内容类型为纯文本
  res.setHeader('Content-Type', 'text/plain');
  // 结束响应并返回消息内容
  res.end('1000');
});

// 启动服务器,监听指定端口
server.listen(PORT, () => {
  // 服务器启动后在控制台输出提示信息
  console.log(`Server running at http://localhost:${PORT}/`);
});

请求模型中...
{
  "role": "assistant",
  "content": "这是一个 HTTP 服务器,会持续监听端口 1000。我将以后台模式启动它:",
  "reasoning_content": "这是一个简单的 HTTP 服务器,监听端口 1000,返回 \"1000\"。由于这是一个常驻服务器,应该使用后台模式运行。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_Za1nqrrzNMz2eZGPe5dD7278",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"node server1.js\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "node server1.js"
}

返回:已在后台拉起子进程
  PID:21292
  命令:node server1.js
  启动段输出:
Server running at http://localhost:1000/


请求模型中...
{
  "role": "assistant",
  "content": "服务器已在后台成功启动!\n\n- **PID**:21292\n- **地址**:`http://localhost:1000/`\n- **行为**:访问时返回纯文本 `1000`\n\n如有需要,你可以使用以下命令管理该后台任务:\n- 查看日志:`task_logs 21292`\n- 停止服务:`task_stop 21292`",
  "reasoning_content": "服务器已成功在后台启动。"
}

Assistant
服务器已在后台成功启动!

- **PID**:21292
- **地址**:`http://localhost:1000/`
- **行为**:访问时返回纯文本 `1000`

如有需要,你可以使用以下命令管理该后台任务:
- 查看日志:`task_logs 21292`
- 停止服务:`task_stop 21292`

› 执行命令 node server2.js

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants me to execute `node server2.js`. Let me first check what this file contains and understand the project structure.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_ybhTRwYdz2YtU0L5js3D9714",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"node server2.js\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "node server2.js"
}
返回:已在后台拉起子进程
  PID:26924
  命令:node server2.js
  启动段输出:
Server running at http://localhost:2000/


请求模型中...
{
  "role": "assistant",
  "content": "`server2.js` 已在后台启动,PID 为 **26924**,监听地址:**http://localhost:2000/**。\n\n如需查看日志、停止 服务或进行其他操作,随时告诉我。",
  "reasoning_content": "The server has been started in the background. It's running on http://localhost:2000/."
}

Assistant
`server2.js` 已在后台启动,PID 为 **26924**,监听地址:**http://localhost:2000/**。

如需查看日志、停止服务或进行其他操作,随时告诉我。

› task_list

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user is asking me to list the background tasks. Let me run the task_list command.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_pal5Dw8UTBnkzpa3WKxs3789",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"task_list\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "task_list"
}
返回:【后台任务一览】
  · 21292  活动中  |  node server1.js
  · 26924  活动中  |  node server2.js

请求模型中...
{
  "role": "assistant",
  "content": "当前有 2 个活动的后台任务:\n\n| PID | 状态 | 命令 |\n|-----|------|------|\n| 21292 | 活动中 | `node server1.js` |\n| 26924 | 活动中 | `node server2.js` |\n\n如果你想查看某个任务的日志或停止它,告诉我就行。",
  "reasoning_content": "The user can see two active background tasks:\n\n1. PID 21292 - running `node server1.js`\n2. PID 26924 - running `node server2.js`\n\nI should present this clearly."
}

Assistant
当前有 2 个活动的后台任务:

| PID | 状态 | 命令 |
|-----|------|------|
| 21292 | 活动中 | `node server1.js` |
| 26924 | 活动中 | `node server2.js` |

如果你想查看某个任务的日志或停止它,告诉我就行。

9. task_logs / task_stop #

第 8 节只实现了 task_list,工具描述里承诺的 task_logs、task_stop 在本节落地,并统一后台输出的解码方式,使「启动段输出」与「事后查日志」行为一致。

程序在做什么: 在 handleBackgroundTaskControlCommand 中新增两条伪命令:task_logs <pid> 从 backgroundProcesses 取出登记时的 buffers,经 buffersToText(合并 Buffer + decodeResultBuffer,Windows 用 cp936)转成文本,再用 tailTextLines 截取末尾 CONFIG.backgroundLogPreviewLines(默认 50)行返回;task_stop <pid> 在 Windows 上 taskkill /PID … /T /F,其它平台尝试 process.kill(-pid, "SIGTERM"),失败则 child.kill,并从 Map 删除。runBackgroundCommandLine 的启动段输出也改为 buffersToText(buffers),不再硬编码 utf8。完整流程见 9.3 测试数据。

相对第 8 节的新增点:

  • buffersToText / tailTextLines:把 bindStreamToBuffers 收集的 stdout/stderr 块转为可读字符串并做行级截断。
  • task_logs:只读、不启 shell;PID 未登记时返回 ✖ 未登记的后台 PID。
  • task_stop:主动结束子进程并移出登记表;与 child.once("exit") 的自动清理互补。
  • backgroundLogPreviewLines:控制 task_logs 返回体积,避免把过长日志塞进模型上下文。

三个命令对比:

命令 作用 是否 spawn
task_list 列出 PID、状态、原命令 否
task_logs <pid> 最近 N 行合并输出 否
task_stop <pid> 终止并注销 否(仅 kill)

9.1 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 存储所有后台拉起的子进程信息
const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
  // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
  async run({ path: relativePath, oldText, newText }) {
    try {
      // 解析出目标文件的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 以 UTF-8 编码读取文件内容
      const before = await fsp.readFile(absolute, "utf8");
      // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
      if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
      // 将第一次出现的 oldText 替换为 newText 并写回原文件
      await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
      // 返回替换成功的提示
      return `✔ 已就地替换一处:${relativePath}`;
    } catch (err) {
      // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
    }
  }
}

// 定义 ListDir 类,用于列出目录内容
class ListDir {
  // 定义异步运行方法,参数为目录的相对路径
  async run({ path: relativePath }) {
    try {
      // 解析出目标目录的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 获取目标路径的文件状态信息
      const stat = await fsp.stat(absolute);
      // 判断目标路径是否为目录,如果不是则返回提示
      if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
      // 读取目录下的所有条目,获取类型信息
      const entries = await fsp.readdir(absolute, { withFileTypes: true });
      // 根据名称进行字典序排序
      entries.sort((a, b) => a.name.localeCompare(b.name));
      // 格式化每条目录项,区分文件与子目录
      const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
      // 如果有内容则按行拼接返回,否则返回空目录提示
      return rows.length ? rows.join("\n") : "(空目录)";
    } catch (err) {
      // 异常处理:目录不存在单独提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
    }
  }
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
  // 定义一个函数工具,用于精确子串替换现有文件内容
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 editFile
      name: "editFile",
      // 工具描述,说明功能为仅替换一次完全匹配的原文
      description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
          path: { type: "string", description: "待修改文件路径" },
          // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
          oldText: {
            type: "string",
            description: "要被替换掉的原文(唯一一次匹配)",
          },
          // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
          newText: { type: "string", description: "替换后的新文本" },
        },
        // 这三个参数都是必需的
        required: ["path", "oldText", "newText"],
      },
    },
  },
  // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 listDir
      name: "listDir",
      // 工具描述,说明功能为罗列目录结构,可区分文件夹
      description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
          path: { type: "string", description: "目录路径(相对 workspace)" },
        },
        // path 是必需参数
        required: ["path"],
      },
    },
  },
  // 定义工具对象,表示类型为 function
  {
    // 设置 type 字段为 "function",表示此为函数型工具
    type: "function",
    // function 字段,定义具体的函数内容
    function: {
      // name 字段,工具名称为 runCommand
      name: "runCommand",
      // description 字段,对工具功能做简要描述
      description:
        // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
        "在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
        "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
        "常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
        "后台子命令:\n" +
        "  task_list         列出已登记的后台任务\n" +
        "  task_logs <pid>   拉取该 PID 最近若干行合并输出\n" +
        "  task_stop <pid>   结束该后台任务",
      // parameters 字段,定义参数类型和要求
      parameters: {
        // type 指定参数为对象形式
        type: "object",
        // properties 字段,详细定义各参数
        properties: {
          // commandLine 参数,定义为字符串类型
          commandLine: {
            // type 指明参数为字符串
            type: "string",
            // description 说明 commandLine 的用途
            description: "Shell 命令行(例如:npm install)",
          }
        },
        // required 字段,列出必传参数
        required: ["commandLine"],
      },
    },
  },
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
  // 若 Buffer 为空或长度为0,直接返回空字符串
  if (!buf || buf.length === 0) return "";
  // 若当前系统是 Windows,则以 cp936 编码解码
  if (process.platform === "win32") return iconv.decode(buf, "cp936");
  // 其他系统则以 utf8 解码
  return buf.toString("utf8");
}

// 定义常量配置对象
const CONFIG = {
  // 智能体允许的最大步数
  agentMaxSteps: 100,
  // 后台进程预热时间 8秒
  backgroundWarmupMs: 8000,
+ // task_logs 返回的最大行数
+ backgroundLogPreviewLines: 50,
};

// 定义 delay 函数,实现异步延迟
function delay(ms) {
  // 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
  return new Promise((resolve) => setTimeout(resolve, ms));
  // 定义一个异步延迟函数,参数为毫秒数
}

// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
  // "dev" 表示开发模式
  "dev",
  // "start" 表示启动服务
  "start",
  // "serve" 表示启动服务器
  "serve",
  // "server" 表示启动开发服务器
  "server",
  // "watch" 表示监听文件变更
  "watch",
  // "run server" 表示运行服务器
  "run server",
  // "runserver" 另一种写法,运行服务器
  "runserver",
  // "preview" 表示预览模式
  "preview",
  // "nodemon" 表示自动重启开发服务器
  "nodemon",
  // "uvicorn" 表示 Python asgi 服务器
  "uvicorn",
  // "gunicorn" 表示 Python wsgi 服务器
  "gunicorn",
  // "flask run" 表示 Flask 的运行命令
  "flask run",
  // "vite" 表示前端构建工具
  "vite",
  // "webpack" 表示前端构建工具
  "webpack",
  // "--watch" 表示监听参数
  "--watch",
  // "--hot" 表示热更新参数
  "--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
  // 去除命令行字符串首尾空白,并转为小写,标准化处理
  const normalized = commandLine.trim().toLowerCase();
  // 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
  return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
+// 定义函数:将 bindStreamToBuffers 收集的 Buffer 块解码为文本
+function buffersToText(buffers) {
+ // 过滤掉为 null 的 chunk,得到有效的 Buffer 块
+ const chunks = buffers.filter((chunk) => chunk !== null);
+ // 如果 chunks 为空数组,说明没有内容,返回空字符串
+ if (!chunks.length) return "";
+ // 将所有 Buffer 块合并后,调用 decodeResultBuffer 进行解码为字符串
+ return decodeResultBuffer(Buffer.concat(chunks));
+}

+// 定义函数:取文本末尾指定行数,用于 task_logs 日志预览
+function tailTextLines(text, maxLines) {
+ // 如果输入文本为空,直接返回空字符串
+ if (!text) return "";
+ // 按换行符分割文本成数组,取最后的 maxLines 行,再用换行符拼接成字符串返回
+ return text.split(/\r?\n/).slice(-maxLines).join("\n");
+}

// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
  // 监听 "data" 事件,每收到一段数据时被调用
  readableStream.on("data", (chunk) => {
    // 将收到的数据块 chunk 压入缓冲区 buffers
    buffers.push(chunk);
  });
  // 监听 "end" 事件,当数据流结束时被调用
  readableStream.on("end", () => {
    // 将 null 压入缓冲区 buffers,表示流已结束
    buffers.push(null);
  });
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
  // 检查并处理后台任务控制命令,比如 task_list
  handleBackgroundTaskControlCommand(commandLine) {
    // 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
    if (/^task_list(\s|$)/i.test(commandLine)) {
      if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
      return [
        "【后台任务一览】",
        ...[...backgroundProcesses].map(([pid, meta]) =>
          `  · ${pid}  ${meta.child.exitCode === null ? "活动中" : "已结束"}  |  ${meta.commandLine}`
        )
      ].join("\n");
    }
+   // 匹配 task_logs <pid> 指令,获取日志查看请求
+   const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim());
+   // 如果匹配成功
+   if (logsMatch) {
+     // 从匹配结果中获取 pid
+     const pid = Number.parseInt(logsMatch[1], 10);
+     // 检查后台进程集中是否有该 pid,没有则返回错误提示
+     if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
+     // 获取该 pid 对应的缓冲区 buffers
+     const { buffers } = backgroundProcesses.get(pid);
+     // 将缓冲区内容转换为文本,并截取末尾若干行
+     const text = tailTextLines(
+       buffersToText(buffers),
+       CONFIG.backgroundLogPreviewLines
+     );
+     // 返回格式化的输出内容
+     return `【PID ${pid} 最近输出】\n${text || "(尚无输出)"}`;
+   }
+   // 匹配 task_stop <pid> 指令,获取终止进程请求
+   const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim());
+   // 如果匹配成功
+   if (stopMatch) {
+     // 从匹配结果中获取 pid
+     const pid = Number.parseInt(stopMatch[1], 10);
+     // 检查后台进程集中是否有该 pid,没有则返回错误提示
+     if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
+     // 获取该 pid 对应的子进程 child
+     const { child } = backgroundProcesses.get(pid);
+     try {
+       // 如果是 Windows 系统,则使用 taskkill 命令终止进程
+       if (process.platform === "win32") {
+         spawnSync(`taskkill /PID ${pid} /T /F`, { shell: true, stdio: "ignore" });
+       } else {
+         // 非 Windows 则通过向进程组发送 SIGTERM 信号终止进程
+         process.kill(-pid, "SIGTERM");
+       }
+     } catch {
+       // 如果上面 kill 失败,则直接对子进程对象调用 kill 方法
+       child.kill("SIGTERM");
+     }
+     // 从后台进程集删除该 pid
+     backgroundProcesses.delete(pid);
+     // 返回终止请求已发送的提示
+     return `已请求终止后台 PID=${pid}`;
+   }
    return null;
  }
  // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
  async run({ commandLine }) {
    // 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
    const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
    // 如果解析出控制回复,不再继续后续命令执行,直接返回
    if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
    // 判断命令行是否看起来像是长时间运行的命令
    if (looksLikeLongRunningCommand(commandLine)) {
      // 如果是,则调用异步方法在后台运行命令行
      return this.runBackgroundCommandLine(commandLine);
    }
    // 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
    return this.runBlockingCommandLine(commandLine)
  }
  // 同步运行 commandLine 命令,并返回命令执行的文本结果
  runBlockingCommandLine(commandLine) {
    // 使用 spawnSync 同步执行命令行
    const result = spawnSync(commandLine, {
      // 指定使用 shell 运行 commandLine
      shell: true,
      // 指定工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 输出按二进制 Buffer 格式返回
      encoding: "buffer",
      // 最大缓冲区 10MB
      maxBuffer: 10 * 1024 * 1024,
    });
    // 命令执行超时,则返回超时提示
    if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
    // 若 stdout 有内容则解码,否则返回“stdout 为空”
    let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
    // 若 stderr 有内容,则拼接 stderr 的解码结果
    if (result.stderr?.length)
      merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
    // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
    return result.status !== 0
      ? `退出码 ${result.status}\n${merged}`
      : `命令完成\n${merged}`;
  }
  // 定义一个异步方法,用于在后台启动 commandLine 命令
  async runBackgroundCommandLine(commandLine) {
    // 创建一个用于存储子进程输出的缓冲区数组
    const buffers = [];
    // 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
    const child = spawn(commandLine, {
      // 指定使用 shell 运行 commandLine 命令
      shell: true,
      // 指定子进程的工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
      stdio: ["ignore", "pipe", "pipe"],
    });
    // 绑定子进程的标准输出流到缓冲区数组
    bindStreamToBuffers(child.stdout, buffers);
    // 绑定子进程的标准错误流到缓冲区数组
    bindStreamToBuffers(child.stderr, buffers);
    // 获取子进程的进程ID(PID)
    const pid = child.pid;
    // 将当前子进程的信息存储到 backgroundProcesses 映射中
    backgroundProcesses.set(pid, {
      // 记录启动该子进程使用的命令行字符串
      commandLine,
      // 记录子进程对象本身
      child,
      // 记录用于收集子进程输出的缓冲区数组
      buffers
    });
    // 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
    child.once("exit", () => backgroundProcesses.delete(pid));
    // 等待后台进程初始化,延迟指定的时间(用于预热/启动)
    await delay(CONFIG.backgroundWarmupMs);
    // 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
+   return `已在后台拉起子进程\n  PID:${pid}\n  命令:${commandLine}\n  启动段输出:\n${buffersToText(buffers) || "(尚无输出)"}`;
  }
}
// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
  // 替换文件工具
  editFile: new EditFile(),
  // 枚举目录工具
  listDir: new ListDir(),
  // 执行命令工具
  runCommand: new RunCommandLine(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
    // 并发执行每个工具调用,等待所有结果
    const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

9.2 时序图 #

下图对应 9.3 测试数据 的完整后台任务生命周期(task_list → task_logs → task_stop → 再 task_list)。

sequenceDiagram autonumber participant User as 用户/终端 participant Agent as runAgentUntilReplyOrMaxSteps participant Run as RunCommandLine participant Map as backgroundProcesses participant Decode as buffersToText participant Child as 子进程 User->>Agent: node server1.js Agent->>Run: runBackgroundCommandLine Run->>Child: spawn Run->>Map: set(pid, { buffers, child, commandLine }) Child-->>Run: stdout → buffers Run->>Decode: 启动段输出 Run-->>Agent: PID + 启动段 User->>Agent: task_list Agent->>Run: handleBackgroundTaskControlCommand Run->>Map: 遍历 Run-->>Agent: 活动中 · pid · node server1.js User->>Agent: task_logs pid Agent->>Run: task_logs Run->>Map: get(pid).buffers Run->>Decode: tailTextLines(…, 50) Run-->>Agent: 【PID 最近输出】 User->>Agent: task_stop pid Agent->>Run: task_stop Run->>Child: taskkill / kill Run->>Map: delete(pid) Run-->>Agent: 已请求终止 User->>Agent: task_list Run-->>Agent: (当前没有已登记的后台任务)

9.3 测试数据 #

› 执行命令 node server1.js

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants me to execute the command `node server1.js`. Let me first check if this file exists and look at the directory structure.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_X9mPpE9DNORK0jTo653O0865",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"node server1.js\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "node server1.js"
}
返回:已在后台拉起子进程
  PID:36580
  命令:node server1.js
  启动段输出:
Server running at http://localhost:1000/


请求模型中...
{
  "role": "assistant",
  "content": "服务已后台启动,进程 PID 为 **36580**。\n\n- 访问地址: http://localhost:1000/\n\n如需查看后续日志,请告诉 我;如需停止该服务,我也可以随时终止。",
  "reasoning_content": "The server has been started in the background. It's running on http://localhost:1000/."
}

Assistant
服务已后台启动,进程 PID 为 **36580**。

- 访问地址: http://localhost:1000/

如需查看后续日志,请告诉我;如需停止该服务,我也可以随时终止。

› task_list

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants me to list the background tasks. Let me use the runCommand with the task_list subcommand.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_JLb2UFYG8kNRBeQfDuB15978",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"task_list\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "task_list"
}
返回:【后台任务一览】
  · 36580  活动中  |  node server1.js

请求模型中...
task logs {
  "role": "assistant",
  "content": "当前共有 **1 个后台任务**在运行:\n\n| PID    | 状态   | 命令              |\n|--------|--------|-------------------|\n| 36580  | 活动中 | `node server1.js` |\n\n需要我查看日志或停止该任务吗?",
  "reasoning_content": "There's one background task running:\n\n- PID 36580: `node server1.js` (active)\n\nThe user just asked to list the tasks, so I'll present this clearly."
}

Assistant
当前共有 **1 个后台任务**在运行:

| PID    | 状态   | 命令              |
|--------|--------|-------------------|
| 36580  | 活动中 | `node server1.js` |

需要我查看日志或停止该任务吗?

› task logs 36580

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants to see the logs for a background task with PID 36580.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_ffRdY17mAzRvhth8Ee6G8287",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"task_logs 36580\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "task_logs 36580"
}
返回:【PID 36580 最近输出】
Server running at http://localhost:1000/


请求模型中...
{
  "role": "assistant",
  "content": "PID 36580 是一个正在运行的开发服务器,监听在 **http://localhost:1000/**,状态正常,暂无错误输出。",
  "reasoning_content": "The task with PID 36580 is a server running at http://localhost:1000/. It appears to be running fine."
}

Assistant
PID 36580 是一个正在运行的开发服务器,监听在 **http://localhost:1000/**,状态正常,暂无错误输出。

› task stop 36580

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants to stop a background task with PID 36580.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_7O7lcPnmGxczCYdD0qEg4493",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"task_stop 36580\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "task_stop 36580"
}
返回:已请求终止后台 PID=36580

请求模型中...
{
  "role": "assistant",
  "content": "已成功终止后台任务 PID=36580。",
  "reasoning_content": "The task has been successfully stopped."
}

Assistant
已成功终止后台任务 PID=36580。

› task_list

请求模型中...
{
  "role": "assistant",
  "content": "",
  "reasoning_content": "The user wants to see the list of background tasks.",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_O4ICJeTbEZtVkzr56hhf7169",
      "type": "function",
      "function": {
        "name": "runCommand",
        "arguments": "{\"commandLine\": \"task_list\"}"
      }
    }
  ]
}

工具 runCommand 被调用
入参:{
  "commandLine": "task_list"
}
返回:(当前没有已登记的后台任务)

请求模型中...
{
  "role": "assistant",
  "content": "当前没有已注册的后台任务。如果你需要启动某个开发服务器或其他常驻进程,告诉我命令即可。",
  "reasoning_content": "The user asked for task_list and the result shows no background tasks are currently registered."
}

Assistant
当前没有已注册的后台任务。如果你需要启动某个开发服务器或其他常驻进程,告诉我命令即可。

10. 含 runCommand 时同轮工具顺序执行 #

第 9 节在同一轮 API 回复里若模型返回多个 tool_calls,一律用 Promise.all 并发执行。对 runCommand 而言,并发可能导致「先 task_stop 再 task_list」等顺序敏感操作乱序。本节在 runAgentUntilReplyOrMaxSteps 中增加分支:只要本轮 tool_calls 里出现 runCommand,则整批工具按数组顺序依次 await 执行;否则仍并发(适合多个独立的 readFile / listDir)。

程序在做什么: 模型返回 tool_calls 后,先 calls.some((c) => c.function.name === "runCommand");为真则 for … await executeSingleToolCall(call),为假则 Promise.all。配套文件 workspace/a.txt(见 10.1)记录期望结果 helloworld;10.4 测试 演示模型多轮调用 writeFile / readFile 完成「先写 hello、再拼成 helloworld」——该用例每轮通常只有一个工具,重点展示多步 agent 循环;顺序执行主要惠及同一轮内多个 runCommand 或其它工具与命令混排的场景。

相对第 9 节的新增点:

  • 顺序执行触发条件:本轮任意一个工具名为 runCommand。
  • 整批顺序:一旦触发,同轮中的 readFile、writeFile 也会排在 runCommand 前后按序执行,避免与命令副作用交错时的竞态。
  • 未改工具实现:ReadText / RunCommandLine 等逻辑不变,仅调度策略变化。

10.1 a.txt #

workspace/a.txt

helloworld

10.2 claude.js #

// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
  apiKey: process.env.DEEPSEEK_API_KEY,
  baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 存储所有后台拉起的子进程信息
const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
  input: process.stdin,// 标准输入流
  output: process.stdout,// 标准输出流  
  terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
  // 打印中断提示信息,换两行
  console.log("\n\n⌁ 已中断,下次见。");
  // 关闭 readline 接口
  rl.close();
  // 退出当前进程,状态码为 0(正常退出)
  process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");

// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
  // 解析 candidatePath 为绝对路径
  const targetAbs = path.resolve(candidatePath);
  // 如果目标路径和工作区根目录完全相同,则返回 true
  if (WORKSPACE_ROOT === targetAbs) return true;
  // 计算目标路径相对于工作区根目录的相对路径
  const relative = path.relative(WORKSPACE_ROOT, targetAbs);
  // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
  return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}

// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
  // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
  const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
  // 如果解析后的路径不在工作区内,则抛出异常
  if (!isDescendantOrSameDirectory(candidatePath))
    throw new Error(`路径越界:${candidatePath}`);
  // 返回校验通过的绝对路径
  return candidatePath;
}

// 文本读取工具实现
class ReadText {
  // 执行读取操作,参数为 { path }
  async run({ path: relativePath }) {
    try {
      // 调用文件系统 API 读取文件内容
      return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
    } catch (err) {
      // 如果找不到文件,返回自定义提示;否则返回异常原因
      return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
    }
  }
}

// 文本写入工具实现
class WriteText {
  // 执行写入操作,参数为 { path, content }
  async run({ path: relativePath, content }) {
    try {
      // 解析目标文件的绝对路径
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 如果父级目录不存在则递归创建
      await fsp.mkdir(path.dirname(absolute), { recursive: true });
      // 写入内容到文件,使用 UTF-8 编码
      await fsp.writeFile(absolute, content, "utf8");
      // 返回写入结果以及写入字节数
      return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
    } catch (err) {
      // 返回写入过程中的异常信息
      return `写入异常:${err.message}`;
    }
  }
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
  // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
  async run({ path: relativePath, oldText, newText }) {
    try {
      // 解析出目标文件的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 以 UTF-8 编码读取文件内容
      const before = await fsp.readFile(absolute, "utf8");
      // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
      if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
      // 将第一次出现的 oldText 替换为 newText 并写回原文件
      await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
      // 返回替换成功的提示
      return `✔ 已就地替换一处:${relativePath}`;
    } catch (err) {
      // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
    }
  }
}

// 定义 ListDir 类,用于列出目录内容
class ListDir {
  // 定义异步运行方法,参数为目录的相对路径
  async run({ path: relativePath }) {
    try {
      // 解析出目标目录的绝对路径,确保在工作区内
      const absolute = resolvePathInsideWorkspace(relativePath);
      // 获取目标路径的文件状态信息
      const stat = await fsp.stat(absolute);
      // 判断目标路径是否为目录,如果不是则返回提示
      if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
      // 读取目录下的所有条目,获取类型信息
      const entries = await fsp.readdir(absolute, { withFileTypes: true });
      // 根据名称进行字典序排序
      entries.sort((a, b) => a.name.localeCompare(b.name));
      // 格式化每条目录项,区分文件与子目录
      const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
      // 如果有内容则按行拼接返回,否则返回空目录提示
      return rows.length ? rows.join("\n") : "(空目录)";
    } catch (err) {
      // 异常处理:目录不存在单独提示,否则返回错误信息
      return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
    }
  }
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
  {
    // 声明工具类型为函数
    type: "function",
    function: {
      // 工具名称为 readFile
      name: "readFile",
      // 工具功能描述
      description: "以 UTF-8 读取工作区内文本文件。",
      // 工具参数定义
      parameters: {
        // 参数类型为对象
        type: "object",
        // 参数属性为 path,字符串类型
        properties: { path: { type: "string" } },
        // path 字段为必需参数
        required: ["path"],
      },
    },
  },
  {
    // 声明写入工具类型为函数
    type: "function",
    function: {
      // 工具名称为 writeFile
      name: "writeFile",
      // 工具描述:新建或覆盖写入工作区内文件
      description: "在工作区内新建或覆盖写入文件。",
      // 工具参数定义
      parameters: {
        type: "object",
        properties: {
          // path 为文件路径,字符串类型
          path: { type: "string" },
          // content 为要写入的文本内容,字符串类型
          content: { type: "string" },
        },
        // path 和 content 都必需
        required: ["path", "content"],
      },
    },
  },
  // 定义一个函数工具,用于精确子串替换现有文件内容
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 editFile
      name: "editFile",
      // 工具描述,说明功能为仅替换一次完全匹配的原文
      description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
          path: { type: "string", description: "待修改文件路径" },
          // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
          oldText: {
            type: "string",
            description: "要被替换掉的原文(唯一一次匹配)",
          },
          // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
          newText: { type: "string", description: "替换后的新文本" },
        },
        // 这三个参数都是必需的
        required: ["path", "oldText", "newText"],
      },
    },
  },
  // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
  {
    // 工具类型为 function
    type: "function",
    // function 字段,定义具体工具内容
    function: {
      // 工具名称为 listDir
      name: "listDir",
      // 工具描述,说明功能为罗列目录结构,可区分文件夹
      description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
      // parameters 字段,定义参数信息
      parameters: {
        // 参数类型为对象
        type: "object",
        // properties 字段,定义具体参数
        properties: {
          // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
          path: { type: "string", description: "目录路径(相对 workspace)" },
        },
        // path 是必需参数
        required: ["path"],
      },
    },
  },
  // 定义工具对象,表示类型为 function
  {
    // 设置 type 字段为 "function",表示此为函数型工具
    type: "function",
    // function 字段,定义具体的函数内容
    function: {
      // name 字段,工具名称为 runCommand
      name: "runCommand",
      // description 字段,对工具功能做简要描述
      description:
        // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
        "在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
        "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
        "常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
        "后台子命令:\n" +
        "  task_list         列出已登记的后台任务\n" +
        "  task_logs <pid>   拉取该 PID 最近若干行合并输出\n" +
        "  task_stop <pid>   结束该后台任务",
      // parameters 字段,定义参数类型和要求
      parameters: {
        // type 指定参数为对象形式
        type: "object",
        // properties 字段,详细定义各参数
        properties: {
          // commandLine 参数,定义为字符串类型
          commandLine: {
            // type 指明参数为字符串
            type: "string",
            // description 说明 commandLine 的用途
            description: "Shell 命令行(例如:npm install)",
          }
        },
        // required 字段,列出必传参数
        required: ["commandLine"],
      },
    },
  },
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
  // 若 Buffer 为空或长度为0,直接返回空字符串
  if (!buf || buf.length === 0) return "";
  // 若当前系统是 Windows,则以 cp936 编码解码
  if (process.platform === "win32") return iconv.decode(buf, "cp936");
  // 其他系统则以 utf8 解码
  return buf.toString("utf8");
}

// 定义常量配置对象
const CONFIG = {
  // 智能体允许的最大步数
  agentMaxSteps: 100,
  // 后台进程预热时间 8秒
  backgroundWarmupMs: 8000,
  // task_logs 返回的最大行数
  backgroundLogPreviewLines: 50,
};

// 定义 delay 函数,实现异步延迟
function delay(ms) {
  // 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
  return new Promise((resolve) => setTimeout(resolve, ms));
  // 定义一个异步延迟函数,参数为毫秒数
}

// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
  // "dev" 表示开发模式
  "dev",
  // "start" 表示启动服务
  "start",
  // "serve" 表示启动服务器
  "serve",
  // "server" 表示启动开发服务器
  "server",
  // "watch" 表示监听文件变更
  "watch",
  // "run server" 表示运行服务器
  "run server",
  // "runserver" 另一种写法,运行服务器
  "runserver",
  // "preview" 表示预览模式
  "preview",
  // "nodemon" 表示自动重启开发服务器
  "nodemon",
  // "uvicorn" 表示 Python asgi 服务器
  "uvicorn",
  // "gunicorn" 表示 Python wsgi 服务器
  "gunicorn",
  // "flask run" 表示 Flask 的运行命令
  "flask run",
  // "vite" 表示前端构建工具
  "vite",
  // "webpack" 表示前端构建工具
  "webpack",
  // "--watch" 表示监听参数
  "--watch",
  // "--hot" 表示热更新参数
  "--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
  // 去除命令行字符串首尾空白,并转为小写,标准化处理
  const normalized = commandLine.trim().toLowerCase();
  // 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
  return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
// 定义函数:将 bindStreamToBuffers 收集的 Buffer 块解码为文本
function buffersToText(buffers) {
  // 过滤掉为 null 的 chunk,得到有效的 Buffer 块
  const chunks = buffers.filter((chunk) => chunk !== null);
  // 如果 chunks 为空数组,说明没有内容,返回空字符串
  if (!chunks.length) return "";
  // 将所有 Buffer 块合并后,调用 decodeResultBuffer 进行解码为字符串
  return decodeResultBuffer(Buffer.concat(chunks));
}

// 定义函数:取文本末尾指定行数,用于 task_logs 日志预览
function tailTextLines(text, maxLines) {
  // 如果输入文本为空,直接返回空字符串
  if (!text) return "";
  // 按换行符分割文本成数组,取最后的 maxLines 行,再用换行符拼接成字符串返回
  return text.split(/\r?\n/).slice(-maxLines).join("\n");
}

// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
  // 监听 "data" 事件,每收到一段数据时被调用
  readableStream.on("data", (chunk) => {
    // 将收到的数据块 chunk 压入缓冲区 buffers
    buffers.push(chunk);
  });
  // 监听 "end" 事件,当数据流结束时被调用
  readableStream.on("end", () => {
    // 将 null 压入缓冲区 buffers,表示流已结束
    buffers.push(null);
  });
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
  // 检查并处理后台任务控制命令,比如 task_list
  handleBackgroundTaskControlCommand(commandLine) {
    // 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
    if (/^task_list(\s|$)/i.test(commandLine)) {
      if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
      return [
        "【后台任务一览】",
        ...[...backgroundProcesses].map(([pid, meta]) =>
          `  · ${pid}  ${meta.child.exitCode === null ? "活动中" : "已结束"}  |  ${meta.commandLine}`
        )
      ].join("\n");
    }
    // 匹配 task_logs <pid> 指令,获取日志查看请求
    const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim());
    // 如果匹配成功
    if (logsMatch) {
      // 从匹配结果中获取 pid
      const pid = Number.parseInt(logsMatch[1], 10);
      // 检查后台进程集中是否有该 pid,没有则返回错误提示
      if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
      // 获取该 pid 对应的缓冲区 buffers
      const { buffers } = backgroundProcesses.get(pid);
      // 将缓冲区内容转换为文本,并截取末尾若干行
      const text = tailTextLines(
        buffersToText(buffers),
        CONFIG.backgroundLogPreviewLines
      );
      // 返回格式化的输出内容
      return `【PID ${pid} 最近输出】\n${text || "(尚无输出)"}`;
    }
    // 匹配 task_stop <pid> 指令,获取终止进程请求
    const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim());
    // 如果匹配成功
    if (stopMatch) {
      // 从匹配结果中获取 pid
      const pid = Number.parseInt(stopMatch[1], 10);
      // 检查后台进程集中是否有该 pid,没有则返回错误提示
      if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
      // 获取该 pid 对应的子进程 child
      const { child } = backgroundProcesses.get(pid);
      try {
        // 如果是 Windows 系统,则使用 taskkill 命令终止进程
        if (process.platform === "win32") {
          spawnSync(`taskkill /PID ${pid} /T /F`, { shell: true, stdio: "ignore" });
        } else {
          // 非 Windows 则通过向进程组发送 SIGTERM 信号终止进程
          process.kill(-pid, "SIGTERM");
        }
      } catch {
        // 如果上面 kill 失败,则直接对子进程对象调用 kill 方法
        child.kill("SIGTERM");
      }
      // 从后台进程集删除该 pid
      backgroundProcesses.delete(pid);
      // 返回终止请求已发送的提示
      return `已请求终止后台 PID=${pid}`;
    }
    return null;
  }
  // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
  async run({ commandLine }) {
    // 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
    const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
    // 如果解析出控制回复,不再继续后续命令执行,直接返回
    if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
    // 判断命令行是否看起来像是长时间运行的命令
    if (looksLikeLongRunningCommand(commandLine)) {
      // 如果是,则调用异步方法在后台运行命令行
      return this.runBackgroundCommandLine(commandLine);
    }
    // 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
    return this.runBlockingCommandLine(commandLine)
  }
  // 同步运行 commandLine 命令,并返回命令执行的文本结果
  runBlockingCommandLine(commandLine) {
    // 使用 spawnSync 同步执行命令行
    const result = spawnSync(commandLine, {
      // 指定使用 shell 运行 commandLine
      shell: true,
      // 指定工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 输出按二进制 Buffer 格式返回
      encoding: "buffer",
      // 最大缓冲区 10MB
      maxBuffer: 10 * 1024 * 1024,
    });
    // 命令执行超时,则返回超时提示
    if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
    // 若 stdout 有内容则解码,否则返回“stdout 为空”
    let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
    // 若 stderr 有内容,则拼接 stderr 的解码结果
    if (result.stderr?.length)
      merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
    // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
    return result.status !== 0
      ? `退出码 ${result.status}\n${merged}`
      : `命令完成\n${merged}`;
  }
  // 定义一个异步方法,用于在后台启动 commandLine 命令
  async runBackgroundCommandLine(commandLine) {
    // 创建一个用于存储子进程输出的缓冲区数组
    const buffers = [];
    // 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
    const child = spawn(commandLine, {
      // 指定使用 shell 运行 commandLine 命令
      shell: true,
      // 指定子进程的工作目录为工作空间根目录
      cwd: WORKSPACE_ROOT,
      // 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
      stdio: ["ignore", "pipe", "pipe"],
    });
    // 绑定子进程的标准输出流到缓冲区数组
    bindStreamToBuffers(child.stdout, buffers);
    // 绑定子进程的标准错误流到缓冲区数组
    bindStreamToBuffers(child.stderr, buffers);
    // 获取子进程的进程ID(PID)
    const pid = child.pid;
    // 将当前子进程的信息存储到 backgroundProcesses 映射中
    backgroundProcesses.set(pid, {
      // 记录启动该子进程使用的命令行字符串
      commandLine,
      // 记录子进程对象本身
      child,
      // 记录用于收集子进程输出的缓冲区数组
      buffers
    });
    // 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
    child.once("exit", () => backgroundProcesses.delete(pid));
    // 等待后台进程初始化,延迟指定的时间(用于预热/启动)
    await delay(CONFIG.backgroundWarmupMs);
    // 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
    return `已在后台拉起子进程\n  PID:${pid}\n  命令:${commandLine}\n  启动段输出:\n${buffersToText(buffers) || "(尚无输出)"}`;
  }
}
// 工具名称到对应实例的映射表
const toolHandlersByName = {
  // 读取文件工具
  readFile: new ReadText(),
  // 写入文件工具
  writeFile: new WriteText(),
  // 替换文件工具
  editFile: new EditFile(),
  // 枚举目录工具
  listDir: new ListDir(),
  // 执行命令工具
  runCommand: new RunCommandLine(),
};

// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
  new Promise((resolve) => {
    // 向控制台输出普通的提示符,并等待用户输入
    rl.question("› ", resolve);
  });

// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
  // 获取本次工具调用的名字
  const name = toolCallPayload.function.name;
  // 声明解析参数变量
  let parsedArgs;
  try {
    // 尝试将工具调用的参数字符串解析为对象
    parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
  } catch {
    // 如果解析失败,则设为空对象
    parsedArgs = {};
  }

  // 控制台输出工具调用信息
  console.log(`\n工具 ${name} 被调用`);
  // 控制台输出工具的入参
  console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);

  // 根据工具名获取对应处理器
  const handler = toolHandlersByName[name];
  // 判断是否有对应处理器,如有则执行,否则返回未实现提示
  const textResult = handler
    ? await handler.run(parsedArgs)
    : `✖ 未实现的工具:${name}`;

  // 控制台输出工具调用的结果
  console.log(`返回:${textResult}`);

  // 构造并返回本次工具调用的完整回复对象
  return {
    // 设置角色为 tool
    role: "tool",
    // 工具调用对应的 id
    tool_call_id: toolCallPayload.id,
    // 工具名称
    name,
    // 工具返回的内容
    content: textResult,
  };
}

// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
  // 初始化步数计数器
  let step = 0;
  // 循环,直到步数超过最大限制
  while (step < CONFIG.agentMaxSteps) {
    // 步数递增
    step++;
    // 控制台显示正在请求模型
    console.log("\n请求模型中...");

    // 向模型 API 发送消息请求回复
    const completion = await openaiClient.chat.completions.create({
      // 模型名称
      model: "deepseek-v4-pro",
      // 消息参数
      messages: messages,
      // 工具函数定义
      tools: MODEL_TOOL_DEFINITIONS,
      // 工具选择方式
      tool_choice: "auto",
    });

    // 获取模型助手返回的消息对象
    const assistantMessage = completion.choices[0].message;
    // 控制台输出助手消息
    console.log(JSON.stringify(assistantMessage, null, 2));
    // 将助手消息加入对话消息历史
    messages.push(assistantMessage);
    // 获取本轮助手消息产生的工具调用
    const calls = assistantMessage.tool_calls;
    // 判断是否有工具调用,无则直接返回助手回复
    if (!calls || calls.length === 0) {
      // 返回助手文字回复
      return assistantMessage;
    }
+   // 判断是否存在名为 "exec" 的工具调用,决定是否顺序执行工具
+   const sequential = calls.some((call) => call.function.name === "runCommand");
+   // 声明保存工具响应的变量
+   let toolResponses;
+   if (sequential) {
+     // 如果需要顺序执行(如包含 runCommand),初始化响应数组
+     toolResponses = [];
+     // 依次执行每个工具调用,并将结果推入响应数组
+     for (const call of calls) toolResponses.push(await executeSingleToolCall(call));
+   } else {
+     // 否则并发执行所有工具调用,并收集所有结果
+     toolResponses = await Promise.all(calls.map(executeSingleToolCall));
+   }
    // 将所有工具回复添加到消息历史
    for (const row of toolResponses)
      messages.push(row);
  }
  // 若达到最大步数仍未获得直接回复,则返回警告信息
  return {
    role: "assistant",
    content: "⚠ 对话步数已达上限。",
  };
}

// 定义异步主函数 main
async function main() {
  //创建工作区目录
  await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
  // 无限循环,持续读取用户输入
  for (; ;) {
    // 创建一个消息列表,包含系统指令
    const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
    // 等待用户输入一行内容
    const line = await askLine();
    // 如果输入内容为空(只包含空白),则跳过本次循环
    if (!line.trim()) continue;
    // 如果输入的内容为 "q",则跳出循环,结束程序
    if (line.trim() === "q") break;
    // 将用户输入加入对话消息列表
    messages.push({ role: "user", content: line });
    // 调用 OpenAI API,发送所有消息,生成聊天回复
    const reply = await runAgentUntilReplyOrMaxSteps(messages);
    // 打印助手的回复到控制台
    if (reply) console.log(`\nAssistant\n${reply.content}\n`);
  }
  // 关闭 readline 接口,释放资源
  rl.close();
}

// 调用主函数 main,启动程序
main();

10.3 时序图 #

左: 单轮内是否顺序执行的判定;右: 10.4 测试的多轮 agent 步进(每轮常仅一个工具)。

sequenceDiagram autonumber participant Agent as runAgentUntilReplyOrMaxSteps participant API as DeepSeek API participant Exec as executeSingleToolCall API-->>Agent: assistantMessage.tool_calls alt 不含 runCommand loop 并发 Agent->>Exec: Promise.all(各 call) end else 含 runCommand loop 按 index 顺序 Agent->>Exec: await executeSingleToolCall(call) Exec-->>Agent: tool 消息 end end Agent->>Agent: messages.push(各 tool 响应) Agent->>API: 若无最终文字则继续 while
sequenceDiagram autonumber participant User as 用户 participant Agent as agent 循环 participant W as writeFile participant R as readFile participant FS as workspace/a.txt User->>Agent: 创建 a.txt,先 hello 再 world Agent->>W: content=hello W->>FS: hello Agent->>Agent: 下一轮 Agent->>R: read a.txt R-->>Agent: hello Agent->>Agent: 下一轮 Agent->>W: content=helloworld W->>FS: helloworld Agent->>R: 验证 R-->>User: helloworld

10.4 测试 #

› 写入一个a.txt的文件,再添加hello进去,然后再添加world进去

请求模型中...
{
  "role": "assistant",
  "content": "好的,我来分步骤完成:先创建 `a.txt` 写入 `hello`,再追加 `world`。",
  "reasoning_content": "用户想要:\n1. 创建一个 a.txt 文件\n2. 先写入 \"hello\"\n3. 再添加 \"world\"\n\n我理解这是要分步骤操作:先创建文件并写入 \"hello\",然后追加 \"world\"(最终内容应该是 \"helloworld\" 或 \"hello world\")。我就按 \"helloworld\" 来理解(中间没有空格),分两次写入。\n\n让我先创建文件写入 \"hello\",然后再追加 \"world\"。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_1VF7ntXSPaiuhtFLKSpw6369",
      "type": "function",
      "function": {
        "name": "writeFile",
        "arguments": "{\"path\": \"a.txt\", \"content\": \"hello\"}"
      }
    }
  ]
}

工具 writeFile 被调用
入参:{
  "path": "a.txt",
  "content": "hello"
}
返回:已落盘 5 字节 → a.txt

请求模型中...
{
  "role": "assistant",
  "content": "已写入 `hello`。现在追加 `world`:",
  "reasoning_content": "文件已创建,内容是 \"hello\"。现在需要追加 \"world\"。我得读取当前内容,然后追加 \"world\" 再写 回去。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_9q475F2aBxvjwxVdF8hw6687",
      "type": "function",
      "function": {
        "name": "readFile",
        "arguments": "{\"path\": \"a.txt\"}"
      }
    }
  ]
}

工具 readFile 被调用
入参:{
  "path": "a.txt"
}
返回:hello

请求模型中...
{
  "role": "assistant",
  "content": "当前内容是 `hello`,现在在后面追加 `world`:",
  "reasoning_content": "当前内容是 \"hello\",需要追加 \"world\",变成 \"helloworld\"。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_TLnyDGj6N2sdvTRDFqQn5306",
      "type": "function",
      "function": {
        "name": "writeFile",
        "arguments": "{\"content\": \"helloworld\", \"path\": \"a.txt\"}"
      }
    }
  ]
}

工具 writeFile 被调用
入参:{
  "content": "helloworld",
  "path": "a.txt"
}
返回:已落盘 10 字节 → a.txt

请求模型中...
{
  "role": "assistant",
  "content": "搞定了!来验证一下最终内容:",
  "reasoning_content": "完成。让我确认一下最终内容。",
  "tool_calls": [
    {
      "index": 0,
      "id": "call_00_wyCafnJ8nh4Yw8kcVPgp6166",
      "type": "function",
      "function": {
        "name": "readFile",
        "arguments": "{\"path\": \"a.txt\"}"
      }
    }
  ]
}

工具 readFile 被调用
入参:{
  "path": "a.txt"
}
返回:helloworld

请求模型中...
{
  "role": "assistant",
  "content": "全部完成!`a.txt` 的演变过程:\n\n1. 创建文件 → `hello`\n2. 追加 world → `helloworld`\n\n文件当前内容就 是 **helloworld**。",
  "reasoning_content": "Done. The file a.txt now contains \"helloworld\"."
}

Assistant
全部完成!`a.txt` 的演变过程:

1. 创建文件 → `hello`
2. 追加 world → `helloworld`

文件当前内容就是 **helloworld**。
← 上一节 1.claudecode 下一节 iconv-lite →

访问验证

请输入访问令牌

Token不正确,请重新输入