diff --git a/src/main/agent-runtime/context-compactor.ts b/src/main/agent-runtime/context-compactor.ts index 2e9b3487..a7d04137 100644 --- a/src/main/agent-runtime/context-compactor.ts +++ b/src/main/agent-runtime/context-compactor.ts @@ -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 { + const names = new Map(); + 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(); + 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, 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)) { + collectStructuredFacts(nested, facts, [...path, key]); + } +} + +function collectTextFacts(text: string, facts: Map): 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|$)/); diff --git a/tests/unit/context-compactor-tool-results.test.ts b/tests/unit/context-compactor-tool-results.test.ts new file mode 100644 index 00000000..9c9e5b22 --- /dev/null +++ b/tests/unit/context-compactor-tool-results.test.ts @@ -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"); + }); +});