diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index cc3cd7e0..103cb68e 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -24,6 +24,20 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export type { + SessionCompactionPolicy, + NativeContextManagement, + AdapterSessionManagement, + ResolvedSessionCompactionPolicy, +} from "./session-compaction.js"; +export { + ADAPTER_SESSION_MANAGEMENT, + LEGACY_SESSIONED_ADAPTER_TYPES, + getAdapterSessionManagement, + readSessionCompactionOverride, + resolveSessionCompactionPolicy, + hasSessionCompactionThresholds, +} from "./session-compaction.js"; export { REDACTED_HOME_PATH_USER, redactHomePathUserSegments, diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts new file mode 100644 index 00000000..0807c926 --- /dev/null +++ b/packages/adapter-utils/src/session-compaction.ts @@ -0,0 +1,174 @@ +export interface SessionCompactionPolicy { + enabled: boolean; + maxSessionRuns: number; + maxRawInputTokens: number; + maxSessionAgeHours: number; +} + +export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none"; + +export interface AdapterSessionManagement { + supportsSessionResume: boolean; + nativeContextManagement: NativeContextManagement; + defaultSessionCompaction: SessionCompactionPolicy; +} + +export interface ResolvedSessionCompactionPolicy { + policy: SessionCompactionPolicy; + adapterSessionManagement: AdapterSessionManagement | null; + explicitOverride: Partial; + source: "adapter_default" | "agent_override" | "legacy_fallback"; +} + +const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, +}; + +const DISABLED_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = { + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, +}; + +export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "cursor", + "gemini_local", + "opencode_local", + "pi_local", +]); + +export const ADAPTER_SESSION_MANAGEMENT: Record = { + claude_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: DISABLED_SESSION_COMPACTION_POLICY, + }, + codex_local: { + supportsSessionResume: true, + nativeContextManagement: "confirmed", + defaultSessionCompaction: DISABLED_SESSION_COMPACTION_POLICY, + }, + cursor: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + gemini_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + opencode_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, + pi_local: { + supportsSessionResume: true, + nativeContextManagement: "unknown", + defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") { + if (value === 1) return true; + if (value === 0) return false; + return undefined; + } + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.floor(value)); + } + if (typeof value !== "string") return undefined; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined; +} + +export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null { + if (!adapterType) return null; + return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null; +} + +export function readSessionCompactionOverride(runtimeConfig: unknown): Partial { + const runtime = isRecord(runtimeConfig) ? runtimeConfig : {}; + const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {}; + const compaction = isRecord( + heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction, + ) + ? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record + : {}; + + const explicit: Partial = {}; + const enabled = readBoolean(compaction.enabled); + const maxSessionRuns = readNumber(compaction.maxSessionRuns); + const maxRawInputTokens = readNumber(compaction.maxRawInputTokens); + const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours); + + if (enabled !== undefined) explicit.enabled = enabled; + if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns; + if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens; + if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours; + + return explicit; +} + +export function resolveSessionCompactionPolicy( + adapterType: string | null | undefined, + runtimeConfig: unknown, +): ResolvedSessionCompactionPolicy { + const adapterSessionManagement = getAdapterSessionManagement(adapterType); + const explicitOverride = readSessionCompactionOverride(runtimeConfig); + const hasExplicitOverride = Object.keys(explicitOverride).length > 0; + const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType)); + const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? { + ...DEFAULT_SESSION_COMPACTION_POLICY, + enabled: fallbackEnabled, + }; + + return { + policy: { + enabled: explicitOverride.enabled ?? basePolicy.enabled, + maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns, + maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens, + maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours, + }, + adapterSessionManagement, + explicitOverride, + source: hasExplicitOverride + ? "agent_override" + : adapterSessionManagement + ? "adapter_default" + : "legacy_fallback", + }; +} + +export function hasSessionCompactionThresholds(policy: Pick< + SessionCompactionPolicy, + "maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours" +>) { + return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0; +} + diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ade4648a..f907d4b4 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -216,6 +216,7 @@ export interface ServerAdapterModule { execute(ctx: AdapterExecutionContext): Promise; testEnvironment(ctx: AdapterEnvironmentTestContext): Promise; sessionCodec?: AdapterSessionCodec; + sessionManagement?: import("./session-compaction.js").AdapterSessionManagement; supportsLocalAgentJwt?: boolean; models?: AdapterModel[]; listModels?: () => Promise; diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index bca52142..a7189fca 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { agents } from "@paperclipai/db"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, @@ -20,6 +22,32 @@ function buildResolvedWorkspace(overrides: Partial = {} }; } +function buildAgent(adapterType: string, runtimeConfig: Record = {}) { + return { + id: "agent-1", + companyId: "company-1", + projectId: null, + goalId: null, + name: "Agent", + role: "engineer", + title: null, + icon: null, + status: "running", + reportsTo: null, + capabilities: null, + adapterType, + adapterConfig: {}, + runtimeConfig, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + permissions: {}, + lastHeartbeatAt: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + } as unknown as typeof agents.$inferSelect; +} + describe("resolveRuntimeSessionParamsForWorkspace", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { const agentId = "agent-123"; @@ -151,3 +179,55 @@ describe("shouldResetTaskSessionForWake", () => { ).toBe(false); }); }); + +describe("parseSessionCompactionPolicy", () => { + it("disables Paperclip-managed rotation by default for codex and claude local", () => { + expect(parseSessionCompactionPolicy(buildAgent("codex_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + expect(parseSessionCompactionPolicy(buildAgent("claude_local"))).toEqual({ + enabled: true, + maxSessionRuns: 0, + maxRawInputTokens: 0, + maxSessionAgeHours: 0, + }); + }); + + it("keeps conservative defaults for adapters without confirmed native compaction", () => { + expect(parseSessionCompactionPolicy(buildAgent("cursor"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({ + enabled: true, + maxSessionRuns: 200, + maxRawInputTokens: 2_000_000, + maxSessionAgeHours: 72, + }); + }); + + it("lets explicit agent overrides win over adapter defaults", () => { + expect( + parseSessionCompactionPolicy( + buildAgent("codex_local", { + heartbeat: { + sessionCompaction: { + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + }, + }, + }), + ), + ).toEqual({ + enabled: true, + maxSessionRuns: 25, + maxRawInputTokens: 500_000, + maxSessionAgeHours: 0, + }); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index e644900e..dcad7527 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,5 @@ import type { ServerAdapterModule } from "./types.js"; +import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, testEnvironment as claudeTestEnvironment, @@ -70,6 +71,7 @@ const claudeLocalAdapter: ServerAdapterModule = { execute: claudeExecute, testEnvironment: claudeTestEnvironment, sessionCodec: claudeSessionCodec, + sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined, models: claudeModels, supportsLocalAgentJwt: true, agentConfigurationDoc: claudeAgentConfigurationDoc, @@ -81,6 +83,7 @@ const codexLocalAdapter: ServerAdapterModule = { execute: codexExecute, testEnvironment: codexTestEnvironment, sessionCodec: codexSessionCodec, + sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined, models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, @@ -93,6 +96,7 @@ const cursorLocalAdapter: ServerAdapterModule = { execute: cursorExecute, testEnvironment: cursorTestEnvironment, sessionCodec: cursorSessionCodec, + sessionManagement: getAdapterSessionManagement("cursor") ?? undefined, models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, @@ -104,6 +108,7 @@ const geminiLocalAdapter: ServerAdapterModule = { execute: geminiExecute, testEnvironment: geminiTestEnvironment, sessionCodec: geminiSessionCodec, + sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, agentConfigurationDoc: geminiAgentConfigurationDoc, @@ -123,6 +128,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { execute: openCodeExecute, testEnvironment: openCodeTestEnvironment, sessionCodec: openCodeSessionCodec, + sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, models: [], listModels: listOpenCodeModels, supportsLocalAgentJwt: true, @@ -134,6 +140,7 @@ const piLocalAdapter: ServerAdapterModule = { execute: piExecute, testEnvironment: piTestEnvironment, sessionCodec: piSessionCodec, + sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined, models: [], listModels: listPiModels, supportsLocalAgentJwt: true, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index c5708d8a..88f27218 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -3,6 +3,7 @@ // imports (process/, http/, heartbeat.ts) don't need rewriting. export type { AdapterAgent, + AdapterSessionManagement, AdapterRuntime, UsageSummary, AdapterExecutionResult, @@ -15,5 +16,8 @@ export type { AdapterEnvironmentTestContext, AdapterSessionCodec, AdapterModel, + NativeContextManagement, + ResolvedSessionCompactionPolicy, + SessionCompactionPolicy, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1f67e5e6..fa3753d0 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -42,6 +42,11 @@ import { resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type SessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -49,14 +54,6 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; -const SESSIONED_LOCAL_ADAPTERS = new Set([ - "claude_local", - "codex_local", - "cursor", - "gemini_local", - "opencode_local", - "pi_local", -]); const heartbeatRunListColumns = { id: heartbeatRuns.id, @@ -133,13 +130,6 @@ type UsageTotals = { outputTokens: number; }; -type SessionCompactionPolicy = { - enabled: boolean; - maxSessionRuns: number; - maxRawInputTokens: number; - maxSessionAgeHours: number; -}; - type SessionCompactionDecision = { rotate: boolean; reason: string | null; @@ -296,23 +286,8 @@ function formatCount(value: number | null | undefined) { return value.toLocaleString("en-US"); } -function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { - const runtimeConfig = parseObject(agent.runtimeConfig); - const heartbeat = parseObject(runtimeConfig.heartbeat); - const compaction = parseObject( - heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtimeConfig.sessionCompaction, - ); - const supportsSessions = SESSIONED_LOCAL_ADAPTERS.has(agent.adapterType); - const enabled = compaction.enabled === undefined - ? supportsSessions - : asBoolean(compaction.enabled, supportsSessions); - - return { - enabled, - maxSessionRuns: Math.max(0, Math.floor(asNumber(compaction.maxSessionRuns, 200))), - maxRawInputTokens: Math.max(0, Math.floor(asNumber(compaction.maxRawInputTokens, 2_000_000))), - maxSessionAgeHours: Math.max(0, Math.floor(asNumber(compaction.maxSessionAgeHours, 72))), - }; +export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy { + return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy; } export function resolveRuntimeSessionParamsForWorkspace(input: { @@ -743,7 +718,7 @@ export function heartbeatService(db: Db) { } const policy = parseSessionCompactionPolicy(agent); - if (!policy.enabled) { + if (!policy.enabled || !hasSessionCompactionThresholds(policy)) { return { rotate: false, reason: null, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index abfc04fb..c6e48cd0 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,11 @@ import { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; +import { + hasSessionCompactionThresholds, + resolveSessionCompactionPolicy, + type ResolvedSessionCompactionPolicy, +} from "@paperclipai/adapter-utils"; import type { Agent, AdapterEnvironmentTestResult, @@ -383,6 +388,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + const effectiveRuntimeConfig = useMemo(() => { + if (isCreate) { + return { + heartbeat: { + enabled: val!.heartbeatEnabled, + intervalSec: val!.intervalSec, + }, + }; + } + const mergedHeartbeat = { + ...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object" + ? runtimeConfig.heartbeat as Record + : {}), + ...overlay.heartbeat, + }; + return { + ...runtimeConfig, + heartbeat: mergedHeartbeat, + }; + }, [isCreate, overlay.heartbeat, runtimeConfig, val]); + const sessionCompaction = useMemo( + () => resolveSessionCompactionPolicy(adapterType, effectiveRuntimeConfig), + [adapterType, effectiveRuntimeConfig], + ); + const showSessionCompactionCard = Boolean(sessionCompaction.adapterSessionManagement); return (
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={val!.heartbeatEnabled} /> + {showSessionCompactionCard && ( + + )}
) : ( @@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { numberHint={help.intervalSec} showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)} /> + {showSessionCompactionCard && ( + + )} +
+
Session compaction
+ + {sourceLabel} + +
+

+ {nativeSummary} +

+

+ {rotationDisabled + ? "No Paperclip-managed fresh-session thresholds are active for this adapter." + : "Paperclip will start a fresh session when one of these thresholds is reached."} +

+
+
+
Runs
+
{formatSessionThreshold(policy.maxSessionRuns, "runs")}
+
+
+
Raw input
+
{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}
+
+
+
Age
+
{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}
+
+
+

+ A large cumulative raw token total does not mean the full session is resent on every heartbeat. + {source === "agent_override" && " This agent has an explicit runtimeConfig session compaction override."} +

+ + ); +} + /* ---- Internal sub-components ---- */ const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);