feat(heartbeat): close ADR-0044 gaps - session-cumulative T1, T5 turn trigger, pre-reset save notice

SOL-5441 follow-up to SOL-5430/PR #6808:

1. T1 now sums cache_read across all heartbeatRuns rows for the session
   (sessionCachedInputTokens) instead of reading only the latest run's
   usage, so many-small-runs sessions can no longer stay under threshold
   in aggregate while still ballooning context (claude_local default
   raised to 5,000,000 to match the session-cumulative semantics).
2. New T5 trigger fires clearSession once session-cumulative turn count
   exceeds maxSessionTurns (default 150 for claude_local), complementing
   the existing per-run maxTurnsPerRun continuation cap which never
   rotated the session on its own.
3. New pre-rotation advisory (decideSessionNearRotationWarning) injects a
   notice into the current run's prompt once T1/T5 counters cross 80% of
   threshold, telling the claude-local agent to persist WORKING-CONTEXT/
   save_to_knowledge before a future dispatch rotates the session out
   from under it.

Updated SessionCompactionPolicy (maxSessionTurns), all adapter defaults
(claude-local, hermes gateway/local, cursor, opencode_local, codex_local),
and heartbeat/session-compaction test suites accordingly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Solved Engineer Agent 2026-09-12 12:15:29 +02:00
parent d1a49877ec
commit 2a663834bb
7 changed files with 285 additions and 17 deletions

View File

@ -4,9 +4,10 @@ export interface SessionCompactionPolicy {
maxRawInputTokens: number;
maxSessionAgeHours: number;
// ADR-0044 additions
maxCachedInputTokens: number; // T1: threshold on cache_read_input_tokens (0 = disabled)
maxCachedInputTokens: number; // T1: threshold on session-cumulative cache_read_input_tokens (0 = disabled)
rotateOnZeroOpenIssues: boolean; // T3: rotate when openIssuesCount == 0
rotateOnNewIssueWake: boolean; // T4: rotate when wakeReason == "issue_assigned"
maxSessionTurns: number; // T5: threshold on session-cumulative turn count (0 = disabled)
}
export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none";
@ -32,6 +33,7 @@ const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
};
// Adapters with native context management still participate in session resume,
@ -44,6 +46,7 @@ const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
};
// ADR-0044 «Heartbeat session lifecycle» — fresh-session policy applied to claude_local by default.
@ -53,9 +56,10 @@ const CLAUDE_LOCAL_ADR_0044_POLICY: SessionCompactionPolicy = {
maxSessionRuns: 0, // not used (variant D rejected by ADR-0044)
maxRawInputTokens: 0, // not used (cached_input is the meaningful signal)
maxSessionAgeHours: 6, // T2 default for execution agents
maxCachedInputTokens: 500_000, // T1 default for execution agents
maxCachedInputTokens: 5_000_000, // T1 default: session-cumulative cache_read across all runs, not per-run
rotateOnZeroOpenIssues: true, // T3
rotateOnNewIssueWake: true, // T4
maxSessionTurns: 150, // T5 default: session-cumulative turns, matches maxTurnsPerRun continuation cap
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
@ -171,6 +175,7 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<S
const maxCachedInputTokens = readNumber(compaction.maxCachedInputTokens);
const rotateOnZeroOpenIssues = readBoolean(compaction.rotateOnZeroOpenIssues);
const rotateOnNewIssueWake = readBoolean(compaction.rotateOnNewIssueWake);
const maxSessionTurns = readNumber(compaction.maxSessionTurns);
if (enabled !== undefined) explicit.enabled = enabled;
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
@ -179,6 +184,7 @@ export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<S
if (maxCachedInputTokens !== undefined) explicit.maxCachedInputTokens = maxCachedInputTokens;
if (rotateOnZeroOpenIssues !== undefined) explicit.rotateOnZeroOpenIssues = rotateOnZeroOpenIssues;
if (rotateOnNewIssueWake !== undefined) explicit.rotateOnNewIssueWake = rotateOnNewIssueWake;
if (maxSessionTurns !== undefined) explicit.maxSessionTurns = maxSessionTurns;
return explicit;
}
@ -205,6 +211,7 @@ export function resolveSessionCompactionPolicy(
maxCachedInputTokens: explicitOverride.maxCachedInputTokens ?? basePolicy.maxCachedInputTokens,
rotateOnZeroOpenIssues: explicitOverride.rotateOnZeroOpenIssues ?? basePolicy.rotateOnZeroOpenIssues,
rotateOnNewIssueWake: explicitOverride.rotateOnNewIssueWake ?? basePolicy.rotateOnNewIssueWake,
maxSessionTurns: explicitOverride.maxSessionTurns ?? basePolicy.maxSessionTurns,
},
adapterSessionManagement,
explicitOverride,
@ -224,6 +231,7 @@ export function hasSessionCompactionThresholds(policy: Pick<
| "maxCachedInputTokens"
| "rotateOnZeroOpenIssues"
| "rotateOnNewIssueWake"
| "maxSessionTurns"
>) {
return (
policy.maxSessionRuns > 0 ||
@ -231,6 +239,7 @@ export function hasSessionCompactionThresholds(policy: Pick<
policy.maxSessionAgeHours > 0 ||
policy.maxCachedInputTokens > 0 ||
policy.rotateOnZeroOpenIssues === true ||
policy.rotateOnNewIssueWake === true
policy.rotateOnNewIssueWake === true ||
policy.maxSessionTurns > 0
);
}

View File

@ -854,10 +854,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? ""
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const nearRotationNotice = asString(context.paperclipSessionNearRotationNotice, "").trim();
const nearRotationAdvisory = nearRotationNotice
? `## Session rotation approaching\n\n${nearRotationNotice}. This session may be rotated to a fresh one soon. Before finishing this run, persist any long-term context worth keeping (open threads, decisions, next steps) now via WORKING-CONTEXT notes or save_to_knowledge — a rotated session will not retain this conversation's in-memory state.`
: "";
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
nearRotationAdvisory,
taskContextNote,
renderedPrompt,
]);
@ -866,6 +871,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
nearRotationAdvisoryChars: nearRotationAdvisory.length,
taskContextChars: taskContextNote.length,
heartbeatPromptChars: renderedPrompt.length,
};

