从零开发一个 Coding Agent(十一):实现 CLI 的 print 模式

本篇文章是《从零开发一个 Coding Agent》系列第十一篇。在上一篇中,我们实现了 CLI 参数解析和静态命令,程序已经能够把下面的参数:

powershell 复制代码
di-code --print "请解释这段代码"

解析成一个明确的命令对象:

ts 复制代码
{
	kind: "run",
	mode: "print",
	prompt: "请解释这段代码",
}

不过,上一篇的 runCli() 只负责解析和分派。它知道用户选择了 print 模式,却还不知道怎样创建 Agent、怎样发送 prompt,也不知道应该把模型返回的哪些内容写到终端。

这一篇将完成 print 模式。最终会形成下面这条可运行链路:

text 复制代码
CLI 参数
  -> runCli()
  -> 创建 Faux Provider 和 Agent
  -> Agent.prompt()
  -> 得到最终 AssistantMessage
  -> 只提取 text 内容
  -> 成功写 stdout,失败写 stderr

本篇仍然不接入真实大模型。我们使用 Faux Provider 提供确定的模型响应,因此不需要网络和 API Key,也不会产生费用。

print 模式到底是什么

print 模式可以理解为"只打印最终答案"的非交互模式。它适合在终端中直接调用,也适合被其他脚本组合:

powershell 复制代码
di-code --print "生成一条提交信息"

假设模型最终返回:

text 复制代码
feat: add CLI print mode

那么 stdout 中应该只有:

text 复制代码
feat: add CLI print mode

不应该混入调试日志、思考过程、工具调用结构或错误提示。这样用户才能安全地把结果通过管道传给其他程序。

例如:

powershell 复制代码
di-code --print "生成文件名" | Set-Content result.txt

如果程序把 Loading model...、错误堆栈或 thinking 一起写入 stdout,result.txt 就不再是纯净的最终结果。

因此,print 模式有三个重要约束:

  1. 成功时,stdout 只包含最终 AssistantMessage 中的文本。
  2. 失败时,stdout 保持为空,诊断写入 stderr。
  3. 成功返回退出码 0,失败返回退出码 1。

为什么不边生成边打印

大模型本来就是流式返回的。假设模型准备回答 hello world,中间可能产生:

text 复制代码
text_delta("hello ")
text_delta("world")
done

看起来,我们可以每收到一个 text_delta 就立即写 stdout。但是 print 模式的契约是"最终文本",逐段输出会带来一个问题:如果模型输出 hello 后请求失败,stdout 中会留下半截答案。

text 复制代码
hello 

调用它的脚本无法判断这究竟是完整答案,还是失败前留下的残片。

所以本项目固定采用以下方式:

text 复制代码
Agent 内部继续处理流事件
        |
        v
等待本轮完整结束
        |
        v
取得最终 AssistantMessage
        |
        v
成功后一次性写 stdout

Agent 仍然可以在内部消费 message_update、执行工具和维护 transcript,但 print 模式只关心最终返回的 AssistantMessage。

stdout、stderr 和退出码

命令行程序通常有两个输出通道:

通道 本篇用途
stdout(标准输出) 成功的最终文本
stderr(标准错误) 参数错误、模型失败、取消和异常诊断

退出码则表示命令整体是否成功:

退出码 含义
0 成功完成
1 用户输入错误、模式不可用或运行失败

例如模型失败时,我们希望得到:

text 复制代码
stdout: ""
stderr: "model failed\n"
exit:   1

把结果和诊断分开后,人在终端中能看到错误,脚本也能通过退出码和输出通道准确判断状态。

本篇的数据流

下面是 print 命令从参数到输出的完整路径:

sequenceDiagram participant User as 用户 participant Main as runMain participant CLI as runCli participant Provider as Faux Provider participant Agent as Agent participant Print as runPrintMode User->>Main: ["--print", "hello"] Main->>CLI: 参数 + run 回调 CLI-->>Main: run 命令 Main->>Provider: 创建脚本化 Provider Main->>Agent: 创建 Agent Main->>Print: prompt + Agent + I/O Print->>Agent: prompt("hello") Agent->>Provider: 请求模型响应 Provider-->>Agent: 流事件和最终消息 Agent-->>Print: AssistantMessage alt 成功 Print-->>User: text -> stdout,return 0 else 失败或取消 Print-->>User: errorMessage -> stderr,return 1 end

