From 2a7cb56abd428f57cd0451ec7fc49f0caffc9da0 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 12:32:47 +0200 Subject: [PATCH] feat(agent): rendered conversation logs with preferred tool syntax - Updated compaction, branch summarization, and session dump formatting to pass preferred model tool syntax into conversation serialization. - Enhanced shared serializers to render assistant tool calls and tool results through grammar envelopes when syntax is available, with the prior compact format as fallback. - Aligned prompt, preview, and test fixtures to the new transcript tags: `[Think]`, `[Tool Call]`, and `[Tool Result]`. --- .../src/compaction/branch-summarization.ts | 3 +- packages/agent/src/compaction/compaction.ts | 7 +-- packages/agent/src/compaction/utils.ts | 53 +++++++++++++++---- .../agent/test/serialize-conversation.test.ts | 2 +- .../snapcompact-shape-preview-doc.md | 4 +- .../components/snapcompact-shape-preview.ts | 4 +- .../src/session/session-dump-format.ts | 23 +++----- .../test/compaction-serialization.test.ts | 4 +- .../src/prompts/snapcompact-summary.md | 2 +- packages/snapcompact/src/snapcompact.ts | 6 +-- packages/snapcompact/test/snapcompact.test.ts | 6 +-- 11 files changed, 68 insertions(+), 46 deletions(-) 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", () => {