Files
PaperClipAI/packages/adapter-utils/src/session-compaction.ts
T
DottaandPaperclip 2ec82c5774 fix(runner): preserve task context when tool connections change (#14963)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents use tools through company-scoped connections and provider
sessions.
> - Resolving a tool connection currently forces a fresh session even
when the provider can load new tools into the existing conversation.
> - A fresh provider conversation can receive too little history to
continue the task.
> - This pull request adds explicit tool-refresh capabilities and uses
them in both runner paths.
> - Fresh attempts receive bounded task history with source IDs and
retrieval instructions.
> - The benefit is that agents can continue the same task after a
connection changes.

## Linked Issues or Issue Description

**What happened?**

A resolved tool connection forced a fresh provider conversation. The new
conversation could lose the original goal and prior answers. Claude also
rejected resume when only the MCP server set changed.

**Expected behavior**

Resume the provider conversation when its harness can refresh tools.
When a fresh session is required, supply enough bounded history to
continue the task. Preserve company, agent, task, workspace, model,
instruction, and skill checks.

**Steps to reproduce**

1. Start a conversation and agree on a task and its constraints.
2. Request and connect a tool needed for the task.
3. Continue the conversation after the connection resolves.
4. Check that the agent remembers the task and can use the new tool.

**Paperclip version or commit**

The bug was reproduced on master at `c46e41e81`. This branch is rebased
on current master.

**Deployment mode**

Self-hosted server. Both legacy adapters and the native runner are
affected.

Related public work: Refs #13282 for task-backed conversations. Refs
#13057 for the broader session-compaction proposal. Refs #14659 for
another report about local CLI session continuity. This change fixes
tool-connection continuation. Provider authentication repairs keep their
existing recovery behavior.

## What Changed

- Expose tool-refresh support in native harness descriptors and legacy
adapter metadata.
- Request tool refresh after connection resolution. Keep provider
authentication repair as a fresh-session wake.
- Reload current tools and credentials while retaining supported Claude,
Codex, Grok, and other provider conversations.
- Allow MCP-only changes during qualified native recovery. Keep all
other compatibility checks.
- Refresh managed-provider and ACPX tool bindings when attaching a new
run.
- Add a fresh-session handoff for both runner paths. Bound database
reads, excerpts, and the final packet to 24,000 bytes.
- Include the original request, recent messages, decisions, plans, prior
answers, and source IDs. Mark omitted content. Apply reset boundaries,
wake cutoffs, quarantine, and secret redaction.
- Add regression tests and document the capabilities and handoff
behavior.

## Verification

- `pnpm -r typecheck` and `pnpm build` passed. Rust formatting passed.
- Final review fixes passed 314 server tests, 333 adapter utility tests,
139 native-session runtime tests, and 12 managed-provider Rust tests.
They verify historical quarantine, raised budgets across attachment, no
history reads on successful resume, and handoff delivery on fresh retry.
- Broader branch verification also passed 1,401 adapter utility tests,
1,047 runner TypeScript tests, 43 Grok adapter tests, and 311 Rust core
tests.
- Live Claude CLI and Grok ACP probes preserved the provider session ID,
recalled a prior task constraint, and called a newly added read-only MCP
tool.
- GitHub CI passed on `b21486d18084a7aa4cafbe8e012f7cad6585d9cc`: 55
successful checks and 4 skipped checks. This includes all test shards,
all eight browser shards, runner checks, and the Grok clean public npm
install canary. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/37057514976).
- A full local test attempt encountered a separate Git snapshot timeout.
All affected local suites passed after the final edits, and the full CI
test gates passed.
- Review the capability matrix in `packages/paperclip-runner/README.md`.
Repeat the four reproduction steps with a supported provider and with an
unsupported harness.

## Risks

- Provider tool refresh can fail. Existing recovery falls back to a
fresh conversation where policy permits it.
- A new transport can replace an old process while preserving the
provider conversation. Tests cover current credentials and unchanged
identity.
- Long history can omit older context. Explicit markers and source IDs
let the agent retrieve needed context within task scope.
- Unknown and unqualified harnesses use the fresh-session path. No
database migration is required.

## Model Used

OpenAI Codex, GPT-6, with reasoning, tool use, code execution, and live
provider testing. The exact model ID and context-window size are not
exposed in this session. Claude and Grok also ran as test subjects.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-02 15:11:44 -05:00

200 lines
6.8 KiB
TypeScript

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,
};
// Adapters with native context management still participate in session resume,
// but Paperclip should not rotate them using threshold-based compaction.
const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
enabled: true,
maxSessionRuns: 0,
maxRawInputTokens: 0,
maxSessionAgeHours: 0,
};
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
"claude_local",
"codex_local",
"cursor_cloud",
"cursor",
"gemini_local",
"grok_local",
"hermes_local",
"kimi_local",
"opencode_local",
"pi_local",
]);
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
claude_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
codex_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
},
cursor_cloud: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
cursor: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
gemini_local: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
grok_local: {
supportsSessionResume: true,
nativeContextManagement: "unknown",
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
},
kimi_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,
},
hermes_local: {
supportsSessionResume: true,
nativeContextManagement: "confirmed",
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_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;
}