Skip to content

Commit 41b30ce

Browse files
feat: expand plugin lifecycle logging
Give operators enough context to diagnose tool registration, invocation, timing, and handler failures without exposing result payloads or nested credentials.
1 parent e37a3ca commit 41b30ce

3 files changed

Lines changed: 121 additions & 16 deletions

File tree

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ the same boilerplate. `hermes-plugin-kit` makes them structurally impossible:
4949
an error that *names the argument and its example*.
5050
- **Explicit tool namespacing** — build names with `tool_name(namespace, verb, noun)`
5151
and reject Hermes agent-loop names such as `memory`.
52-
- **Logging**`WARNING` on a rejected call (arguments truncated, secret-looking
53-
values redacted), `INFO` on success, under your plugin's own logger.
52+
- **Logging**`DEBUG` when a tool is invoked, `WARNING` on rejected calls and
53+
exceptions (including tracebacks), and `INFO` on success with elapsed time and
54+
result mode. Arguments are truncated and nested secret-looking values are
55+
recursively redacted. `register_all` also logs the registered tool inventory.
5456
- **Envelope + safety** — return a plain `dict` (or raise); the kit encodes the JSON
5557
string, catches exceptions, and always returns `str` from an `(args, **kwargs)`
5658
handler.
@@ -154,6 +156,24 @@ A handler returns a `dict` (becomes the success `data`), or raises (becomes a to
154156
error), or returns a `str` as an escape hatch (treated as already-encoded JSON). It must
155157
accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as kwargs.
156158

159+
## Logging contract
160+
161+
The kit logs under the decorated handler's module logger, so each plugin can
162+
control verbosity with normal Python logging configuration. Tool lifecycle logs
163+
include:
164+
165+
- `DEBUG`: invocation with truncated, recursively redacted arguments and safe
166+
`session_id`/`task_id` context when supplied by Hermes.
167+
- `WARNING`: required-argument rejection or a handler exception. Exceptions use
168+
`logger.exception`, preserving the traceback for runtime diagnosis.
169+
- `INFO`: successful completion with `elapsed_ms` and whether the handler returned
170+
a dictionary-like result or an already-encoded string.
171+
- `INFO`: a registration summary from `register_all`, including count and names.
172+
173+
The kit never logs handler result payloads. Keys containing `token`, `secret`,
174+
`password`, `passwd`, `api_key`, `apikey`, or `auth` are replaced with `***` at
175+
any nesting depth before arguments are logged.
176+
157177
## Development
158178

