* fix(gateway): avoid premature agent.wait completion on transient errors * fix(agent): preemptively guard tool results against context overflow * fix: harden tool-result context guard and add message_id metadata * fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID The run.skill-filter test was mocking ../../routing/session-key.js with only buildAgentMainSessionKey and normalizeAgentId, but the module also exports DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts. Switch to importOriginal pattern so all real exports are preserved alongside the mocked functions. * pi-runner: guard accumulated tool-result overflow in transformContext * PI runner: compact overflowing tool-result context * Subagent: harden tool-result context recovery * Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios. * Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies. * Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior. * Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features. * Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios. * Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels. * fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
740 lines
27 KiB
TypeScript
740 lines
27 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
import {
|
|
createAgentSession,
|
|
estimateTokens,
|
|
SessionManager,
|
|
SettingsManager,
|
|
} from "@mariozechner/pi-coding-agent";
|
|
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
|
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
|
|
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
|
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
|
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
|
import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js";
|
|
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
|
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
|
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
|
import { resolveUserPath } from "../../utils.js";
|
|
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
|
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
|
import { resolveOpenClawAgentDir } from "../agent-paths.js";
|
|
import { resolveSessionAgentIds } from "../agent-scope.js";
|
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
|
import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js";
|
|
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
|
import { resolveOpenClawDocsPath } from "../docs-path.js";
|
|
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
|
import { ensureOpenClawModelsJson } from "../models-config.js";
|
|
import {
|
|
ensureSessionHeader,
|
|
validateAnthropicTurns,
|
|
validateGeminiTurns,
|
|
} from "../pi-embedded-helpers.js";
|
|
import {
|
|
ensurePiCompactionReserveTokens,
|
|
resolveCompactionReserveTokensFloor,
|
|
} from "../pi-settings.js";
|
|
import { createOpenClawCodingTools } from "../pi-tools.js";
|
|
import { resolveSandboxContext } from "../sandbox.js";
|
|
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
|
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
|
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
|
import {
|
|
acquireSessionWriteLock,
|
|
resolveSessionLockMaxHoldFromTimeout,
|
|
} from "../session-write-lock.js";
|
|
import { detectRuntimeShell } from "../shell-utils.js";
|
|
import {
|
|
applySkillEnvOverrides,
|
|
applySkillEnvOverridesFromSnapshot,
|
|
loadWorkspaceSkillEntries,
|
|
resolveSkillsPromptForRun,
|
|
type SkillSnapshot,
|
|
} from "../skills.js";
|
|
import { resolveTranscriptPolicy } from "../transcript-policy.js";
|
|
import {
|
|
compactWithSafetyTimeout,
|
|
EMBEDDED_COMPACTION_TIMEOUT_MS,
|
|
} from "./compaction-safety-timeout.js";
|
|
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
|
import {
|
|
logToolSchemasForGoogle,
|
|
sanitizeSessionHistory,
|
|
sanitizeToolsForGoogle,
|
|
} from "./google.js";
|
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
|
import { log } from "./logger.js";
|
|
import { buildModelAliasLines, resolveModel } from "./model.js";
|
|
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
|
|
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
|
import {
|
|
applySystemPromptOverrideToSession,
|
|
buildEmbeddedSystemPrompt,
|
|
createSystemPromptOverride,
|
|
} from "./system-prompt.js";
|
|
import { splitSdkTools } from "./tool-split.js";
|
|
import type { EmbeddedPiCompactResult } from "./types.js";
|
|
import { describeUnknownError, mapThinkingLevel } from "./utils.js";
|
|
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
|
|
|
|
export type CompactEmbeddedPiSessionParams = {
|
|
sessionId: string;
|
|
runId?: string;
|
|
sessionKey?: string;
|
|
messageChannel?: string;
|
|
messageProvider?: string;
|
|
agentAccountId?: string;
|
|
authProfileId?: string;
|
|
/** Group id for channel-level tool policy resolution. */
|
|
groupId?: string | null;
|
|
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
|
groupChannel?: string | null;
|
|
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
|
groupSpace?: string | null;
|
|
/** Parent session key for subagent policy inheritance. */
|
|
spawnedBy?: string | null;
|
|
/** Whether the sender is an owner (required for owner-only tools). */
|
|
senderIsOwner?: boolean;
|
|
sessionFile: string;
|
|
workspaceDir: string;
|
|
agentDir?: string;
|
|
config?: OpenClawConfig;
|
|
skillsSnapshot?: SkillSnapshot;
|
|
provider?: string;
|
|
model?: string;
|
|
thinkLevel?: ThinkLevel;
|
|
reasoningLevel?: ReasoningLevel;
|
|
bashElevated?: ExecElevatedDefaults;
|
|
customInstructions?: string;
|
|
trigger?: "overflow" | "manual";
|
|
diagId?: string;
|
|
attempt?: number;
|
|
maxAttempts?: number;
|
|
lane?: string;
|
|
enqueue?: typeof enqueueCommand;
|
|
extraSystemPrompt?: string;
|
|
ownerNumbers?: string[];
|
|
};
|
|
|
|
type CompactionMessageMetrics = {
|
|
messages: number;
|
|
historyTextChars: number;
|
|
toolResultChars: number;
|
|
estTokens?: number;
|
|
contributors: Array<{ role: string; chars: number; tool?: string }>;
|
|
};
|
|
|
|
function createCompactionDiagId(): string {
|
|
return `cmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function getMessageTextChars(msg: AgentMessage): number {
|
|
const content = (msg as { content?: unknown }).content;
|
|
if (typeof content === "string") {
|
|
return content.length;
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return 0;
|
|
}
|
|
let total = 0;
|
|
for (const block of content) {
|
|
if (!block || typeof block !== "object") {
|
|
continue;
|
|
}
|
|
const text = (block as { text?: unknown }).text;
|
|
if (typeof text === "string") {
|
|
total += text.length;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function resolveMessageToolLabel(msg: AgentMessage): string | undefined {
|
|
const candidate =
|
|
(msg as { toolName?: unknown }).toolName ??
|
|
(msg as { name?: unknown }).name ??
|
|
(msg as { tool?: unknown }).tool;
|
|
return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : undefined;
|
|
}
|
|
|
|
function summarizeCompactionMessages(messages: AgentMessage[]): CompactionMessageMetrics {
|
|
let historyTextChars = 0;
|
|
let toolResultChars = 0;
|
|
const contributors: Array<{ role: string; chars: number; tool?: string }> = [];
|
|
let estTokens = 0;
|
|
let tokenEstimationFailed = false;
|
|
|
|
for (const msg of messages) {
|
|
const role = typeof msg.role === "string" ? msg.role : "unknown";
|
|
const chars = getMessageTextChars(msg);
|
|
historyTextChars += chars;
|
|
if (role === "toolResult") {
|
|
toolResultChars += chars;
|
|
}
|
|
contributors.push({ role, chars, tool: resolveMessageToolLabel(msg) });
|
|
if (!tokenEstimationFailed) {
|
|
try {
|
|
estTokens += estimateTokens(msg);
|
|
} catch {
|
|
tokenEstimationFailed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
messages: messages.length,
|
|
historyTextChars,
|
|
toolResultChars,
|
|
estTokens: tokenEstimationFailed ? undefined : estTokens,
|
|
contributors: contributors.toSorted((a, b) => b.chars - a.chars).slice(0, 3),
|
|
};
|
|
}
|
|
|
|
function classifyCompactionReason(reason?: string): string {
|
|
const text = (reason ?? "").trim().toLowerCase();
|
|
if (!text) {
|
|
return "unknown";
|
|
}
|
|
if (text.includes("nothing to compact")) {
|
|
return "no_compactable_entries";
|
|
}
|
|
if (text.includes("below threshold")) {
|
|
return "below_threshold";
|
|
}
|
|
if (text.includes("already compacted")) {
|
|
return "already_compacted_recently";
|
|
}
|
|
if (text.includes("guard")) {
|
|
return "guard_blocked";
|
|
}
|
|
if (text.includes("summary")) {
|
|
return "summary_failed";
|
|
}
|
|
if (text.includes("timed out") || text.includes("timeout")) {
|
|
return "timeout";
|
|
}
|
|
if (
|
|
text.includes("400") ||
|
|
text.includes("401") ||
|
|
text.includes("403") ||
|
|
text.includes("429")
|
|
) {
|
|
return "provider_error_4xx";
|
|
}
|
|
if (
|
|
text.includes("500") ||
|
|
text.includes("502") ||
|
|
text.includes("503") ||
|
|
text.includes("504")
|
|
) {
|
|
return "provider_error_5xx";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
/**
|
|
* Core compaction logic without lane queueing.
|
|
* Use this when already inside a session/global lane to avoid deadlocks.
|
|
*/
|
|
export async function compactEmbeddedPiSessionDirect(
|
|
params: CompactEmbeddedPiSessionParams,
|
|
): Promise<EmbeddedPiCompactResult> {
|
|
const startedAt = Date.now();
|
|
const diagId = params.diagId?.trim() || createCompactionDiagId();
|
|
const trigger = params.trigger ?? "manual";
|
|
const attempt = params.attempt ?? 1;
|
|
const maxAttempts = params.maxAttempts ?? 1;
|
|
const runId = params.runId ?? params.sessionId;
|
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
|
const prevCwd = process.cwd();
|
|
|
|
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
|
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
|
const fail = (reason: string): EmbeddedPiCompactResult => {
|
|
log.warn(
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
|
|
`durationMs=${Date.now() - startedAt}`,
|
|
);
|
|
return {
|
|
ok: false,
|
|
compacted: false,
|
|
reason,
|
|
};
|
|
};
|
|
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
|
|
await ensureOpenClawModelsJson(params.config, agentDir);
|
|
const { model, error, authStorage, modelRegistry } = resolveModel(
|
|
provider,
|
|
modelId,
|
|
agentDir,
|
|
params.config,
|
|
);
|
|
if (!model) {
|
|
const reason = error ?? `Unknown model: ${provider}/${modelId}`;
|
|
return fail(reason);
|
|
}
|
|
try {
|
|
const apiKeyInfo = await getApiKeyForModel({
|
|
model,
|
|
cfg: params.config,
|
|
profileId: params.authProfileId,
|
|
agentDir,
|
|
});
|
|
|
|
if (!apiKeyInfo.apiKey) {
|
|
if (apiKeyInfo.mode !== "aws-sdk") {
|
|
throw new Error(
|
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
|
);
|
|
}
|
|
} else if (model.provider === "github-copilot") {
|
|
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
|
|
const copilotToken = await resolveCopilotApiToken({
|
|
githubToken: apiKeyInfo.apiKey,
|
|
});
|
|
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
|
} else {
|
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
|
}
|
|
} catch (err) {
|
|
const reason = describeUnknownError(err);
|
|
return fail(reason);
|
|
}
|
|
|
|
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
|
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
|
const sandbox = await resolveSandboxContext({
|
|
config: params.config,
|
|
sessionKey: sandboxSessionKey,
|
|
workspaceDir: resolvedWorkspace,
|
|
});
|
|
const effectiveWorkspace = sandbox?.enabled
|
|
? sandbox.workspaceAccess === "rw"
|
|
? resolvedWorkspace
|
|
: sandbox.workspaceDir
|
|
: resolvedWorkspace;
|
|
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
|
await ensureSessionHeader({
|
|
sessionFile: params.sessionFile,
|
|
sessionId: params.sessionId,
|
|
cwd: effectiveWorkspace,
|
|
});
|
|
|
|
let restoreSkillEnv: (() => void) | undefined;
|
|
process.chdir(effectiveWorkspace);
|
|
try {
|
|
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
|
const skillEntries = shouldLoadSkillEntries
|
|
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
|
: [];
|
|
restoreSkillEnv = params.skillsSnapshot
|
|
? applySkillEnvOverridesFromSnapshot({
|
|
snapshot: params.skillsSnapshot,
|
|
config: params.config,
|
|
})
|
|
: applySkillEnvOverrides({
|
|
skills: skillEntries ?? [],
|
|
config: params.config,
|
|
});
|
|
const skillsPrompt = resolveSkillsPromptForRun({
|
|
skillsSnapshot: params.skillsSnapshot,
|
|
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
|
config: params.config,
|
|
workspaceDir: effectiveWorkspace,
|
|
});
|
|
|
|
const sessionLabel = params.sessionKey ?? params.sessionId;
|
|
const { contextFiles } = await resolveBootstrapContextForRun({
|
|
workspaceDir: effectiveWorkspace,
|
|
config: params.config,
|
|
sessionKey: params.sessionKey,
|
|
sessionId: params.sessionId,
|
|
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
|
});
|
|
const runAbortController = new AbortController();
|
|
const toolsRaw = createOpenClawCodingTools({
|
|
exec: {
|
|
elevated: params.bashElevated,
|
|
},
|
|
sandbox,
|
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
|
agentAccountId: params.agentAccountId,
|
|
sessionKey: params.sessionKey ?? params.sessionId,
|
|
groupId: params.groupId,
|
|
groupChannel: params.groupChannel,
|
|
groupSpace: params.groupSpace,
|
|
spawnedBy: params.spawnedBy,
|
|
senderIsOwner: params.senderIsOwner,
|
|
agentDir,
|
|
workspaceDir: effectiveWorkspace,
|
|
config: params.config,
|
|
abortSignal: runAbortController.signal,
|
|
modelProvider: model.provider,
|
|
modelId,
|
|
modelContextWindowTokens: model.contextWindow,
|
|
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
|
});
|
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
|
logToolSchemasForGoogle({ tools, provider });
|
|
const machineName = await getMachineDisplayName();
|
|
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
|
let runtimeCapabilities = runtimeChannel
|
|
? (resolveChannelCapabilities({
|
|
cfg: params.config,
|
|
channel: runtimeChannel,
|
|
accountId: params.agentAccountId,
|
|
}) ?? [])
|
|
: undefined;
|
|
if (runtimeChannel === "telegram" && params.config) {
|
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
|
cfg: params.config,
|
|
accountId: params.agentAccountId ?? undefined,
|
|
});
|
|
if (inlineButtonsScope !== "off") {
|
|
if (!runtimeCapabilities) {
|
|
runtimeCapabilities = [];
|
|
}
|
|
if (
|
|
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
|
|
) {
|
|
runtimeCapabilities.push("inlineButtons");
|
|
}
|
|
}
|
|
}
|
|
const reactionGuidance =
|
|
runtimeChannel && params.config
|
|
? (() => {
|
|
if (runtimeChannel === "telegram") {
|
|
const resolved = resolveTelegramReactionLevel({
|
|
cfg: params.config,
|
|
accountId: params.agentAccountId ?? undefined,
|
|
});
|
|
const level = resolved.agentReactionGuidance;
|
|
return level ? { level, channel: "Telegram" } : undefined;
|
|
}
|
|
if (runtimeChannel === "signal") {
|
|
const resolved = resolveSignalReactionLevel({
|
|
cfg: params.config,
|
|
accountId: params.agentAccountId ?? undefined,
|
|
});
|
|
const level = resolved.agentReactionGuidance;
|
|
return level ? { level, channel: "Signal" } : undefined;
|
|
}
|
|
return undefined;
|
|
})()
|
|
: undefined;
|
|
// Resolve channel-specific message actions for system prompt
|
|
const channelActions = runtimeChannel
|
|
? listChannelSupportedActions({
|
|
cfg: params.config,
|
|
channel: runtimeChannel,
|
|
})
|
|
: undefined;
|
|
const messageToolHints = runtimeChannel
|
|
? resolveChannelMessageToolHints({
|
|
cfg: params.config,
|
|
channel: runtimeChannel,
|
|
accountId: params.agentAccountId,
|
|
})
|
|
: undefined;
|
|
|
|
const runtimeInfo = {
|
|
host: machineName,
|
|
os: `${os.type()} ${os.release()}`,
|
|
arch: os.arch(),
|
|
node: process.version,
|
|
model: `${provider}/${modelId}`,
|
|
shell: detectRuntimeShell(),
|
|
channel: runtimeChannel,
|
|
capabilities: runtimeCapabilities,
|
|
channelActions,
|
|
};
|
|
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
|
const reasoningTagHint = isReasoningTagProvider(provider);
|
|
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
|
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
|
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
|
sessionKey: params.sessionKey,
|
|
config: params.config,
|
|
});
|
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
|
const promptMode =
|
|
isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey)
|
|
? "minimal"
|
|
: "full";
|
|
const docsPath = await resolveOpenClawDocsPath({
|
|
workspaceDir: effectiveWorkspace,
|
|
argv1: process.argv[1],
|
|
cwd: process.cwd(),
|
|
moduleUrl: import.meta.url,
|
|
});
|
|
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
|
workspaceDir: effectiveWorkspace,
|
|
defaultThinkLevel: params.thinkLevel,
|
|
reasoningLevel: params.reasoningLevel ?? "off",
|
|
extraSystemPrompt: params.extraSystemPrompt,
|
|
ownerNumbers: params.ownerNumbers,
|
|
reasoningTagHint,
|
|
heartbeatPrompt: isDefaultAgent
|
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
|
: undefined,
|
|
skillsPrompt,
|
|
docsPath: docsPath ?? undefined,
|
|
ttsHint,
|
|
promptMode,
|
|
runtimeInfo,
|
|
reactionGuidance,
|
|
messageToolHints,
|
|
sandboxInfo,
|
|
tools,
|
|
modelAliasLines: buildModelAliasLines(params.config),
|
|
userTimezone,
|
|
userTime,
|
|
userTimeFormat,
|
|
contextFiles,
|
|
memoryCitationsMode: params.config?.memory?.citations,
|
|
});
|
|
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
|
|
|
|
const sessionLock = await acquireSessionWriteLock({
|
|
sessionFile: params.sessionFile,
|
|
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({
|
|
timeoutMs: EMBEDDED_COMPACTION_TIMEOUT_MS,
|
|
}),
|
|
});
|
|
try {
|
|
await repairSessionFileIfNeeded({
|
|
sessionFile: params.sessionFile,
|
|
warn: (message) => log.warn(message),
|
|
});
|
|
await prewarmSessionFile(params.sessionFile);
|
|
const transcriptPolicy = resolveTranscriptPolicy({
|
|
modelApi: model.api,
|
|
provider,
|
|
modelId,
|
|
});
|
|
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
|
agentId: sessionAgentId,
|
|
sessionKey: params.sessionKey,
|
|
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
|
});
|
|
trackSessionManagerAccess(params.sessionFile);
|
|
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
|
ensurePiCompactionReserveTokens({
|
|
settingsManager,
|
|
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
|
|
});
|
|
// Call for side effects (sets compaction/pruning runtime state)
|
|
buildEmbeddedExtensionPaths({
|
|
cfg: params.config,
|
|
sessionManager,
|
|
provider,
|
|
modelId,
|
|
model,
|
|
});
|
|
|
|
const { builtInTools, customTools } = splitSdkTools({
|
|
tools,
|
|
sandboxEnabled: !!sandbox?.enabled,
|
|
});
|
|
|
|
const { session } = await createAgentSession({
|
|
cwd: resolvedWorkspace,
|
|
agentDir,
|
|
authStorage,
|
|
modelRegistry,
|
|
model,
|
|
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
|
tools: builtInTools,
|
|
customTools,
|
|
sessionManager,
|
|
settingsManager,
|
|
});
|
|
applySystemPromptOverrideToSession(session, systemPromptOverride());
|
|
|
|
try {
|
|
const prior = await sanitizeSessionHistory({
|
|
messages: session.messages,
|
|
modelApi: model.api,
|
|
modelId,
|
|
provider,
|
|
sessionManager,
|
|
sessionId: params.sessionId,
|
|
policy: transcriptPolicy,
|
|
});
|
|
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
|
? validateGeminiTurns(prior)
|
|
: prior;
|
|
const validated = transcriptPolicy.validateAnthropicTurns
|
|
? validateAnthropicTurns(validatedGemini)
|
|
: validatedGemini;
|
|
// Capture full message history BEFORE limiting — plugins need the complete conversation
|
|
const preCompactionMessages = [...session.messages];
|
|
const truncated = limitHistoryTurns(
|
|
validated,
|
|
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
|
);
|
|
// Re-run tool_use/tool_result pairing repair after truncation, since
|
|
// limitHistoryTurns can orphan tool_result blocks by removing the
|
|
// assistant message that contained the matching tool_use.
|
|
const limited = transcriptPolicy.repairToolUseResultPairing
|
|
? sanitizeToolUseResultPairing(truncated)
|
|
: truncated;
|
|
if (limited.length > 0) {
|
|
session.agent.replaceMessages(limited);
|
|
}
|
|
// Run before_compaction hooks (fire-and-forget).
|
|
// The session JSONL already contains all messages on disk, so plugins
|
|
// can read sessionFile asynchronously and process in parallel with
|
|
// the compaction LLM call — no need to block or wait for after_compaction.
|
|
const hookRunner = getGlobalHookRunner();
|
|
const hookCtx = {
|
|
agentId: params.sessionKey?.split(":")[0] ?? "main",
|
|
sessionKey: params.sessionKey,
|
|
sessionId: params.sessionId,
|
|
workspaceDir: params.workspaceDir,
|
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
|
};
|
|
if (hookRunner?.hasHooks("before_compaction")) {
|
|
hookRunner
|
|
.runBeforeCompaction(
|
|
{
|
|
messageCount: preCompactionMessages.length,
|
|
compactingCount: limited.length,
|
|
messages: preCompactionMessages,
|
|
sessionFile: params.sessionFile,
|
|
},
|
|
hookCtx,
|
|
)
|
|
.catch((hookErr: unknown) => {
|
|
log.warn(`before_compaction hook failed: ${String(hookErr)}`);
|
|
});
|
|
}
|
|
|
|
const diagEnabled = log.isEnabled("debug");
|
|
const preMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
|
|
if (diagEnabled && preMetrics) {
|
|
log.debug(
|
|
`[compaction-diag] start runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} ` +
|
|
`pre.messages=${preMetrics.messages} pre.historyTextChars=${preMetrics.historyTextChars} ` +
|
|
`pre.toolResultChars=${preMetrics.toolResultChars} pre.estTokens=${preMetrics.estTokens ?? "unknown"}`,
|
|
);
|
|
log.debug(
|
|
`[compaction-diag] contributors diagId=${diagId} top=${JSON.stringify(preMetrics.contributors)}`,
|
|
);
|
|
}
|
|
|
|
const compactStartedAt = Date.now();
|
|
const result = await compactWithSafetyTimeout(() =>
|
|
session.compact(params.customInstructions),
|
|
);
|
|
// Estimate tokens after compaction by summing token estimates for remaining messages
|
|
let tokensAfter: number | undefined;
|
|
try {
|
|
tokensAfter = 0;
|
|
for (const message of session.messages) {
|
|
tokensAfter += estimateTokens(message);
|
|
}
|
|
// Sanity check: tokensAfter should be less than tokensBefore
|
|
if (tokensAfter > result.tokensBefore) {
|
|
tokensAfter = undefined; // Don't trust the estimate
|
|
}
|
|
} catch {
|
|
// If estimation fails, leave tokensAfter undefined
|
|
tokensAfter = undefined;
|
|
}
|
|
// Run after_compaction hooks (fire-and-forget).
|
|
// Also includes sessionFile for plugins that only need to act after
|
|
// compaction completes (e.g. analytics, cleanup).
|
|
if (hookRunner?.hasHooks("after_compaction")) {
|
|
hookRunner
|
|
.runAfterCompaction(
|
|
{
|
|
messageCount: session.messages.length,
|
|
tokenCount: tokensAfter,
|
|
compactedCount: limited.length - session.messages.length,
|
|
sessionFile: params.sessionFile,
|
|
},
|
|
hookCtx,
|
|
)
|
|
.catch((hookErr) => {
|
|
log.warn(`after_compaction hook failed: ${hookErr}`);
|
|
});
|
|
}
|
|
|
|
const postMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
|
|
if (diagEnabled && preMetrics && postMetrics) {
|
|
log.debug(
|
|
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
|
|
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
|
|
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=compacted reason=none ` +
|
|
`durationMs=${Date.now() - compactStartedAt} retrying=false ` +
|
|
`post.messages=${postMetrics.messages} post.historyTextChars=${postMetrics.historyTextChars} ` +
|
|
`post.toolResultChars=${postMetrics.toolResultChars} post.estTokens=${postMetrics.estTokens ?? "unknown"} ` +
|
|
`delta.messages=${postMetrics.messages - preMetrics.messages} ` +
|
|
`delta.historyTextChars=${postMetrics.historyTextChars - preMetrics.historyTextChars} ` +
|
|
`delta.toolResultChars=${postMetrics.toolResultChars - preMetrics.toolResultChars} ` +
|
|
`delta.estTokens=${typeof preMetrics.estTokens === "number" && typeof postMetrics.estTokens === "number" ? postMetrics.estTokens - preMetrics.estTokens : "unknown"}`,
|
|
);
|
|
}
|
|
return {
|
|
ok: true,
|
|
compacted: true,
|
|
result: {
|
|
summary: result.summary,
|
|
firstKeptEntryId: result.firstKeptEntryId,
|
|
tokensBefore: result.tokensBefore,
|
|
tokensAfter,
|
|
details: result.details,
|
|
},
|
|
};
|
|
} finally {
|
|
await flushPendingToolResultsAfterIdle({
|
|
agent: session?.agent,
|
|
sessionManager,
|
|
});
|
|
session.dispose();
|
|
}
|
|
} finally {
|
|
await sessionLock.release();
|
|
}
|
|
} catch (err) {
|
|
const reason = describeUnknownError(err);
|
|
return fail(reason);
|
|
} finally {
|
|
restoreSkillEnv?.();
|
|
process.chdir(prevCwd);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compacts a session with lane queueing (session lane + global lane).
|
|
* Use this from outside a lane context. If already inside a lane, use
|
|
* `compactEmbeddedPiSessionDirect` to avoid deadlocks.
|
|
*/
|
|
export async function compactEmbeddedPiSession(
|
|
params: CompactEmbeddedPiSessionParams,
|
|
): Promise<EmbeddedPiCompactResult> {
|
|
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
|
const globalLane = resolveGlobalLane(params.lane);
|
|
const enqueueGlobal =
|
|
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
|
return enqueueCommandInLane(sessionLane, () =>
|
|
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
|
|
);
|
|
}
|