从零开发一个 Coding Agent(八):如何使用 Agent 类管理对话状态

本篇文章是《从零开发一个 Coding Agent》系列第八篇。在上一篇中,我们实现了纯文本 agentLoop:传入一条用户消息,它会调用一次 Provider,并把模型事件转换成一组更容易被上层使用的 AgentEvent

不过,上一篇的 agentLoop 仍然是一个"用完即走"的函数。每次调用时,我们都要自己准备历史消息、自己消费事件流、自己保存最终结果。例如:

ts 复制代码
const stream = agentLoop(prompt, context, config);

for await (const event of stream) {
	// 自己处理事件
}

const messages = await stream.result();
// 自己保存 messages,下一轮再传回去

这适合实现底层控制流程,却不适合直接交给 CLI 或终端界面使用。上层真正想要的接口更接近这样:

ts 复制代码
const agent = new Agent({ provider, model });

agent.subscribe((event) => {
	// 渲染实时事件
});

const assistant = await agent.prompt("你好");
console.log(assistant);
console.log(agent.transcript);

因此,这一篇会在 agentLoop 外面增加一个有状态的 Agent 类。它负责保存内存中的对话记录、管理运行状态、通知事件监听者,并防止两次 prompt 同时修改同一份对话。

工具调用、prompt 排队、磁盘会话、CLI 和 TUI 仍然不在本篇范围内。

为什么有了 Agent Loop 还需要 Agent 类

可以把 agentLoopAgent 的关系想成一次列车运行和一座车站:

  • agentLoop 负责"一趟车怎样从起点开到终点"。它接收本轮输入,产生生命周期事件,最终返回完整消息。
  • Agent 负责"车站当前是什么状态"。它保存历史记录,阻止两趟车占用同一条轨道,并把运行事件通知给显示屏。

agentLoop 本身不保存上一次调用的结果。相同的输入参数会启动一条新的流,这让底层逻辑更容易测试。Agent 则把多次调用串成一段连续对话:

text 复制代码
第一次 prompt
[] + user1 -> agentLoop -> [user1, assistant1]
                              |
                              v
                        Agent 保存 transcript

第二次 prompt
[user1, assistant1] + user2 -> agentLoop
                           -> [user1, assistant1, user2, assistant2]

这里的职责边界很重要:Agent 不重新解析 Provider 的 text_delta,也不重新构造最终的 AssistantMessage。这些事情已经由 agentLoop 完成。Agent 只消费 AgentEvent,并在正确的时间更新状态。

一次 prompt 的完整流程

假设调用者执行:

ts 复制代码
await agent.prompt("say hello");

内部流程如下:

sequenceDiagram participant C as 调用者 participant A as Agent participant L as agentLoop participant P as Provider participant S as listener C->>A: prompt("say hello") A->>A: isStreaming = true A->>L: UserMessage + 历史消息快照 L->>P: stream(model, context, signal) P-->>L: StreamEvent L-->>A: AgentEvent A->>S: 按注册顺序 await listener L-->>A: agent_end(messages) A->>A: 提交完整 transcript A->>A: finally: isStreaming = false A-->>C: AssistantMessage

可以先记住四条规则:

  1. prompt() 开始时立即把 isStreaming 设为 true
  2. message_update 只用于实时显示,不立即写入正式对话记录。
  3. 收到 agent_end 后,一次性提交完整的 messages
  4. 不管成功、失败还是取消,finally 都要把 isStreaming 恢复为 false

下面逐步实现这些规则。

定义 Agent 的公共接口

打开文件:

text 复制代码
packages/agent/src/agent.ts

先导入 AI 包的公共类型,以及上一篇完成的 agentLoopAgentEvent

ts 复制代码
import type {
	AssistantMessage,
	Message,
	Model,
	Provider,
} from "@di-code/ai";
import { agentLoop } from "./agent-loop.ts";
import type { AgentContext, AgentEvent } from "./types.ts";

接着定义构造参数:

ts 复制代码
export interface AgentOptions {
	readonly provider: Provider;
	readonly model: Model;
	readonly systemPrompt?: string;
	readonly now?: () => number;
}

