fix(hermes): bound question responses and await native steering identity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 07:08:06 -05:00
1 parent 8196aa179c
commit f01a5f02f8
3 files changed
+72 -5

No files matched your search

@@ -87,6 +87,8 @@ interface AcpxRuntimeGoalState {
interface AcpxRuntimeExtensionTurn {
nativeTurnToken?: string;
nativeTurnReady: Promise<void>;
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<void>(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<T>(result: Promise<T>, signal: AbortSign
finally { if (rejectAbort) signal.removeEventListener("abort", rejectAbort); }
}
async function waitForHermesNativeTurn(active: AcpxRuntimeExtensionTurn): Promise<void> {
if (active.nativeTurnToken) return;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await abortableExtensionResult(Promise.race([active.nativeTurnReady, new Promise<never>((_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 {
@@ -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);
@@ -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) {