这条链路分成三层:

  • runCli() 负责参数、help/version 和命令分派。
  • runMain() 负责组合 Provider、Agent 和具体输出模式。
  • runPrintMode() 只负责把最终消息投影到 stdout/stderr。

每一层只处理自己的问题,后续加入 JSON 模式时不需要复制参数解析或 Agent Loop。

第一步:加入 workspace 依赖

coding-agent 现在需要创建 Agent 和 Faux Provider,因此要在下面的文件中声明直接依赖:

text 复制代码
di-code/packages/coding-agent/package.json

加入:

json 复制代码
"dependencies": {
	"@di-code/agent": "0.0.0",
	"@di-code/ai": "0.0.0"
}

这里两个包都必须直接声明:

  • @di-code/agent 提供 Agent。
  • @di-code/ai 提供 AssistantMessage、createFauxProvider() 和 FauxResponse。

不要因为 agent 自己依赖 ai,就省略 coding-agent 对 ai 的声明。只要代码直接 import 了一个包,就应该把它列为直接依赖。

修改后在 PowerShell 中更新 lockfile:

powershell 复制代码
Set-Location D:\pi\di-code
npm install --workspace @di-code/coding-agent --package-lock-only --ignore-scripts

--package-lock-only 表示只同步依赖记录,--ignore-scripts 防止执行依赖包的生命周期脚本。

第二步:定义 print 模式的最小接口

创建文件:

text 复制代码
di-code/packages/coding-agent/src/modes/print.ts

首先导入最终助手消息类型,并定义两个小接口:

ts 复制代码
import type { AssistantMessage } from "@di-code/ai";

export interface PrintIo {
	stdout(text: string): void;
	stderr(text: string): void;
}

export interface PromptRunner {
	prompt(text: string): Promise<AssistantMessage>;
}

PrintIo 有什么用

我们没有在 runPrintMode() 中直接调用:

ts 复制代码
process.stdout.write(...);
process.stderr.write(...);

而是把两个写入函数作为参数传进来。生产环境可以把它们连接到真实终端,测试则可以传入 vi.fn(),准确检查写了什么。

PromptRunner 为什么不直接写成 Agent

print 模式真正需要的能力只有一个:

ts 复制代码
prompt(text): Promise<AssistantMessage>

它不需要知道 Provider 是谁,也不需要读取 Agent 的 transcript、工具列表或订阅事件。因此这里使用最小的 PromptRunner 接口。

真实的 Agent 已经具有同样的 prompt() 方法,所以它可以直接传入;单元测试也可以使用一个只有 prompt() 的 fake 对象。

这就是依赖倒置最直观的例子:print 模式依赖"它真正需要的能力",而不是依赖一个庞大的具体对象。

第三步:理解 AssistantMessage 的两种结果

Agent.prompt() 返回 AssistantMessage。这个类型包含成功和失败两种形态。

成功消息可能是:

ts 复制代码
{
	role: "assistant",
	content: [{ type: "text", text: "hello" }],
	stopReason: "stop",
	// provider、model、usage、timestamp 等字段
}

失败消息可能是:

ts 复制代码
{
	role: "assistant",
	content: [],
	stopReason: "error",
	errorMessage: "model failed",
	// provider、model、usage、timestamp 等字段
}

取消也是失败消息的一种:

ts 复制代码
{
	stopReason: "aborted",
	errorMessage: "request cancelled",
	// 其他字段
}

这里很容易产生误解:模型失败不一定让 Agent.prompt() 抛异常。Provider 的正常协议可以返回一条 stopReason: "error" 的结构化消息。只有监听器失败、内部状态不满足约束等异常路径,prompt() 才会 reject。

因此 print 模式必须处理两类失败:

text 复制代码
await runner.prompt(prompt)
        |
        +-> 正常返回,但 stopReason 是 error/aborted
        |
        +-> Promise 直接 reject

