Compaction: add handoff mode and summary prefix
This commit is contained in:
parent
c8063bdcd8
commit
6f32dcbda2
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Api> | 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) {
|
||||
|
||||
@ -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<number, ImageContent[]>,
|
||||
@ -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") {
|
||||
|
||||
490
src/agents/pi-extensions/compaction-handoff.ts
Normal file
490
src/agents/pi-extensions/compaction-handoff.ts
Normal file
@ -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<CustomEntryLike>): 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(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
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<string, unknown>;
|
||||
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<string>();
|
||||
|
||||
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<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
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<import("@mariozechner/pi-coding-agent").ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
reserveTokens: number;
|
||||
instructions?: string;
|
||||
previousSummary?: string;
|
||||
fileOpsText?: string;
|
||||
}): Promise<string> {
|
||||
const base = params.instructions?.trim() || DEFAULT_INSTRUCTIONS;
|
||||
const llmText = serializeConversation(params.messages);
|
||||
let prompt = `${base}\n\n<conversation>\n${llmText}\n</conversation>`;
|
||||
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<import("@mariozechner/pi-coding-agent").ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
customInstructions?: string;
|
||||
}): Promise<string> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
192
src/agents/pi-extensions/compaction-summary-prefix.ts
Normal file
192
src/agents/pi-extensions/compaction-summary-prefix.ts
Normal file
@ -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<conversation>\n${summary}\n</conversation>`;
|
||||
}
|
||||
|
||||
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] };
|
||||
});
|
||||
}
|
||||
@ -71,6 +71,7 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
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({
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -237,7 +237,7 @@ export type AgentDefaultsConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCompactionMode = "default" | "safeguard";
|
||||
export type AgentCompactionMode = "default" | "safeguard" | "handoff";
|
||||
|
||||
export type AgentCompactionConfig = {
|
||||
/** Compaction summarization mode. */
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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<string, unknown> | 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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user