diff --git a/scripts/format-staged.js b/scripts/format-staged.js index 0ad2d7dd7..346d67389 100644 --- a/scripts/format-staged.js +++ b/scripts/format-staged.js @@ -79,9 +79,11 @@ function getGitPaths(args, repoRoot) { } function formatFiles(repoRoot, oxfmt, files) { + const useShell = process.platform === "win32" && /\.cmd$/i.test(oxfmt.command); const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], { cwd: repoRoot, stdio: "inherit", + shell: useShell, }); return result.status === 0; } diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 2daafd086..d02b4a5a5 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -77,6 +77,8 @@ export type CompactEmbeddedPiSessionParams = { messageProvider?: string; agentAccountId?: string; authProfileId?: string; + /** Optional override for compaction keep-recent tokens (manual /compact). */ + compactionKeepRecentTokensOverride?: number; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ @@ -370,6 +372,14 @@ export async function compactEmbeddedPiSessionDirect( settingsManager, minReserveTokens: resolveCompactionReserveTokensFloor(params.config), }); + const isHandoffMode = params.config?.agents?.defaults?.compaction?.mode === "handoff"; + if (typeof params.compactionKeepRecentTokensOverride === "number") { + settingsManager.applyOverrides({ + compaction: { + keepRecentTokens: Math.max(0, Math.floor(params.compactionKeepRecentTokensOverride)), + }, + }); + } const additionalExtensionPaths = buildEmbeddedExtensionPaths({ cfg: params.config, sessionManager, @@ -377,6 +387,12 @@ export async function compactEmbeddedPiSessionDirect( modelId, model, }); + if (isHandoffMode) { + log.debug("compaction extensions", { + count: additionalExtensionPaths.length, + extensions: additionalExtensionPaths, + }); + } const { builtInTools, customTools } = splitSdkTools({ tools, @@ -424,7 +440,10 @@ export async function compactEmbeddedPiSessionDirect( if (limited.length > 0) { session.agent.replaceMessages(limited); } - const result = await session.compact(params.customInstructions); + const customInstructions = isHandoffMode + ? params.customInstructions?.trim() + : params.customInstructions; + const result = await session.compact(customInstructions || undefined); // Estimate tokens after compaction by summing token estimates for remaining messages let tokensAfter: number | undefined; try { diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 73deae21d..46b05bb5c 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -12,6 +12,7 @@ import { computeEffectiveSettings } from "../pi-extensions/context-pruning/setti import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js"; import { ensurePiCompactionReserveTokens } from "../pi-settings.js"; import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "./cache-ttl.js"; +import { log } from "./logger.js"; function resolvePiExtensionPath(id: string): string { const self = fileURLToPath(import.meta.url); @@ -62,8 +63,10 @@ function buildContextPruningExtension(params: { }; } -function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" { - return cfg?.agents?.defaults?.compaction?.mode === "safeguard" ? "safeguard" : "default"; +function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" | "handoff" { + const mode = cfg?.agents?.defaults?.compaction?.mode; + if (mode === "safeguard" || mode === "handoff") return mode; + return "default"; } export function buildEmbeddedExtensionPaths(params: { @@ -74,8 +77,13 @@ export function buildEmbeddedExtensionPaths(params: { model: Model | undefined; }): string[] { const paths: string[] = []; - if (resolveCompactionMode(params.cfg) === "safeguard") { + const compactionMode = resolveCompactionMode(params.cfg); + log.debug(`compaction mode: ${compactionMode}`); + if (compactionMode === "safeguard") { paths.push(resolvePiExtensionPath("compaction-safeguard")); + } else if (compactionMode === "handoff") { + paths.push(resolvePiExtensionPath("compaction-summary-prefix")); + paths.push(resolvePiExtensionPath("compaction-handoff")); } const pruning = buildContextPruningExtension(params); if (pruning.additionalExtensionPaths) { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index f1c487470..f2ed395ab 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -86,6 +86,43 @@ import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; import { detectAndLoadPromptImages } from "./images.js"; +function extractMessageText(message: AgentMessage): string | null { + if (!message || typeof message !== "object") return null; + if (message.role === "assistant") { + const parts = + "content" in message && Array.isArray(message.content) + ? message.content + .map((block) => + block?.type === "text" && typeof block.text === "string" ? block.text : "", + ) + .filter(Boolean) + : []; + const joined = parts.join("\n").trim(); + return joined.length > 0 ? joined : null; + } + if (message.role === "user" || message.role === "custom") { + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Array.isArray(content)) { + const parts = content + .map((block) => + block?.type === "text" && typeof block.text === "string" ? block.text : "", + ) + .filter(Boolean); + const joined = parts.join("\n").trim(); + return joined.length > 0 ? joined : null; + } + } + if (message.role === "compactionSummary") { + const summary = (message as { summary?: unknown }).summary; + return typeof summary === "string" ? summary.trim() : null; + } + return null; +} + export function injectHistoryImagesIntoMessages( messages: AgentMessage[], historyImagesByIndex: Map, @@ -712,6 +749,26 @@ export async function runEmbeddedAttempt( messages: activeSession.messages, }); + const extensionRunner = activeSession.extensionRunner; + const isHandoffMode = params.config?.agents?.defaults?.compaction?.mode === "handoff"; + if (isHandoffMode) { + if (extensionRunner?.hasHandlers("context")) { + try { + const preview = await extensionRunner.emitContext(activeSession.messages); + const firstRoles = preview.slice(0, 6).map((msg) => msg.role); + const firstText = extractMessageText(preview[0])?.slice(0, 160); + log.info( + `[agent/embedded] context preview: firstRoles=${firstRoles.join(",")} ` + + `firstText=${firstText ? JSON.stringify(firstText) : "(none)"}`, + ); + } catch (err) { + log.warn(`[agent/embedded] context preview failed: ${String(err)}`); + } + } else { + log.info("[agent/embedded] context preview: no context handlers registered"); + } + } + // Repair orphaned trailing user messages so new prompts don't violate role ordering. const leafEntry = sessionManager.getLeafEntry(); if (leafEntry?.type === "message" && leafEntry.message.role === "user") { diff --git a/src/agents/pi-extensions/compaction-handoff.ts b/src/agents/pi-extensions/compaction-handoff.ts new file mode 100644 index 000000000..1f89868ca --- /dev/null +++ b/src/agents/pi-extensions/compaction-handoff.ts @@ -0,0 +1,490 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent"; +import { completeSimple } from "@mariozechner/pi-ai"; + +import { + computeAdaptiveChunkRatio, + estimateMessagesTokens, + pruneHistoryForContextShare, + resolveContextWindowTokens, + summarizeInStages, +} from "../compaction.js"; + +import { log } from "../pi-embedded-runner/logger.js"; + +type ModelSnapshotEntry = { + timestamp: number; + provider?: string; + modelApi?: string | null; + modelId?: string; +}; + +type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown }; + +const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot"; + +function readLastModelSnapshot(entries: Array): ModelSnapshotEntry | null { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue; + const data = entry?.data as ModelSnapshotEntry | undefined; + if (data && typeof data === "object") return data; + } + return null; +} + +const SYSTEM_PROMPT = + "You are a compaction summarizer. Produce a concise summary for future context." + + " Do not continue the conversation or answer questions."; +const DEFAULT_INSTRUCTIONS = + "Summarize the conversation for continuity. Preserve exact file paths, commands, and errors." + + " Keep it concise."; +const MAX_TOOL_FAILURES = 8; +const MAX_TOOL_FAILURE_CHARS = 240; + +function computeFileLists(fileOps: FileOperations): { + readFiles: string[]; + modifiedFiles: string[]; +} { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles, modifiedFiles }; +} + +function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +type ToolFailure = { + toolCallId: string; + toolName: string; + summary: string; + meta?: string; +}; + +function normalizeFailureText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function truncateFailureText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, Math.max(0, maxChars - 3))}...`; +} + +function formatToolFailureMeta(details: unknown): string | undefined { + if (!details || typeof details !== "object") return undefined; + const record = details as Record; + const status = typeof record.status === "string" ? record.status : undefined; + const exitCode = + typeof record.exitCode === "number" && Number.isFinite(record.exitCode) + ? record.exitCode + : undefined; + const parts: string[] = []; + if (status) parts.push(`status=${status}`); + if (exitCode !== undefined) parts.push(`exitCode=${exitCode}`); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractToolResultText(content: unknown): string { + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const rec = block as { type?: unknown; text?: unknown }; + if (rec.type === "text" && typeof rec.text === "string") { + parts.push(rec.text); + } + } + return parts.join("\n"); +} + +function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { + const failures: ToolFailure[] = []; + const seen = new Set(); + + for (const message of messages) { + if (!message || typeof message !== "object") continue; + const role = (message as { role?: unknown }).role; + if (role !== "toolResult") continue; + const toolResult = message as { + toolCallId?: unknown; + toolName?: unknown; + content?: unknown; + details?: unknown; + isError?: unknown; + }; + if (toolResult.isError !== true) continue; + const toolCallId = typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : ""; + if (!toolCallId || seen.has(toolCallId)) continue; + seen.add(toolCallId); + + const toolName = + typeof toolResult.toolName === "string" && toolResult.toolName.trim() + ? toolResult.toolName + : "tool"; + const rawText = extractToolResultText(toolResult.content); + const meta = formatToolFailureMeta(toolResult.details); + const normalized = normalizeFailureText(rawText); + const summary = truncateFailureText( + normalized || (meta ? "failed" : "failed (no output)"), + MAX_TOOL_FAILURE_CHARS, + ); + failures.push({ toolCallId, toolName, summary, meta }); + } + + return failures; +} + +function formatToolFailuresSection(failures: ToolFailure[]): string { + if (failures.length === 0) return ""; + const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => { + const meta = failure.meta ? ` (${failure.meta})` : ""; + return `- ${failure.toolName}${meta}: ${failure.summary}`; + }); + if (failures.length > MAX_TOOL_FAILURES) { + lines.push(`- ...and ${failures.length - MAX_TOOL_FAILURES} more`); + } + return `\n\n## Tool Failures\n${lines.join("\n")}`; +} + +function extractTextFromBlocks(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => { + if (!block || typeof block !== "object") return ""; + const rec = block as { type?: unknown; text?: unknown; thinking?: unknown; name?: unknown }; + if (rec.type === "text" && typeof rec.text === "string") return rec.text; + if (rec.type === "thinking" && typeof rec.thinking === "string") return rec.thinking; + if (rec.type === "toolCall" && typeof rec.name === "string") return `[tool:${rec.name}]`; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function serializeConversation(messages: AgentMessage[]): string { + const parts: string[] = []; + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + switch (msg.role) { + case "user": { + const text = extractTextFromBlocks((msg as { content?: unknown }).content); + if (text) parts.push(`[User]: ${text}`); + break; + } + case "assistant": { + const text = extractTextFromBlocks((msg as { content?: unknown }).content); + if (text) parts.push(`[Assistant]: ${text}`); + break; + } + case "toolResult": { + const text = extractTextFromBlocks((msg as { content?: unknown }).content); + if (text) parts.push(`[Tool result]: ${text}`); + break; + } + case "bashExecution": { + const rec = msg as { + command?: string; + output?: string; + exitCode?: number | undefined; + cancelled?: boolean; + }; + const command = rec.command ?? ""; + const output = rec.output ?? ""; + const suffix = rec.cancelled ? " (cancelled)" : ""; + const exit = rec.exitCode != null ? ` (exit ${rec.exitCode})` : ""; + parts.push(`[Bash]: ${command}${suffix}${exit}\n${output}`.trim()); + break; + } + case "custom": + case "branchSummary": + case "compactionSummary": { + const text = extractTextFromBlocks( + (msg as { content?: unknown; summary?: string }).content, + ); + const summary = (msg as { summary?: string }).summary; + const payload = text || summary || ""; + if (payload) parts.push(`[Context]: ${payload}`); + break; + } + default: + break; + } + } + return parts.join("\n\n"); +} + +function summarizeRoleCounts(messages: AgentMessage[]): Record { + const counts: Record = {}; + for (const msg of messages) { + const role = (msg as { role?: unknown }).role; + if (typeof role !== "string") continue; + counts[role] = (counts[role] ?? 0) + 1; + } + return counts; +} + +async function summarizeMessages(params: { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + reserveTokens: number; + instructions?: string; + previousSummary?: string; + fileOpsText?: string; +}): Promise { + const base = params.instructions?.trim() || DEFAULT_INSTRUCTIONS; + const llmText = serializeConversation(params.messages); + let prompt = `${base}\n\n\n${llmText}\n`; + if (params.fileOpsText) { + prompt += `\n\n${params.fileOpsText}`; + } + + const maxTokens = Math.max(1, Math.floor(params.reserveTokens * 0.8)); + const response = await completeSimple( + params.model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [ + { + role: "user" as const, + content: [{ type: "text" as const, text: prompt }], + timestamp: Date.now(), + }, + ], + }, + { maxTokens, signal: params.signal, apiKey: params.apiKey }, + ); + if (response.stopReason === "error") { + throw new Error(`Compaction summarization failed: ${response.errorMessage || "Unknown error"}`); + } + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); +} + +type CompactionPreparationLike = { + firstKeptEntryId: string; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: { reserveTokens: number }; +}; + +function buildSplitTurnMessages( + messagesToSummarize: AgentMessage[], + turnPrefixMessages: AgentMessage[], +): AgentMessage[] { + if (turnPrefixMessages.length === 0) return messagesToSummarize; + const marker: AgentMessage = { + role: "custom", + customType: "split-turn", + display: false, + timestamp: Date.now(), + content: [ + { + type: "text", + text: "[Split turn prefix follows; summarize with the rest of the conversation.]", + }, + ], + }; + return [...messagesToSummarize, marker, ...turnPrefixMessages]; +} + +async function summarizeSafeguard(params: { + preparation: CompactionPreparationLike; + model: NonNullable; + apiKey: string; + signal: AbortSignal; + customInstructions?: string; +}): Promise { + const { preparation, model, apiKey, signal, customInstructions } = params; + const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); + const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles); + const toolFailures = collectToolFailures([ + ...preparation.messagesToSummarize, + ...preparation.turnPrefixMessages, + ]); + const toolFailureSection = formatToolFailuresSection(toolFailures); + + const contextWindowTokens = resolveContextWindowTokens(model); + const turnPrefixMessages = preparation.turnPrefixMessages ?? []; + let messagesToSummarize = preparation.messagesToSummarize; + + const tokensBefore = + typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore) + ? preparation.tokensBefore + : undefined; + if (tokensBefore !== undefined) { + const summarizableTokens = + estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages); + const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens)); + const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5); + + if (newContentTokens > maxHistoryTokens) { + const pruned = pruneHistoryForContextShare({ + messages: messagesToSummarize, + maxContextTokens: contextWindowTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + if (pruned.droppedChunks > 0) { + const newContentRatio = (newContentTokens / contextWindowTokens) * 100; + console.warn( + `Compaction safeguard: new content uses ${newContentRatio.toFixed( + 1, + )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` + + `(${pruned.droppedMessages} messages) to fit history budget.`, + ); + messagesToSummarize = pruned.messages; + } + } + } + + const allMessages = buildSplitTurnMessages(messagesToSummarize, turnPrefixMessages); + const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); + const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio)); + const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); + + const historySummary = await summarizeInStages({ + messages: allMessages, + model, + apiKey, + signal, + reserveTokens, + maxChunkTokens, + contextWindow: contextWindowTokens, + customInstructions, + previousSummary: preparation.previousSummary, + }); + + let summary = historySummary; + summary += toolFailureSection; + summary += fileOpsSummary; + return summary; +} + +export default function compactionFreeformExtension(api: ExtensionAPI): void { + api.on("session_before_compact", async (event, ctx) => { + const { preparation, customInstructions, signal } = event; + let model = ctx.model; + if (!model) { + const snapshot = readLastModelSnapshot(ctx.sessionManager.getEntries() as CustomEntryLike[]); + if (snapshot?.provider && snapshot?.modelId) { + model = ctx.modelRegistry.find(snapshot.provider, snapshot.modelId); + } + } + if (!model) { + log.warn("compaction handoff: missing model"); + return; + } + const apiKey = await ctx.modelRegistry.getApiKey(model); + if (!apiKey) { + log.warn("compaction handoff: missing api key"); + return; + } + + const messagesToSummarize = preparation.messagesToSummarize ?? []; + const turnPrefixMessages = preparation.turnPrefixMessages ?? []; + const mergedMessages = buildSplitTurnMessages(messagesToSummarize, turnPrefixMessages); + const messagesForSummary = mergedMessages; + const roleCounts = summarizeRoleCounts(messagesForSummary); + const prefixRoleCounts = summarizeRoleCounts(turnPrefixMessages); + const firstRoles = messagesForSummary + .slice(0, 6) + .map((msg: AgentMessage) => msg.role) + .join(","); + log.info("compaction handoff: input snapshot", { + messagesToSummarize: messagesToSummarize.length, + turnPrefixMessages: turnPrefixMessages.length, + mergedMessages: mergedMessages.length, + isSplitTurn: preparation.isSplitTurn, + roleCounts, + prefixRoleCounts, + firstRoles, + }); + + const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps); + const previousSummary = preparation.previousSummary; + const fileOpsText = ""; + const conversationPreview = serializeConversation(messagesForSummary); + log.debug("compaction handoff: running", { + messagesToSummarize: messagesToSummarize.length, + turnPrefixMessages: turnPrefixMessages.length, + instructionsChars: customInstructions?.length ?? 0, + conversationChars: conversationPreview.length, + conversationPreview: conversationPreview.slice(0, 400), + model: `${model.provider}/${model.id}`, + }); + + try { + const historySummary = await summarizeMessages({ + messages: messagesForSummary, + model, + apiKey, + signal, + reserveTokens: preparation.settings.reserveTokens, + instructions: customInstructions, + previousSummary, + fileOpsText, + }); + + return { + compaction: { + summary: historySummary, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: { readFiles, modifiedFiles }, + }, + }; + } catch (error) { + console.warn( + `Freeform compaction failed; trying safeguard: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + try { + const safeSummary = await summarizeSafeguard({ + preparation, + model, + apiKey, + signal, + customInstructions, + }); + log.info("compaction handoff: used safeguard fallback"); + return { + compaction: { + summary: safeSummary, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: { readFiles, modifiedFiles }, + }, + }; + } catch (error) { + console.warn( + `Safeguard compaction failed; falling back to default compaction: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + log.info("compaction handoff: falling back to default"); + return; + } + }); +} diff --git a/src/agents/pi-extensions/compaction-summary-prefix.ts b/src/agents/pi-extensions/compaction-summary-prefix.ts new file mode 100644 index 000000000..52e97a243 --- /dev/null +++ b/src/agents/pi-extensions/compaction-summary-prefix.ts @@ -0,0 +1,192 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; + +const COMPACTION_SUMMARY_PREFIX = + "Another language model started to solve this problem and produced a summary of its thinking process. " + + "You also have access to the state of the tools that were used by that language model. " + + "Use this to build on the work that has already been done and avoid duplicating work. " + + "Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis"; +const COMPACTION_PREFIX_CUSTOM_TYPE = "compaction-prefix"; + +type CustomMessage = AgentMessage & { + role: "custom"; + customType?: unknown; + content?: unknown; +}; + +type UserMessage = AgentMessage & { + role: "user"; + content?: unknown; + timestamp?: number; +}; + +type TextMessage = AgentMessage & { + content?: unknown; + summary?: unknown; +}; + +function isCompactionPrefixMessage(message: AgentMessage): message is CustomMessage { + return message.role === "custom" && message.customType === COMPACTION_PREFIX_CUSTOM_TYPE; +} + +function extractText(message: TextMessage): string | null { + if (typeof message.content === "string") { + const trimmed = message.content.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (!Array.isArray(message.content)) return null; + const parts = message.content + .map((block) => { + if (!block || typeof block !== "object") return ""; + const rec = block as { type?: unknown; text?: unknown }; + return rec.type === "text" && typeof rec.text === "string" ? rec.text : ""; + }) + .filter(Boolean); + if (parts.length === 0) return null; + const joined = parts.join("\n").trim(); + return joined.length > 0 ? joined : null; +} + +function extractSummaryText(message: TextMessage): string | null { + if (typeof message.summary !== "string") return null; + const trimmed = message.summary.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeSummary(summary: unknown): string | null { + if (typeof summary !== "string") return null; + const trimmed = summary.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildWrappedSummaryText(prefix: string, summary: string): string { + return `${prefix}\n\n\n${summary}\n`; +} + +function buildUserPrefixMessage(text: string, timestamp?: number): UserMessage { + return { + role: "user", + content: [{ type: "text", text }], + timestamp: typeof timestamp === "number" ? timestamp : Date.now(), + }; +} + +function isCompactionSummaryAssistant(message: AgentMessage): boolean { + if (message.role !== "assistant") return false; + const text = extractText(message as TextMessage) ?? ""; + return /context checkpoint compaction/i.test(text) || /compaction summary/i.test(text); +} + +function isCompactionSummaryMessage(message: AgentMessage): boolean { + return message.role === "compactionSummary"; +} + +function hasUserMessage(messages: AgentMessage[]): boolean { + return messages.some((message) => message.role === "user"); +} + +function getLatestCompactionSummary(ctx: ExtensionContext): string | null { + try { + const entries = ctx.sessionManager.getEntries(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] as { type?: unknown; summary?: unknown }; + if (entry?.type === "compaction" && typeof entry.summary === "string") { + const trimmed = entry.summary.trim(); + return trimmed.length > 0 ? trimmed : null; + } + } + } catch { + return null; + } + return null; +} + +export default function compactionSummaryPrefixExtension(api: ExtensionAPI): void { + api.on("session_compact", (event) => { + const summary = normalizeSummary((event.compactionEntry as { summary?: unknown })?.summary); + if (!summary) { + return; + } + const wrapped = buildWrappedSummaryText(COMPACTION_SUMMARY_PREFIX, summary); + const ts = new Date(event.compactionEntry.timestamp).getTime(); + api.sendMessage( + { + customType: COMPACTION_PREFIX_CUSTOM_TYPE, + content: wrapped, + display: false, + details: { compactionId: event.compactionEntry.id }, + }, + { triggerTurn: false }, + ); + // Debug: confirm compaction hook fired and message queued. + // This uses info so it shows without debug flags. + // eslint-disable-next-line no-console + console.info("[agent/embedded] compaction prefix: queued prefix", { + compactionId: event.compactionEntry.id, + timestamp: ts, + }); + }); + + api.on("context", async (event, ctx) => { + // eslint-disable-next-line no-console + console.info("[agent/embedded] compaction prefix: context event", { + count: event.messages.length, + firstRoles: event.messages.slice(0, 5).map((msg) => msg.role), + hasPrefix: event.messages.some(isCompactionPrefixMessage), + }); + const prefixIndex = event.messages.findIndex(isCompactionPrefixMessage); + const prefixMessage = prefixIndex >= 0 ? (event.messages[prefixIndex] as CustomMessage) : null; + let prefixText: string | null = null; + let prefixTimestamp: number | undefined; + if (prefixMessage) { + prefixText = extractText(prefixMessage as TextMessage); + prefixTimestamp = prefixMessage.timestamp; + } + + let prefixSource = ""; + if (!prefixText) { + const compactionMessage = event.messages.find(isCompactionSummaryMessage) as + | TextMessage + | undefined; + const summaryText = compactionMessage ? extractSummaryText(compactionMessage) : null; + if (summaryText) { + prefixText = buildWrappedSummaryText(COMPACTION_SUMMARY_PREFIX, summaryText); + prefixSource = "context:compactionSummary"; + } + } + + if (!prefixText) { + const summaryText = getLatestCompactionSummary(ctx); + if (summaryText) { + prefixText = buildWrappedSummaryText(COMPACTION_SUMMARY_PREFIX, summaryText); + prefixSource = "session:compactionEntry"; + } + } + + if (!prefixText) return; + + if (!hasUserMessage(event.messages)) { + // Only inject the prefix when a real user message is present. + // This avoids creating a standalone user turn after manual /compact. + return; + } + + const rest = event.messages.filter((msg, idx) => { + if (idx === prefixIndex) return false; + if (isCompactionSummaryMessage(msg)) return false; + if (isCompactionSummaryAssistant(msg)) return false; + return true; + }); + + const userPrefix = buildUserPrefixMessage(prefixText, prefixTimestamp); + const firstUserIndex = rest.findIndex((msg) => msg.role === "user"); + const restFromFirstUser = firstUserIndex >= 0 ? rest.slice(firstUserIndex) : rest; + // eslint-disable-next-line no-console + console.info("[agent/embedded] compaction prefix: injected user prefix", { + source: prefixSource || (prefixMessage ? "context:custom" : "unknown"), + insertIndex: 0, + droppedLeading: firstUserIndex > 0 ? firstUserIndex : 0, + }); + return { messages: [userPrefix, ...restFromFirstUser] }; + }); +} diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts index bcc465bce..1c45b09a7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts @@ -71,6 +71,7 @@ async function withTempHome(fn: (home: string) => Promise): Promise { async (home) => { vi.mocked(runEmbeddedPiAgent).mockClear(); vi.mocked(abortEmbeddedPiRun).mockClear(); + vi.mocked(compactEmbeddedPiSession).mockClear(); return await fn(home); }, { prefix: "clawdbot-triggers-" }, @@ -150,6 +151,61 @@ describe("trigger handling", () => { expect(store[sessionKey]?.compactionCount).toBe(1); }); }); + + it("suppresses /compact reply in handoff mode", async () => { + await withTempHome(async (home) => { + const storePath = join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`); + vi.mocked(compactEmbeddedPiSession).mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + CommandAuthorized: true, + }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + compaction: { mode: "handoff" }, + }, + }, + channels: { + whatsapp: { + allowFrom: ["*"], + }, + }, + session: { + store: storePath, + }, + }, + ); + + expect(res).toBeUndefined(); + expect(compactEmbeddedPiSession).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + const call = vi.mocked(compactEmbeddedPiSession).mock.calls[0]?.[0]; + expect(call?.compactionKeepRecentTokensOverride).toBe(1); + const store = loadSessionStore(storePath); + const sessionKey = resolveSessionKey("per-sender", { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }); + expect(store[sessionKey]?.compactionCount).toBe(1); + }); + }); it("ignores think directives that only appear in the context wrapper", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index 9210a04a4..ec2d87405 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -13,6 +13,10 @@ import type { CommandHandler } from "./commands-types.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { incrementCompactionCount } from "./session-updates.js"; +function isHandoffCompactionMode(cfg: ClawdbotConfig): boolean { + return cfg?.agents?.defaults?.compaction?.mode === "handoff"; +} + function extractCompactInstructions(params: { rawBody?: string; ctx: import("../templating.js").MsgContext; @@ -63,6 +67,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { agentId: params.agentId, isGroup: params.isGroup, }); + const handoffMode = isHandoffCompactionMode(params.cfg); const result = await compactEmbeddedPiSession({ sessionId, sessionKey: params.sessionKey, @@ -83,6 +88,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { allowed: false, defaultLevel: "off", }, + compactionKeepRecentTokensOverride: handoffMode ? 1 : undefined, customInstructions, ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, }); @@ -106,6 +112,9 @@ export const handleCompactCommand: CommandHandler = async (params) => { tokensAfter: result.result?.tokensAfter, }); } + if (handoffMode) { + return { shouldContinue: false }; + } // Use the post-compaction token count for context summary if available const tokensAfterCompaction = result.result?.tokensAfter; const totalTokens = diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index e85ea3aeb..899b72045 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -46,6 +46,36 @@ describe("config compaction settings", () => { }); }); + it("accepts handoff compaction mode", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + agents: { + defaults: { + compaction: { + mode: "handoff", + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.agents?.defaults?.compaction?.mode).toBe("handoff"); + }); + }); + it("defaults compaction mode to safeguard", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2a42d3623..e63f740f3 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -237,7 +237,7 @@ export type AgentDefaultsConfig = { }; }; -export type AgentCompactionMode = "default" | "safeguard"; +export type AgentCompactionMode = "default" | "safeguard" | "handoff"; export type AgentCompactionConfig = { /** Compaction summarization mode. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index c4b8a8f2c..b1dfcb92d 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -88,7 +88,9 @@ export const AgentDefaultsSchema = z .optional(), compaction: z .object({ - mode: z.union([z.literal("default"), z.literal("safeguard")]).optional(), + mode: z + .union([z.literal("default"), z.literal("safeguard"), z.literal("handoff")]) + .optional(), reserveTokensFloor: z.number().int().nonnegative().optional(), memoryFlush: z .object({ diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 50f441779..40bd4e00e 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -429,6 +429,9 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, ackPayload, undefined, { runId: clientRunId }); const trimmedMessage = parsedMessage.trim(); + const isCompactCommand = /^\/compact(?:\s|:|$)/i.test(trimmedMessage); + const isHandoffMode = cfg?.agents?.defaults?.compaction?.mode === "handoff"; + const suppressCompactReplies = isHandoffMode && isCompactCommand; const injectThinking = Boolean( p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"), ); @@ -468,6 +471,7 @@ export const chatHandlers: GatewayRequestHandlers = { }, deliver: async (payload, info) => { if (info.kind !== "final") return; + if (suppressCompactReplies) return; const text = payload.text?.trim() ?? ""; if (!text) return; finalReplyParts.push(text); @@ -504,31 +508,33 @@ export const chatHandlers: GatewayRequestHandlers = { .trim(); let message: Record | undefined; if (combinedReply) { - const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( - p.sessionKey, - ); - const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; - const appended = appendAssistantTranscriptMessage({ - message: combinedReply, - sessionId, - storePath: latestStorePath, - sessionFile: latestEntry?.sessionFile, - createIfMissing: true, - }); - if (appended.ok) { - message = appended.message; - } else { - context.logGateway.warn( - `webchat transcript append failed: ${appended.error ?? "unknown error"}`, + if (!suppressCompactReplies) { + const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry( + p.sessionKey, ); - const now = Date.now(); - message = { - role: "assistant", - content: [{ type: "text", text: combinedReply }], - timestamp: now, - stopReason: "injected", - usage: { input: 0, output: 0, totalTokens: 0 }, - }; + const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId; + const appended = appendAssistantTranscriptMessage({ + message: combinedReply, + sessionId, + storePath: latestStorePath, + sessionFile: latestEntry?.sessionFile, + createIfMissing: true, + }); + if (appended.ok) { + message = appended.message; + } else { + context.logGateway.warn( + `webchat transcript append failed: ${appended.error ?? "unknown error"}`, + ); + const now = Date.now(); + message = { + role: "assistant", + content: [{ type: "text", text: combinedReply }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + } } } broadcastChatFinal({