这些参数分别表示:

  • provider:真正产生模型事件的 Provider。测试中使用 Faux Provider。
  • model:本次 Agent 固定使用的模型。
  • systemPrompt:可选的系统提示词,每一轮都会传给 agentLoop
  • now:可替换的时间函数。生产代码默认使用 Date.now,测试可以传入固定时间。

Agent 不读取环境变量,也不自己寻找 API Key。它需要的依赖全部通过构造器传入,这种方式叫作 依赖注入(Dependency Injection)。它让 Agent 可以同时配合 Faux Provider 和未来的真实 Provider,而不需要修改自身代码。

然后定义 listener:

ts 复制代码
export type AgentListener = (
	event: AgentEvent,
	signal?: AbortSignal,
) => void | Promise<void>;

listener 是事件监听函数。它既可以同步执行,也可以返回 Promise。例如,CLI 可以同步打印文本,TUI 将来可以异步刷新界面。

最后定义公开状态:

ts 复制代码
export interface AgentState {
	readonly messages: readonly Message[];
	readonly isStreaming: boolean;
}

messagesisStreaming 都是只读的。调用者可以观察 Agent,却不能通过公共接口直接替换内部状态。

创建辅助函数

在类之前加入两个小函数。

第一个函数把未知异常统一变成 Error

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

JavaScript 允许抛出任意值:

ts 复制代码
throw new Error("failed");
throw "failed";
throw 123;

因此 catch (cause) 中的 cause 应当按 unknown 处理,不能假设它一定有 messagetoError() 保证 prompt() 最终拒绝时总是给调用者一个真正的 Error

第二个函数负责创建用户消息:

ts 复制代码
function createUserMessage(
	text: string,
	now: () => number,
): Extract<Message, { role: "user" }> {
	return {
		role: "user",
		content: [{ type: "text", text }],
		timestamp: now(),
	};
}

Message 是一个可辨识联合类型,里面可能是 user、assistant 或 tool 消息。这里使用:

ts 复制代码
Extract<Message, { role: "user" }>

意思是"从 Message 联合类型中取出 roleuser 的成员"。这样返回值仍然来自公共 Message 契约,又能精确表示这个函数只创建用户消息。

保存 Agent 的内部状态

开始定义 Agent 类:

ts 复制代码
export class Agent {
	private messages: Message[] = [];
	private streaming = false;
	private readonly listeners = new Set<AgentListener>();
	private readonly provider: Provider;
	private readonly model: Model;
	private readonly systemPrompt?: string;
	private readonly now: () => number;

	constructor(options: AgentOptions) {
		this.provider = options.provider;
		this.model = options.model;
		this.systemPrompt = options.systemPrompt;
		this.now = options.now ?? Date.now;
	}
}

这里有三类数据:

  • messages:已经完成并正式提交的对话记录,也叫 transcript。
  • streaming:当前是否有一条 prompt 正在运行。
  • listeners:所有订阅 Agent 事件的监听函数。

listeners 使用 Set 而不是数组,因此同一个函数重复订阅时只会保留一份。Set 还会保持插入顺序,后面可以按注册顺序通知 listener。

返回状态快照

给类加入三个 getter:

ts 复制代码
get state(): AgentState {
	return { messages: [...this.messages], isStreaming: this.streaming };
}

get transcript(): readonly Message[] {
	return [...this.messages];
}

get isStreaming(): boolean {
	return this.streaming;
}

这里没有直接返回 this.messages,而是使用:

ts 复制代码
[...this.messages]

创建一个新数组。否则调用者只要取得数组引用,就可能绕过 Agent 修改内部记录:

ts 复制代码
const history = agent.transcript as Message[];
history.push(otherMessage);

使用新数组后,调用者修改 history 的长度不会影响 Agent 内部的 messages

需要注意,这里实现的是浅拷贝(shallow copy) :数组本身是新的,数组中的消息对象仍然是原对象。readonly 也只是 TypeScript 编译阶段的限制,不是运行时深冻结。当前任务只要求保护数组边界;如果以后要把消息交给不可信代码,还需要更强的复制或冻结策略。

