AgentMessage 到模型消息
分清 transformContext 与 convertToLlm 的职责,说明界面专用数据为什么不进模型上下文。
AgentMessage 到模型消息
Pi 内部保存的消息不是模型认识的消息。
内部用 AgentMessage——它比模型能理解的格式宽:除了 user、assistant、toolResult,还能塞进你自己的类型。但模型只理解 user、assistant、toolResult(以及承载提示与工具声明的 system)。多出来的那些怎么办?
两个钩子:transformContext 和 convertToLlm。它们名字相似、位置相邻,但职责完全不同:一个是裁剪和注入,一个是过滤和转换。 混用就会出现"我明明裁掉了,怎么模型还看得到"这类问题。
这篇讲清一次请求里消息经过的两个转换点、AgentMessage 的可扩展结构、系统提示的所有权,以及两种"不进模型上下文"的写法。
需要的前置只有一条:知道 Pi 的会话内部用比模型协议更宽的消息类型来保存记录。
一、消息流转链路
官方 README 给的链路图:
# 一次 provider 请求里消息经过的两个转换点
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
(可选) (必需)展开成一次请求的实际路径:
# 一次请求实际经过的四个环节
会话里的完整记录(AgentMessage[])
│
├─ transformContext(messages, signal) ← 可选。裁剪旧消息、注入外部上下文
│ 输出仍是 AgentMessage[]
│
├─ convertToLlm(messages) ← 必需。把 AgentMessage[] 转成 Message[]
│ 过滤掉模型不认识的角色,转换自定义类型
│ 输出是 Message[]
│
├─ normalizeContext({ messages }) ← 把系统提示与工具声明折进首条 system 消息
│
└─ provider 请求两个钩子的职责对照:
关键区别:transformContext 之后仍然是 AgentMessage[]。 它在"内部世界"里工作——可以注入只有 Pi 认识的消息。convertToLlm 是通往"模型世界"的唯一出口。
源码里这个顺序是硬编码的:
// 先变换上下文
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
// 再转换成模型格式
const llmMessages = await config.convertToLlm(messages);顺序反了会出问题:如果你在 transformContext 里注入了一条自定义消息,而 convertToLlm 没过滤它,provider 会直接拒绝整个请求。
二、AgentMessage 是什么
AgentMessage 是一个可扩展的联合类型:
// Message 是内置角色,加上声明合并扩展进来的自定义角色
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];CustomAgentMessages 默认是空接口,应用通过声明合并扩展它。
编码智能体往里加了四个角色:
custom 这个角色的字段最能说明问题:
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 时,用的是一个只保留四种角色的过滤器:
// 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 的自定义类型示例展示了另一条路:
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 消息,而不是丢掉:
注意末尾那个 default 分支:遇到不认识的自定义角色,返回 undefined,然后被 .filter() 剔除。所以扩展自己声明的新消息类型,默认也是被丢掉的——除非扩展提供自己的转换逻辑。
扩展的 context 事件
扩展不直接改 convertToLlm,而是通过 context 事件介入。这条路径在 SDK 里被接到了 transformContext 上:
// 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 是只读的,而且它不是一份独立的字符串——它是从记录里的系统消息回放出来的。
SystemMessage 的完整形状:
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 的回放规则:
// 按顺序回放记录里的所有系统消息,还原出当前生效的头部
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 里的工具出集合,回放结束时得到当前生效的工具集。
要改系统提示,正确做法是追加一条系统消息:
// 追加一条带 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 的消息数组(包括带自定义角色的智能体记录):
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 里有两条不同的路,别混:
官方 ExtensionAPI 的注释写得很明确:appendEntry 是 "Append a custom entry to the session for state persistence (not sent to LLM)"。而 custom 消息的 details 才是不发给模型的那部分——content 是发给模型的。
写法一:声明合并自定义消息类型
这是 Agent Core 层的机制。声明合并扩展 CustomAgentMessages,再提供 convertToLlm 决定它去留:
// 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 的默认实现只保留那四种角色,不报错——你扩展了类型却忘了提供转换器,内容就是静默消失。
写法二:用条目承载纯界面数据
在编码智能体里,审计记录这类东西的正确载体是会话条目:
// 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 里跑起来
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 层。
六、验收清单
最后一行是 context 与 context_with_system 的选择依据:如果你需要动系统消息,就用 context_with_system;如果只想动对话内容,用 context。
你现在能做什么
- 画出一次请求里消息经过的两个转换点,并在源码里找到
transformContext先于convertToLlm的那一行。 - 定义一个自己的消息类型,不提供
convertToLlm,验证它被静默丢掉;然后补上转换器,验证它按预期出现或被过滤。 - 用
pi.appendEntry()写一条审计记录,确认它出现在会话记录里但不在下一次 provider 请求里。 - 追加一条带
sections的系统消息替换某个命名段,用getCurrentSystemMessage()读回确认该段已被替换而其它段没动。 - 在
context_with_system处理器里删掉首条 system 消息,观察 Pi 报出的错误和"输出照样生效"的行为。