diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 51a0d542d7f..b1508e9d02f 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -87,6 +87,8 @@ interface AcpxRuntimeGoalState { interface AcpxRuntimeExtensionTurn { nativeTurnToken?: string; + nativeTurnReady: Promise; + acknowledgeNativeTurn(): void; requestId: string; sessionId: string; controller: AbortController; @@ -352,6 +354,7 @@ export async function openQualifiedAcpxRuntime( if (options.profile.agent === "hermes" && method === "_hermes/turn_started") { if (!active || !ownsExtensionTurn(active, params) || params.version !== 1 || typeof params.turnToken !== "string" || !/^[0-9a-f-]{36}$/.test(params.turnToken) || active.nativeTurnToken) throw new Error("Invalid Hermes turn binding"); active.nativeTurnToken = params.turnToken; + active.acknowledgeNativeTurn(); return; } if (!extensionNotifications.has(method) || !active?.onNotification || !ownsExtensionTurn(active, params)) return; @@ -1045,7 +1048,11 @@ function runtimePort( await abortableExtensionResult(active.promptStarted, active.signal); active.signal.throwIfAborted(); if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired"); - if (method === "_hermes/steer" && !active.nativeTurnToken) throw new Error("Hermes has not acknowledged an active turn"); + if (method === "_hermes/steer") { + await waitForHermesNativeTurn(active); + active.signal.throwIfAborted(); + if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired"); + } const response = await abortableExtensionResult(runtime.requestExtension!({ handle, method, params: { sessionId: identity.backendSessionId, message: text, ...(method === "_hermes/steer" ? { version: 1, turnToken: active.nativeTurnToken } : {}), @@ -1392,7 +1399,10 @@ function runtimePort( if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); const approval = new AbortController(); const controller = new AbortController(); + let acknowledgeNativeTurn!: () => void; + const nativeTurnReady = new Promise(resolve => { acknowledgeNativeTurn = resolve; }); const extensionTurn: AcpxRuntimeExtensionTurn = { + nativeTurnReady, acknowledgeNativeTurn, requestId: input.requestId, sessionId: identity.backendSessionId, controller, signal: input.signal ? AbortSignal.any([controller.signal, input.signal, approval.signal]) : AbortSignal.any([controller.signal, approval.signal]), promptStarted: Promise.resolve(), @@ -2025,6 +2035,17 @@ async function abortableExtensionResult(result: Promise, signal: AbortSign finally { if (rejectAbort) signal.removeEventListener("abort", rejectAbort); } } +async function waitForHermesNativeTurn(active: AcpxRuntimeExtensionTurn): Promise { + if (active.nativeTurnToken) return; + let timer: ReturnType | undefined; + try { + await abortableExtensionResult(Promise.race([active.nativeTurnReady, new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("Hermes native turn acknowledgement timed out")), 5_000); + timer.unref(); + })]), active.signal); + } finally { if (timer) clearTimeout(timer); } +} + function goalCapabilityFromAcpMessage( message: unknown, ): AcpxRuntimeGoalCapability | null { diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts index e6ae3da9de6..265dccafc64 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts @@ -30,11 +30,35 @@ describe("Hermes native extensions", () => { const response = (value: string) => ({ action: "submit" as const, response: { schema: "paperclip.question_response.v1" as const, answers: { q0: answerMode === "text" ? { text: value } : { customText: value } }, } }); - expect(result.input.resolve(response("😀".repeat(32_768)))).toMatchObject({ outcome: "answered" }); - for (const value of ["x".repeat(65_537), "x".repeat(70_000), "😀".repeat(32_769)]) { - expect(() => result.input.resolve(response(value))).toThrow("at most 65536 characters"); + const limit = result.input.questionSet.questions[0]!.textValidation!.maxLength!; + expect(limit).toBeLessThan(65_536); + expect(result.input.resolve(response("😀".repeat(Math.floor(limit / 2))))).toMatchObject({ outcome: "answered" }); + for (const value of ["x".repeat(limit + 1), "x".repeat(70_000), "😀".repeat(Math.floor(limit / 2) + 1)]) { + expect(() => result.input.resolve(response(value))).toThrow(`at most ${limit} characters`); } }); + it.each(["text", "single_select", "multi_select"])("keeps all accepted %s answers inside ACPX's encoded response bound", async answerMode => { + const result = await adapter().request("_hermes/ask_questions", { version: 1, sessionId: "session", input: { + schema: "paperclip.question_set.v1", questions: Array.from({ length: 5 }, (_, i) => ({ + id: `q${i}` + "\u0000".repeat(158), prompt: "Why?", required: true, answerMode, + textValidation: { maxLength: 65_536 }, + ...(answerMode === "text" ? {} : { options: Array.from({ length: 4 }, (_, j) => ({ + id: `o${j}` + "\u0000".repeat(158), label: `Choice ${j}`, + })), customAnswer: { enabled: true } }), + })), + } }); + if (!("input" in result)) throw new Error("Missing canonical question form"); + const answers = Object.fromEntries(result.input.questionSet.questions.map(question => [question.id, { + selectedOptionIds: answerMode === "multi_select" ? question.options!.map(option => option.id) : [], + [answerMode === "text" ? "text" : "customText"]: "\u0000".repeat(question.textValidation!.maxLength!), + }])); + const response = result.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers } }); + expect(Buffer.byteLength(JSON.stringify(response))).toBeLessThanOrEqual(256 * 1024); + const oversized = Object.fromEntries(result.input.questionSet.questions.map(question => [question.id, { + [answerMode === "text" ? "text" : "customText"]: "x".repeat(60_000), + }])); + expect(() => result.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: oversized } })).toThrow("at most"); + }); it("preserves estimated-cost provenance without inventing billed cost", async () => { const events = await adapter().notification("_hermes/usage", { version: 1, sessionId: "session", tokens: "reported", cost: "estimated", estimatedUsd: 0.012 }); events.forEach(validateAcpxRichEvent); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index 9bbf64099ca..4922a042d41 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -33,6 +33,28 @@ export interface AcpxProfileExtensionContext { turnId: string; } +/** ACPX 0.13.1 bounds the complete encoded extension response at 256 KiB. + * Reserve its fixed fields and every selectable ID, then budget text for the + * worst JSON escape (six bytes per JavaScript string code unit). The persisted + * form and response validator must share this limit before a human submits. + */ +function boundedHermesQuestionSet(value: unknown): PaperclipQuestionSet { + const input = parsePaperclipQuestionSet(value); + const largestEmptyAnswers = Object.fromEntries(input.questions.map(question => [question.id, { + selectedOptionIds: (question.options ?? []).map(option => option.id), + ...(question.answerMode === "text" ? { text: "" } + : question.customAnswer?.enabled ? { customText: "" } : {}), + }])); + const overhead = Buffer.byteLength(JSON.stringify({ outcome: "answered", answers: largestEmptyAnswers })); + const maxLength = Math.min(65_536, Math.floor((256 * 1024 - overhead) / (6 * input.questions.length))); + if (maxLength < 1) throw new Error("Hermes question answers exceed the encoded response limit"); + return parsePaperclipQuestionSet({ ...input, questions: input.questions.map(question => ({ + ...question, textValidation: { ...question.textValidation, + maxLength: Math.min(question.textValidation?.maxLength ?? maxLength, maxLength), + }, + })) }); +} + /** Provider branches install their closed, pinned adapters here after qualification research. */ export function createAcpxProfileExtensionAdapter( agent: QualifiedAcpxAgent, @@ -42,7 +64,7 @@ export function createAcpxProfileExtensionAdapter( if (agent === "hermes") return { async request(method, params) { if (method !== "_hermes/ask_questions" || params.version !== 1 || params.sessionId !== context.sessionId) throw new Error("Unsupported Hermes question extension"); - const questionSet = parsePaperclipQuestionSet(params.input); + const questionSet = boundedHermesQuestionSet(params.input); return { input: { method, questionSet, resolve(resolution) {