只写 try/catch 而不检查 stopReason,会把结构化失败误当成成功。

第四步:只提取 text 内容块

AssistantMessage 的 content 不一定全是普通文本。它还可能包含 thinking 或 tool call:

ts 复制代码
[
	{ type: "thinking", thinking: "先分析问题" },
	{ type: "text", text: "hello " },
	{ type: "text", text: "world" },
]

print 模式只应该输出:

text 复制代码
hello world

加入 textContent():

ts 复制代码
function textContent(message: AssistantMessage): string {
	return message.content
		.filter(
			(content): content is Extract<AssistantMessage["content"][number], { type: "text" }> =>
				content.type === "text",
		)
		.map((content) => content.text)
		.join("");
}

这段代码分三步工作:

  1. filter() 只保留 type === "text" 的内容块。
  2. 类型谓词告诉 TypeScript:过滤后的 content 一定具有 text 字段。
  3. map() 取出每段文本,join("") 按原顺序拼接。

不要对整个 content 调用 JSON.stringify()。thinking 是模型内部推理内容,tool call 是结构化控制信息,它们都不应该伪装成面向用户的最终答案。

第五步:实现 runPrintMode

先加入一个小工具,把未知异常统一转换成 Error:

ts 复制代码
function toError(cause: unknown): Error {
	return cause instanceof Error ? cause : new Error(String(cause));
}

然后实现 print 模式的主函数:

ts 复制代码
export async function runPrintMode(prompt: string, runner: PromptRunner, io: PrintIo): Promise<number> {
	try {
		const assistant = await runner.prompt(prompt);
		if (assistant.stopReason === "error" || assistant.stopReason === "aborted") {
			io.stderr(`${assistant.errorMessage}\n`);
			return 1;
		}

		const text = textContent(assistant);
		if (text.length > 0) {
			io.stdout(`${text}\n`);
		}
		return 0;
	} catch (cause) {
		io.stderr(`${toError(cause).message}\n`);
		return 1;
	}
}

它的控制流程可以概括为:

text 复制代码
调用 prompt
  |
  +-> 返回 error/aborted 消息 -> stderr -> 1
  |
  +-> 返回成功消息 -> 提取 text -> stdout -> 0
  |
  +-> Promise reject -> catch -> stderr -> 1

成功但没有 text block 时,函数返回 0,同时不写空行。这样 stdout 仍然表示"模型实际给出的最终文本",不会凭空增加内容。

第六步:测试 print 输出投影

创建测试文件:

text 复制代码
di-code/packages/coding-agent/test/print.test.ts

这里不需要真实 Agent。我们要测试的是"AssistantMessage 怎样变成输出",所以使用 fake PromptRunner 更直接。

先准备 I/O 和 runner:

ts 复制代码
function createIo(): PrintIo {
	return { stdout: vi.fn(), stderr: vi.fn() };
}

function createRunner(message: AssistantMessage): PromptRunner {
	return { prompt: vi.fn(async () => message) };
}

测试只输出 text

ts 复制代码
const io = createIo();
const runner = createRunner(
	successfulMessage([
		{ type: "thinking", thinking: "hidden" },
		{ type: "text", text: "hello " },
		{ type: "text", text: "world" },
	]),
);

expect(await runPrintMode("say hello", runner, io)).toBe(0);
expect(runner.prompt).toHaveBeenCalledWith("say hello");
expect(io.stdout).toHaveBeenCalledWith("hello world\n");
expect(io.stderr).not.toHaveBeenCalled();

这个测试同时证明了三件事:prompt 被原样转发,thinking 被隐藏,多段 text 按顺序拼接。

测试结构化失败

ts 复制代码
const io = createIo();
const runner = createRunner(failedMessage("error", "model failed"));

expect(await runPrintMode("fail", runner, io)).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("model failed\n");

aborted 也要用同样的方式测试,确保取消不会污染 stdout。

测试 Promise rejection

ts 复制代码
const io = createIo();
const runner: PromptRunner = {
	prompt: vi.fn(async () => {
		throw new Error("listener failed");
	}),
};