实现事件订阅

加入 subscribe()

ts 复制代码
subscribe(listener: AgentListener): () => void {
	this.listeners.add(listener);
	return () => {
		this.listeners.delete(listener);
	};
}

订阅后返回一个取消订阅函数:

ts 复制代码
const unsubscribe = agent.subscribe((event) => {
	console.log(event.type);
});

// 不再接收后续事件
unsubscribe();

这种接口不要求调用者记住 Agent 内部使用了 Set,也不需要再提供一个单独的 removeListener(listener) 方法。

再加入真正执行通知的私有方法:

ts 复制代码
private async notify(
	event: AgentEvent,
	signal?: AbortSignal,
): Promise<void> {
	for (const listener of this.listeners) {
		await listener(event, signal);
	}
}

这里必须使用 for...ofawait。不要写成:

ts 复制代码
// 错误示例
this.listeners.forEach(async (listener) => {
	await listener(event, signal);
});

forEach() 不会等待回调返回的 Promise。假设第一个 listener 正在异步刷新屏幕,第二个 listener 可能已经开始处理同一个事件,最终顺序就会变得不可预测。

当前实现保证:

text 复制代码
第一个 listener 开始
第一个 listener 完成
第二个 listener 开始
第二个 listener 完成

这种顺序虽然会让慢 listener 暂停事件消费,但它的行为简单、稳定,也容易测试。以后如果需要并行 listener,应当明确设计新的错误和顺序语义,不能只把 await 去掉。

实现 prompt 方法

prompt() 是 Agent 最重要的方法。它负责把一段普通字符串变成用户消息,调用 agentLoop,转发事件,并在结束时提交对话记录。

先写方法签名:

ts 复制代码
async prompt(
	text: string,
	signal?: AbortSignal,
): Promise<AssistantMessage> {
	// 后面逐段补充
}

拒绝并发 prompt

方法开始时先检查运行状态:

ts 复制代码
if (this.streaming) {
	throw new Error("Agent is already processing a prompt.");
}

this.streaming = true;

当前 Agent 只有一份 transcript。如果两次 prompt 同时运行,就可能出现这种情况:

text 复制代码
prompt A 从旧历史开始
prompt B 也从同一份旧历史开始
prompt B 先完成并保存结果
prompt A 后完成,又覆盖 prompt B 的结果

因此,本阶段不做排队,而是立即拒绝第二次调用。

prompt()async 函数,但它会从函数开头同步执行到第一个 await。所以第一次调用在返回 Promise 前就已经设置了 streaming = true,紧接着发起的第二次调用能够立刻看到忙碌状态。

创建本轮输入和上下文

继续加入:

ts 复制代码
const prompt = createUserMessage(text, this.now);
const context: AgentContext = {
	systemPrompt: this.systemPrompt,
	messages: [...this.messages],
};

传给 agentLoop 的也是历史数组快照。这样底层 Loop 只能读取本轮开始时的历史,不能直接持有并修改 Agent 的内部数组。

然后启动事件流:

ts 复制代码
const stream = agentLoop(
	prompt,
	context,
	{
		provider: this.provider,
		model: this.model,
		now: this.now,
	},
	signal,
);

这里没有直接调用 Provider。Provider 事件到 Agent 事件的转换仍然完全由上一篇的 agentLoop 负责。

消费事件并通知 listener

先准备三个局部变量:

ts 复制代码
let finalAssistant: AssistantMessage | undefined;
let listenerFailed = false;
let listenerFailure: unknown;
  • finalAssistant 保存最终要返回的 assistant 消息。
  • listenerFailed 记录是否有 listener 失败。
  • listenerFailure 保存遇到的第一个失败原因。

之所以同时使用布尔值和错误值,是因为 JavaScript 甚至允许 throw undefined。如果只检查 listenerFailure === undefined,就无法区分"没有失败"和"确实抛出了 undefined"。

接着消费 agentLoop 返回的流:

