跳到主内容
创建时间:最后更新:作者:白川

AgentMessage 到模型消息

分清 transformContext 与 convertToLlm 的职责,说明界面专用数据为什么不进模型上下文。

AgentMessage 到模型消息

Pi 内部保存的消息不是模型认识的消息。

内部用 AgentMessage——它比模型能理解的格式宽:除了 user、assistant、toolResult,还能塞进你自己的类型。但模型只理解 user、assistant、toolResult(以及承载提示与工具声明的 system)。多出来的那些怎么办?

两个钩子:transformContext 和 convertToLlm。它们名字相似、位置相邻,但职责完全不同:一个是裁剪和注入,一个是过滤和转换。 混用就会出现"我明明裁掉了,怎么模型还看得到"这类问题。

这篇讲清一次请求里消息经过的两个转换点、AgentMessage 的可扩展结构、系统提示的所有权,以及两种"不进模型上下文"的写法。

需要的前置只有一条:知道 Pi 的会话内部用比模型协议更宽的消息类型来保存记录。

一、消息流转链路

官方 README 给的链路图:

text
# 一次 provider 请求里消息经过的两个转换点
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
                   (可选)                            (必需)

展开成一次请求的实际路径:

text
# 一次请求实际经过的四个环节
会话里的完整记录(AgentMessage[])
  │
  ├─ transformContext(messages, signal)      ← 可选。裁剪旧消息、注入外部上下文
  │     输出仍是 AgentMessage[]
  │
  ├─ convertToLlm(messages)                  ← 必需。把 AgentMessage[] 转成 Message[]
  │     过滤掉模型不认识的角色,转换自定义类型
  │     输出是 Message[]
  │
  ├─ normalizeContext({ messages })          ← 把系统提示与工具声明折进首条 system 消息
  │
  └─ provider 请求

两个钩子的职责对照:

transformContextconvertToLlm
输入
AgentMessage[]
AgentMessage[]
输出
AgentMessage[]
Message[]
干什么
裁剪旧消息、注入外部上下文
过滤模型不认识的类型、转换自定义类型
可选吗
可选
必需(自定义消息类型必须有它)
谁调用
每次 provider 请求前
每次 provider 请求前,在 transformContext 之后
返回空数组的含义
上下文清空
该消息不发给模型

关键区别:transformContext 之后仍然是 AgentMessage[]。 它在"内部世界"里工作——可以注入只有 Pi 认识的消息。convertToLlm 是通往"模型世界"的唯一出口。

源码里这个顺序是硬编码的:

typescript
// 先变换上下文
let messages = context.messages;
if (config.transformContext) {
	messages = await config.transformContext(messages, signal);
}

// 再转换成模型格式
const llmMessages = await config.convertToLlm(messages);

顺序反了会出问题:如果你在 transformContext 里注入了一条自定义消息,而 convertToLlm 没过滤它,provider 会直接拒绝整个请求。

二、AgentMessage 是什么

AgentMessage 是一个可扩展的联合类型:

typescript
// Message 是内置角色,加上声明合并扩展进来的自定义角色
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];

CustomAgentMessages 默认是空接口,应用通过声明合并扩展它。

编码智能体往里加了四个角色:

角色用途发给模型时
bashExecution
直接执行的 shell 命令(不是 LLM 工具结果)
转成 user 角色的文本;excludeFromContext 为真时完全丢弃
custom
扩展发送的上下文消息
转成 user 角色消息;details 不发给模型
branchSummary
从持久化的 branch_summary 条目生成的上下文消息
转成 user 角色的摘要文本
compactionSummary
从持久化的 compaction 条目生成的上下文消息
转成 user 角色的摘要文本

custom 这个角色的字段最能说明问题:

typescript
interface CustomMessage<T = unknown> {
	role: "custom";
	// customType 用来区分不同扩展发来的自定义消息
	customType: string;
	// content 是唯一会进模型上下文的部分:它会被转成 user 消息
	content: string | (TextContent | ImageContent)[];
	display: boolean;      // 控制终端是否渲染
	details?: T;          // 不发给模型
	timestamp: number;
}

display 是给界面看的开关,details 是给程序看的结构化数据——两个都不进模型上下文。只有 content 会变成 user 消息发给模型。

官方消息类型文档的原话:"display controls terminal rendering; details is not sent to the model."

这就是"界面专用数据不直接发给模型"的落地方式:不是靠约定不写进去,而是类型上就分开了。

三、convertToLlm 的过滤写法

Agent Core 层的默认实现

Agent 类不传 convertToLlm 时,用的是一个只保留四种角色的过滤器:

typescript
// Agent Core 的默认转换器:只保留模型认识的那四种角色
// system 承载提示与工具声明,user/assistant 是对话,toolResult 是工具结果
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
	return messages.filter(
		(message) =>
			message.role === "system" ||
			message.role === "user" ||
			message.role === "assistant" ||
			message.role === "toolResult",
	);
}