expect(await runPrintMode("reject", runner, io)).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("listener failed\n");

运行定向测试:

powershell 复制代码
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run print.test.ts

当前应收集 1 个测试文件,共 4 个测试,四个测试全部通过。

第七步:用 runMain 组合完整链路

runPrintMode() 只认识 PromptRunner,它不会自己创建 Provider 和 Agent。我们还需要一个组合入口把各层接起来。

创建:

text 复制代码
di-code/packages/coding-agent/src/main.ts

完整实现如下:

ts 复制代码
import { Agent } from "@di-code/agent";
import { createFauxProvider, type FauxResponse } from "@di-code/ai";
import { type CliDependencies, runCli } from "./cli.ts";
import { type PrintIo, runPrintMode } from "./modes/print.ts";

export interface MainOptions extends PrintIo {
	readonly version: string;
	readonly fauxResponses: readonly FauxResponse[];
	readonly now?: () => number;
}

export async function runMain(args: readonly string[], options: MainOptions): Promise<number> {
	const dependencies: CliDependencies = {
		stdout: options.stdout,
		stderr: options.stderr,
		version: options.version,
		run: async (command) => {
			if (command.mode === "json") {
				options.stderr("JSON mode is not available until Task 6c.\n");
				return 1;
			}

			const faux = createFauxProvider({ responses: options.fauxResponses, now: options.now });
			const agent = new Agent({ provider: faux.provider, model: faux.model, now: options.now });
			return runPrintMode(command.prompt, agent, options);
		},
	};

	return runCli(args, dependencies);
}

runMain() 没有重新解析参数,而是继续调用上一篇的 runCli()。当命令是 help 或 version 时,runCli() 会直接返回;只有 kind: "run" 才会调用这里注入的 run 回调。

为什么 Provider 要延迟创建

注意,Faux Provider 和 Agent 都是在 run 回调内部创建的:

ts 复制代码
run: async (command) => {
	// 进入这里以后,才创建 Provider 和 Agent
}

如果在 runMain() 一开始就创建运行时,那么 --help 也会执行 Provider 初始化。未来换成真实 Provider 后,这可能提前读取凭据、配置文件甚至访问外部资源。

延迟创建保证了静态命令始终轻量:

text 复制代码
--help / --version
        |
        v
runCli 直接返回
        |
        X 不创建 Provider
        X 不创建 Agent
        X 不读取凭据

JSON 判断也放在 Provider 创建之前。因为本篇还没有实现 JSON 模式,程序会明确写入:

text 复制代码
JSON mode is not available until Task 6c.

然后返回 1,而不是偷偷走 print 模式,让用户误以为自己得到了 JSON。

第八步:使用 Faux Provider 做集成测试

创建:

text 复制代码
di-code/packages/coding-agent/test/main.test.ts

这一次不再使用 fake PromptRunner,而是通过 runMain() 走过真实的 runCli -> Faux Provider -> Agent -> runPrintMode 链路。

成功测试如下:

ts 复制代码
const io = { stdout: vi.fn(), stderr: vi.fn() };

const exitCode = await runMain(["--print", "hello"], {
	...io,
	version: "0.0.0",
	fauxResponses: [
		{
			type: "success",
			content: [{ type: "text", text: "done" }],
		},
	],
});

expect(exitCode).toBe(0);
expect(io.stdout).toHaveBeenCalledWith("done\n");
expect(io.stderr).not.toHaveBeenCalled();

它证明的不是一个孤立函数,而是整条链路:CLI 把 prompt 解析出来,Faux Provider 生成标准事件,Agent 得到最终消息,print 模式最后输出 done。

还需要测试 JSON 边界:

ts 复制代码
const exitCode = await runMain(["--mode", "json", "hello"], {
	...io,
	version: "0.0.0",
	fauxResponses: [
		{
			type: "success",
			content: [{ type: "text", text: "must not run" }],
		},
	],
});

expect(exitCode).toBe(1);
expect(io.stdout).not.toHaveBeenCalled();
expect(io.stderr).toHaveBeenCalledWith("JSON mode is not available until Task 6c.\n");