ts 复制代码
try {
	for await (const event of stream) {
		try {
			await this.notify(event, signal);
		} catch (cause) {
			listenerFailed = true;
			listenerFailure ??= cause;
		}

		if (event.type === "agent_end") {
			const candidate = event.messages.at(-1);
			if (!candidate || candidate.role !== "assistant") {
				throw new Error(
					"agent_end did not contain an assistant message",
				);
			}
			this.messages = [...event.messages];
			finalAssistant = candidate;
		}
	}
} finally {
	this.streaming = false;
}

这段代码有两个不太直观、却非常重要的设计。

第一个设计是:只有收到 agent_end 才提交 this.messages

模型生成时会不断产生 message_update,其中的 AssistantMessagePreview 可能依次是:

text 复制代码
"he"
"hell"
"hello"

这些只是用于显示的临时预览,还没有完整的 usagestopReason 和最终时间。如果把每次预览都塞进 transcript,不仅会留下多条半成品消息,失败和取消时也很难判断哪一份才是正式结果。

agent_end 则表示本轮已经收敛。它携带完整的:

text 复制代码
[之前的历史消息, 本轮 user 消息, 最终 assistant 消息]

所以 Agent 在这个时刻一次性替换 transcript。这种做法类似数据库事务:生成过程中的变化可以观察,但只有完整结束后才正式提交。

第二个设计是:listener 抛错后仍然继续消费事件流。

假设 TUI listener 在处理一次 message_update 时抛错。如果这里直接跳出 for await,底层 agentLoop 可能还没有走到 agent_end,Provider 也可能停在半途。于是代码先记住第一个 listener 错误,然后继续消费后续事件,让底层流程正常收尾。

当前 notify() 是串行的,所以某个 listener 对当前事件抛错后,排在它后面的 listener 不会收到这一个事件;但下一条事件到来时,Agent 仍会重新从第一个 listener 开始通知。更复杂的故障隔离留给后续设计。

返回结果并传播 listener 错误

循环结束后加入:

ts 复制代码
if (listenerFailed) throw toError(listenerFailure);
if (!finalAssistant) {
	throw new Error("Agent loop ended without agent_end");
}
return finalAssistant;

如果 listener 失败,prompt() 最终会 reject,但这个 reject 发生在事件流被消费完、transcript 已提交、streaming 已清理之后。

如果流意外结束却没有 agent_end,说明内部生命周期契约被破坏。此时不能假装成功,而要抛出明确错误。

为什么一定要用 finally

streaming 的清理放在:

ts 复制代码
finally {
	this.streaming = false;
}

是因为下面这些路径都必须回到空闲状态:

  • 模型正常完成。
  • 模型返回错误消息。
  • 调用者取消生成。
  • listener 抛出异常。
  • Agent Loop 的内部契约被破坏。

如果只在正常返回前写一次 this.streaming = false,任何提前抛出的异常都会让 Agent 永久停留在忙碌状态,之后的 prompt 全部被拒绝。

完整的 Agent 实现

把上面的代码合在一起,packages/agent/src/agent.ts 内容如下:

ts 复制代码
import type { AssistantMessage, Message, Model, Provider } from "@di-code/ai";
import { agentLoop } from "./agent-loop.ts";
import type { AgentContext, AgentEvent } from "./types.ts";

export interface AgentOptions {
	readonly provider: Provider;
	readonly model: Model;
	readonly systemPrompt?: string;
	readonly now?: () => number;
}

export type AgentListener = (
	event: AgentEvent,
	signal?: AbortSignal,
) => void | Promise<void>;

export interface AgentState {
	readonly messages: readonly Message[];
	readonly isStreaming: boolean;
}

function toError(cause: unknown): Error {
	return cause instanceof Error ? cause : new Error(String(cause));
}

function createUserMessage(
	text: string,
	now: () => number,
): Extract<Message, { role: "user" }> {
	return {
		role: "user",
		content: [{ type: "text", text }],
		timestamp: now(),
	};
}

export class Agent {
	private messages: Message[] = [];
	private streaming = false;
	private readonly listeners = new Set<AgentListener>();
	private readonly provider: Provider;
	private readonly model: Model;
	private readonly systemPrompt?: string;
	private readonly now: () => number;