默认实现会静默丢弃所有自定义类型。 你扩展了 CustomAgentMessages 却不提供 convertToLlm,那你的自定义消息只是到模型门口就被扔了——不报错,只是内容不见了。

官方 README 的自定义类型示例展示了另一条路:

typescript
const agent = new Agent({
	streamFn: models.streamSimple.bind(models),
	// 这个转换器要覆盖全部自定义角色:漏掉一个,provider 就会拒整个请求
	convertToLlm: (messages) => messages.flatMap(m => {
		if (m.role === "notification") return []; // 过滤掉
		return [m];
	}),
});

flatMap + 返回空数组,就是过滤的标准写法——比 filter 更灵活,因为一个输入消息可以展开成多条模型消息。

编码智能体的实现

编码智能体提供了自己的 convertToLlm,并从包入口导出。它的处理方式是把非标准角色降级成 user 消息,而不是丢掉:

输入角色输出
bashExecution
user 消息,文本是命令加输出;excludeFromContext 为真则丢弃
custom
user 消息,内容来自 content;details 不参与
branchSummary
user 消息,摘要包在摘要标签里
compactionSummary
user 消息,摘要包在摘要标签里
system / user / assistant / toolResult
原样保留

注意末尾那个 default 分支:遇到不认识的自定义角色,返回 undefined,然后被 .filter() 剔除。所以扩展自己声明的新消息类型,默认也是被丢掉的——除非扩展提供自己的转换逻辑。

扩展的 context 事件

扩展不直接改 convertToLlm,而是通过 context 事件介入。这条路径在 SDK 里被接到了 transformContext 上:

typescript
// sdk.ts 里的接线
const agentOptions = {
	transformContext: async (messages: AgentMessage[]) => {
		const runner = extensionRunnerRef.current;
		if (!runner) return messages;
		return runner.emitContext(messages);
	},
};

context 事件的语义很重要:它看不到提示和工具系统消息。 Pi 在处理器返回后把这些状态恢复回去,所以处理器既不能丢掉它们,也不需要维护它们。

恢复逻辑的细节:处理器看到的 event.messages 已经过滤掉了 system 角色;处理器可以返回一个新列表,也可以就地编辑 event.messages。Pi 会比较前后两份,如果内容变了,就把新的可见消息接回原来的完整记录,头部提示与工具状态被还原。

想要连系统消息一起改,用 context_with_system:它的 messages 是完整记录,处理器的返回值直接生效,也就是处理器自己拥有提示和工具声明。这条路径的责任更重,用之前要清楚代价。

源码里还有一层保护:如果处理器把开头的 system 消息丢了,Pi 会报一条错误——"Handler removed the leading system message; the request has no prompt or initial tool declarations. Keep it at index 0 or replace a dropped prefix with getCurrentSystemMessage()."。错误会被报出来,但处理器的输出照样生效。 所以这个保护是提醒,不是拦截。

反过来,context 处理器不用担心这个:Pi 在它返回后会恢复提示和工具状态。

四、系统提示归 transcript 所有

这是本篇第二个容易记错的点。

agent.state.systemPrompt 是只读的,而且它不是一份独立的字符串——它是从记录里的系统消息回放出来的。

事实说明
首条系统消息
就是系统提示本身
后续系统消息
通过 content 追加指令,通过 sections 按名字替换或删除段,通过 toolsAdded / toolsRemoved 改工具集
getCurrentSystemMessage(messages)
把所有系统消息按顺序回放,返回当前的头部

SystemMessage 的完整形状:

typescript
interface SystemMessage {
	role: "system";
	// content 是追加语义:多条系统消息的正文按顺序拼接
	content: string | TextContent[];
	// sections 是按名字 patch:值为 null 表示删掉这一段
	sections?: Record<string, string | null>;
	// 工具集差量:toolsAdded 进集合,toolsRemoved 出集合
	toolsAdded?: Tool[];
	toolsRemoved?: ToolReference[];
	timestamp: number;
}

getCurrentSystemMessage 的回放规则:

typescript
// 按顺序回放记录里的所有系统消息,还原出当前生效的头部
export function getCurrentSystemMessage(messages: TranscriptMessages): SystemMessage | undefined {
	const content: string[] = [];
	const sections = new Map<string, string>();
	let timestamp: number | undefined;
	for (const message of messages) {
		if (!isSystemMessage(message)) continue;
		timestamp ??= message.timestamp;
		// content 追加:多条文本按顺序用 \n\n 拼起来
		const text = contentText(message.content);
		if (text.length > 0) content.push(text);
		// sections 按名字 patch
		for (const [name, value] of Object.entries(message.sections ?? {})) {
			if (value === null) sections.delete(name);
			else sections.set(name, value);
		}
	}
	// 工具集也是差量维护
	const tools = getCurrentTools(messages);
	if (timestamp === undefined && tools.length === 0) return undefined;
	return {
		role: "system",
		content: content.join("\n\n"),
		...(sections.size > 0 ? { sections: Object.fromEntries(sections) } : {}),
		...(tools.length > 0 ? { toolsAdded: tools } : {}),
		timestamp: timestamp ?? 0,
	};
}

