mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
fix(hermes): bound question responses and await native steering identity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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) {
|
||||
|
||||
Reference in new issue
Block a user