diff --git a/packages/agent/src/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts
index ae2209468..8ff13c0db 100644
--- a/packages/agent/src/compaction/branch-summarization.ts
+++ b/packages/agent/src/compaction/branch-summarization.ts
@@ -6,6 +6,7 @@
*/
import type { ApiKey, Model } from "@oh-my-pi/pi-ai";
+import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
import { prompt } from "@oh-my-pi/pi-utils";
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
import type { AgentMessage } from "../types";
@@ -290,7 +291,7 @@ export async function generateBranchSummary(
// Transform to LLM-compatible messages, then serialize to text
// Serialization prevents the model from treating it as a conversation to continue
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
- const conversationText = serializeConversation(llmMessages);
+ const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
// Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts
index b871e9bd6..059fe3416 100644
--- a/packages/agent/src/compaction/compaction.ts
+++ b/packages/agent/src/compaction/compaction.ts
@@ -18,6 +18,7 @@ import {
type Usage,
withAuth,
} from "@oh-my-pi/pi-ai";
+import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
import { countTokens } from "@oh-my-pi/pi-natives";
import { logger, prompt } from "@oh-my-pi/pi-utils";
@@ -642,7 +643,7 @@ export async function generateSummary(
// Serialize conversation to text so model doesn't try to continue it
// Convert to LLM messages first (handles custom app messages when caller provides a transformer).
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
- const conversationText = serializeConversation(llmMessages);
+ const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
// Build the prompt with conversation wrapped in tags
let promptText = `\n${conversationText}\n\n\n`;
@@ -790,7 +791,7 @@ async function generateShortSummary(
): Promise {
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
- const conversationText = serializeConversation(llmMessages);
+ const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
let promptText = `\n${conversationText}\n\n\n`;
if (historySummary) {
@@ -1155,7 +1156,7 @@ async function generateTurnPrefixSummary(
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
- const conversationText = serializeConversation(llmMessages);
+ const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
{
diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts
index a79fad0c8..4108c629b 100644
--- a/packages/agent/src/compaction/utils.ts
+++ b/packages/agent/src/compaction/utils.ts
@@ -2,7 +2,8 @@
* Shared utilities for compaction and branch summarization.
*/
-import type { Message } from "@oh-my-pi/pi-ai";
+import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
+import { type Grammar, getInbandGrammar, type GrammarToolResult, type ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
import type { AgentMessage } from "../types";
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
@@ -188,7 +189,8 @@ function truncateForSummary(text: string, maxChars: number): string {
* This prevents the model from treating it as a conversation to continue.
* Call convertToLlm() first to handle custom message types.
*/
-export function serializeConversation(messages: Message[]): string {
+export function serializeConversation(messages: Message[], syntax?: ToolCallSyntax): string {
+ const grammar = syntax ? getInbandGrammar(syntax) : undefined;
const parts: string[] = [];
// Tool results flagged contextually useless (and their paired calls) are
@@ -215,7 +217,7 @@ export function serializeConversation(messages: Message[]): string {
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
- const toolCalls: string[] = [];
+ const toolCalls: ToolCall[] = [];
for (const block of msg.content) {
if (block.type === "text") {
@@ -224,22 +226,18 @@ export function serializeConversation(messages: Message[]): string {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
if (uselessCallIds.has(block.id)) continue;
- const args = block.arguments as Record;
- const argsStr = Object.entries(args)
- .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
- .join(", ");
- toolCalls.push(`${block.name}(${argsStr})`);
+ toolCalls.push(block);
}
}
if (thinkingParts.length > 0) {
- parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
+ parts.push(`[Think]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
- parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
+ parts.push(`[Tool Call]: ${renderToolCalls(toolCalls, grammar)}`);
}
} else if (msg.role === "toolResult") {
if (uselessCallIds.has(msg.toolCallId)) continue;
@@ -248,7 +246,8 @@ export function serializeConversation(messages: Message[]): string {
.map(c => c.text)
.join("");
if (content) {
- parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
+ const text = truncateForSummary(content, TOOL_RESULT_MAX_CHARS);
+ parts.push(`[Tool Result]: ${renderToolResult(msg.toolCallId, msg.toolName, msg.isError === true, text, grammar)}`);
}
}
}
@@ -256,6 +255,38 @@ export function serializeConversation(messages: Message[]): string {
return parts.join("\n\n");
}
+/**
+ * Render an assistant turn's tool calls. With a grammar, emit the model's
+ * native invocation block; otherwise fall back to a compact `name(args)` list.
+ */
+function renderToolCalls(calls: ToolCall[], grammar: Grammar | undefined): string {
+ if (grammar) return grammar.renderAssistantToolCalls(calls);
+ return calls
+ .map(call => {
+ const argsStr = Object.entries(call.arguments as Record)
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
+ .join(", ");
+ return `${call.name}(${argsStr})`;
+ })
+ .join("; ");
+}
+
+/**
+ * Render a single tool result. With a grammar, emit the model's native
+ * tool-result envelope; otherwise return the (already truncated) text verbatim.
+ */
+function renderToolResult(
+ id: string,
+ name: string,
+ isError: boolean,
+ text: string,
+ grammar: Grammar | undefined,
+): string {
+ if (!grammar) return text;
+ const result: GrammarToolResult = { id, name, index: 0, text, isError };
+ return grammar.renderToolResults([result]);
+}
+
// ============================================================================
// Summarization System Prompt
// ============================================================================
diff --git a/packages/agent/test/serialize-conversation.test.ts b/packages/agent/test/serialize-conversation.test.ts
index 9fe87279a..87ad21cfd 100644
--- a/packages/agent/test/serialize-conversation.test.ts
+++ b/packages/agent/test/serialize-conversation.test.ts
@@ -60,6 +60,6 @@ describe("serializeConversation — useless pairs", () => {
]);
expect(out).toContain('pattern="beta"');
- expect(out).toContain("[Tool result]: grep crashed");
+ expect(out).toContain("[Tool Result]: grep crashed");
});
});
diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md
index 6d6f218fc..7420d7046 100644
--- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md
+++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview-doc.md
@@ -1,8 +1,8 @@
[User]: Fix the settings overlay crash. Wheeling past the last row throws.
-[Assistant tool calls]: read(path="src/select-list.ts:140-180")
+[Tool Call]: read(path="src/select-list.ts:140-180")
-[Tool result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
+[Tool Result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
[Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
diff --git a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts
index 5f5418310..3074fa25f 100644
--- a/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts
+++ b/packages/coding-agent/src/modes/components/snapcompact-shape-preview.ts
@@ -38,10 +38,10 @@ const ZOOM_SCALE = 4;
const MAX_IMAGE_COLS = 28;
const MAX_IMAGE_ROWS = 14;
-/** Sample transcript with `[Tool result]:` bodies wrapped in dim-ink toggles. */
+/** Sample transcript with `[Tool Result]:` bodies wrapped in dim-ink toggles. */
const PREVIEW_TEXT = sampleDoc
.trim()
- .replace(/\[Tool result\]: ([^[]*)/g, (_match, body: string) => `[Tool result]: ${DIM_ON}${body}${DIM_OFF}`);
+ .replace(/\[Tool Result\]: ([^[]*)/g, (_match, body: string) => `[Tool Result]: ${DIM_ON}${body}${DIM_OFF}`);
type PreviewEntry =
| { state: "rendering" }
diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts
index a44f70cd7..fc7efaa7b 100644
--- a/packages/coding-agent/src/session/session-dump-format.ts
+++ b/packages/coding-agent/src/session/session-dump-format.ts
@@ -4,7 +4,8 @@
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
-import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
+import { getInbandGrammar, renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
+import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
import { getVisibleThinkingText } from "../utils/thinking-display";
import {
type BashExecutionMessage,
@@ -34,22 +35,12 @@ export interface FormatSessionDumpTextOptions {
tools?: readonly SessionDumpToolInfo[];
}
-/** Serialize an object as XML parameter elements, one per key. */
-function formatArgsAsXml(args: Record, indent = "\t"): string {
- const parts: string[] = [];
- for (const [key, value] of Object.entries(args)) {
- if (key === INTENT_FIELD) continue;
- const text = typeof value === "string" ? value : JSON.stringify(value);
- parts.push(`${indent}${text}`);
- }
- return parts.join("\n");
-}
-
/**
* Format messages and session metadata as markdown/plain text (same as AgentSession.formatSessionAsText / /dump).
*/
export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
const lines: string[] = [];
+ const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
if (systemPrompt.length > 0) {
@@ -112,11 +103,9 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
lines.push(thinking);
lines.push("\n");
} else if (c.type === "toolCall") {
- lines.push(``);
- if (c.arguments && typeof c.arguments === "object") {
- lines.push(formatArgsAsXml(c.arguments as Record));
- }
- lines.push("<" + "/invoke>\n");
+ const args = { ...(c.arguments as Record) };
+ delete args[INTENT_FIELD];
+ lines.push(grammar.renderToolCall({ ...c, arguments: args }));
}
}
lines.push("");
diff --git a/packages/coding-agent/test/compaction-serialization.test.ts b/packages/coding-agent/test/compaction-serialization.test.ts
index 86738c230..e6965354a 100644
--- a/packages/coding-agent/test/compaction-serialization.test.ts
+++ b/packages/coding-agent/test/compaction-serialization.test.ts
@@ -18,7 +18,7 @@ describe("serializeConversation", () => {
const result = serializeConversation(messages);
- expect(result).toContain("[Tool result]:");
+ expect(result).toContain("[Tool Result]:");
expect(result).toContain("[... 3000 more characters truncated]");
expect(result).toContain("x".repeat(2000));
expect(result).not.toContain("x".repeat(3000));
@@ -39,7 +39,7 @@ describe("serializeConversation", () => {
const result = serializeConversation(messages);
- expect(result).toBe(`[Tool result]: ${shortContent}`);
+ expect(result).toBe(`[Tool Result]: ${shortContent}`);
expect(result).not.toContain("truncated");
});
diff --git a/packages/snapcompact/src/prompts/snapcompact-summary.md b/packages/snapcompact/src/prompts/snapcompact-summary.md
index bbd65ecea..182f23d0a 100644
--- a/packages/snapcompact/src/prompts/snapcompact-summary.md
+++ b/packages/snapcompact/src/prompts/snapcompact-summary.md
@@ -1,6 +1,6 @@
Prior conversation history has been archived verbatim onto {{frameCount}} snapcompact frame{{#if multipleFrames}}s{{/if}} — the bitmap image{{#if multipleFrames}}s{{/if}} attached below{{#if multipleFrames}}, ordered oldest to newest{{/if}}.
-Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each — read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) — treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Assistant thinking]:, [Assistant tool calls]:, and [Tool result]:.
+Reading a frame: monospace {{fontCell}} pixel font on a white background, {{#if docColumns}}typeset as two word-wrapped newspaper columns of {{cols}} characters by {{rows}} lines each — read the left column top to bottom, then the right column{{else}}{{cols}} characters per row, {{rows}} text rows per frame; read left to right, top to bottom. Text flows continuously with no word wrap, so words may break across row ends{{/if}}. Horizontal whitespace runs were collapsed to single spaces; line breaks print as a solid black cell (one character wide) — treat each as a newline. {{#if sentenceInk}}Ink color cycles through six colors, advancing at sentence boundaries — a color change marks a new sentence.{{else}}Glyphs are plain black ink.{{/if}}{{#if stopwordDimmed}} Common function words (the, of, and, …) are printed in dim gray; content words carry the full ink.{{/if}}{{#if dimmedToolResults}} Tool output is printed in dim gray ink — gray text is archived tool output, not conversation.{{/if}}{{#if lineRepeated}} Every text line is printed twice in a row — first on the white background, then repeated on a pale yellow band. The copies are identical: read each line once and use the duplicate only to double-check hard glyphs.{{/if}} Roles are tagged inline as [User]:, [Assistant]:, [Think]:, [Tool Call]:, and [Tool Result]:.
{{#if mixedShapes}}
Older frames may use a different font, grid, or ink coloring than described above; the reading order is always the same (left to right, top to bottom, oldest frame first).
diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts
index 2b915a200..2ea94b123 100644
--- a/packages/snapcompact/src/snapcompact.ts
+++ b/packages/snapcompact/src/snapcompact.ts
@@ -726,13 +726,13 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
}
if (thinkingParts.length > 0) {
- parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
+ parts.push(`[Think]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
- parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
+ parts.push(`[Tool Call]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
if (uselessCallIds.has(msg.toolCallId)) continue;
@@ -743,7 +743,7 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
if (content) {
// Args above are JSON-escaped, so only raw result text can carry toggles.
const body = truncateForSummary(stripDimMarkers(content), toolResultMaxChars, headRatio);
- parts.push(dimToolResults ? `[Tool result]: ${DIM_ON}${body}${DIM_OFF}` : `[Tool result]: ${body}`);
+ parts.push(dimToolResults ? `[Tool Result]: ${DIM_ON}${body}${DIM_OFF}` : `[Tool Result]: ${body}`);
}
}
}
diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts
index f683000d7..c7fe54ec4 100644
--- a/packages/snapcompact/test/snapcompact.test.ts
+++ b/packages/snapcompact/test/snapcompact.test.ts
@@ -459,7 +459,7 @@ describe("serializeConversation", () => {
const text = `HEAD-${"x".repeat(5000)}-TAIL`;
const out = snapcompact.serializeConversation([createToolResultMessage(text)]);
// Default cap 2000 at 0.6 head ratio: 1200 head + 800 tail survive.
- expect(out).toContain("[Tool result]: ");
+ expect(out).toContain("[Tool Result]: ");
expect(out).toContain("HEAD-");
expect(out).toContain("[... 3010 chars elided ...]");
expect(out.endsWith(`-TAIL${snapcompact.DIM_OFF}`)).toBe(true);
@@ -506,14 +506,14 @@ describe("serializeConversation", () => {
createUserMessage(`hello ${snapcompact.DIM_ON}world`),
createToolResultMessage("ok"),
]);
- expect(out).toContain(`[Tool result]: ${snapcompact.DIM_ON}ok${snapcompact.DIM_OFF}`);
+ expect(out).toContain(`[Tool Result]: ${snapcompact.DIM_ON}ok${snapcompact.DIM_OFF}`);
// A stray toggle in user content cannot forge a dim span.
expect(out).toContain("[User]: hello world");
});
it("omits dim toggles when dimToolResults is false", () => {
const out = snapcompact.serializeConversation([createToolResultMessage("ok")], { dimToolResults: false });
- expect(out).toBe("[Tool result]: ok");
+ expect(out).toBe("[Tool Result]: ok");
});
it("skips tool call/result pairs flagged useless", () => {