三个细节:

第一,content 是追加不是替换。 多条系统消息的文本按顺序拼接,用 \n\n 连接。

第二,sections 是按名字 patch。 后续消息里 sections: { skills: "..." } 替换 skills 段,sections: { skills: null } 删掉它。段落名最好自包含(带标签或标题),因为模型要能把更新和原文对上。

第三,工具集是差量维护的。 toolsAdded 里的工具进集合,toolsRemoved 里的工具出集合,回放结束时得到当前生效的工具集。

要改系统提示,正确做法是追加一条系统消息:

typescript
// 追加一条带 sections 的系统消息
await agent.prompt([
	{ role: "system", content: "", sections: { skills: "<skills>...</skills>" }, timestamp: Date.now() },
	{ role: "user", content: "继续", timestamp: Date.now() },
]);

不要去改 agent.state.systemPrompt——它是只读的,而且改了不会进记录。

要读当前生效的系统提示,用 getCurrentSystemMessage,它接受任意带 role 的消息数组(包括带自定义角色的智能体记录):

typescript
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";

// 回放当前头部:提示正文、命名段、生效的工具集
const head = getCurrentSystemMessage(agent.state.messages);
console.log(head?.content);          // 拼接后的提示正文
console.log(head?.sections?.skills); // 当前生效的 skills 段
console.log(head?.toolsAdded?.map((t) => t.name)); // 当前生效的工具

另外,每次请求前 Pi 都会 diff 工具集:如果 agent.state.tools 和记录里声明的工具不一致,就发一条系统消息说明这个变化;如果已经有待合并的系统消息,就合并进去。无法表达这种过渡的 provider 会收到一个完整的记录检查点,这可能让缓存前缀失效。

五、完整示例:两种"不进模型"的写法

"界面专用数据"在 Pi 里有两条不同的路,别混:

路径载体进不进模型上下文用在哪
pi.appendEntry(customType, data)
会话条目(CustomEntry)
不进。条目不参与 LLM 上下文
纯状态:审计记录、进度、统计
pi.sendMessage({ customType, content, display, details })
custom 角色的消息
进。content 会转成 user 消息
要让模型看到的内容 + 附带结构化细节

官方 ExtensionAPI 的注释写得很明确:appendEntry 是 "Append a custom entry to the session for state persistence (not sent to LLM)"。而 custom 消息的 details 才是不发给模型的那部分——content 是发给模型的。

写法一:声明合并自定义消息类型

这是 Agent Core 层的机制。声明合并扩展 CustomAgentMessages,再提供 convertToLlm 决定它去留:

typescript
// audit-note.ts
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

// 1. 用声明合并扩展 AgentMessage
declare module "@earendil-works/pi-agent-core" {
	interface CustomAgentMessages {
		auditNote: {
			role: "auditNote";
			text: string;
			level: "info" | "warning";
			timestamp: number;
		};
	}
}

// 2. 现在它是合法的 AgentMessage
const note: AgentMessage = {
	role: "auditNote",
	text: "跳过了 3 个生成文件",
	level: "info",
	timestamp: Date.now(),
};

// 3. 提供 convertToLlm:把 auditNote 过滤掉,只保留模型认识的角色
function convertToLlm(messages: AgentMessage[]) {
	return messages.filter(
		(message) =>
			message.role === "system" ||
			message.role === "user" ||
			message.role === "assistant" ||
			message.role === "toolResult",
	);
}

export { convertToLlm, note };

不提供 convertToLlm 的话,auditNote 会被静默丢掉。 Agent Core 的默认实现只保留那四种角色,不报错——你扩展了类型却忘了提供转换器,内容就是静默消失。

写法二:用条目承载纯界面数据

在编码智能体里,审计记录这类东西的正确载体是会话条目:

typescript
// audit-extension.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Box, Text } from "@earendil-works/pi-tui";

interface AuditData {
	toolName: string;
	toolCallId: string;
	at: number;
}

