Improve ACPX adapter configuration (#5290)

## Thinking Path

> - Paperclip orchestrates AI agents across several adapter
implementations.
> - ACPX is a local adapter path that can proxy Claude and Codex-style
execution.
> - Its configuration needed stronger schema defaults, provider-aware
model handling, and better UI support.
> - Plugin authors also need clear docs for managed resources.
> - This pull request improves ACPX adapter configuration and documents
plugin-managed resources.
> - The benefit is a more predictable adapter setup path without
changing unrelated control-plane behavior.

## What Changed

- Improved ACPX config schema, execution config handling, UI build
config, and route coverage.
- Added ACPX model filtering support and tests.
- Updated the agent config form and storybook coverage for ACPX
model/provider behavior.
- Expanded plugin authoring documentation for managed resources.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/acpx-local-execute.test.ts
server/src/__tests__/adapter-routes.test.ts
ui/src/lib/acpx-model-filter.test.ts`

## Risks

- Low-to-medium risk: adapter configuration behavior changes can affect
ACPX users, but the change is isolated to ACPX/plugin-doc surfaces and
covered by targeted adapter tests.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-06 06:06:47 -05:00 committed by GitHub
parent 454edfe81e
commit 11ffd6f2c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 949 additions and 211 deletions

View file

@ -45,10 +45,10 @@ import {
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
} from "../index.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000;
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
@ -59,6 +59,7 @@ interface RuntimeCacheEntry {
handle: AcpRuntimeHandle;
fingerprint: string;
lastUsedAt: number;
cleanupTimer?: NodeJS.Timeout;
}
interface ExecuteDeps {
@ -79,6 +80,9 @@ interface AcpxPreparedRuntime {
stateDir: string;
permissionMode: "approve-all" | "approve-reads" | "deny-all";
nonInteractivePermissions: "deny" | "fail";
requestedModel: string;
requestedThinkingEffort: string;
fastMode: boolean;
timeoutSec: number;
sessionKey: string;
fingerprint: string;
@ -504,6 +508,15 @@ function normalizeNonInteractivePermissions(config: Record<string, unknown>): "d
: "deny";
}
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
return (
asString(config.modelReasoningEffort, "") ||
asString(config.reasoningEffort, "") ||
asString(config.thinkingEffort, "") ||
asString(config.effort, "")
).trim();
}
function isCompatibleSession(
params: Record<string, unknown>,
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
@ -534,6 +547,9 @@ function buildSessionParams(input: {
mode: prepared.mode,
stateDir: prepared.stateDir,
configFingerprint: prepared.fingerprint,
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
...(prepared.fastMode ? { fastMode: true } : {}),
skills: prepared.skillsIdentity,
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
@ -644,6 +660,9 @@ async function buildRuntime(input: {
const mode = normalizeMode(config);
const permissionMode = normalizePermissionMode(config);
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
const requestedModel = asString(config.model, "").trim();
const requestedThinkingEffort = normalizeRequestedThinkingEffort(config);
const fastMode = acpxAgent === "codex" && config.fastMode === true;
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
await fs.mkdir(stateDir, { recursive: true });
@ -741,6 +760,9 @@ async function buildRuntime(input: {
mode,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
remoteExecutionIdentity,
skillsIdentity,
skillPromptInstructions,
@ -766,13 +788,16 @@ async function buildRuntime(input: {
stateDir,
permissionMode,
nonInteractivePermissions,
requestedModel,
requestedThinkingEffort,
fastMode,
timeoutSec,
sessionKey,
fingerprint,
agentCommand,
agentRegistry,
remoteExecutionIdentity,
skillPromptInstructions,
skillPromptInstructions,
skillsIdentity: {
...skillsIdentity,
commandNotes: skillCommandNotes,
@ -780,6 +805,51 @@ async function buildRuntime(input: {
};
}
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
if (prepared.requestedThinkingEffort) {
options.push({
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
value: prepared.requestedThinkingEffort,
});
}
if (prepared.fastMode) {
options.push(
{ key: "service_tier", value: "fast" },
{ key: "features.fast_mode", value: "true" },
);
}
return options;
}
async function applySessionConfigOptions(input: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
prepared: AcpxPreparedRuntime;
onLog: AdapterExecutionContext["onLog"];
}) {
const options = sessionConfigOptions(input.prepared);
if (options.length === 0) return;
if (!input.runtime.setConfigOption) {
const message =
"ACPX runtime does not expose session config controls; upgrade ACPX or remove configured model, effort, and fast mode overrides.";
await input.onLog("stderr", `[paperclip] ${message}\n`);
throw new Error(message);
}
for (const option of options) {
await input.runtime.setConfigOption({
handle: input.handle,
key: option.key,
value: option.value,
});
await input.onLog(
"stdout",
`[paperclip] Applied ACPX ${input.prepared.acpxAgent} config ${option.key}=${option.value}\n`,
);
}
}
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
prompt: string;
promptMetrics: Record<string, number>;
@ -951,20 +1021,77 @@ async function cleanupIdleHandles(input: {
now: number;
idleMs: number;
}) {
if (input.idleMs <= 0) return;
const stale: Array<[string, RuntimeCacheEntry]> = [];
for (const entry of input.handles.entries()) {
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
}
for (const [key, entry] of stale) {
input.handles.delete(key);
await entry.runtime.close({
handle: entry.handle,
await closeWarmHandle({
handles: input.handles,
key,
entry,
reason: "paperclip idle cleanup",
discardPersistentState: false,
}).catch(() => {});
});
}
}
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
if (!entry.cleanupTimer) return;
clearTimeout(entry.cleanupTimer);
entry.cleanupTimer = undefined;
}
async function closeWarmHandle(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
reason: string;
discardPersistentState?: boolean;
}) {
if (input.handles.get(input.key) === input.entry) {
input.handles.delete(input.key);
}
clearWarmHandleTimer(input.entry);
await input.entry.runtime.close({
handle: input.entry.handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState ?? false,
}).catch(() => {});
}
function scheduleIdleHandleCleanup(input: {
handles: Map<string, RuntimeCacheEntry>;
key: string;
entry: RuntimeCacheEntry;
idleMs: number;
now: () => number;
}) {
clearWarmHandleTimer(input.entry);
if (input.idleMs <= 0) return;
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
input.entry.cleanupTimer = setTimeout(() => {
void (async () => {
const current = input.handles.get(input.key);
if (current !== input.entry) return;
const idleForMs = input.now() - input.entry.lastUsedAt;
if (idleForMs < input.idleMs) {
scheduleIdleHandleCleanup(input);
return;
}
await closeWarmHandle({
handles: input.handles,
key: input.key,
entry: input.entry,
reason: "paperclip idle cleanup",
});
})();
}, delayMs);
input.entry.cleanupTimer.unref?.();
}
function warmHandleMatches(
entry: RuntimeCacheEntry | undefined,
runtime: AcpRuntime,
@ -980,7 +1107,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const prepared = await buildRuntime({ ctx });
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS);
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
const previousParams = parseObject(ctx.runtime.sessionParams);
@ -996,6 +1123,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
};
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
if (cached) clearWarmHandleTimer(cached);
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
await ctx.onLog(
"stdout",
@ -1044,7 +1172,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: message,
...classified,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession,
resultJson: { phase: "ensure_session" },
summary: message,
@ -1059,12 +1187,52 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorMessage: "ACPX did not return a runtime session handle.",
errorCode: "acpx_runtime_error",
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
resultJson: { phase: "ensure_session" },
summary: "ACPX did not return a runtime session handle.",
};
}
const sessionHandle = handle;
try {
await applySessionConfigOptions({
runtime,
handle: sessionHandle,
prepared,
onLog: ctx.onLog,
});
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
await runtime.close({
handle: sessionHandle,
reason: "paperclip config cleanup",
discardPersistentState: false,
}).catch(() => {});
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: message,
...classified,
provider: "acpx",
model: prepared.requestedModel || null,
clearSession,
resultJson: {
phase: "configure_session",
agent: prepared.acpxAgent,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: message,
};
}
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
await emitAcpxLog(ctx, {
@ -1076,6 +1244,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
runtimeSessionName: sessionHandle.runtimeSessionName,
mode: prepared.mode,
permissionMode: prepared.permissionMode,
model: prepared.requestedModel || null,
thinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
});
if (ctx.onMeta) {
await ctx.onMeta({
@ -1085,6 +1256,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
commandNotes: [
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
...(Array.isArray(prepared.skillsIdentity.commandNotes)
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
: []),
@ -1130,15 +1304,23 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
const terminal = await turn.result;
if (timeout) clearTimeout(timeout);
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
warmHandles.delete(prepared.sessionKey);
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
});
} else {
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
}
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
discardPersistentState: terminal.status === "cancelled" || timedOut,
}).catch(() => {});
} else if (prepared.mode === "persistent") {
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
const existing = warmHandles.get(prepared.sessionKey);
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
await runtime.close({
@ -1147,13 +1329,37 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
discardPersistentState: false,
}).catch(() => {});
} else {
warmHandles.set(prepared.sessionKey, {
const entry: RuntimeCacheEntry = {
runtime,
handle: sessionHandle,
fingerprint: prepared.fingerprint,
lastUsedAt: now(),
};
warmHandles.set(prepared.sessionKey, entry);
scheduleIdleHandleCleanup({
handles: warmHandles,
key: prepared.sessionKey,
entry,
idleMs: warmIdleMs,
now,
});
}
} else {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
await closeWarmHandle({
handles: warmHandles,
key: prepared.sessionKey,
entry: existing,
reason: "paperclip completed turn cleanup",
});
} else {
await runtime.close({
handle: sessionHandle,
reason: "paperclip completed turn cleanup",
discardPersistentState: false,
}).catch(() => {});
}
}
const errorMessage = timedOut
@ -1176,7 +1382,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
billingType: "unknown",
costUsd: null,
resultJson: {
@ -1184,6 +1390,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
stopReason: terminalStopReason,
permissionMode: prepared.permissionMode,
mode: prepared.mode,
requestedModel: prepared.requestedModel || null,
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
fastMode: prepared.fastMode,
},
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
clearSession,
@ -1199,7 +1408,9 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
discardPersistentState: timedOut,
}).catch(() => {});
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
const existing = warmHandles.get(prepared.sessionKey);
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
@ -1211,7 +1422,7 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
errorMeta: classified.errorMeta,
provider: "acpx",
model: null,
model: prepared.requestedModel || null,
clearSession: clearSession || timedOut,
resultJson: { phase: "turn" },
summary: message,