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 12341.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 时序图 #
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,因此可直接使用官方openainpm 包,只需改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 --save2.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-fcfe6462dcd24095ab2f7e238e74143f2.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 时序图 #
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 Completionstools参数的 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 步 会重复)。
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 you5. 扩展工具: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 循环。
5.3 msg.txt #
workspace/msg.txt
I love her5.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 --save6.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 后给出文字总结。
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 GB7. 长驻命令后台运行: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 节同步路径(图中未画出)。
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)。
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)。
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
helloworld10.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 步进(每轮常仅一个工具)。
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**。