从零开发一个 Coding Agent(九):实现 Agent 的工具调用闭环

本篇文章是《从零开发一个 Coding Agent》系列第九篇。在上一篇中,我们用 Agent 类保存了对话历史,并把 agentLoop 产生的事件转发给上层。

不过,当前的 Agent 还只会"聊天"。如果模型返回一个工具调用,例如"请读取某个文件",Agent 会把这条消息当作普通回复结束,既不会执行工具,也不会把执行结果告诉模型。

这一篇要补上最关键的一段控制流程:

text 复制代码
用户提出要求
    -> 模型决定调用工具
    -> Agent 查找并执行工具
    -> Agent 把工具结果追加到消息历史
    -> 再次请求模型
    -> 模型根据工具结果给出最终回答

这就是工具调用闭环(Tool Loop)。完成它以后,Agent 才不只是一个聊天接口,而是具备了"观察、行动、再思考"的基本能力。

本篇只实现通用的工具执行机制,不实现真正的 readwritebash 工具。文件权限、命令超时和输出截断等安全问题,会在实现具体工具时单独处理。

模型不会亲自执行工具

第一次接触工具调用时,很容易产生一个误解:模型返回了 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 请求。模型只有看到工具结果,才知道工具到底返回了什么。

因此,一次看起来很简单的"调用工具并回答",通常至少需要两次模型请求:

sequenceDiagram participant U as 用户 participant A as Agent participant P as Provider participant T as Tool U->>A: echo hello A->>P: 第一次请求 P-->>A: tool_call(echo, { value: 'hello' }) A->>T: execute('call-1', { value: 'hello' }) T-->>A: echoed: hello A->>P: 第二次请求,附带 tool_result P-->>A: 最终回答 done A-->>U: done

一次 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_startturn_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[] };

这里有三个变化:

  1. message_startmessage_end 现在也允许携带 ToolResultMessage
  2. turn_end 增加 toolResults,方便上层知道本轮执行了哪些工具。
  3. 新增 tool_execution_starttool_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) 中的 causeunknown,不能直接假设它有 .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_endagent_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,模型这次能够看到真实执行结果。

情况三:工具执行期间取消

取消后必须同时做到三件事:

  1. 不执行当前批次中剩余的工具;
  2. 不发起下一次 Provider 请求;
  3. 仍然让 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);

这不是再次请求模型,而是本地安全收尾。最后的消息角色仍然是 assistantstopReasonaborted,所以 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_startagent_end
  • 每次请求模型前都有一个 turn_start
  • 工具结果也属于正式消息,因此有 message_startmessage_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 扩展成了真正的工具调用闭环。整个过程可以浓缩为六步:

  1. 把工具的名称、说明和参数 schema 告诉 Provider。
  2. 从模型回复中提取 tool_call
  3. 按名称查找本地工具,并在执行前校验参数。
  4. 把成功、未知工具、非法参数和执行异常统一成 ToolResultMessage
  5. 先把工具结果追加到消息历史,再发起下一次模型请求。
  6. 没有新工具调用时结束;取消时停止剩余工具和后续请求,并安全收尾。

现在,Agent 已经拥有了"模型提出行动 -> 本地执行 -> 模型观察结果"的核心循环。下一步可以在这个稳定的通用机制上接入 CLI,让用户从命令行观察最终文本和完整事件;再之后才会实现真正具有文件系统副作用的 read 工具。

git地址:qddidi/di-code

如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金

相关推荐
Luhui Dev1 小时前
如何在 WorkBuddy 中使用大角几何:从 MCP 接入到 AI 几何作图
人工智能·数学·算法·agent·luhuidev
孙启超1 小时前
【AI应用开发】什么是混合检索(Hybrid Search)?向量检索 + BM25 关键词检索,适用场景与 RRF 融合原理
人工智能·缓存·llm·向量数据库·bm25·向量化·ai应用开发
The moon forgets1 小时前
Qwen团队提出Ego2Robot, 第一人称视频助力具身VLA训练新数据
人工智能·机器学习·音视频
szxinmai主板定制专家2 小时前
基于 RK3588 + Xilinx Kintex-7 FPGA 异构工业主控板设计方案
arm开发·人工智能·嵌入式硬件·fpga开发·zynq
青山科技分享2 小时前
跨境电商如何做GEO,让商品被AI搜索推荐?
大数据·人工智能·跨境电商
Cloud云卷云舒2 小时前
HaishanDB(海山)|磐维数据库|YashanDB(崖山)深度对比分析
数据库·人工智能·海山数据库·haishandb·移动云海山数据库
泰迪智能科技2 小时前
滇西科技师范学院AI人工智能实验室案例分享
人工智能·科技
xywww1682 小时前
真实后台页实测:Opus 5 看图写前端的可用边界在哪
linux·服务器·前端·数据库·人工智能·gpt
文心快码BaiduComate2 小时前
文心快码能力扩展、记忆、代码可视化上线
人工智能·ai编程·vibecoding