最后检查 help 不需要运行时。测试可以传入空响应队列:

ts 复制代码
const exitCode = await runMain(["--help"], {
	...io,
	version: "0.0.0",
	fauxResponses: [],
});

expect(exitCode).toBe(0);
expect(io.stdout.mock.calls[0]?.[0]).toContain("Usage: di-code");
expect(io.stderr).not.toHaveBeenCalled();

如果 help 错误地创建并调用了 Faux Provider,空响应队列会导致失败。因此这个测试也间接证明了运行时没有被触碰。

运行集成测试:

powershell 复制代码
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run main.test.ts

当前应收集 1 个测试文件,共 3 个测试,三个测试全部通过。

完整验证

先分别运行三个最小测试集合:

powershell 复制代码
Set-Location D:\pi\di-code
npm test --workspace packages/coding-agent -- --run print.test.ts
npm test --workspace packages/coding-agent -- --run main.test.ts
npm test --workspace packages/coding-agent -- --run cli.test.ts

预期结果分别是:

text 复制代码
print.test.ts  -> 1 file / 4 tests passed
main.test.ts   -> 1 file / 3 tests passed
cli.test.ts    -> 1 file / 8 tests passed

然后运行 coding-agent 包内全部测试、根质量检查和构建:

powershell 复制代码
npm test --workspace packages/coding-agent
npm run check
npm run build --workspace @di-code/coding-agent

coding-agent 当前应有 3 个测试文件、15 个测试全部通过。npm run check 应没有 Biome 或 TypeScript 错误,构建应成功生成 packages/coding-agent/dist。

不要手工修改 dist,它只由构建脚本生成。

总结

这一篇让上一章的 CLI 参数真正连接到了 Agent,并完成了第一种输出模式:

  1. PromptRunner 把 print 模式限制在最小的 prompt() 能力上。
  2. PrintIo 把 stdout/stderr 与全局进程隔离,方便测试和后续组合。
  3. runPrintMode() 等待最终 AssistantMessage,只输出其中的 text block。
  4. error、aborted 和 rejected Promise 都写 stderr,并返回退出码 1。
  5. runMain() 复用 runCli(),并延迟创建 Faux Provider 和 Agent。
  6. help/version 不进入运行时,尚未实现的 JSON 模式也会明确失败。
  7. 单元测试验证消息投影,集成测试验证 CLI 到 Faux Agent 的完整链路。

现在,我们已经可以在不访问真实网络的情况下证明:一个 print 命令能够经过参数解析、模型事件流和 Agent Loop,最后得到干净的文本结果。

下一步将实现 JSON 模式。它不会只保留最终文本,而是把 Agent 的生命周期事件包装成版本化 JSON,并保证 stdout 的每一行都是独立、合法的 JSON 对象。

相关推荐
JVM高并发3 小时前
使用 Cloudflare Tunnel(本地管理隧道)搭配 DigitalPlat 免费域名实现内网穿透
网络协议·全栈
liangshanbo12153 小时前
Webpack 文件指纹面试题整理
前端·webpack·node.js
liangshanbo12155 小时前
Webpack 常见 Plugin 面试题
前端·webpack·node.js
liangshanbo12155 小时前
Webpack Tree Shaking 面试题整理
前端·webpack·node.js
搬砖的小码农_Sky6 小时前
AI Agent:Node.js和浏览器运行环境的本质区别
javascript·node.js·人机交互
李游Leo7 小时前
HarmonyOS 7 ArkTS + Node.js:把权限声明、运行时授权与隐私文案做成提审前一致性扫描【鸿蒙心迹】
华为·node.js·harmonyos
liangshanbo12159 小时前
Webpack的分包策略面试题
前端·webpack·node.js
搬砖的小码农_Sky1 天前
AI Agent:Windows上Node.js安装后 npm -v报错
前端·npm·node.js·人机交互
不是株1 天前
零到全栈(第一次上线:把全栈应用搬上服务器)
全栈
liangshanbo12152 天前
面试题:如何优化 Webpack 的打包速度?
前端·webpack·node.js