159179
Uses [uv](https://docs.astral.sh/uv/). Install it with `brew install uv` (macOS) or

hermes_plugin_kit/__init__.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
to the tool description, the one field a model always sees.
1212
- **Validation + instructive errors** — a missing/blank required argument returns
1313
an error that names the argument and its example, and logs a WARNING.
14-
- **Logging** — WARNING on a rejected call (args truncated, secret-looking values
15-
redacted), INFO on success, under the plugin's own logger namespace.
14+
- **Logging** — DEBUG on invocation, WARNING on rejected/failed calls with
15+
tracebacks for exceptions, and INFO on success with elapsed time. Arguments
16+
are truncated and secret-looking values are recursively redacted.
1617
- **Envelope + safety** — a handler returns a plain ``dict`` (or raises); the kit
1718
encodes the JSON string, wraps exceptions, and always returns ``str`` from an
1819
``(args, **kwargs)`` signature, exactly as the registry requires.
@@ -48,6 +49,7 @@ def register(ctx):
4849
import logging
4950
import re
5051
import sys
52+
import time
5153
from typing import Any, Callable
5254

5355
__all__ = [
@@ -213,11 +215,23 @@ def build_schema(name: str, description: str, params: dict | None) -> dict:
213215
# Logging helpers
214216
# ---------------------------------------------------------------------------
215217

218+
def _redacted_value(value: Any) -> Any:
219+
if isinstance(value, dict):
220+
return {
221+
key: (
222+
"***"
223+
if any(hint in str(key).lower() for hint in _REDACT_HINTS)
224+
else _redacted_value(item)
225+
)
226+
for key, item in value.items()
227+
}
228+
if isinstance(value, (list, tuple)):
229+
return [_redacted_value(item) for item in value]
230+
return value
231+
232+
216233
def _redacted_args(args: dict) -> dict:
217-
out: dict[str, Any] = {}
218-
for key, value in (args or {}).items():
219-
out[key] = "***" if any(hint in key.lower() for hint in _REDACT_HINTS) else value
220-
return out
234+
return _redacted_value(args or {})
221235

222236

223237
def _truncate(value: Any) -> str:
@@ -264,6 +278,19 @@ def decorate(fn: Callable) -> Callable:
264278
@functools.wraps(fn)
265279
def wrapper(args: dict, **kwargs: Any) -> str:
266280
args = args or {}
281+
started = time.perf_counter()
282+
safe_args = _truncate(_redacted_args(args))
283+
context = {
284+
key: kwargs[key]
285+
for key in ("session_id", "task_id")
286+
if kwargs.get(key) is not None
287+
}
288+
log.debug(
289+
"%s: invoked; args=%s; context=%s",
290+
tool_name,
291+
safe_args,
292+
_truncate(context),
293+
)
267294
for key in required:
268295
value = args.get(key)
269296
if value is None or (isinstance(value, str) and not value.strip()):
@@ -272,23 +299,39 @@ def wrapper(args: dict, **kwargs: Any) -> str:
272299
f" (e.g. {example!r})" if example is not None else ""
273300
)
274301
log.warning(
275-
"%s: rejected call, missing %s; args=%s",
302+
"%s: rejected call, missing %s; elapsed_ms=%.2f; args=%s",
276303
tool_name,
277304
key,
278-
_truncate(_redacted_args(args)),
305+
(time.perf_counter() - started) * 1000,
306+
safe_args,
279307
)
280308
return json.dumps({"success": False, "error": message}, ensure_ascii=False)
281309
try:
282310
result = fn(args, **kwargs)
283311
except Exception as exc: # noqa: BLE001 — tool errors stay in-band
284-
log.warning("%s: handler raised: %s", tool_name, exc)
312+
log.exception(
313+
"%s: handler raised; elapsed_ms=%.2f; error=%s",
314+
tool_name,
315+
(time.perf_counter() - started) * 1000,
316+
exc,
317+
)
285318
return json.dumps(
286319
{"success": False, "error": f"{tool_name} failed: {exc}"},
287320
ensure_ascii=False,
288321
)
289322
if isinstance(result, str):
323+
log.info(
324+
"%s: ok; elapsed_ms=%.2f; result=encoded_string",
325+
tool_name,
326+
(time.perf_counter() - started) * 1000,
327+
)
290328
return result
291-
log.info("%s: ok", tool_name)
329+
log.info(
330+
"%s: ok; elapsed_ms=%.2f; result=%s",
331+
tool_name,
332+
(time.perf_counter() - started) * 1000,
333+
type(result).__name__,
334+
)
292335
return json.dumps({"success": True, "data": result}, ensure_ascii=False)
293336

294337
setattr(
@@ -319,6 +362,7 @@ def register_all(ctx: Any, module: Any) -> int:
319362
"""
320363
if isinstance(module, str):
321364
module = sys.modules[module]
365+
log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit"))
322366
count = 0
323367
seen: set[str] = set()
324368
for _, obj in inspect.getmembers(module):
@@ -336,4 +380,9 @@ def register_all(ctx: Any, module: Any) -> int:
336380
emoji=spec["emoji"],
337381
)
338382
count += 1
383+
log.info(
384+
"hermes_plugin_kit: registered %d tool(s); names=%s",
385+
count,
386+
",".join(sorted(seen)) or "<none>",
387+
)
339388
return count

tests/test_kit.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,16 @@ def test_description_self_documents_required_arg_and_example(self) -> None:
6565

6666
class HandlerBehaviorTests(unittest.TestCase):
6767
def test_success_envelope_and_tolerates_runtime_kwargs(self) -> None:
68-
out = json.loads(sample_read({"thread_id_or_url": "999"}, task_id="t", session_id="s"))
68+
with self.assertLogs(level="DEBUG") as cap:
69+
out = json.loads(sample_read({"thread_id_or_url": "999"}, task_id="t", session_id="s"))
6970
self.assertTrue(out["success"])
7071
self.assertEqual(out["data"]["thread"], "999")
7172
self.assertEqual(out["data"]["kwargs"], ["session_id", "task_id"])
73+
joined = "\n".join(cap.output)
74+
self.assertIn("sample_read_thread: invoked", joined)
75+
self.assertIn('"session_id": "s"', joined)
76+
self.assertRegex(joined, r"elapsed_ms=\d+\.\d{2}")
77+
self.assertIn("result=dict", joined)
7278

7379
def test_missing_required_returns_instructive_error_and_warns(self) -> None:
7480
with self.assertLogs(level="WARNING") as cap:
@@ -83,10 +89,14 @@ def test_blank_string_counts_as_missing(self) -> None:
8389
self.assertFalse(out["success"])
8490

8591
def test_exception_caught_in_band(self) -> None:
86-
with self.assertLogs(level="WARNING"):
92+
with self.assertLogs(level="WARNING") as cap:
8793
out = json.loads(sample_boom({"q": "x"}))
8894
self.assertFalse(out["success"])
8995
self.assertIn("sample_boom failed", out["error"])
96+
joined = "\n".join(cap.output)
97+
self.assertIn("Traceback (most recent call last)", joined)
98+
self.assertIn("RuntimeError: kaboom", joined)
99+
self.assertRegex(joined, r"elapsed_ms=\d+\.\d{2}")
90100

91101
def test_reserved_agent_loop_tool_name_rejected(self) -> None:
92102
with self.assertRaisesRegex(ValueError, "reserved"):
@@ -120,19 +130,42 @@ def needs_id(args, **kwargs):
120130
self.assertIn("***", joined)
121131
self.assertNotIn("supersecret", joined)
122132

133+
def test_nested_secret_looking_values_redacted_in_logs(self) -> None:
134+
@hpk.tool(toolset="x")
135+
def nested(args, **kwargs):
136+
"""Accept nested configuration."""
137+
return {}
138+
139+
with self.assertLogs(level="DEBUG") as cap:
140+
nested(
141+
{
142+
"config": {
143+
"api_key": "nested-secret",
144+
"headers": [{"authorization": "Bearer hidden"}],
145+
}
146+
}
147+
)
148+
joined = "\n".join(cap.output)
149+
self.assertNotIn("nested-secret", joined)
150+
self.assertNotIn("Bearer hidden", joined)
151+
self.assertGreaterEqual(joined.count("***"), 2)
152+
123153
def test_string_return_is_passthrough(self) -> None:
124154
@hpk.tool(toolset="x")
125155
def already_json(args, **kwargs):
126156
"""Returns its own JSON."""
127157
return '{"raw": true}'
128158

129-
self.assertEqual(already_json({}), '{"raw": true}')
159+
with self.assertLogs(level="INFO") as cap:
160+
self.assertEqual(already_json({}), '{"raw": true}')
161+
self.assertIn("result=encoded_string", "\n".join(cap.output))
130162

131163

132164
class RegisterAllTests(unittest.TestCase):
133165
def test_registers_every_decorated_tool_with_convention(self) -> None:
134166
ctx = FakeCtx()
135-
count = hpk.register_all(ctx, __name__)
167+
with self.assertLogs(level="INFO") as cap:
168+
count = hpk.register_all(ctx, __name__)
136169
self.assertGreaterEqual(count, 2)
137170
by_name = {tool["name"]: tool for tool in ctx.tools}
138171
self.assertIn("sample_read_thread", by_name)
@@ -142,6 +175,9 @@ def test_registers_every_decorated_tool_with_convention(self) -> None:
142175
self.assertEqual(sample["emoji"], "🧵")
143176
self.assertIn("parameters", sample["schema"])
144177
self.assertTrue(callable(sample["handler"]))
178+
joined = "\n".join(cap.output)
179+
self.assertIn(f"registered {count} tool(s)", joined)
180+
self.assertIn("sample_read_thread", joined)
145181

146182
def test_description_requires_a_docstring(self) -> None:
147183
with self.assertRaises(ValueError):

0 commit comments

Comments
 (0)