桌面 Agent 已经能安全地调用工具。接下来,用户会自然地连续提问:
用户:读取 src/config.ts,告诉我默认端口。
Agent:默认端口是 3000。
用户:把它改成 8080。
第二句话里的“它”指什么?
模型若只收到最后一句,就不知道“它”是 src/config.ts 中的端口。桌面应用必须把前面的对话带入下一次请求。
但“保存对话”不是把界面里的所有文字拼成一个字符串。Pi 至少涉及四种不同概念:
pi-ai的 LLM Message;pi-agent的 Agent Message;- 发送模型前的 Context;
- 桌面应用自己渲染的 UI Message。
本章从这个连续提问的例子出发,逐步拆开它们。
1. 最小多轮对话
使用 Agent 时,多轮记忆已经包含在它的运行状态中:
const agent = new Agent({
initialState: {
model,
systemPrompt: "你是桌面开发助手。",
tools: [readFileTool, editFileTool],
},
streamFn: models.streamSimple.bind(models),
});
await agent.prompt(
"读取 src/config.ts,告诉我默认端口。",
);
await agent.prompt("把它改成 8080。");
第一次 prompt() 完成后,agent.state.messages 已经包含这一轮产生的消息。第二次 prompt() 会从当前状态创建 Context 快照,再追加新的 User Message。
概念上的消息序列是:
sequenceDiagram
participant U as 用户
participant A as Agent
participant M as 模型
participant T as 文件工具
U->>A: 读取 config.ts,告诉我默认端口
A->>M: 历史 + 第一句
M-->>A: Tool Call: read_file
A->>T: 读取文件
T-->>A: Tool Result: 文件内容
A->>M: 历史 + Tool Call + Tool Result
M-->>A: 默认端口是 3000
U->>A: 把它改成 8080
A->>M: 全部相关历史 + 第二句
模型不是从 Agent 的 JavaScript 变量中“记住”上一轮。它是在下一次请求中再次看到了相关消息。
2. LLM Message:模型协议认识的消息
packages/ai/src/types.ts 定义:
type Message =
| UserMessage
| AssistantMessage
| ToolResultMessage;
UserMessage
interface UserMessage {
role: "user";
content:
| string
| (TextContent | ImageContent)[];
timestamp: number;
}
它表示用户输入,也可以同时包含文字和图片。
AssistantMessage
Assistant Message 不只有回答文本,还保存:
TextContent;ThinkingContent;ToolCall;- Provider、API 和模型标识;
- Usage;
- Stop Reason;
- Provider 返回的签名或响应标识;
- 错误与诊断信息。
ToolResultMessage
interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
details?: unknown;
usage?: Usage;
addedToolNames?: string[];
isError: boolean;
timestamp: number;
}
toolCallId 把结果和 Assistant Message 中的 Tool Call 配对。
不要把一轮工具对话扁平化成:
Assistant 调用了 read_file,结果是……
原始结构包含 Provider 继续对话可能需要的 Tool Call ID、Thinking Signature,以及延迟工具加载使用的 addedToolNames 等信息。恢复会话时应持久化完整的标准消息,而不是只保存可见文本。
3. AgentMessage:Agent 运行时认识的消息
基础定义在 packages/agent/src/types.ts:
interface CustomAgentMessages {}
type AgentMessage =
| Message
| CustomAgentMessages[
keyof CustomAgentMessages
];
默认情况下,CustomAgentMessages 是空接口,所以 Agent Message 看起来和 LLM Message 相同。
但应用可以通过 TypeScript declaration merging 扩展它:
type ApprovalNotice = {
role: "approvalNotice";
toolCallId: string;
decision: "allowed" | "denied";
timestamp: number;
};
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
approvalNotice: ApprovalNotice;
}
}
此后 Agent 的消息序列既可以包含模型协议消息,也可以包含应用自定义消息。
flowchart LR
L["LLM Message"] --> A["AgentMessage"]
C["Custom Agent Message"] --> A
A --> S["Agent 状态与事件"]
这就是为什么 Agent Loop 全程使用 AgentMessage[],只在真正调用模型前才转换成 Message[]。
4. UI Message:桌面应用自己的显示模型
Pi 没有定义一个名为 UIMessage 的通用核心类型。它应由桌面应用根据界面需求设计:
type ChatItem =
| {
kind: "user";
id: string;
text: string;
}
| {
kind: "assistant";
id: string;
text: string;
isStreaming: boolean;
}
| {
kind: "tool";
toolCallId: string;
label: string;
state:
| "running"
| "success"
| "error";
}
| {
kind: "approval";
toolCallId: string;
decision: string;
};
UI Message 可以包含:
- 是否展开;
- 动画状态;
- 头像和颜色;
- Tool 卡片进度;
- 是否正在流式生成;
- 复制按钮状态;
- 本地错误提示;
- 审批弹窗状态。
这些数据通常不应该发送给模型。
5. 三种消息不要共用一个类型
flowchart TD
E["AgentEvent"] --> R["UI Reducer"]
R --> U["ChatItem[]:用于渲染"]
E --> A["Agent.state.messages:Agent transcript"]
A --> T["transformContext"]
T --> C["convertToLlm"]
C --> L["Message[]:发送给模型"]
三个集合承担不同职责:
| 集合 | 用途 | 是否直接发送模型 |
|---|---|---|
ChatItem[] |
桌面界面渲染 | 否 |
AgentMessage[] |
Agent 运行时 Transcript | 转换后才发送 |
Message[] |
Provider 请求协议 | 是 |
如果直接把 UI 状态塞进 User Message,模型会看到“折叠”“复制成功”“窗口失焦”等无关文字。反过来,如果只保存 UI 展示文本,Tool Call 的结构化信息又会丢失。
6. Agent Context 不只是消息
基础 Agent 的 Context 是:
interface AgentContext {
systemPrompt: string;
messages: AgentMessage[];
tools?: AgentTool[];
}
真正交给 pi-ai 的 Context 是:
interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
因此模型一次请求看到的是:
System Prompt
+ 转换后的消息历史
+ 当前可用工具定义
System Prompt 不需要伪装成 User Message。工具定义也不是普通对话文字,它们由 Provider 适配层转换成对应模型协议。
7. 第二次 prompt 时发生了什么
packages/agent/src/agent.ts 的关键步骤是:
prompt()把字符串变成 User Message;- 从
AgentState创建 Context 快照; runAgentLoop()把新消息追加到快照;- Loop 发出新 User Message 的生命周期事件;
- 调用模型;
- Assistant Message 和 Tool Result 继续追加到当前 Context;
- Agent 消费事件,把最终消息写回
state.messages。
flowchart TD
P["agent.prompt('把它改成 8080')"] --> U["创建 UserMessage"]
U --> S["复制 state.messages / tools"]
S --> A["追加新 UserMessage"]
A --> M["请求模型"]
M --> R["追加 AssistantMessage"]
R --> T{"有 Tool Call?"}
T -->|是| X["执行并追加 ToolResult"]
X --> M
T -->|否| E["agent_end"]
E --> Q["state.messages 保留完整 Transcript"]
这里的“快照”是顶层数组复制,不是递归深拷贝。应用不要在一次运行进行中原地修改历史消息对象。
8. 真正发送模型前的两道转换
packages/agent/src/agent-loop.ts 的 streamAssistantResponse() 按顺序执行:
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(
messages,
signal,
);
}
const llmMessages =
await config.convertToLlm(messages);
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
两者职责不同:
transformContext
输入和输出都是 AgentMessage[],适合:
- 压缩旧历史;
- 裁剪不相关消息;
- 注入外部上下文;
- 在 Agent Message 层调整请求视图。
convertToLlm
把 AgentMessage[] 变成 Provider 能理解的 Message[],适合:
- 把自定义消息转成 User Message;
- 过滤只供 UI 使用的消息;
- 保留标准 user、assistant、toolResult。
顺序固定为:
AgentMessage[]
→ transformContext
→ AgentMessage[]
→ convertToLlm
→ Message[]
→ pi-ai Context
9. 返回新数组只改变本次请求视图
假设桌面应用临时裁剪很老的通知:
const agent = new Agent({
initialState: {
model,
tools,
},
streamFn,
transformContext: async (messages) => {
return messages.filter(
(message) =>
message.role !== "approvalNotice",
);
},
});
transformContext() 返回的新数组用于本次模型请求,并不会自动替换 agent.state.messages。
因此可以同时拥有:
- 完整的运行 Transcript;
- 更短的模型 Context;
- 独立的 UI View Model。
这是后面会话存储和上下文压缩的基础。
但 Context 快照只复制顶层数组,里面的消息对象仍与 Agent State 浅共享。转换器应按纯函数方式工作:
- 返回新数组;
- 不要原地改写传入数组;
- 不要原地修改消息对象;
- 需要改消息时创建新对象。
原地修改消息对象可能污染 agent.state.messages;原地修改 Context 数组也会影响当前 Run 后续 Turn 的上下文。“只改变本次请求视图”成立的前提,是转换器没有修改共享输入。
转换函数的契约要求不要抛错。基础 Loop 不会把这里的异常自动转换成正常的 Assistant Error Message。应用应返回原消息或另一个安全回退值。
10. 默认 convertToLlm 会做什么
基础 Agent 的默认转换很保守:
function defaultConvertToLlm(
messages: AgentMessage[],
): Message[] {
return messages.filter(
(message) =>
message.role === "user" ||
message.role === "assistant" ||
message.role === "toolResult",
);
}
也就是说,自定义 Agent Message 默认不会发送给模型。
如果自定义消息确实要影响模型,应用必须明确转换:
function convertMessages(
messages: AgentMessage[],
): Message[] {
return messages.flatMap((message) => {
if (message.role === "approvalNotice") {
return [{
role: "user",
content: [{
type: "text",
text:
`用户${message.decision === "allowed"
? "批准"
: "拒绝"}了工具调用。`,
}],
timestamp: message.timestamp,
}];
}
if (
message.role === "user" ||
message.role === "assistant" ||
message.role === "toolResult"
) {
return [message];
}
return [];
});
}
决定一条消息是否进入模型 Context 的,是 convertToLlm,不是它是否在 UI 中可见。
11. coding-agent 增加了哪些自定义消息
packages/coding-agent/src/core/messages.ts 通过 declaration merging 增加四类:
| Role | 作用 |
|---|---|
bashExecution |
表示用户直接执行的 Shell 命令 |
custom |
扩展注入的自定义消息 |
branchSummary |
从一个分支返回时的摘要 |
compactionSummary |
压缩旧历史后的摘要 |
coding-agent 自己提供的 convertToLlm() 会:
- 把普通 user、assistant、toolResult 原样保留;
- 把 Bash 执行格式化成 User Message;
- 把 Custom Message 转成 User Message;
- 给 Branch Summary 和 Compaction Summary 加标记后转成 User Message;
- 过滤
excludeFromContext: true的 Bash Message。
这说明“自定义 Agent Message”不等于“仅供 UI 使用”。最终行为取决于转换器。
12. display 不等于模型可见性
coding-agent 的 CustomMessage 有一个字段:
interface CustomMessage {
role: "custom";
customType: string;
content:
| string
| (TextContent | ImageContent)[];
display: boolean;
details?: unknown;
timestamp: number;
}
display 表达显示意图。当前 convertToLlm() 转换 Custom Message 时没有检查它:
case "custom":
return {
role: "user",
content: normalizedContent,
timestamp: message.timestamp,
};
所以:
display: false
不代表:
不会发送给模型
相反,一条消息可以:
- UI 不显示,但进入模型 Context;
- UI 显示,但被自定义
convertToLlm过滤; - UI 和模型都看到;
- 两边都不保留,只作为瞬时状态。
桌面应用必须分别定义 visibleInUI 和 includeInModelContext,不要用一个布尔值承担两个安全含义。
13. details 通常用于 UI,不会自动发送
Tool Result 的 content 是模型可见内容,details 是任意结构化数据:
return {
content: [{
type: "text",
text: "找到 3 个文件",
}],
details: {
files: fullFileMetadata,
},
};
Provider 的 LLM Message 转换使用 content,不会自动把 details 当成对话正文。
因此可以让:
- 模型只看到简洁摘要;
- UI 使用
details渲染表格、图标或调试信息。
但 details 仍可能被 Session 持久化、日志记录或扩展读取。它不是秘密保险箱,敏感信息依然需要最小化和脱敏。
14. 哪些 UI 状态不应该发送给模型
通常应留在 View Model:
- 输入框草稿;
- 光标和选区;
- 某条消息是否折叠;
- 滚动位置;
- 动画进度;
- “复制成功”提示;
- 当前窗口大小;
- Tool 卡片的视觉状态;
- 审批弹窗是否获得焦点;
- 网络重连次数;
- 本地调试堆栈。
可能需要转成模型消息的业务事实:
- 用户拒绝了某项操作;
- 某个外部任务已经完成;
- 用户上传了一张图片;
- 工具返回了错误;
- 分支或压缩历史的摘要。
判断标准不是“UI 上有没有显示”,而是:
模型要正确完成下一步,是否必须知道这个事实?
15. 流式消息应该保存哪一个版本
生成中,Agent 会不断发出:
message_start;- 多个
message_update; message_end。
在 Assistant 的 message_update 阶段,streamingMessage 保存当前 Partial Assistant Message。实现也会在其他消息的 message_start 与 message_end 之间,让它短暂指向该 User、Custom 或 ToolResult Message。因此桌面 UI 更适合直接按事件类型和消息角色归约,而不是仅凭 streamingMessage 推断内容类型。
一个最小 Reducer 应处理完整生命周期:
function reduceMessageEvent(
state: ChatState,
event: AgentEvent,
): ChatState {
if (event.type === "message_start") {
if (event.message.role === "assistant") {
return startAssistantCard(
state,
event.message,
);
}
if (event.message.role === "user") {
return appendUserCard(
state,
event.message,
);
}
if (event.message.role === "toolResult") {
return startToolResultCard(
state,
event.message,
);
}
return state;
}
if (event.type === "message_update") {
return replaceStreamingCard(
state,
event.message,
);
}
if (event.type === "message_end") {
if (event.message.role !== "assistant") {
return finalizeNonAssistantCard(
state,
event.message,
);
}
return finalizeMessageCard(
state,
event.message,
);
}
return state;
}
message_update 只会为 Assistant Message 发出,而 message_start 和 message_end 也会覆盖 User、ToolResult 和自定义 Agent Message。最终 Transcript 保存完整的消息;UI 增量只是生成同一条 Assistant Message 的过程,不是几十条对话历史。
16. 一份桌面端分层设计
type DesktopConversationState = {
transcript: AgentMessage[];
items: ChatItem[];
composerDraft: string;
activeApproval?: {
toolCallId: string;
};
};
职责可以拆成:
flowchart LR
P["持久层"] --> A["AgentMessage[]"]
A --> G["Agent"]
G --> E["AgentEvent"]
E --> U["UI Reducer"]
U --> V["ChatItem[]"]
A --> X["Context Policy"]
X --> L["Message[]"]
L --> M["模型"]
- 持久层保存可重建的 Transcript;
- Agent 管理当前运行;
- UI Reducer 管理展示状态;
- Context Policy 决定模型能看到什么;
- Provider 只接收标准 Message。
下一章会继续解决 Transcript 如何写入磁盘并在重启后恢复。
17. 完整走一遍“把它改成 8080”
第一次请求结束后,Agent Message 可能是:
UserMessage
AssistantMessage(toolCall: read_file)
ToolResultMessage(file content)
AssistantMessage("默认端口是 3000")
第二次 User Message 追加:
UserMessage("把它改成 8080")
在下一次模型调用前:
transformContext选择本次要保留的 Agent Message;convertToLlm过滤或转换自定义消息;- 组装
Context的 System Prompt、Message 和 Tool; - Provider 适配器把统一结构转成厂商协议;
- 模型从历史 Tool Call、Tool Result 和回答中理解“它”;
- 模型生成
edit_fileTool Call; - 上一章的 Permission Gate 再决定这次修改是否允许。
这也把前面的知识串起来了:
flowchart LR
H["历史 AgentMessage"] --> C["Context 转换"]
N["新 UserMessage"] --> C
C --> M["模型"]
M --> T["edit_file Tool Call"]
T --> P["Permission Gate"]
P --> R["Tool Result"]
R --> H
18. 常见误区
“模型服务会替我长期保存对话”
不要依赖这个假设。Pi 每次构建 Context,并由 Provider 适配器发送当前请求需要的历史。
“界面上看到的文字就是模型收到的全部内容”
错误。System Prompt、工具定义、隐藏的自定义消息和结构化 Tool Result 都可能进入 Context。
“AgentMessage 和 Message 完全相同”
错误。AgentMessage 可以包含应用自定义类型,必须转换后才能发给模型。
“display: false 就是安全地隐藏模型上下文”
错误。在 coding-agent 的 Custom Message 中,它不控制 convertToLlm。
“只存 Assistant 的最终文本就足够恢复多轮对话”
错误。Tool Call ID、Tool Result、图片、Thinking Signature 和其他协议字段可能影响后续兼容性。
“details 没发给模型,所以可以随便放秘密”
错误。它仍可能被持久化、展示或被扩展读取。
19. 本章小结
这一章从连续追问“把它改成 8080”拆开了 Pi 的消息层:
Message是pi-ai和 Provider 能理解的协议消息;AgentMessage在此基础上允许应用增加自定义消息;AgentContext保存 System Prompt、Agent Message 和工具;transformContext先调整 Agent Message 请求视图;convertToLlm再过滤和转换成标准 Message;ChatItem或 UI Message 是桌面应用自己的 View Model,不应直接等同于模型 Context;- coding-agent 的
display与模型可见性不是同一个开关; - 流式 Update 应更新同一张消息卡片,最终只保存完整 Assistant Message;
- 多轮“记忆”本质上是下一次请求重新携带相关历史。
20. 自测
Message和AgentMessage的关系是什么?- 为什么桌面应用还需要独立的
ChatItem? transformContext和convertToLlm的调用顺序是什么?- 自定义 Agent Message 默认会不会被基础 Agent 发给模型?
- coding-agent 的
CustomMessage.display能否控制模型可见性? - Tool Result 的
content和details分别适合放什么? - 为什么不能把每次
message_update都追加成一条历史消息? - 第二次
prompt("把它改成 8080")时,模型怎样理解“它”?