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:
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user