	constructor(options: AgentOptions) {
		this.provider = options.provider;
		this.model = options.model;
		this.systemPrompt = options.systemPrompt;
		this.now = options.now ?? Date.now;
	}

	get state(): AgentState {
		return {
			messages: [...this.messages],
			isStreaming: this.streaming,
		};
	}

	get transcript(): readonly Message[] {
		return [...this.messages];
	}

	get isStreaming(): boolean {
		return this.streaming;
	}

	subscribe(listener: AgentListener): () => void {
		this.listeners.add(listener);
		return () => {
			this.listeners.delete(listener);
		};
	}

	async prompt(
		text: string,
		signal?: AbortSignal,
	): Promise<AssistantMessage> {
		if (this.streaming) {
			throw new Error("Agent is already processing a prompt.");
		}

		this.streaming = true;
		const prompt = createUserMessage(text, this.now);
		const context: AgentContext = {
			systemPrompt: this.systemPrompt,
			messages: [...this.messages],
		};
		const stream = agentLoop(
			prompt,
			context,
			{
				provider: this.provider,
				model: this.model,
				now: this.now,
			},
			signal,
		);

		let finalAssistant: AssistantMessage | undefined;
		let listenerFailed = false;
		let listenerFailure: unknown;
		try {
			for await (const event of stream) {
				try {
					await this.notify(event, signal);
				} catch (cause) {
					listenerFailed = true;
					listenerFailure ??= cause;
				}

				if (event.type === "agent_end") {
					const candidate = event.messages.at(-1);
					if (!candidate || candidate.role !== "assistant") {
						throw new Error(
							"agent_end did not contain an assistant message",
						);
					}
					this.messages = [...event.messages];
					finalAssistant = candidate;
				}
			}
		} finally {
			this.streaming = false;
		}

		if (listenerFailed) throw toError(listenerFailure);
		if (!finalAssistant) {
			throw new Error("Agent loop ended without agent_end");
		}
		return finalAssistant;
	}

	private async notify(
		event: AgentEvent,
		signal?: AbortSignal,
	): Promise<void> {
		for (const listener of this.listeners) {
			await listener(event, signal);
		}
	}
}

从公共入口导出

打开:

text 复制代码
packages/agent/src/index.ts

确认它包含:

ts 复制代码
export * from "./agent.ts";
export * from "./agent-loop.ts";
export * from "./types.ts";

测试和上层包都应该从 @di-code/agent 的公共入口导入 Agent,而不是依赖内部文件:

ts 复制代码
import { Agent } from "@di-code/agent";

包内测试可以从源码公共入口导入:

ts 复制代码
import { Agent } from "../src/index.ts";

这样测试不仅验证 Agent 类本身,也会验证公共导出是否正确。

使用 Faux Provider 验证 Agent

这一篇仍然不访问真实网络。Faux Provider 可以精确控制模型响应,因此测试不会消耗 API 额度,也不会因为网络波动而失败。

测试文件位于:

text 复制代码
packages/agent/test/agent.test.ts

正常响应只在 agent_end 后提交

ts 复制代码
it("commits transcript only when agent_end arrives", async () => {
	const faux = createFauxProvider({
		responses: [
			{
				type: "success",
				content: [{ type: "text", text: "hello" }],
			},
		],
		now: () => 100,
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
		systemPrompt: "Be brief.",
		now: () => 10,
	});
	const snapshots: number[] = [];

	agent.subscribe((event) => {
		if (event.type === "message_update") {
			snapshots.push(agent.state.messages.length);
		}
	});

	expect(agent.state).toEqual({ messages: [], isStreaming: false });
	const assistant = await agent.prompt("say hello");

	expect(assistant).toMatchObject({
		role: "assistant",
		stopReason: "stop",
	});
	expect(snapshots.length).toBeGreaterThan(0);
	expect(snapshots.every((length) => length === 0)).toBe(true);
	expect(agent.transcript.map((message) => message.role)).toEqual([
		"user",
		"assistant",
	]);
});

