mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## 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>
200 lines
6.8 KiB
TypeScript
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;
|
|
}
|