View File

@ -17,6 +17,7 @@ const sessionManagement: AdapterSessionManagement = {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
},
};

View File

@ -57,6 +57,7 @@ const sessionManagement: AdapterSessionManagement = {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
},
};

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
decideSessionCompactionTrigger,
decideSessionNearRotationWarning,
type SessionCompactionTriggerInput,
} from "../services/heartbeat.ts";
import type { SessionCompactionPolicy } from "@paperclipai/adapter-utils";
@ -13,6 +14,7 @@ const DISABLED_POLICY: SessionCompactionPolicy = {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
};
const ADR_0044_DEFAULT_POLICY: SessionCompactionPolicy = {
@ -23,6 +25,7 @@ const ADR_0044_DEFAULT_POLICY: SessionCompactionPolicy = {
maxCachedInputTokens: 500_000,
rotateOnZeroOpenIssues: true,
rotateOnNewIssueWake: true,
maxSessionTurns: 150,
};
function buildInput(overrides: Partial<SessionCompactionTriggerInput> = {}): SessionCompactionTriggerInput {
@ -30,6 +33,8 @@ function buildInput(overrides: Partial<SessionCompactionTriggerInput> = {}): Ses
policy: DISABLED_POLICY,
runsCount: 1,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
sessionCachedInputTokens: 0,
sessionTurnsCount: 0,
sessionAgeHours: 0,
openIssuesCount: 1,
wakeReason: null,
@ -42,17 +47,79 @@ describe("decideSessionCompactionTrigger", () => {
expect(decideSessionCompactionTrigger(buildInput({ policy: ADR_0044_DEFAULT_POLICY }))).toBeNull();
});
it("T1 triggers when cached_input >= maxCachedInputTokens", () => {
it("T1 triggers when session-cumulative cache_read >= maxCachedInputTokens", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 },
sessionCachedInputTokens: 600_000,
}),
);
expect(result?.triggeredBy).toBe("t1");
expect(result?.reason).toMatch(/cache_read reached 600,000 tokens/);
});
it("T1 does NOT trigger from a single run's latestRawUsage alone (must be session-cumulative)", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 600_000, outputTokens: 0 },
sessionCachedInputTokens: 200_000,
}),
);
expect(result).toBeNull();
});
it("T5 triggers when session-cumulative turns >= maxSessionTurns", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionTurnsCount: 150,
}),
);
expect(result?.triggeredBy).toBe("t5");
expect(result?.reason).toMatch(/turns reached 150/);
});
it("T5 does NOT trigger below maxSessionTurns", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionTurnsCount: 149,
}),
);
expect(result).toBeNull();
});
it("ignores T5 when maxSessionTurns is 0 (disabled)", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionTurns: 0 };
const result = decideSessionCompactionTrigger(
buildInput({ policy, sessionTurnsCount: 1_000 }),
);
expect(result).toBeNull();
});
it("priority: T1 wins over T5 when both conditions hold", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionCachedInputTokens: 600_000,
sessionTurnsCount: 200,
}),
);
expect(result?.triggeredBy).toBe("t1");
});
it("priority: T5 wins over T2 when both conditions hold", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionTurnsCount: 200,
sessionAgeHours: 10,
}),
);
expect(result?.triggeredBy).toBe("t5");
});
it("T2 triggers when sessionAgeHours >= maxSessionAgeHours", () => {
const result = decideSessionCompactionTrigger(
buildInput({
@ -134,7 +201,7 @@ describe("decideSessionCompactionTrigger", () => {
buildInput({
policy,
runsCount: 6,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 },
sessionCachedInputTokens: 800_000,
}),
);
expect(result?.triggeredBy).toBe("legacy_runs");
@ -144,7 +211,7 @@ describe("decideSessionCompactionTrigger", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 800_000, outputTokens: 0 },
sessionCachedInputTokens: 800_000,
sessionAgeHours: 10,
}),
);
@ -190,7 +257,7 @@ describe("decideSessionCompactionTrigger", () => {
const result = decideSessionCompactionTrigger(
buildInput({
policy,
latestRawUsage: { inputTokens: 0, cachedInputTokens: 10_000_000, outputTokens: 0 },
sessionCachedInputTokens: 10_000_000,
}),
);
expect(result).toBeNull();
@ -202,3 +269,67 @@ describe("decideSessionCompactionTrigger", () => {
expect(result).toBeNull();
});
});
describe("decideSessionNearRotationWarning", () => {
it("returns null when nothing is close to threshold", () => {
expect(
decideSessionNearRotationWarning(buildInput({ policy: ADR_0044_DEFAULT_POLICY })),
).toBeNull();
});
it("warns when cache_read is at 80% of maxCachedInputTokens", () => {
const result = decideSessionNearRotationWarning(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionCachedInputTokens: 400_000,
}),
);
expect(result?.reason).toMatch(/cache_read is at 400,000 tokens/);
});
it("does NOT warn just below the 80% cache_read ratio", () => {
const result = decideSessionNearRotationWarning(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionCachedInputTokens: 399_999,
}),
);
expect(result).toBeNull();
});
it("warns when turns are at 80% of maxSessionTurns", () => {
const result = decideSessionNearRotationWarning(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionTurnsCount: 120,
}),
);
expect(result?.reason).toMatch(/used 120 turns/);
});
it("does NOT warn just below the 80% turns ratio", () => {
const result = decideSessionNearRotationWarning(
buildInput({
policy: ADR_0044_DEFAULT_POLICY,
sessionTurnsCount: 119,
}),
);
expect(result).toBeNull();
});
it("ignores cache_read ratio when maxCachedInputTokens is 0 (disabled)", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxCachedInputTokens: 0 };
const result = decideSessionNearRotationWarning(
buildInput({ policy, sessionCachedInputTokens: 10_000_000 }),
);
expect(result).toBeNull();
});
it("ignores turns ratio when maxSessionTurns is 0 (disabled)", () => {
const policy: SessionCompactionPolicy = { ...ADR_0044_DEFAULT_POLICY, maxSessionTurns: 0 };
const result = decideSessionNearRotationWarning(
buildInput({ policy, sessionTurnsCount: 1_000 }),
);
expect(result).toBeNull();
});
});

