Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- Fixed compaction summary inputs escaping Harmony control tokens so Copilot `gpt-5.6-*` models no longer reject serialized analysis-channel markers. ([#5184](https://github.com/can1357/oh-my-pi/issues/5184))

## [16.4.3] - 2026-07-11

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/compaction/branch-summarization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
extractFileOpsFromMessage,
type FileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
serializeConversationForSummary,
stripReadSelector,
truncateToolResultForSummary,
upsertFileOperations,
Expand Down Expand Up @@ -320,7 +320,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, preferredDialect(model.id));
const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));

// Build prompt
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
Expand Down
8 changes: 4 additions & 4 deletions packages/agent/src/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
extractFileOpsFromMessage,
type FileOperations,
SUMMARIZATION_SYSTEM_PROMPT,
serializeConversation,
serializeConversationForSummary,
stripReadSelector,
upsertFileOperations,
} from "./utils";
Expand Down Expand Up @@ -816,7 +816,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, preferredDialect(model.id));
const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));

// Build the prompt with conversation wrapped in tags
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
Expand Down Expand Up @@ -1030,7 +1030,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, preferredDialect(model.id));
const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));

let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (historySummary) {
Expand Down Expand Up @@ -1578,7 +1578,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, preferredDialect(model.id));
const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
const summarizationMessages = [
{
Expand Down
14 changes: 12 additions & 2 deletions packages/agent/src/compaction/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,19 @@ export function truncateToolResultForSummary(text: string): string {
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
}

const HARMONY_CONTROL_TOKEN_RE = /<\|(start|end|message|channel|constrain|return|call)\|>/g;

/**
* Serialize LLM messages as plain summary input without provider control tokens.
*/
export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
const conversation = serializeConversation(messages, dialect);
if (dialect !== "harmony") return conversation;
return conversation.replace(HARMONY_CONTROL_TOKEN_RE, "<\\|$1\\|>");
}

/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
* Serialize LLM messages to transcript text.
* Call convertToLlm() first to handle custom message types.
*/
export function serializeConversation(messages: Message[], dialect?: Dialect): string {
Expand Down
37 changes: 36 additions & 1 deletion packages/agent/test/serialize-conversation.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { serializeConversation } from "@oh-my-pi/pi-agent-core/compaction";
import { serializeConversation, serializeConversationForSummary } from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, Message, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";

const ZERO_USAGE: Usage = {
Expand Down Expand Up @@ -82,6 +82,41 @@ describe("serializeConversation — useless pairs", () => {
expect(out).not.toContain("[Assistant tool calls]:");
});

test("summary serialization escapes Harmony control tokens while preserving assistant thinking", () => {
const messages = [
assistantMessage([
{ type: "thinking", thinking: "Need to inspect the failing compaction path." },
{ type: "text", text: "The final answer stays visible." },
]),
];

const out = serializeConversationForSummary(messages, "harmony");

expect(out).not.toContain("<|channel|>analysis");
expect(out).not.toContain("<|message|>");
expect(out).toContain("<\\|channel\\|>analysis");
expect(out).toContain("<\\|channel\\|>final");
expect(out).toContain("Need to inspect the failing compaction path.");
expect(out).toContain("The final answer stays visible.");
});

test("native Harmony serialization keeps raw transcript markers", () => {
const out = serializeConversation(
[
assistantMessage([
{ type: "thinking", thinking: "Native transcript includes analysis." },
{ type: "text", text: "Native final text." },
]),
],
"harmony",
);

expect(out).toContain("<|channel|>analysis");
expect(out).toContain("<|message|>Native transcript includes analysis.");
expect(out).toContain("<|channel|>final");
expect(out).toContain("Native final text.");
});

test("native dialect serialization drops empty assistants left by useless calls", () => {
const out = serializeConversation(
[
Expand Down
Loading