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]`.
This commit is contained in:
can1357
2026-06-15 12:32:47 +02:00
parent fc912904e1
commit 2a7cb56abd
11 changed files with 68 additions and 46 deletions
@@ -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;
+4 -3
View File
@@ -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 = `<conversation>\n${conversationText}\n</conversation>\n\n`;
@@ -790,7 +791,7 @@ async function generateShortSummary(
): Promise<string> {
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 = `<conversation>\n${conversationText}\n</conversation>\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 = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
{
+42 -11
View File
@@ -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<string, unknown>;
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<string, unknown>)
.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
// ============================================================================
@@ -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");
});
});
@@ -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.
@@ -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" }
@@ -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<string, unknown>, 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}<parameter name="${key}">${text}</parameter>`);
}
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("</thinking>\n");
} else if (c.type === "toolCall") {
lines.push(`<invoke name="${c.name}">`);
if (c.arguments && typeof c.arguments === "object") {
lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
}
lines.push("<" + "/invoke>\n");
const args = { ...(c.arguments as Record<string, unknown>) };
delete args[INTENT_FIELD];
lines.push(grammar.renderToolCall({ ...c, arguments: args }));
}
}
lines.push("");
@@ -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");
});
@@ -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).
+3 -3
View File
@@ -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}`);
}
}
}
@@ -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", () => {