View File

@ -3041,6 +3041,7 @@ describe("parseSessionCompactionPolicy", () => {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
});
});
@ -3050,9 +3051,10 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 6,
maxCachedInputTokens: 500_000,
maxCachedInputTokens: 5_000_000,
rotateOnZeroOpenIssues: true,
rotateOnNewIssueWake: true,
maxSessionTurns: 150,
});
});
@ -3065,6 +3067,7 @@ describe("parseSessionCompactionPolicy", () => {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
});
expect(parseSessionCompactionPolicy(buildAgent("opencode_local"))).toEqual({
enabled: true,
@ -3074,6 +3077,7 @@ describe("parseSessionCompactionPolicy", () => {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
});
});
@ -3097,10 +3101,11 @@ describe("parseSessionCompactionPolicy", () => {
maxCachedInputTokens: 0,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 0,
});
});
it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4)", () => {
it("supports ADR-0044 per-agent overrides (T1/T2/T3/T4/T5)", () => {
expect(
parseSessionCompactionPolicy(
buildAgent("claude_local", {
@ -3110,6 +3115,7 @@ describe("parseSessionCompactionPolicy", () => {
maxSessionAgeHours: 12,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 100,
},
},
}),
@ -3122,6 +3128,7 @@ describe("parseSessionCompactionPolicy", () => {
maxCachedInputTokens: 1_000_000,
rotateOnZeroOpenIssues: false,
rotateOnNewIssueWake: false,
maxSessionTurns: 100,
});
});
});

