diff --git a/packages/paperclip-runner/docs/hermes.md b/packages/paperclip-runner/docs/hermes.md index 34102634ae7..95513a8cf99 100644 --- a/packages/paperclip-runner/docs/hermes.md +++ b/packages/paperclip-runner/docs/hermes.md @@ -69,6 +69,9 @@ changes within the same run are rejected. - Reasoning and assistant text use distinct message identities. Native tool call IDs preserve overlapping calls and edit snapshots. +- Native questions publish a 65,536-character limit for text and custom + answers. The form, canonical response validation and bridge count UTF-16 + code units consistently, so an accepted answer can resume the native callback. - Images accept PNG, JPEG, WebP and GIF; documents accept UTF-8 text/Markdown. Limits are eight attachments, 2 MiB per item and 4 MiB total. The combined message and attachments also have a 7 MiB JSON-encoded limit, including 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 613358c4db1..e6ae3da9de6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts @@ -19,6 +19,22 @@ describe("Hermes native extensions", () => { expect(result.input.cancel()).toEqual({ outcome: "cancelled" }); expect(result.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: { q0: { text: "Because" } } } })).toEqual({ outcome: "answered", answers: { q0: { text: "Because" } } }); }); + it.each(["text", "single_select", "multi_select"])("enforces the native %s limit before returning an answer", async answerMode => { + const result = await adapter().request("_hermes/ask_questions", { version: 1, sessionId: "session", turnToken: "token", input: { + schema: "paperclip.question_set.v1", questions: [{ id: "q0", prompt: "Why?", required: true, answerMode, + textValidation: { maxLength: 65_536 }, + ...(answerMode === "text" ? {} : { options: [{ id: "o0", label: "A" }], customAnswer: { enabled: true } }), + }], + } }); + if (!("input" in result)) throw new Error("Missing canonical question form"); + 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"); + } + }); 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/hermes-installation.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts index 92ff8ddc9ba..40674c4f1fc 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-installation.ts @@ -8,8 +8,8 @@ import type { QualifiedAcpxProfile } from "./qualified-profiles.js"; // Pins are produced by explicit provisioning and reviewed alongside the bridge. // An unbuilt target has no admission pin; it must not inherit a different ABI. export const HERMES_CLOSURES: Readonly> = Object.freeze({ - "darwin-arm64": "970f0c48b905d17e616a3b75ef28d91a0e28afba8dbb3218628cd02b3b1e709c", - "linux-x64": "15fc9631318d50a2aafa9c566410b4d486265fb3e58a7981fd0c2525a5fb8f7a", + "darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829", + "linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771", }); export async function verifyHermesInstallation(profile: QualifiedAcpxProfile): Promise { if (profile.agent !== "hermes" || profile.agentServerPackage !== "builtin:hermes-acp" || profile.agentServerVersion !== "1" || profile.agentRuntimePackage !== "native:hermes" || profile.agentRuntimeVersion !== "v2026.9.24") throw new Error("Hermes native profile identity mismatch"); diff --git a/packages/paperclip-runner/src/providers/hermes/bridge.py b/packages/paperclip-runner/src/providers/hermes/bridge.py index 1bef70e2fe5..15236289dd0 100644 --- a/packages/paperclip-runner/src/providers/hermes/bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/bridge.py @@ -24,6 +24,7 @@ from policy import authorize_tool, REPORTING_TOOLS from tool_process import tool_policy, install_tool_process_policy EXTENSION_VERSION = 1 +ANSWER_MAX_LENGTH = 65536 USAGE_COUNTERS = ("input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", "reasoning_tokens", "total_tokens", "estimated_cost_usd") @@ -74,6 +75,7 @@ def question_set(question, choices=None, multi_select=False, questions=None): ): raise ValueError("Invalid Hermes question choices") entry = {"id": item.get("qid", f"q{i}"), "prompt": text, "required": True, + "textValidation": {"maxLength": ANSWER_MAX_LENGTH}, "answerMode": ("multi_select" if item.get("multi_select") else "single_select") if options else "text"} if options: entry["options"] = [{"id": f"o{j}", "label": label} for j, label in enumerate(options)] @@ -103,7 +105,9 @@ def native_answers(form, response, batch): for name in ("text", "customText"): value = answer.get(name) if value is not None: - if not isinstance(value, str) or len(value) > 65536: + # Match the canonical form's JavaScript string length, including + # astral characters and unpaired surrogates, for custom text too. + if not isinstance(value, str) or len(value.encode("utf-16-le", errors="surrogatepass")) // 2 > ANSWER_MAX_LENGTH: raise ValueError("Invalid Hermes text answer") if value.strip(): parts.append(value) diff --git a/packages/paperclip-runner/src/providers/hermes/test_bridge.py b/packages/paperclip-runner/src/providers/hermes/test_bridge.py index 93821c42997..16a0f30bead 100644 --- a/packages/paperclip-runner/src/providers/hermes/test_bridge.py +++ b/packages/paperclip-runner/src/providers/hermes/test_bridge.py @@ -61,6 +61,21 @@ class ToolPolicy(unittest.TestCase): class Questions(unittest.TestCase): + def test_published_answer_limit_matches_native_text_and_custom_answers(self): + for options, multi_select in [(None, False), (["A"], False), (["A"], True)]: + form = question_set("Which?", options, multi_select) + self.assertEqual(form["questions"][0]["textValidation"], {"maxLength": 65536}) + field = "customText" if options else "text" + for value, accepted in [("x" * 65536, True), ("x" * 65537, False), ("x" * 70000, False), + ("\U0001f600" * 32768, True), ("\U0001f600" * 32769, False)]: + with self.subTest(options=options, multi_select=multi_select, length=len(value)): + response = {"outcome": "answered", "answers": {"q0": {field: value}}} + if accepted: + self.assertEqual(native_answers(form, response, False), [value] if multi_select else value) + else: + with self.assertRaisesRegex(ValueError, "Invalid Hermes text answer"): + native_answers(form, response, False) + def test_batches_preserve_native_question_ids_and_multiple_answers(self): form = question_set(None, questions=[ {"qid": "q0", "question": "Which?", "choices": ["A", "B"], "multi_select": True},