fix(hermes): publish and enforce native answer limits

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 18:15:41 -05:00
1 parent 45f591120d
commit de39c85a75
5 files changed
+41 -3

No files matched your search

+3
View File
@@ -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
@@ -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);
@@ -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<Record<string, string>> = Object.freeze({
"darwin-arm64": "970f0c48b905d17e616a3b75ef28d91a0e28afba8dbb3218628cd02b3b1e709c",
"linux-x64": "15fc9631318d50a2aafa9c566410b4d486265fb3e58a7981fd0c2525a5fb8f7a",
"darwin-arm64": "690b3b84a543b04a47a6859969bd613ced61d68776d80b57c85ca424bde31829",
"linux-x64": "6166fadd24dae41b9fdfd994e6c7bc129e9317ed252b7dabdd777b691ab47771",
});
export async function verifyHermesInstallation(profile: QualifiedAcpxProfile): Promise<VerifiedAcpxInstallation> {
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");
@@ -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)
@@ -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},