listener 在每次 message_update 时读取 Agent 状态。所有长度都应是 0,证明生成中的 preview 没有提前进入 transcript。prompt() 完成后,记录才一次性变成 user 和 assistant 两条消息。

还要验证返回的是数组快照,而不是内部数组:

ts 复制代码
const snapshot = agent.state.messages;
(snapshot as Message[]).push({
	role: "user",
	content: [{ type: "text", text: "outside" }],
	timestamp: 999,
});

expect(agent.transcript).toHaveLength(2);

测试中的类型断言故意绕过 readonly。即使外部强行修改拿到的数组,Agent 内部记录仍然保持两条。

模型错误仍然形成一轮正式对话

ts 复制代码
it("returns a failed assistant and clears streaming after a model error", async () => {
	const faux = createFauxProvider({
		responses: [
			{ type: "failure", errorMessage: "model failed" },
		],
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
	});

	const assistant = await agent.prompt("fail");

	expect(assistant).toMatchObject({
		role: "assistant",
		stopReason: "error",
		errorMessage: "model failed",
	});
	expect(agent.state.messages.at(-1)).toBe(assistant);
	expect(agent.isStreaming).toBe(false);
});

Provider 的模型错误属于对话协议的一部分。上一篇的 agentLoop 会把它收敛成 stopReason: "error"AssistantMessage,所以这里的 prompt() 正常 resolve,而不是 reject。

换句话说:模型确实回答失败了,但 Agent 的控制流程正常完成了。

取消后也要回到空闲状态

ts 复制代码
it("settles an aborted prompt and clears streaming", async () => {
	const controller = new AbortController();
	const faux = createFauxProvider({
		responses: [
			{
				type: "success",
				content: [{ type: "text", text: "abcdef" }],
			},
		],
		chunkSize: 2,
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
	});

	agent.subscribe((event) => {
		if (
			event.type === "message_update" &&
			event.event.type === "text_delta"
		) {
			controller.abort("test cancellation");
		}
	});

	const assistant = await agent.prompt("cancel", controller.signal);

	expect(assistant.stopReason).toBe("aborted");
	expect(agent.isStreaming).toBe(false);
});

取消也会形成一条 stopReason: "aborted" 的 AssistantMessage。它不是 JavaScript 异常,而是一次已经安全收尾的对话结果。

listener 必须按顺序执行

ts 复制代码
it("awaits listeners in registration order", async () => {
	const faux = createFauxProvider({
		responses: [
			{
				type: "success",
				content: [{ type: "text", text: "ok" }],
			},
		],
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
	});
	const order: string[] = [];

	agent.subscribe(async (event) => {
		if (event.type === "agent_start") {
			order.push("first:start");
			await Promise.resolve();
			order.push("first:end");
		}
	});
	agent.subscribe((event) => {
		if (event.type === "agent_start") {
			order.push("second");
		}
	});

	await agent.prompt("order");

	expect(order).toEqual([
		"first:start",
		"first:end",
		"second",
	]);
});

如果 notify() 使用 forEach(async ...)second 很可能出现在 first:end 之前。这个测试锁定了串行等待的契约。

listener 失败不能让状态卡死

ts 复制代码
it("rejects listener failure but always clears streaming", async () => {
	const faux = createFauxProvider({
		responses: [
			{
				type: "success",
				content: [{ type: "text", text: "ok" }],
			},
		],
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
	});

	agent.subscribe((event) => {
		if (event.type === "message_update") {
			throw new Error("listener failed");
		}
	});

	await expect(agent.prompt("listener")).rejects.toThrow(
		"listener failed",
	);
	expect(agent.isStreaming).toBe(false);
	expect(agent.transcript).toHaveLength(2);
});

这里同时证明三件事:listener 错误会传回调用者,底层流仍然完成并提交 transcript,finally 最终清除了 streaming 状态。

第二次 prompt 要立即拒绝