export default function auditExtension(pi: ExtensionAPI) {
	// 纯状态写入会话条目:不进模型上下文
	pi.on("tool_result", (event) => {
		pi.appendEntry<AuditData>("audit-note", {
			toolName: event.toolName,
			toolCallId: event.toolCallId,
			at: Date.now(),
		});
	});

	// 条目渲染器:让这些记录出现在交互界面的记录里
	pi.registerEntryRenderer<AuditData>("audit-note", (entry, { expanded }, theme) => {
		const data = entry.data;
		const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
		box.addChild(new Text(`${theme.fg("accent", "[audit]")} ${data?.toolName ?? "unknown"}`, 0, 0));
		if (expanded && data?.at) {
			box.addChild(new Text(theme.fg("dim", new Date(data.at).toLocaleTimeString()), 0, 0));
		}
		return box;
	});
}

渲染器返回的是 TUI 组件(Component | undefined),不是字符串。官方示例里的 entry-renderer.ts 正是这个模式:Box 加 Text,颜色从 theme 取。

在 SDK 里跑起来

typescript
import {
	createAgentSession,
	DefaultResourceLoader,
	getAgentDir,
	SessionManager,
} from "@earendil-works/pi-coding-agent";

const loader = new DefaultResourceLoader({
	cwd: process.cwd(),
	agentDir: getAgentDir(),
	// additionalExtensionPaths 指向本地的扩展文件;SDK 不会自动加载它
	additionalExtensionPaths: ["./audit-extension.ts"],
});
// 必须自己 reload:SDK 只有在内部创建加载器时才会替你 reload
await loader.reload();

const { session } = await createAgentSession({
	resourceLoader: loader,
	sessionManager: SessionManager.inMemory(),
});

try {
	// SDK 不自动绑定扩展,要显式调
	// 不调这一步,扩展的处理器一个都不会注册,审计条目也不会产生
	await session.bindExtensions({});
	await session.prompt("跑一下检查");
	// 审计条目在会话里,但不在模型上下文里
	console.log("会话记录条数:", session.sessionManager.getBranch().length);
} finally {
	session.dispose();
}

注意:convertToLlm 是在 Agent 构造时传入的,编码智能体的 SDK 内部已经固定用它自己的实现。 上面的 convertToLlm 是为了说明形状;要在 SDK 里替换它,得自己构造 Agent 或用 Agent Core 层。

六、验收清单

场景触发方式可观察结果
成功(条目不进模型)
触发一次 tool_result
会话记录里多一条 audit-note 条目;下一次 provider 请求里没有它
成功(自定义类型被过滤)
定义一个自定义角色,不改转换器
模型收到的历史里没有该类型的消息
成功(系统提示按段 patch)
追加一条带 sections 的系统消息
getCurrentSystemMessage() 返回的头部里该段已被替换
成功(工具差量生效)
改 agent.state.tools
记录里出现一条带 toolsAdded / toolsRemoved 的系统消息
失败(provider 拒绝请求)
自定义类型没被过滤
provider 报消息格式错误;检查 convertToLlm 是否覆盖了该角色
失败(context 处理器丢了提示)
在 context_with_system 里返回不含原消息的列表
Pi 报出"移除了首条 system 消息"的错误,但处理器的输出照样生效

最后一行是 context 与 context_with_system 的选择依据:如果你需要动系统消息,就用 context_with_system;如果只想动对话内容,用 context。

你现在能做什么

  • 画出一次请求里消息经过的两个转换点,并在源码里找到 transformContext 先于 convertToLlm 的那一行。
  • 定义一个自己的消息类型,不提供 convertToLlm,验证它被静默丢掉;然后补上转换器,验证它按预期出现或被过滤。
  • 用 pi.appendEntry() 写一条审计记录,确认它出现在会话记录里但不在下一次 provider 请求里。
  • 追加一条带 sections 的系统消息替换某个命名段,用 getCurrentSystemMessage() 读回确认该段已被替换而其它段没动。
  • 在 context_with_system 处理器里删掉首条 system 消息,观察 Pi 报出的错误和"输出照样生效"的行为。

排障速查

现象检查点处理
自定义消息模型看不到
convertToLlm
默认实现只保留四种角色;覆盖它并显式过滤你的类型
审计记录进了模型上下文
载体选错
条目(appendEntry)不进上下文;sendMessage 的 content 会进
provider 报消息格式错误
自定义类型是否漏过滤
在 convertToLlm 里用 flatMap 返回 [] 过滤掉
裁剪没生效
钩子选错
裁剪属于 transformContext,过滤属于 convertToLlm
transformContext 注入的消息丢了
是不是被 convertToLlm 过滤
transformContext 在前,注入后仍要过 convertToLlm
改了系统提示没生效
是否改了只读属性
agent.state.systemPrompt 只读;追加系统消息而不是改属性
系统提示被整段覆盖
是否用了 content 而非 sections
content 是追加;要替换某段用 sections 按名字 patch
工具变更没告诉模型
是否改了 agent.state.tools
Pi 会在下次请求前 diff 并发一条系统消息声明变更
context 里改不动系统消息
事件选错
context 看不到系统消息;用 context_with_system

参考资料