本篇文章是《从零开发一个 Coding Agent》系列第九篇。在上一篇中,我们用 Agent 类保存了对话历史,并把 agentLoop 产生的事件转发给上层。
不过,当前的 Agent 还只会"聊天"。如果模型返回一个工具调用,例如"请读取某个文件",Agent 会把这条消息当作普通回复结束,既不会执行工具,也不会把执行结果告诉模型。
这一篇要补上最关键的一段控制流程:
text
用户提出要求
-> 模型决定调用工具
-> Agent 查找并执行工具
-> Agent 把工具结果追加到消息历史
-> 再次请求模型
-> 模型根据工具结果给出最终回答
这就是工具调用闭环(Tool Loop)。完成它以后,Agent 才不只是一个聊天接口,而是具备了"观察、行动、再思考"的基本能力。
本篇只实现通用的工具执行机制,不实现真正的 read、write 或 bash 工具。文件权限、命令超时和输出截断等安全问题,会在实现具体工具时单独处理。
模型不会亲自执行工具
第一次接触工具调用时,很容易产生一个误解:模型返回了 tool_call,是不是代表工具已经执行完了?
不是。
大模型运行在远端服务中,它既看不到我们的本地文件,也不能直接调用项目里的 TypeScript 函数。模型只能返回一份结构化的"调用意图":
ts
{
type: "tool_call",
id: "call-1",
name: "echo",
arguments: { value: "hello" },
}
这段数据表达的是:
- 我想调用名为
echo的工具; - 本次调用编号是
call-1; - 我准备传入
{ value: "hello" }。
真正找到 echo 函数、校验参数并执行它的人,是本地的 Agent。假设工具执行后得到 echoed: hello,Agent 还要构造一条工具结果消息:
ts
{
role: "tool_result",
toolCallId: "call-1",
toolName: "echo",
content: [{ type: "text", text: "echoed: hello" }],
isError: false,
timestamp: 30,
}
然后把这条消息放进下一次 Provider 请求。模型只有看到工具结果,才知道工具到底返回了什么。
因此,一次看起来很简单的"调用工具并回答",通常至少需要两次模型请求:
一次 prompt 和一次 turn 不是一回事
上一篇的纯文本 Agent 中,一次 prompt() 只会请求一次模型,所以"一个 prompt"和"一个 turn"看起来没有区别。
加入工具以后,一次 prompt 可能包含多个 turn。这里的 turn 可以理解为"一次模型响应,以及这次响应引发的工具执行"。
例如:
text
一次 prompt
|
+-- turn 1:模型要求调用 echo -> Agent 执行 echo
|
+-- turn 2:模型看到 echo 的结果 -> 返回最终文本
所以整个 prompt 只发出一次 agent_start 和一次 agent_end,但中间可以出现多组 turn_start 和 turn_end。
成功调用一个工具时,消息历史最终应该是:
text
user
assistant <- 包含 tool_call
tool_result <- Agent 在本地执行后追加
assistant <- 模型的最终回答
顺序不能改变。尤其不能在追加 tool_result 以前就发起第二次模型请求,否则模型仍然不知道工具返回了什么。
ToolDefinition 和 AgentTool 的区别
在 AI 包中,我们已经定义过 ToolDefinition。它包含工具名称、说明和参数 schema:
ts
interface ToolDefinition<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
这三项信息是给模型看的。例如:
ts
{
name: "echo",
description: "Return the supplied value",
parameters: Type.Object({ value: Type.String() }),
}
模型根据这些信息决定工具叫什么、何时使用,以及应该生成什么参数。但是 ToolDefinition 没有 execute(),因为 AI 包只负责 Provider 无关的协议,不能持有本地执行能力。
执行函数属于 Agent 层。打开:
text
packages/agent/src/types.ts
从 AI 包导入工具相关类型:
ts
import type {
AssistantMessage,
Message,
Model,
Provider,
Static,
StreamEvent,
ToolDefinition,
ToolResultContent,
ToolResultMessage,
TSchema,
UserMessage,
} from "@di-code/ai";
然后定义 AgentTool:
ts
export interface AgentTool<TParameters extends TSchema = TSchema> extends ToolDefinition<TParameters> {
execute(toolCallId: string, parameters: Static<TParameters>, signal?: AbortSignal): Promise<ToolResultContent[]>;
}
这个接口可以拆成两半理解:
text
模型可见的定义
name + description + parameters
本地可执行的能力
execute(toolCallId, parameters, signal)
Static<TParameters> 会把 TypeBox schema 转换成 TypeScript 类型。比如:
ts
const echoParameters = Type.Object({ value: Type.String() });
对应的 parameters 类型就是:
ts
{ value: string }
因此,在 execute() 中访问 parameters.value 时,TypeScript 知道它一定是字符串。
toolCallId 用于把结果和原始调用对应起来。一次模型响应可能要求调用多个工具,不能只靠工具名称辨认结果。
signal 是同一个 AbortSignal。它从 Agent.prompt() 一路传到 Provider 和工具,让取消操作可以跨越所有异步边界。
扩展 AgentContext 和事件类型
Agent Loop 需要知道本轮有哪些工具可用,因此给 AgentContext 增加 tools:
ts
export interface AgentContext {
systemPrompt?: string;
messages: Message[];
tools?: readonly AgentTool[];
}
这里使用 readonly,表示 Loop 只能读取这份工具列表,不能在运行期间向里面添加或删除工具。
接着扩展 AgentEvent。工具开始和结束时,上层界面需要收到事件,未来才能显示"正在读取文件"或"命令执行失败"等状态:
ts
export type AgentEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| {
type: "message_start";
message: UserMessage | AssistantMessagePreview | ToolResultMessage;
}
| { type: "message_update"; event: MessageUpdateEvent; message: AssistantMessagePreview }
| { type: "message_end"; message: UserMessage | AssistantMessage | ToolResultMessage }
| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
arguments: Record<string, unknown>;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: ToolResultMessage;
}
| { type: "agent_end"; messages: Message[] };
这里有三个变化:
message_start和message_end现在也允许携带ToolResultMessage。turn_end增加toolResults,方便上层知道本轮执行了哪些工具。- 新增
tool_execution_start和tool_execution_end,明确包住单次工具执行。
纯文本轮次没有工具结果,所以它的 toolResults 是空数组:
ts
emit({ type: "turn_end", message: assistant, toolResults: [] });
把工具注入 Agent
工具应该在创建 Agent 时传入,而不是在 Loop 中写死。打开:
text
packages/agent/src/agent.ts
先让 AgentOptions 接受工具:
ts
export interface AgentOptions {
readonly provider: Provider;
readonly model: Model;
readonly tools?: readonly AgentTool[];
readonly systemPrompt?: string;
readonly now?: () => number;
}
在 Agent 中保存一份工具数组快照:
ts
private readonly tools: readonly AgentTool[];
constructor(options: AgentOptions) {
this.provider = options.provider;
this.model = options.model;
this.tools = [...(options.tools ?? [])];
this.systemPrompt = options.systemPrompt;
this.now = options.now ?? Date.now;
}
为什么要写成 [...(options.tools ?? [])],而不是直接保存 options.tools?
因为调用者可能还持有原数组。如果 Agent 直接引用它,调用者在 prompt 执行过程中修改数组,就可能让同一轮对话前后看到不同的工具集合。复制数组以后,Agent 拥有稳定的工具列表。
每次调用 prompt() 时,再把工具放进本轮上下文:
ts
const context: AgentContext = {
systemPrompt: this.systemPrompt,
messages: [...this.messages],
tools: [...this.tools],
};
这里再次复制,是为了让每次 Loop 使用自己的上下文快照。Agent 的职责仍然只是保存配置和对话状态,真正的工具查找与执行放在 agent-loop.ts 中。
只把工具定义交给 Provider
打开:
text
packages/agent/src/agent-loop.ts
传给 Provider 的上下文中需要包含工具定义,否则模型不知道自己能调用什么。但不能把整个 AgentTool 直接交给 Provider,因为其中的 execute() 是本地函数,无法也不应该被序列化到网络请求里。
先写一个转换函数:
ts
function toolDefinitions(context: AgentContext) {
return context.tools?.map(({ name, description, parameters }) => ({
name,
description,
parameters,
}));
}
它只挑出 Provider 需要的三个字段。然后在请求模型时加入 tools:
ts
const response = config.provider.stream(
config.model,
{
systemPrompt: context.systemPrompt,
messages: [...messages],
tools: toolDefinitions(context),
},
{ signal },
);
这个边界可以概括为:
text
Provider 能看到:name、description、parameters
Provider 看不到:execute 函数
Agent 两者都能看到
把执行结果统一成 ToolResultMessage
工具可能成功,也可能失败,但无论发生什么,Loop 都需要得到统一的消息形状。先创建一个辅助函数:
ts
function createToolResult(
toolCall: ToolCallContent,
content: ToolResultContent[],
isError: boolean,
config: AgentLoopConfig,
): ToolResultMessage {
return {
role: "tool_result",
toolCallId: toolCall.id,
toolName: toolCall.name,
content,
isError,
timestamp: (config.now ?? Date.now)(),
};
}
再准备一个把未知异常转成文本的函数:
ts
function errorMessage(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}
JavaScript 允许 throw 任意值,所以 catch (cause) 中的 cause 是 unknown,不能直接假设它有 .message。这个函数让错误处理保持类型安全。
实现单个工具调用
下面是整篇文章最重要的函数:executeToolCall()。先看完整代码,再逐段拆解:
ts
async function executeToolCall(
toolCall: ToolCallContent,
context: AgentContext,
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: (event: AgentEvent) => void,
): Promise<ToolResultMessage> {
emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
arguments: toolCall.arguments,
});
let content: ToolResultContent[];
let isError = false;
const tool = context.tools?.find((candidate) => candidate.name === toolCall.name);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
} else if (!tool) {
content = [{ type: "text", text: `Unknown tool "${toolCall.name}".` }];
isError = true;
} else {
try {
const parameters = validateToolArguments(tool, toolCall.arguments);
content = await tool.execute(toolCall.id, parameters, signal);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
} catch (cause) {
content = signal?.aborted
? [{ type: "text", text: "Tool execution aborted." }]
: [{ type: "text", text: `Tool "${toolCall.name}" failed: ${errorMessage(cause)}` }];
isError = true;
}
}
const result = createToolResult(toolCall, content, isError, config);
emit({
type: "tool_execution_end",
toolCallId: toolCall.id,
toolName: toolCall.name,
result,
});
emit({ type: "message_start", message: result });
emit({ type: "message_end", message: result });
return result;
}
第一步:先发出开始事件
ts
emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
arguments: toolCall.arguments,
});
这个事件不代表工具一定存在,也不代表参数一定合法。它只表示 Loop 已经开始处理这条调用。这样,未知工具和参数错误也拥有完整的开始、结束生命周期。
第二步:按名称查找工具
ts
const tool = context.tools?.find((candidate) => candidate.name === toolCall.name);
toolCall.name 来自模型,是不可信输入。模型可能拼错名称,也可能返回一个根本不存在的工具。因此必须查找,不能使用非空断言强行认为工具存在。
如果找不到,就产生一个模型可见的错误结果:
ts
content = [{ type: "text", text: `Unknown tool "${toolCall.name}".` }];
isError = true;
注意,这里没有 throw 终止整个 Agent。未知工具通常是模型可以修正的错误。把结果发回模型后,它可以换一个正确的工具,或者向用户解释当前无法完成操作。
第三步:执行前校验参数
ts
const parameters = validateToolArguments(tool, toolCall.arguments);
content = await tool.execute(toolCall.id, parameters, signal);
模型生成的参数同样是不可信输入。即使 TypeScript 声明工具需要 { value: string },运行时仍可能收到 { value: 42 }。
上一篇工具参数教程已经实现了 validateToolArguments()。这里必须先调用它,再把返回值传给 execute()。不能直接把 toolCall.arguments 传进去。
顺序是:
text
模型原始参数
-> validateToolArguments
-> 经过校验和复制的强类型参数
-> tool.execute
如果参数不符合 schema,校验函数会抛错,工具不会执行,异常随后会被转成 isError: true 的工具结果。
还要注意:schema 校验只能证明数据结构正确。例如它能证明 path 是字符串,却不能证明这个路径允许读取。路径权限必须由未来的具体文件工具继续检查。
第四步:捕获工具异常
ts
} catch (cause) {
content = signal?.aborted
? [{ type: "text", text: "Tool execution aborted." }]
: [{ type: "text", text: `Tool "${toolCall.name}" failed: ${errorMessage(cause)}` }];
isError = true;
}
参数校验失败和工具执行抛错都在这里被规范化。它们不会让 agentLoop 直接崩溃,而是成为一条正式的 ToolResultMessage。
为什么这样设计?因为这些错误与 Provider 网络中断不同。工具名、参数或执行失败都属于本轮对话的一部分,模型有机会看到错误并作出下一步判断。
第五步:执行前后都检查取消
函数在调用工具以前检查一次:
ts
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
工具返回以后还要再检查一次:
ts
content = await tool.execute(toolCall.id, parameters, signal);
if (signal?.aborted) {
content = [{ type: "text", text: "Tool execution aborted." }];
isError = true;
}
第二次检查很重要。AbortSignal 只是在表达"请取消",它不会自动打断任意 Promise。有些工具可能忽略 signal,也可能在执行期间才收到取消,然后仍然返回一个结果。
因此 Loop 必须在 await 后重新确认状态。一旦已经取消,就丢弃表面上的成功结果,不再把它当作正常输出。
从 AssistantMessage 中取出工具调用
模型的回复内容是一个联合类型数组,里面可能同时出现文本、思考内容和工具调用。我们只需要 type === "tool_call" 的部分:
ts
function getToolCalls(message: AssistantMessage): ToolCallContent[] {
return message.content.filter((content): content is ToolCallContent => content.type === "tool_call");
}
content is ToolCallContent 是类型谓词(type predicate)。它告诉 TypeScript:只要过滤条件成立,返回数组中的元素就可以按 ToolCallContent 使用。
把一次请求改造成循环
上一篇的 runAgentLoop() 只请求一次 Provider。现在要把"请求模型 -> 检查工具 -> 执行工具"放进 while (true) 中。
先保留 prompt 开始时的事件:
ts
const messages: Message[] = [...context.messages, prompt];
const emit = (event: AgentEvent): void => stream.push(event);
emit({ type: "agent_start" });
emit({ type: "turn_start" });
emit({ type: "message_start", message: prompt });
emit({ type: "message_end", message: prompt });
然后进入循环:
ts
while (true) {
const assistant = await streamAssistantResponse(messages, context, config, signal, emit);
messages.push(assistant);
emit({ type: "message_end", message: assistant });
const toolCalls = getToolCalls(assistant);
if (
assistant.stopReason === "error" ||
assistant.stopReason === "aborted" ||
assistant.stopReason !== "tool_use" ||
toolCalls.length === 0
) {
emit({ type: "turn_end", message: assistant, toolResults: [] });
emit({ type: "agent_end", messages });
return;
}
const toolResults: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const result = await executeToolCall(toolCall, context, config, signal, emit);
messages.push(result);
toolResults.push(result);
if (signal?.aborted) {
break;
}
}
emit({ type: "turn_end", message: assistant, toolResults });
if (signal?.aborted) {
emit({ type: "turn_start" });
const aborted = createFailureMessage(config, signal, new Error("Tool loop aborted"));
messages.push(aborted);
emit({ type: "message_start", message: createPreview(config, "") });
emit({ type: "message_end", message: aborted });
emit({ type: "turn_end", message: aborted, toolResults: [] });
emit({ type: "agent_end", messages });
return;
}
emit({ type: "turn_start" });
}
下面按控制流程解释这段代码。
情况一:模型给出最终回答
如果 stopReason 不是 tool_use,说明模型不要求继续调用工具。Loop 发出 turn_end 和 agent_end,然后结束:
ts
emit({ type: "turn_end", message: assistant, toolResults: [] });
emit({ type: "agent_end", messages });
return;
Provider 错误和模型生成取消也走这个分支,因为它们同样不能继续执行工具。
同时检查 toolCalls.length === 0 是一道防御边界。如果 Provider 声称结束原因是 tool_use,消息里却没有完整工具调用,Loop 不应该盲目进入下一轮。
情况二:模型要求调用工具
如果消息中包含工具调用,就按顺序逐个执行:
ts
const toolResults: ToolResultMessage[] = [];
for (const toolCall of toolCalls) {
const result = await executeToolCall(toolCall, context, config, signal, emit);
messages.push(result);
toolResults.push(result);
if (signal?.aborted) {
break;
}
}
这里采用串行执行,而不是 Promise.all() 并行执行。串行更容易保证:
- 工具开始、结束事件的顺序固定;
messages中的结果顺序固定;- 某个工具触发取消后,后续工具不会启动;
- 测试不依赖谁先完成。
当前阶段,确定性比并发速度更重要。
所有结果都追加到 messages 后,才结束当前 turn:
ts
emit({ type: "turn_end", message: assistant, toolResults });
如果没有取消,就发出下一次 turn_start,回到 while 顶部再次请求 Provider。由于 messages 已经包含刚才的 tool_result,模型这次能够看到真实执行结果。
情况三:工具执行期间取消
取消后必须同时做到三件事:
- 不执行当前批次中剩余的工具;
- 不发起下一次 Provider 请求;
- 仍然让 Agent 以完整、可提交的状态结束。
前两点由 break 和取消分支保证。第三点稍微特殊:上一篇的 Agent.prompt() 约定 agent_end.messages 最后一条必须是 AssistantMessage。如果直接停在 tool_result,Agent 无法返回最终助手消息。
因此取消后追加一条本地构造的 aborted 消息:
ts
const aborted = createFailureMessage(config, signal, new Error("Tool loop aborted"));
messages.push(aborted);
这不是再次请求模型,而是本地安全收尾。最后的消息角色仍然是 assistant,stopReason 是 aborted,所以 Agent.prompt() 可以正常 settled,并把 isStreaming 恢复为 false。
完整事件顺序
一次成功的工具调用会产生下面这些事件:
text
agent_start
turn_start
message_start(user)
message_end(user)
message_start(assistant preview)
message_update(...工具调用的流式事件...)
message_end(assistant with tool_call)
tool_execution_start
tool_execution_end
message_start(tool_result)
message_end(tool_result)
turn_end(toolResults: [result])
turn_start
message_start(assistant preview)
message_update(...最终文本的流式事件...)
message_end(final assistant)
turn_end(toolResults: [])
agent_end
可以观察到:
- 一个 prompt 中只有一对
agent_start和agent_end; - 每次请求模型前都有一个
turn_start; - 工具结果也属于正式消息,因此有
message_start和message_end; tool_execution_start/end描述执行动作,message_start/end描述结果进入消息历史,两者职责不同。
使用 Faux Provider 测试成功闭环
工具循环不能依赖真实 API 测试。Faux Provider 可以提前准备两次响应,稳定复现完整流程。
创建测试文件:
text
packages/agent/test/tool-loop.test.ts
先定义一个最简单的 echo 工具参数:
ts
const echoParameters = Type.Object({ value: Type.String() });
然后准备两条模型响应:第一次要求调用工具,第二次给出最终答案。
ts
const faux = createFauxProvider({
responses: [
{
type: "success",
content: [
{
type: "tool_call",
id: "call-1",
name: "echo",
arguments: { value: "hello" },
},
],
},
{ type: "success", content: [{ type: "text", text: "done" }] },
],
now: () => 20,
});
定义本地工具:
ts
const executions: Array<{ id: string; value: string; signal?: AbortSignal }> = [];
const echo = {
name: "echo",
description: "Return the supplied value",
parameters: echoParameters,
async execute(id, parameters, signal) {
executions.push({ id, value: parameters.value, signal });
return [{ type: "text" as const, text: `echoed: ${parameters.value}` }];
},
} satisfies AgentTool<typeof echoParameters>;
satisfies 会检查对象符合 AgentTool,同时保留 echoParameters 推导出的精确参数类型。
创建 Agent 并调用:
ts
const agent = new Agent({
provider: faux.provider,
model: faux.model,
tools: [echo],
now: () => 30,
});
const assistant = await agent.prompt("echo hello");
核心断言包括:
ts
expect(assistant).toMatchObject({
stopReason: "stop",
content: [{ type: "text", text: "done" }],
});
expect(executions).toEqual([
{ id: "call-1", value: "hello", signal: undefined },
]);
expect(agent.transcript.map((message) => message.role)).toEqual([
"user",
"assistant",
"tool_result",
"assistant",
]);
expect(faux.pendingResponses()).toBe(0);
最后一个断言证明两条 Faux 响应都被消费了,也就是 Loop 的确请求了两次模型。
还应该截获第二次 Provider 请求的上下文,确认它真的包含工具结果。否则可能出现一种假成功:代码领取了第二条响应,却没有把第一轮结果交给模型。
第二次请求的消息角色必须是:
ts
expect(requestedMessages[1]?.map((message) => message.role)).toEqual([
"user",
"assistant",
"tool_result",
]);
测试未知工具
模型可能要求调用一个没有注册的工具:
ts
{
type: "tool_call",
id: "missing-1",
name: "missing",
arguments: {},
}
Agent 不应该崩溃,而应该产生错误结果并继续请求模型:
ts
expect(toolResult(agent.transcript, "missing-1")).toMatchObject({
toolName: "missing",
isError: true,
content: [{ type: "text", text: 'Unknown tool "missing".' }],
});
这项测试证明工具存在性是在执行前检查的。
测试非法参数
echo 工具要求 value 是字符串,但模型可能返回数字:
ts
arguments: { value: 42 }
测试中用计数器记录工具是否被调用:
ts
let executions = 0;
const echo = {
name: "echo",
description: "Return the supplied value",
parameters: echoParameters,
async execute(_id, parameters) {
executions++;
return [{ type: "text" as const, text: parameters.value }];
},
} satisfies AgentTool<typeof echoParameters>;
最终必须满足:
ts
expect(executions).toBe(0);
expect(result.isError).toBe(true);
这比只检查错误文本更重要。它直接证明副作用函数没有在非法参数下运行。
测试工具执行失败
工具内部也可能抛错:
ts
async execute() {
throw new Error("disk offline");
}
Loop 应把异常转换成模型可见的结果:
ts
expect(toolResult(agent.transcript, "failed-1")).toMatchObject({
isError: true,
content: [{ type: "text", text: 'Tool "echo" failed: disk offline' }],
});
Faux Provider 的第二条响应也必须被消费,证明模型有机会看到失败并解释它。
测试工具执行期间取消
取消测试需要比"传入一个已经 aborted 的 signal"更严格。我们让第一个工具在执行期间主动触发取消,然后故意返回一个看似成功的结果:
ts
const controller = new AbortController();
const abort = {
name: "abort",
description: "Abort this run",
parameters: Type.Object({}),
async execute(_id, _parameters, signal) {
controller.abort("test cancellation");
return [{ type: "text" as const, text: "too late" }];
},
};
同一条模型消息再要求调用第二个工具 never。正确结果应该是:
ts
expect(executions).toEqual(["abort"]);
expect(assistant.stopReason).toBe("aborted");
expect(faux.pendingResponses()).toBe(1);
这三个断言分别证明:
- 第二个工具没有启动;
- Agent 以明确的取消状态结束;
- 第二次模型响应仍留在队列中,也就是取消后没有再请求 Provider。
运行验证
在 PowerShell 中进入项目目录:
powershell
Set-Location D:\pi\di-code
先运行工具循环测试:
powershell
npm test --workspace packages/agent -- --run tool
当前应收集 tool-loop.test.ts,共 1 file / 5 tests,五个测试全部通过。
然后运行上一篇的回归测试,确认纯文本 Agent 行为没有被破坏:
powershell
npm test --workspace packages/agent -- --run agent-loop.test.ts
npm test --workspace packages/agent -- --run agent.test.ts
最后运行全仓检查和 Agent 构建:
powershell
npm run check
npm run build --workspace @di-code/agent
这些命令分别检查工具闭环、旧行为回归、代码格式、TypeScript 类型和构建产物。
总结
这一篇把原来"一次请求就结束"的 Agent Loop 扩展成了真正的工具调用闭环。整个过程可以浓缩为六步:
- 把工具的名称、说明和参数 schema 告诉 Provider。
- 从模型回复中提取
tool_call。 - 按名称查找本地工具,并在执行前校验参数。
- 把成功、未知工具、非法参数和执行异常统一成
ToolResultMessage。 - 先把工具结果追加到消息历史,再发起下一次模型请求。
- 没有新工具调用时结束;取消时停止剩余工具和后续请求,并安全收尾。
现在,Agent 已经拥有了"模型提出行动 -> 本地执行 -> 模型观察结果"的核心循环。下一步可以在这个稳定的通用机制上接入 CLI,让用户从命令行观察最终文本和完整事件;再之后才会实现真正具有文件系统副作用的 read 工具。
git地址:qddidi/di-code
如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金