ts 复制代码
it("rejects a second prompt while the first is active", async () => {
	const faux = createFauxProvider({
		responses: [
			{
				type: "success",
				content: [{ type: "text", text: "first" }],
			},
			{
				type: "success",
				content: [{ type: "text", text: "second" }],
			},
		],
	});
	const agent = new Agent({
		provider: faux.provider,
		model: faux.model,
	});

	const first = agent.prompt("first");

	expect(agent.isStreaming).toBe(true);
	await expect(agent.prompt("second")).rejects.toThrow(
		"Agent is already processing a prompt.",
	);
	await first;
	expect(faux.pendingResponses()).toBe(1);
});

pendingResponses() 仍然是 1,说明第二次 prompt 不仅报了错,也没有偷偷消耗下一条 Faux 响应。

运行测试与构建

在 PowerShell 中执行:

powershell 复制代码
Set-Location D:\pi\di-code
npx --no-install biome check packages\agent\src\agent.ts packages\agent\src\index.ts packages\agent\test\agent.test.ts
npm test --workspace packages/agent -- --run agent.test.ts
npm run check
npm run build --workspace @di-code/agent

当前这组 Agent 状态测试应当收集 1 个测试文件、6 个测试,并全部通过:

text 复制代码
Test Files  1 passed (1)
Tests       6 passed (6)

npm run check 会继续检查全仓 Biome 格式和 TypeScript 类型,最后的 build 会确认 Agent 已经通过公共入口进入 @di-code/agent 的构建产物。

如果只出现格式问题,可以执行:

powershell 复制代码
npx --no-install biome check --write packages\agent\src\agent.ts packages\agent\src\index.ts packages\agent\test\agent.test.ts

然后重新运行上面的四条验证命令。

总结

这一篇在无状态的 agentLoop 外面增加了一个有状态的 Agent 包装器。它没有重复 Provider 或 Agent Loop 的工作,只负责四件事:

  1. 保存已经完成的对话记录。
  2. AgentEvent 按顺序通知给 listener。
  3. 管理 isStreaming,并拒绝并发 prompt。
  4. agent_end 到来时原子地提交本轮结果。

现在,调用者已经不需要手动拼装 UserMessage、保存历史数组或直接消费 agentLoop.result()。它只需要创建一次 Agent,然后连续调用:

ts 复制代码
await agent.prompt("第一句话");
await agent.prompt("继续刚才的话题");

第二次调用会自动带上第一次已经提交的 transcript。

不过,这个 Agent 目前仍然只会进行一次模型请求。如果模型要求调用工具,它还不会执行工具,也不会把工具结果送回模型。下一阶段会在这个稳定的状态和生命周期边界上,实现真正的工具调用闭环。

本章节git分支地址:qddidi/di-code at 0810/text-agent-loop

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

相关推荐
这张生成的图像能检测吗2 小时前
(论文速读)基于鸡群算法变分模式分解的超低温振动传感器去噪
人工智能·信号处理·信号去噪·鸡群算法·模态分解
AI即插即用2 小时前
即插即用系列 | IEEE TMI PLG-HN:原型学习引导的 CNN-Transformer 混合网络,攻克乳腺肿瘤分割难题
人工智能·深度学习·神经网络·学习·目标检测·cnn·transformer
光影少年2 小时前
RN 网络请求:Fetch / Axios 封装、超时、拦截器
前端·react native·react.js
AIyy8662 小时前
2026小红书封面AI绘图工具横评:实测各平台出图质感与平台适配度
人工智能
自由能燃气设备2 小时前
燃气容积式热水器厂家选购指南:2026年商用热水设备采购避坑手册
大数据·数据库·数据仓库·人工智能
学习星球2 小时前
Vue 3 实战:拆解 RealWorld 项目
前端·vue.js
Nomarsgo2 小时前
3C电子智能制造产线嵌入式工控机解决方案——基于研华 MIC-770H 打造高性能、紧凑型智能制造边缘控制平台
人工智能·科技·视觉检测·电脑·制造
yinghuoAI20262 小时前
电商卖货,本质上是“视觉的战争”
人工智能·ai·ai作画·ai作图·ai生视频
铁皮饭盒2 小时前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端