From c149d097a99a7655a16164b9e4e90c484abda30a Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Fri, 30 Oct 2026 14:39:51 -0500 Subject: [PATCH] feat(tools): add ToolError for expected failures with structured fields A @tool handler that raised lost everything but its text: the kit encoded " failed: " and logged a traceback even for an expected refusal. Plugins that need codes or state in the result (memory-sync sends path, rolled_back, committed, pending_push and more on 158 error paths) kept their own JSON encoders instead. raise ToolError(message, **fields) now encodes {"success": false, "error": message, **fields}, byte-for-byte the shape those private encoders produce, and logs a warning without a traceback. Fields may not set success or error. Claude-Session: https://claude.ai/code/session_01B6XeAU5Gn7ZaLQJVSg4AKC --- README.md | 6 ++++++ hermes_plugin_kit/__init__.py | 32 +++++++++++++++++++++++++++++++- tests/test_kit.py | 23 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8dde4ca..e202583 100644 --- a/README.md +++ b/README.md @@ -489,6 +489,12 @@ A handler returns a `dict` (becomes the success `data`), or raises (becomes a to error), or returns a `str` as an escape hatch (treated as already-encoded JSON). It must accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as kwargs. +For an expected failure, raise `ToolError(message, **fields)`. The result is +`{"success": false, "error": message, **fields}`, so codes and state such as +`rolled_back` reach the model. It is logged as a warning without a traceback. +Any other exception is unexpected: it is logged with its traceback and reported +as `" failed: "`. + ## Session state helpers The kit can read sessions and messages and append transcript rows through diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 063d978..f52f3f1 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -77,6 +77,7 @@ def register(ctx): __all__ = [ "tool", + "ToolError", "command", "middleware", "hook", @@ -2156,6 +2157,23 @@ def deliver_media( ) +class ToolError(Exception): + """An expected tool failure whose fields reach the model beside the message. + + Raise it from a :func:`tool` handler: the result is + ``{"success": false, "error": message, **fields}``. Any other exception is + an unexpected failure, logged with its traceback and reported by its text. + """ + + def __init__(self, message: str, **fields: Any) -> None: + reserved = {"success", "error"} & fields.keys() + if reserved: + raise ValueError(f"ToolError fields may not set {sorted(reserved)}") + super().__init__(message) + self.message = message + self.fields = fields + + def tool( *, toolset: str, @@ -2171,7 +2189,8 @@ def tool( """Register-ready hermes tool from a plain handler. The wrapped handler receives ``(args, **kwargs)`` and returns a ``dict`` - (becomes the success ``data``) or raises (becomes a tool error). It may also + (becomes the success ``data``) or raises (becomes a tool error; raise + :class:`ToolError` to add structured fields). It may also return a ``str`` as an escape hatch (treated as already-encoded JSON). Supply either kit ``params`` or an existing Hermes function ``schema``. ``validate_required=False`` leaves required-field errors to the handler @@ -2239,6 +2258,17 @@ def wrapper(args: dict, **kwargs: Any) -> str: ) try: result = fn(args, **kwargs) + except ToolError as exc: + log.warning( + "%s: refused; elapsed_ms=%.2f; error=%s", + tool_name, + (time.perf_counter() - started) * 1000, + exc.message, + ) + return json.dumps( + {"success": False, "error": exc.message, **exc.fields}, + ensure_ascii=False, + ) except Exception as exc: # noqa: BLE001 — tool errors stay in-band log.exception( "%s: handler raised; elapsed_ms=%.2f; error=%s", diff --git a/tests/test_kit.py b/tests/test_kit.py index cf5a9d5..89066a9 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -226,6 +226,12 @@ def sample_boom(args, **kwargs): raise RuntimeError("kaboom") +@hpk.tool(toolset="x", params={"q": hpk.str_arg("query", required=True)}) +def sample_refuse(args, **kwargs): + """Always refuses with structured fields.""" + raise hpk.ToolError("file changed under you", path="notes/a.md", rolled_back=True) + + class SchemaConventionTests(unittest.TestCase): def setUp(self) -> None: self.schema = getattr(sample_read, "_hpk_tool_spec")["schema"] @@ -396,6 +402,23 @@ def test_blank_string_counts_as_missing(self) -> None: out = json.loads(sample_read({"thread_id_or_url": " "})) self.assertFalse(out["success"]) + def test_tool_error_keeps_its_fields_and_logs_no_traceback(self) -> None: + with self.assertLogs(level="WARNING") as cap: + raw = sample_refuse({"q": "x"}) + self.assertEqual( + raw, + '{"success": false, "error": "file changed under you", ' + '"path": "notes/a.md", "rolled_back": true}', + ) + joined = "\n".join(cap.output) + self.assertIn("sample_refuse: refused", joined) + self.assertNotIn("Traceback", joined) + + def test_tool_error_refuses_reserved_field_names(self) -> None: + for key in ("success", "error"): + with self.subTest(key=key), self.assertRaises(ValueError): + hpk.ToolError("x", **{key: 1}) + def test_exception_caught_in_band(self) -> None: with self.assertLogs(level="WARNING") as cap: out = json.loads(sample_boom({"q": "x"}))