View File

@ -3526,6 +3526,7 @@ export type SessionCompactionTrigger =
| "t2"
| "t3"
| "t4"
| "t5"
| "legacy_runs"
| "legacy_raw_input";
@ -3535,24 +3536,40 @@ type SessionCompactionDecision = {
triggeredBy: SessionCompactionTrigger | null;
handoffMarkdown: string | null;
previousRunId: string | null;
nearRotationReason: string | null;
};
export interface SessionCompactionTriggerInput {
policy: SessionCompactionPolicy;
runsCount: number;
latestRawUsage: UsageTotals | null;
// Session-cumulative cache_read across all heartbeatRuns rows for this session (T1).
// Distinct from latestRawUsage, which stays single-run for legacy_raw_input.
sessionCachedInputTokens: number | null;
// Session-cumulative turn count (sum of each run's num_turns result) (T5).
sessionTurnsCount: number | null;
sessionAgeHours: number;
openIssuesCount: number | null;
wakeReason: string | null;
}
// Pure decision rule for session rotation. Priority order:
// legacy_runs > legacy_raw_input > T1 (cached input) > T2 (age) > T3 (zero open issues) > T4 (new issue wake).
// legacy_runs > legacy_raw_input > T1 (session-cumulative cached input) > T5 (session-cumulative turns) >
// T2 (age) > T3 (zero open issues) > T4 (new issue wake).
// First match wins so triggeredBy is deterministic for retrospective tuning (ADR-0044 §Acceptance criteria).
export function decideSessionCompactionTrigger(
input: SessionCompactionTriggerInput,
): { reason: string; triggeredBy: SessionCompactionTrigger } | null {
const { policy, runsCount, latestRawUsage, sessionAgeHours, openIssuesCount, wakeReason } = input;
const {
policy,
runsCount,
latestRawUsage,
sessionCachedInputTokens,
sessionTurnsCount,
sessionAgeHours,
openIssuesCount,
wakeReason,
} = input;
if (policy.maxSessionRuns > 0 && runsCount > policy.maxSessionRuns) {
return { reason: `session exceeded ${policy.maxSessionRuns} runs`, triggeredBy: "legacy_runs" };
@ -3571,16 +3588,28 @@ export function decideSessionCompactionTrigger(
}
if (
policy.maxCachedInputTokens > 0 &&
latestRawUsage &&
latestRawUsage.cachedInputTokens >= policy.maxCachedInputTokens
typeof sessionCachedInputTokens === "number" &&
sessionCachedInputTokens >= policy.maxCachedInputTokens
) {
return {
reason:
`session cache_read reached ${formatCount(latestRawUsage.cachedInputTokens)} tokens ` +
`session cache_read reached ${formatCount(sessionCachedInputTokens)} tokens ` +
`(threshold ${formatCount(policy.maxCachedInputTokens)})`,
triggeredBy: "t1",
};
}
if (
policy.maxSessionTurns > 0 &&
typeof sessionTurnsCount === "number" &&
sessionTurnsCount >= policy.maxSessionTurns
) {
return {
reason:
`session turns reached ${formatCount(sessionTurnsCount)} ` +
`(threshold ${formatCount(policy.maxSessionTurns)})`,
triggeredBy: "t5",
};
}
if (policy.maxSessionAgeHours > 0 && sessionAgeHours >= policy.maxSessionAgeHours) {
return { reason: `session age reached ${Math.floor(sessionAgeHours)} hours`, triggeredBy: "t2" };
}
@ -3597,6 +3626,43 @@ export function decideSessionCompactionTrigger(
return null;
}
// Pre-rotation advisory: not a trigger, just a heads-up injected into the *current*
// run when session-cumulative counters are already close to a T1/T5 threshold, so the
// agent gets a chance to persist WORKING-CONTEXT/save_to_knowledge before a future
// dispatch's evaluateSessionCompaction call actually rotates the session out from
// under it (that call only runs between dispatches, never mid-run).
const NEAR_ROTATION_WARNING_RATIO = 0.8;
export function decideSessionNearRotationWarning(
input: SessionCompactionTriggerInput,
): { reason: string } | null {
const { policy, sessionCachedInputTokens, sessionTurnsCount } = input;
if (
policy.maxCachedInputTokens > 0 &&
typeof sessionCachedInputTokens === "number" &&
sessionCachedInputTokens >= policy.maxCachedInputTokens * NEAR_ROTATION_WARNING_RATIO
) {
return {
reason:
`session cache_read is at ${formatCount(sessionCachedInputTokens)} tokens, ` +
`approaching the ${formatCount(policy.maxCachedInputTokens)} rotation threshold`,
};
}
if (
policy.maxSessionTurns > 0 &&
typeof sessionTurnsCount === "number" &&
sessionTurnsCount >= policy.maxSessionTurns * NEAR_ROTATION_WARNING_RATIO
) {
return {
reason:
`session has used ${formatCount(sessionTurnsCount)} turns, ` +
`approaching the ${formatCount(policy.maxSessionTurns)} turn rotation threshold`,
};
}
return null;
}
interface ParsedIssueAssigneeAdapterOverrides {
adapterConfig: Record<string, unknown> | null;
useProjectWorkspace: boolean | null;
@ -11624,6 +11690,7 @@ export function heartbeatService(
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
nearRotationReason: null,
};
}
@ -11635,6 +11702,7 @@ export function heartbeatService(
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
nearRotationReason: null,
};
}
@ -11667,6 +11735,7 @@ export function heartbeatService(
triggeredBy: null,
handoffMarkdown: null,
previousRunId: null,
nearRotationReason: null,
};
}
@ -11686,24 +11755,62 @@ export function heartbeatService(
)
: 0;
const trigger = decideSessionCompactionTrigger({
let sessionCachedInputTokens: number | null = null;
let sessionTurnsCount: number | null = null;
if (policy.maxCachedInputTokens > 0 || policy.maxSessionTurns > 0) {
const [aggregate] = await db
.select({
sessionCachedInputTokens: sql<string>`sum(
coalesce(
(${heartbeatRuns.usageJson} ->> 'rawCachedInputTokens')::numeric,
(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric,
0
)
)`.as("sessionCachedInputTokens"),
sessionTurnsCount: sql<string>`sum(
coalesce((${heartbeatRuns.resultJson} ->> 'num_turns')::numeric, 0)
)`.as("sessionTurnsCount"),
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.agentId, agent.id),
eq(heartbeatRuns.sessionIdAfter, sessionId),
),
);
sessionCachedInputTokens =
policy.maxCachedInputTokens > 0 && aggregate?.sessionCachedInputTokens != null
? Number(aggregate.sessionCachedInputTokens)
: null;
sessionTurnsCount =
policy.maxSessionTurns > 0 && aggregate?.sessionTurnsCount != null
? Number(aggregate.sessionTurnsCount)
: null;
}
const triggerInput: SessionCompactionTriggerInput = {
policy,
runsCount: runs.length,
latestRawUsage,
sessionCachedInputTokens,
sessionTurnsCount,
sessionAgeHours,
openIssuesCount: typeof input.openIssuesCount === "number" ? input.openIssuesCount : null,
wakeReason: input.wakeReason ?? null,
});
};
const trigger = decideSessionCompactionTrigger(triggerInput);
const reason: string | null = trigger?.reason ?? null;
const triggeredBy: SessionCompactionTrigger | null = trigger?.triggeredBy ?? null;
if (!reason || !latestRun) {
const nearRotationReason = decideSessionNearRotationWarning(triggerInput)?.reason ?? null;
return {
rotate: false,
reason: null,
triggeredBy: null,
handoffMarkdown: null,
previousRunId: latestRun?.id ?? null,
nearRotationReason,
};
}
@ -11742,6 +11849,7 @@ export function heartbeatService(
triggeredBy,
handoffMarkdown,
previousRunId: latestRun.id,
nearRotationReason: null,
};
}
@ -21695,6 +21803,11 @@ export function heartbeatService(
delete context.paperclipSessionRotationReason;
delete context.paperclipPreviousSessionId;
}
if (!sessionCompaction.rotate && sessionCompaction.nearRotationReason) {
context.paperclipSessionNearRotationNotice = sessionCompaction.nearRotationReason;
} else {
delete context.paperclipSessionNearRotationNotice;
}
const runtimeForAdapter = {
sessionId: runtimeSessionIdForAdapter,