fix(runtime): preserve tool result facts in compaction
This commit is contained in:
1 parent
6dfa1a851d
commit
529bb109db
2 files changed
+201
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user