Make session compaction adapter-aware
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -24,6 +24,20 @@ export type {
|
|||||||
CLIAdapterModule,
|
CLIAdapterModule,
|
||||||
CreateConfigValues,
|
CreateConfigValues,
|
||||||
} from "./types.js";
|
} 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 {
|
export {
|
||||||
REDACTED_HOME_PATH_USER,
|
REDACTED_HOME_PATH_USER,
|
||||||
redactHomePathUserSegments,
|
redactHomePathUserSegments,
|
||||||
|
|||||||
174
packages/adapter-utils/src/session-compaction.ts
Normal file
174
packages/adapter-utils/src/session-compaction.ts
Normal file
@@ -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<SessionCompactionPolicy>;
|
||||||
|
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<string, AdapterSessionManagement> = {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<SessionCompactionPolicy> {
|
||||||
|
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<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const explicit: Partial<SessionCompactionPolicy> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -216,6 +216,7 @@ export interface ServerAdapterModule {
|
|||||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||||
sessionCodec?: AdapterSessionCodec;
|
sessionCodec?: AdapterSessionCodec;
|
||||||
|
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
|
||||||
supportsLocalAgentJwt?: boolean;
|
supportsLocalAgentJwt?: boolean;
|
||||||
models?: AdapterModel[];
|
models?: AdapterModel[];
|
||||||
listModels?: () => Promise<AdapterModel[]>;
|
listModels?: () => Promise<AdapterModel[]>;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { agents } from "@paperclipai/db";
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
import {
|
import {
|
||||||
|
parseSessionCompactionPolicy,
|
||||||
resolveRuntimeSessionParamsForWorkspace,
|
resolveRuntimeSessionParamsForWorkspace,
|
||||||
shouldResetTaskSessionForWake,
|
shouldResetTaskSessionForWake,
|
||||||
type ResolvedWorkspaceForRun,
|
type ResolvedWorkspaceForRun,
|
||||||
@@ -20,6 +22,32 @@ function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAgent(adapterType: string, runtimeConfig: Record<string, unknown> = {}) {
|
||||||
|
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", () => {
|
describe("resolveRuntimeSessionParamsForWorkspace", () => {
|
||||||
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
|
||||||
const agentId = "agent-123";
|
const agentId = "agent-123";
|
||||||
@@ -151,3 +179,55 @@ describe("shouldResetTaskSessionForWake", () => {
|
|||||||
).toBe(false);
|
).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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ServerAdapterModule } from "./types.js";
|
import type { ServerAdapterModule } from "./types.js";
|
||||||
|
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
|
||||||
import {
|
import {
|
||||||
execute as claudeExecute,
|
execute as claudeExecute,
|
||||||
testEnvironment as claudeTestEnvironment,
|
testEnvironment as claudeTestEnvironment,
|
||||||
@@ -70,6 +71,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: claudeExecute,
|
execute: claudeExecute,
|
||||||
testEnvironment: claudeTestEnvironment,
|
testEnvironment: claudeTestEnvironment,
|
||||||
sessionCodec: claudeSessionCodec,
|
sessionCodec: claudeSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("claude_local") ?? undefined,
|
||||||
models: claudeModels,
|
models: claudeModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
||||||
@@ -81,6 +83,7 @@ const codexLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: codexExecute,
|
execute: codexExecute,
|
||||||
testEnvironment: codexTestEnvironment,
|
testEnvironment: codexTestEnvironment,
|
||||||
sessionCodec: codexSessionCodec,
|
sessionCodec: codexSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("codex_local") ?? undefined,
|
||||||
models: codexModels,
|
models: codexModels,
|
||||||
listModels: listCodexModels,
|
listModels: listCodexModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
@@ -93,6 +96,7 @@ const cursorLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: cursorExecute,
|
execute: cursorExecute,
|
||||||
testEnvironment: cursorTestEnvironment,
|
testEnvironment: cursorTestEnvironment,
|
||||||
sessionCodec: cursorSessionCodec,
|
sessionCodec: cursorSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("cursor") ?? undefined,
|
||||||
models: cursorModels,
|
models: cursorModels,
|
||||||
listModels: listCursorModels,
|
listModels: listCursorModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
@@ -104,6 +108,7 @@ const geminiLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: geminiExecute,
|
execute: geminiExecute,
|
||||||
testEnvironment: geminiTestEnvironment,
|
testEnvironment: geminiTestEnvironment,
|
||||||
sessionCodec: geminiSessionCodec,
|
sessionCodec: geminiSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined,
|
||||||
models: geminiModels,
|
models: geminiModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
agentConfigurationDoc: geminiAgentConfigurationDoc,
|
||||||
@@ -123,6 +128,7 @@ const openCodeLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: openCodeExecute,
|
execute: openCodeExecute,
|
||||||
testEnvironment: openCodeTestEnvironment,
|
testEnvironment: openCodeTestEnvironment,
|
||||||
sessionCodec: openCodeSessionCodec,
|
sessionCodec: openCodeSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined,
|
||||||
models: [],
|
models: [],
|
||||||
listModels: listOpenCodeModels,
|
listModels: listOpenCodeModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
@@ -134,6 +140,7 @@ const piLocalAdapter: ServerAdapterModule = {
|
|||||||
execute: piExecute,
|
execute: piExecute,
|
||||||
testEnvironment: piTestEnvironment,
|
testEnvironment: piTestEnvironment,
|
||||||
sessionCodec: piSessionCodec,
|
sessionCodec: piSessionCodec,
|
||||||
|
sessionManagement: getAdapterSessionManagement("pi_local") ?? undefined,
|
||||||
models: [],
|
models: [],
|
||||||
listModels: listPiModels,
|
listModels: listPiModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// imports (process/, http/, heartbeat.ts) don't need rewriting.
|
// imports (process/, http/, heartbeat.ts) don't need rewriting.
|
||||||
export type {
|
export type {
|
||||||
AdapterAgent,
|
AdapterAgent,
|
||||||
|
AdapterSessionManagement,
|
||||||
AdapterRuntime,
|
AdapterRuntime,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
@@ -15,5 +16,8 @@ export type {
|
|||||||
AdapterEnvironmentTestContext,
|
AdapterEnvironmentTestContext,
|
||||||
AdapterSessionCodec,
|
AdapterSessionCodec,
|
||||||
AdapterModel,
|
AdapterModel,
|
||||||
|
NativeContextManagement,
|
||||||
|
ResolvedSessionCompactionPolicy,
|
||||||
|
SessionCompactionPolicy,
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
} from "@paperclipai/adapter-utils";
|
} from "@paperclipai/adapter-utils";
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ import {
|
|||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.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 MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
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 DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
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 = {
|
const heartbeatRunListColumns = {
|
||||||
id: heartbeatRuns.id,
|
id: heartbeatRuns.id,
|
||||||
@@ -133,13 +130,6 @@ type UsageTotals = {
|
|||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SessionCompactionPolicy = {
|
|
||||||
enabled: boolean;
|
|
||||||
maxSessionRuns: number;
|
|
||||||
maxRawInputTokens: number;
|
|
||||||
maxSessionAgeHours: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SessionCompactionDecision = {
|
type SessionCompactionDecision = {
|
||||||
rotate: boolean;
|
rotate: boolean;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
@@ -296,23 +286,8 @@ function formatCount(value: number | null | undefined) {
|
|||||||
return value.toLocaleString("en-US");
|
return value.toLocaleString("en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy {
|
export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): SessionCompactionPolicy {
|
||||||
const runtimeConfig = parseObject(agent.runtimeConfig);
|
return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy;
|
||||||
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 resolveRuntimeSessionParamsForWorkspace(input: {
|
export function resolveRuntimeSessionParamsForWorkspace(input: {
|
||||||
@@ -743,7 +718,7 @@ export function heartbeatService(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const policy = parseSessionCompactionPolicy(agent);
|
const policy = parseSessionCompactionPolicy(agent);
|
||||||
if (!policy.enabled) {
|
if (!policy.enabled || !hasSessionCompactionThresholds(policy)) {
|
||||||
return {
|
return {
|
||||||
rotate: false,
|
rotate: false,
|
||||||
reason: null,
|
reason: null,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
||||||
|
import {
|
||||||
|
hasSessionCompactionThresholds,
|
||||||
|
resolveSessionCompactionPolicy,
|
||||||
|
type ResolvedSessionCompactionPolicy,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
@@ -383,6 +388,31 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
const codexSearchEnabled = adapterType === "codex_local"
|
const codexSearchEnabled = adapterType === "codex_local"
|
||||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||||
: false;
|
: 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<string, unknown>
|
||||||
|
: {}),
|
||||||
|
...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 (
|
return (
|
||||||
<div className={cn("relative", cards && "space-y-6")}>
|
<div className={cn("relative", cards && "space-y-6")}>
|
||||||
@@ -813,6 +843,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
numberHint={help.intervalSec}
|
numberHint={help.intervalSec}
|
||||||
showNumber={val!.heartbeatEnabled}
|
showNumber={val!.heartbeatEnabled}
|
||||||
/>
|
/>
|
||||||
|
{showSessionCompactionCard && (
|
||||||
|
<SessionCompactionPolicyCard
|
||||||
|
adapterType={adapterType}
|
||||||
|
resolution={sessionCompaction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -835,6 +871,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
numberHint={help.intervalSec}
|
numberHint={help.intervalSec}
|
||||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
||||||
/>
|
/>
|
||||||
|
{showSessionCompactionCard && (
|
||||||
|
<SessionCompactionPolicyCard
|
||||||
|
adapterType={adapterType}
|
||||||
|
resolution={sessionCompaction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title="Advanced Run Policy"
|
title="Advanced Run Policy"
|
||||||
@@ -922,6 +964,69 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSessionThreshold(value: number, suffix: string) {
|
||||||
|
if (value <= 0) return "Off";
|
||||||
|
return `${value.toLocaleString("en-US")} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionCompactionPolicyCard({
|
||||||
|
adapterType,
|
||||||
|
resolution,
|
||||||
|
}: {
|
||||||
|
adapterType: string;
|
||||||
|
resolution: ResolvedSessionCompactionPolicy;
|
||||||
|
}) {
|
||||||
|
const { adapterSessionManagement, policy, source } = resolution;
|
||||||
|
if (!adapterSessionManagement) return null;
|
||||||
|
|
||||||
|
const adapterLabel = adapterLabels[adapterType] ?? adapterType;
|
||||||
|
const sourceLabel = source === "agent_override" ? "Agent override" : "Adapter default";
|
||||||
|
const rotationDisabled = !policy.enabled || !hasSessionCompactionThresholds(policy);
|
||||||
|
const nativeSummary =
|
||||||
|
adapterSessionManagement.nativeContextManagement === "confirmed"
|
||||||
|
? `${adapterLabel} is treated as natively managing long context, so Paperclip fresh-session rotation defaults to off.`
|
||||||
|
: adapterSessionManagement.nativeContextManagement === "likely"
|
||||||
|
? `${adapterLabel} likely manages long context itself, but Paperclip still keeps conservative rotation defaults for now.`
|
||||||
|
: `${adapterLabel} does not have verified native compaction behavior, so Paperclip keeps conservative rotation defaults.`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-xs font-medium text-sky-50">Session compaction</div>
|
||||||
|
<span className="rounded-full border border-sky-400/30 px-2 py-0.5 text-[11px] text-sky-100">
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-sky-100/90">
|
||||||
|
{nativeSummary}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-sky-100/80">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-[11px] text-sky-100/85 tabular-nums">
|
||||||
|
<div>
|
||||||
|
<div className="text-sky-100/60">Runs</div>
|
||||||
|
<div>{formatSessionThreshold(policy.maxSessionRuns, "runs")}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sky-100/60">Raw input</div>
|
||||||
|
<div>{formatSessionThreshold(policy.maxRawInputTokens, "tokens")}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sky-100/60">Age</div>
|
||||||
|
<div>{formatSessionThreshold(policy.maxSessionAgeHours, "hours")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-sky-100/75">
|
||||||
|
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."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Internal sub-components ---- */
|
/* ---- Internal sub-components ---- */
|
||||||
|
|
||||||
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "cursor"]);
|
||||||
|
|||||||
Reference in New Issue
Block a user