Chapter 8 · AgentMessage and Message Transformation
Chapter goals: Understand the difference between
AgentMessage's flexible type and LLM-native messages, masterconvertToLlmbridging, and learn to declare custom message types via module augmentation.
8.1 Two Message Systems
Messages flowing inside the Agent ≠ messages sent to the LLM:
AgentMessage (application layer, flexible)
│ Can contain user / assistant / toolResult
│ Can also contain custom types: notification / progress / card …
▼
transformContext() (Chapter 9)
▼
convertToLlm() ← the hero of this chapter: filter + transform
▼
Message[] (LLM layer, strict)
Only recognizes user / assistant / toolResult rolesWhy design it this way? UI prompts, approval cards, system notifications — information that humans need to see but machines don't — stays in the Agent history and gets filtered out before each LLM call.
8.2 convertToLlm: The Bridging Function
convertToLlm runs before every LLM call. The simplest implementation keeps only the three standard roles:
const agent = new Agent({
streamFn: models.streamSimple.bind(models),
// Filter out non-LLM messages; even without custom types, explicit declaration is recommended to prevent leaks
convertToLlm: (messages) =>
messages.filter((m) =>
["user", "assistant", "toolResult"].includes(m.role)
),
initialState: { systemPrompt: "...", model },
});8.3 Custom Message Types: Declaration Merging
Use TypeScript's module augmentation to add new members to the AgentMessage union type:
// Extend pi-agent-core's CustomAgentMessages interface
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
notification: {
role: "notification";
text: string;
timestamp: number;
};
}
}
// Now you can construct this message type legally and push it into history
const msg: AgentMessage = {
role: "notification",
text: "User has uploaded attachment report.pdf",
timestamp: Date.now(),
};
agent.state.messages.push(msg);Then decide its fate in convertToLlm — filter out or downgrade:
convertToLlm: (messages) =>
messages.flatMap((m) => {
if (m.role === "notification") return []; // Choice A: hide from the model
// return [{ role: "user", content: `[System notice] ${m.text}`,
// timestamp: m.timestamp }]; // Choice B: convert to a user message
return [m];
}),8.4 In Practice: Approval Cards
A typical production scenario: human approval is required before a tool call.
declare module "@earendil-works/pi-agent-core" {
interface CustomAgentMessages {
approval_request: {
role: "approval_request";
action: string;
status: "pending" | "approved" | "rejected";
timestamp: number;
};
}
}
const agent = new Agent({
streamFn: models.streamSimple.bind(models),
initialState: { systemPrompt: "...", model },
// Approval records are invisible to the model but fully retained in session history for audit
convertToLlm: (msgs) => msgs.filter((m) => m.role !== "approval_request"),
});8.5 Relationship with the Lower-Level API
When using the lower-level agentLoop(), there is no default conversion — you must provide one explicitly:
import { agentLoop } from "@earendil-works/pi-agent-core";
const config = {
model,
// The lower-level API requires you to provide convertToLlm yourself
convertToLlm: (msgs) => msgs.filter((m) =>
["user", "assistant", "toolResult"].includes(m.role)),
};
for await (const event of agentLoop([userMsg], context, config)) {
console.log(event.type);
}8.6 Chapter Summary
AgentMessageis extensible for applications; the LLM only recognizes user/assistant/toolResult;convertToLlmis the bridge between the two: filtering and transforming on every LLM call;- Use
declare module + CustomAgentMessagesto add custom message types via declaration merging; - Typical pattern: notification/approval-type messages stay in history but do not enter the model context;
- The lower-level
agentLoop()has no default conversion and must be provided by the caller.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Which roles can the LLM understand?
2. When does convertToLlm execute?
3. How do you add a custom notification type to AgentMessage?
4. What happens when using the lower-level agentLoop() without providing convertToLlm?
🛠️ Hands-on Practice
- Implement a "progress log" custom message type
progressthat displays in the UI but is completely transparent to the model. - Change the notification message to a "convert to user text" strategy, observe the model behavior difference, and record applicable scenarios.
- Write a unit test: after mixing 3 kinds of custom messages into the history, verify that convertToLlm output contains only the three standard roles.
Next Chapter 9: before convertToLlm there is another layer, transformContext — the lifesaver for long conversations.