fix(runtime): preserve tool result facts in compaction

This commit is contained in:
ZacharyZhang-NY committed 2026-04-22 14:22:28 -04:00
1 parent 6dfa1a851d
commit 529bb109db
2 files changed
+201

No files matched your search

+114
View File
@@ -41,11 +41,16 @@ const RECENT_MESSAGE_TAIL = 20;
const CHARS_PER_TOKEN = 2;
/** Per-assistant-message summary cap to keep the compacted turn readable. */
const MAX_SUMMARY_CHARS_PER_MESSAGE = 160;
const MAX_TOOL_RESULT_LINES = 12;
const MAX_TOOL_RESULT_FACTS = 10;
const MAX_FACT_VALUE_CHARS = 120;
interface MessageLike {
role?: string;
content?: unknown;
timestamp?: number;
toolCallId?: string;
details?: unknown;
}
export interface CompactionOutcome {
@@ -180,6 +185,14 @@ function buildSummaryText(older: AgentMessage[]): string {
lines.push(`- ${point}`);
}
}
const toolResultFacts = collectToolResultFacts(older);
if (toolResultFacts.length > 0) {
lines.push("");
lines.push("Tool results:");
for (const fact of toolResultFacts) {
lines.push(`- ${fact}`);
}
}
lines.push("");
lines.push(`(${older.length} earlier message${older.length === 1 ? "" : "s"} compacted.)`);
return lines.join("\n");
@@ -208,6 +221,107 @@ function collectAssistantHighlights(messages: AgentMessage[]): string[] {
return highlights;
}
function collectToolResultFacts(messages: AgentMessage[]): string[] {
const toolNamesById = collectToolNamesById(messages);
const facts: string[] = [];
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "toolResult") continue;
const toolCallId = raw.toolCallId ?? "unknown";
const toolName = toolNamesById.get(toolCallId) ?? "tool";
const resultFacts = collectFactsFromToolResult(raw);
const factText =
resultFacts.length > 0
? resultFacts.slice(0, MAX_TOOL_RESULT_FACTS).join("; ")
: `result=${truncate(firstSentence(extractTextForEstimate(raw)), MAX_FACT_VALUE_CHARS)}`;
facts.push(`${toolName} ${toolCallId}: ${factText}`);
if (facts.length >= MAX_TOOL_RESULT_LINES) break;
}
return facts;
}
function collectToolNamesById(messages: AgentMessage[]): Map<string, string> {
const names = new Map<string, string>();
for (const message of messages) {
const raw = message as MessageLike;
if (raw.role !== "assistant" || !Array.isArray(raw.content)) continue;
for (const block of raw.content) {
if (!block || typeof block !== "object") continue;
const obj = block as { type?: string; id?: string; name?: string };
if (obj.type === "toolCall" && typeof obj.id === "string" && typeof obj.name === "string") {
names.set(obj.id, obj.name);
}
}
}
return names;
}
function collectFactsFromToolResult(message: MessageLike): string[] {
const facts = new Map<string, string>();
if (message.toolCallId) facts.set("toolCallId", message.toolCallId);
collectStructuredFacts(message.details, facts);
const text = extractTextForEstimate(message).trim();
collectStructuredFacts(parseJsonObject(text), facts);
collectTextFacts(text, facts);
return [...facts.entries()].map(([key, value]) => `${key}=${value}`);
}
function parseJsonObject(text: string): unknown {
if (!text.startsWith("{") && !text.startsWith("[")) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
function collectStructuredFacts(value: unknown, facts: Map<string, string>, path: string[] = []): void {
if (facts.size >= MAX_TOOL_RESULT_FACTS) return;
if (value === null || value === undefined) return;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
const key = path.at(-1);
if (key && shouldPreserveFactKey(key)) {
facts.set(key, truncate(String(value), MAX_FACT_VALUE_CHARS));
}
return;
}
if (Array.isArray(value)) {
for (const item of value.slice(0, 8)) {
collectStructuredFacts(item, facts, path);
}
return;
}
if (typeof value !== "object") return;
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
collectStructuredFacts(nested, facts, [...path, key]);
}
}
function collectTextFacts(text: string, facts: Map<string, string>): void {
const pattern = /\b(taskId|approvalId|messageId|status|state|decision|error)\s*[:=]\s*("[^"]+"|'[^']+'|[^,;\s}]+)/g;
for (const match of text.matchAll(pattern)) {
const key = match[1];
const rawValue = match[2];
if (!key || !rawValue || facts.has(key)) continue;
facts.set(key, truncate(rawValue.replace(/^["']|["']$/g, ""), MAX_FACT_VALUE_CHARS));
if (facts.size >= MAX_TOOL_RESULT_FACTS) break;
}
}
function shouldPreserveFactKey(key: string): boolean {
const lower = key.toLowerCase();
return (
lower === "id" ||
lower.endsWith("id") ||
lower.endsWith("_id") ||
lower === "status" ||
lower === "state" ||
lower === "error" ||
lower === "decision" ||
lower === "ok"
);
}
function firstSentence(text: string): string {
const collapsed = text.replace(/\s+/g, " ").trim();
const sentenceEnd = collapsed.search(/[.?!](\s|$)/);
@@ -0,0 +1,87 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { compactMessagesIfNeeded } from "../../src/main/agent-runtime/context-compactor";
function textOf(message: AgentMessage): string {
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) return "";
const first = content[0] as { text?: string } | undefined;
return first?.text ?? "";
}
function makeLongTranscript(olderFacts: AgentMessage[]): AgentMessage[] {
const messages: AgentMessage[] = [
{
role: "user",
content: [{ type: "text", text: `Ship the approval workflow.\n${"context ".repeat(12_000)}` }],
timestamp: 1,
} as AgentMessage,
...olderFacts,
];
for (let i = 0; i < 42; i += 1) {
messages.push({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `recent turn ${i}\n${"tail ".repeat(4_000)}` }],
timestamp: 100 + i,
} as AgentMessage);
}
return messages;
}
describe("context compactor tool result facts", () => {
it("preserves task, approval, message, status, decision, and error facts from tool results", () => {
const messages = makeLongTranscript([
{
role: "assistant",
content: [
{ type: "text", text: "I will request approval for the deliverable." },
{
type: "toolCall",
id: "call-approval",
name: "file_approval",
args: { taskId: "task_123" },
},
],
timestamp: 2,
} as AgentMessage,
{
role: "toolResult",
toolCallId: "call-approval",
content: [{
type: "text",
text: JSON.stringify({
ok: false,
taskId: "task_123",
approvalId: "approval_456",
messageId: "message_789",
status: "blocked",
decision: "requires_human_review",
error: "missing reviewer",
}),
}],
details: {
taskId: "task_123",
approvalId: "approval_456",
messageId: "message_789",
status: "blocked",
decision: "requires_human_review",
error: "missing reviewer",
},
timestamp: 3,
} as AgentMessage,
]);
const outcome = compactMessagesIfNeeded(messages);
expect(outcome.changed).toBe(true);
const summary = textOf(outcome.messages?.[0] as AgentMessage);
expect(summary).toContain("file_approval");
expect(summary).toContain("taskId=task_123");
expect(summary).toContain("approvalId=approval_456");
expect(summary).toContain("messageId=message_789");
expect(summary).toContain("status=blocked");
expect(summary).toContain("decision=requires_human_review");
expect(summary).toContain("error=missing reviewer");
});
});