feat(tools): add session_compact tool for agent-invoked context compaction

Adds a new core tool that allows agents to proactively compact their own session
context without user intervention. This enables agents to self-manage their
context window when nearing limits.

The tool:
- Uses the existing compactEmbeddedPiSession infrastructure
- Supports optional custom instructions for focused compaction
- Returns token usage before/after for transparency
- Integrates with existing compaction count tracking

Use case: Agents can monitor context usage via session_status and invoke
session_compact when approaching thresholds (e.g., 60%+ context), then
read their memory/compactions/ files to restore state.

Closes: Agent self-management of context window
This commit is contained in:
sasheenmusic 2026-01-29 10:34:27 -08:00
parent c9fe062824
commit efd6209bc8
2 changed files with 172 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
import { createNodesTool } from "./tools/nodes-tool.js";
import { createSessionCompactTool } from "./tools/session-compact-tool.js";
import { createSessionStatusTool } from "./tools/session-status-tool.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
@ -134,6 +135,11 @@ export function createMoltbotTools(options?: {
agentSessionKey: options?.agentSessionKey,
config: options?.config,
}),
createSessionCompactTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
workspaceDir: options?.workspaceDir,
}),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(imageTool ? [imageTool] : []),

View File

@ -0,0 +1,166 @@
import { Type } from "@sinclair/typebox";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded.js";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveSessionFilePath,
resolveStorePath,
updateSessionStore,
} from "../../config/sessions.js";
import { resolveAgentIdFromSessionKey, DEFAULT_AGENT_ID } from "../../routing/session-key.js";
import { formatContextUsageShort, formatTokenCount } from "../../auto-reply/status.js";
import { incrementCompactionCount } from "../../auto-reply/reply/session-updates.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
createAgentToAgentPolicy,
} from "./sessions-helpers.js";
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
const SessionCompactToolSchema = Type.Object({
instructions: Type.Optional(
Type.String({
description:
"Optional instructions for what to focus on during compaction (e.g., 'Focus on decisions and open tasks')",
}),
),
});
interface SessionCompactToolOpts {
config?: ReturnType<typeof loadConfig>;
agentSessionKey?: string;
workspaceDir?: string;
thinkLevel?: string;
}
export function createSessionCompactTool(opts?: SessionCompactToolOpts): AnyAgentTool {
return {
label: "Session Compact",
name: "session_compact",
description:
"Compact the current session's context to free up token space. Use when context is above 60% to proactively manage memory. The compaction summarizes older conversation history while preserving recent messages. After compaction, read your latest compaction file from memory/compactions/ to restore state.",
parameters: SessionCompactToolSchema,
execute: async (_toolCallId, args) => {
const params = args as { instructions?: string };
const cfg = opts?.config ?? loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const sessionKey = opts?.agentSessionKey;
if (!sessionKey) {
throw new Error("sessionKey required for compaction");
}
const agentId = resolveAgentIdFromSessionKey(sessionKey) || DEFAULT_AGENT_ID;
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
// Resolve the session entry
const internalKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const entry = store[sessionKey] ?? store[internalKey];
if (!entry?.sessionId) {
return {
content: [{ type: "text", text: "⚙️ Compaction unavailable (missing session id)." }],
details: { ok: false, reason: "no sessionId" },
};
}
const sessionId = entry.sessionId;
// Abort any active run before compacting
if (isEmbeddedPiRunActive(sessionId)) {
abortEmbeddedPiRun(sessionId);
await waitForEmbeddedPiRunEnd(sessionId, 15_000);
}
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const workspaceDir = opts?.workspaceDir ?? resolveAgentDir(cfg, agentId);
const result = await compactEmbeddedPiSession({
sessionId,
sessionKey,
messageChannel: entry.lastChannel ?? entry.channel ?? "unknown",
groupId: entry.groupId,
groupChannel: entry.groupChannel,
groupSpace: entry.space,
spawnedBy: entry.spawnedBy,
sessionFile: resolveSessionFilePath(sessionId, entry),
workspaceDir,
config: cfg,
skillsSnapshot: entry.skillsSnapshot,
provider: entry.providerOverride ?? configured.provider,
model: entry.modelOverride ?? configured.model,
thinkLevel: (opts?.thinkLevel ?? cfg.agents?.defaults?.thinkingDefault ?? "medium") as any,
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
customInstructions: params.instructions,
});
const compactLabel = result.ok
? result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${formatTokenCount(result.result.tokensBefore)}${formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
: "Compaction failed";
if (result.ok && result.compacted) {
await incrementCompactionCount({
sessionEntry: entry,
sessionStore: store,
sessionKey,
storePath,
tokensAfter: result.result?.tokensAfter,
});
}
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens =
tokensAfterCompaction ??
entry.totalTokens ??
(entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
entry.contextTokens ?? null,
);
const reason = result.reason?.trim();
const line = reason
? `${compactLabel}: ${reason}${contextSummary}`
: `${compactLabel}${contextSummary}`;
return {
content: [
{
type: "text",
text: `🧹 ${line}\n\nNext: Read your latest file from memory/compactions/ to restore context state.`,
},
],
details: {
ok: result.ok,
compacted: result.compacted,
tokensBefore: result.result?.tokensBefore,
tokensAfter: result.result?.tokensAfter,
reason: result.reason,
},
};
},
};
}