mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
fix(hermes): publish and enforce native answer limits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
45f591120d
commit
de39c85a75
5 files changed
+41
-3
No files matched your search
@@ -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},
|
||||
|
||||
Reference in new issue
Block a user