Skip to content

Chapter 8 · AgentMessage and Message Transformation ​

Chapter goals: Understand the difference between AgentMessage's flexible type and LLM-native messages, master convertToLlm bridging, 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:

text
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 roles

Why 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:

typescript
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:

typescript
// 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:

typescript
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.

typescript
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:

typescript
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 ​

  • AgentMessage is extensible for applications; the LLM only recognizes user/assistant/toolResult;
  • convertToLlm is the bridge between the two: filtering and transforming on every LLM call;
  • Use declare module + CustomAgentMessages to 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 ​

  1. Implement a "progress log" custom message type progress that displays in the UI but is completely transparent to the model.
  2. Change the notification message to a "convert to user text" strategy, observe the model behavior difference, and record applicable scenarios.
  3. 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.