Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<tool> failed: <exception>"`.

## Session state helpers

The kit can read sessions and messages and append transcript rows through
Expand Down
32 changes: 31 additions & 1 deletion hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def register(ctx):

__all__ = [
"tool",
"ToolError",
"command",
"middleware",
"hook",
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions tests/test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"}))
Expand Down
Loading