Skip to content

Commit 86bb928

Browse files
feat(registration): centralize decorated plugin surfaces
1 parent e578206 commit 86bb928

4 files changed

Lines changed: 375 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ plugin.
1818
`log_registration_summary`; preserve its stable field order and actual
1919
command, tool, middleware, hook, skill, and skipped optional skill names.
2020
`register_plugin` must emit exactly one receipt through that helper.
21+
- Use `@tool(schema=...)` when a consumer already owns a valid Hermes function
22+
schema; do not translate it through a second argument-spec format. Keep
23+
`schema` and `params` exclusive, deep-copy supplied schemas, and preserve
24+
schema-required fields even when `validate_required=False` delegates
25+
missing-argument errors to a legacy handler.
26+
- Runtime-gated consumers should pass their active decorated callables to
27+
`register_plugin` with an explicit receipt identity. Iterable registration
28+
must retain module registration's duplicate checks, deterministic ordering,
29+
skills, and `RegistrationSummary` contract.
2130
- Use `invoke_host_tool` for host-managed capabilities such as `send_message`;
2231
do not assume every Hermes capability is registered in `tools.registry`.
2332
Nested host calls must remain visible to `pre_tool_call` and `post_tool_call`.

README.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,37 @@ That's it. `discord_read_thread` is registered with a `parameters`-wrapped schem
132132
self-documenting description, required-argument validation, logging, and the JSON
133133
envelope — none of which you had to write.
134134

135+
Plugins that already own a Hermes function schema can adopt the same decorator
136+
without rebuilding their schema from `params`:
137+
138+
```python
139+
LEGACY_WRITE_SCHEMA = {
140+
"description": "Write one entry through the existing memory service.",
141+
"parameters": {
142+
"type": "object",
143+
"properties": {"content": {"type": "string"}},
144+
"required": ["content"],
145+
"additionalProperties": False,
146+
},
147+
}
148+
149+
@tool(
150+
name="workspace_write_entry",
151+
toolset="memory-sync",
152+
schema=LEGACY_WRITE_SCHEMA,
153+
validate_required=False,
154+
)
155+
def workspace_write_entry(args, **kwargs):
156+
return legacy_service.write(args)
157+
```
158+
159+
`schema` and `params` are mutually exclusive. The kit deep-copies and validates
160+
a supplied schema, including its `parameters` shape and required-property
161+
references. Required fields stay visible to the model. The default
162+
`validate_required=True` keeps the kit's instructive missing-argument response;
163+
set it to `False` only when an existing handler must retain its established
164+
validation and error payload.
165+
135166
## Commands, middleware, hooks, and plugin skills
136167

137168
Use the lifecycle entrypoint when a plugin provides more than tools:
@@ -187,6 +218,29 @@ def register(ctx):
187218
return register_plugin(ctx, __name__, skills=SKILLS)
188219
```
189220

221+
For runtime-gated surfaces, pass only the active decorated declarations instead
222+
of exposing a module full of inactive ones:
223+
224+
```python
225+
def register(ctx):
226+
active = [inject_context]
227+
if authored_memory_enabled(ctx):
228+
active.append(workspace_write_entry)
229+
return register_plugin(
230+
ctx,
231+
active,
232+
skills=SKILLS,
233+
plugin_name="memory-sync",
234+
logger=logger,
235+
)
236+
```
237+
238+
The second argument may be a module, a loaded module name, or an iterable of
239+
decorated callables. Explicit `plugin_name` and `logger` values control the
240+
single registration receipt; module registration keeps the existing manifest
241+
and module-derived defaults. Duplicate detection and returned
242+
`RegistrationSummary` inventories are identical for both declaration forms.
243+
190244
`@command` requires a bare lowercase kebab-case name without the leading slash.
191245
Its handler receives the trailing command text unchanged and may return
192246
`str | None` synchronously or asynchronously. The optional `args_hint` is
@@ -257,8 +311,10 @@ visible in container logs without forcing verbose plugin logging everywhere.
257311
`log_registration_summary(logger, plugin_name, summary)` helper. The receipt
258312
uses the Hermes manifest name when available and lists the actual registered
259313
command, tool, middleware, hook, and skill names, plus skipped optional skills.
260-
Consumers with a custom registration path can call the same helper with their
261-
own `RegistrationSummary` instead of inventing a second receipt format.
314+
Runtime-gated consumers should pass their active decorated declarations to
315+
`register_plugin`; a truly custom registration path can call the same helper
316+
with its own `RegistrationSummary` instead of inventing a second receipt
317+
format.
262318

263319
## Tool names
264320

hermes_plugin_kit/__init__.py

Lines changed: 144 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def register(ctx):
6363
from dataclasses import dataclass
6464
from enum import Enum
6565
from pathlib import Path
66-
from typing import Any, Callable
66+
from typing import Any, Callable, Iterable
6767

6868
__all__ = [
6969
"tool",
@@ -483,6 +483,48 @@ def build_schema(name: str, description: str, params: dict | None) -> dict:
483483
}
484484

485485

486+
def _copy_and_validate_schema(
487+
name: str,
488+
description: str,
489+
schema: dict[str, Any],
490+
) -> dict[str, Any]:
491+
"""Return an isolated, convention-valid Hermes function schema."""
492+
if not isinstance(schema, dict):
493+
raise TypeError("schema must be a dict")
494+
copied = copy.deepcopy(schema)
495+
if "properties" in copied:
496+
raise ValueError(
497+
"schema arguments must live under schema['parameters'], "
498+
"not top-level properties"
499+
)
500+
schema_name = copied.get("name")
501+
if schema_name is not None and schema_name != name:
502+
raise ValueError(
503+
f"schema name {schema_name!r} does not match tool name {name!r}"
504+
)
505+
parameters = copied.get("parameters")
506+
if not isinstance(parameters, dict):
507+
raise ValueError("schema.parameters must be an object-shaped dict")
508+
if parameters.get("type") != "object":
509+
raise ValueError("schema.parameters.type must be 'object'")
510+
properties = parameters.get("properties")
511+
if not isinstance(properties, dict):
512+
raise ValueError("schema.parameters.properties must be a dict")
513+
required = parameters.get("required", [])
514+
if not isinstance(required, list) or any(
515+
not isinstance(item, str) for item in required
516+
):
517+
raise ValueError("schema.parameters.required must be a list of strings")
518+
unknown_required = sorted(set(required).difference(properties))
519+
if unknown_required:
520+
raise ValueError(
521+
"schema.parameters.required references unknown properties: "
522+
+ ", ".join(unknown_required)
523+
)
524+
copied["description"] = description
525+
return copied
526+
527+
486528
# ---------------------------------------------------------------------------
487529
# Logging helpers
488530
# ---------------------------------------------------------------------------
@@ -1276,6 +1318,8 @@ def tool(
12761318
*,
12771319
toolset: str,
12781320
params: dict | None = None,
1321+
schema: dict | None = None,
1322+
validate_required: bool = True,
12791323
name: str | None = None,
12801324
namespace: str | None = None,
12811325
description: str | None = None,
@@ -1287,21 +1331,37 @@ def tool(
12871331
The wrapped handler receives ``(args, **kwargs)`` and returns a ``dict``
12881332
(becomes the success ``data``) or raises (becomes a tool error). It may also
12891333
return a ``str`` as an escape hatch (treated as already-encoded JSON).
1334+
Supply either kit ``params`` or an existing Hermes function ``schema``.
1335+
``validate_required=False`` leaves required-field errors to the handler
1336+
without removing those fields from the model-facing schema.
12901337
"""
1338+
if params is not None and schema is not None:
1339+
raise ValueError("schema and params are mutually exclusive")
1340+
if not isinstance(validate_required, bool):
1341+
raise TypeError("validate_required must be a bool")
12911342

12921343
def decorate(fn: Callable) -> Callable:
12931344
tool_name = validate_tool_name(name or fn.__name__, namespace=namespace)
1294-
doc = (description or inspect.getdoc(fn) or "").strip()
1345+
schema_description = schema.get("description") if isinstance(schema, dict) else None
1346+
doc = (description or schema_description or inspect.getdoc(fn) or "").strip()
12951347
if not doc:
12961348
raise ValueError(
12971349
f"@tool {tool_name!r}: a description is required (docstring or description=)."
12981350
)
1299-
schema = build_schema(tool_name, doc, params)
1300-
required = list(schema["parameters"].get("required", []))
1301-
examples = {
1302-
key: (params or {}).get(key, {}).get("_example")
1303-
for key in required
1304-
}
1351+
emitted_schema = (
1352+
_copy_and_validate_schema(tool_name, doc, schema)
1353+
if schema is not None
1354+
else build_schema(tool_name, doc, params)
1355+
)
1356+
required = list(emitted_schema["parameters"].get("required", []))
1357+
examples = (
1358+
{
1359+
key: (params or {}).get(key, {}).get("_example")
1360+
for key in required
1361+
}
1362+
if schema is None
1363+
else {}
1364+
)
13051365
log = logging.getLogger(fn.__module__ or "hermes_plugin_kit")
13061366

13071367
@functools.wraps(fn)
@@ -1316,21 +1376,25 @@ def wrapper(args: dict, **kwargs: Any) -> str:
13161376
safe_args,
13171377
_truncate(context),
13181378
)
1319-
for key in required:
1320-
value = args.get(key)
1321-
if value is None or (isinstance(value, str) and not value.strip()):
1322-
example = examples.get(key)
1323-
message = f"{key} is required" + (
1324-
f" (e.g. {example!r})" if example is not None else ""
1325-
)
1326-
log.warning(
1327-
"%s: rejected call, missing %s; elapsed_ms=%.2f; args=%s",
1328-
tool_name,
1329-
key,
1330-
(time.perf_counter() - started) * 1000,
1331-
safe_args,
1332-
)
1333-
return json.dumps({"success": False, "error": message}, ensure_ascii=False)
1379+
if validate_required:
1380+
for key in required:
1381+
value = args.get(key)
1382+
if value is None or (isinstance(value, str) and not value.strip()):
1383+
example = examples.get(key)
1384+
message = f"{key} is required" + (
1385+
f" (e.g. {example!r})" if example is not None else ""
1386+
)
1387+
log.warning(
1388+
"%s: rejected call, missing %s; elapsed_ms=%.2f; args=%s",
1389+
tool_name,
1390+
key,
1391+
(time.perf_counter() - started) * 1000,
1392+
safe_args,
1393+
)
1394+
return json.dumps(
1395+
{"success": False, "error": message},
1396+
ensure_ascii=False,
1397+
)
13341398
try:
13351399
result = fn(args, **kwargs)
13361400
except Exception as exc: # noqa: BLE001 — tool errors stay in-band
@@ -1365,7 +1429,7 @@ def wrapper(args: dict, **kwargs: Any) -> str:
13651429
{
13661430
"name": tool_name,
13671431
"toolset": toolset,
1368-
"schema": schema,
1432+
"schema": emitted_schema,
13691433
"requires_env": requires_env,
13701434
"emoji": emoji,
13711435
},
@@ -1420,24 +1484,76 @@ def _register_tool(ctx: Any, handler: Callable, spec: dict[str, Any]) -> None:
14201484

14211485
def register_plugin(
14221486
ctx: Any,
1423-
module: Any,
1487+
module: Any | Iterable[Callable],
14241488
skills: tuple[PluginSkill, ...] | list[PluginSkill] = (),
1489+
*,
1490+
plugin_name: str | None = None,
1491+
logger: logging.Logger | None = None,
14251492
) -> RegistrationSummary:
14261493
"""Register decorated commands, tools, middleware, hooks, and skills.
14271494
14281495
Unlike the backward-compatible :func:`register_all`, this lifecycle-level
14291496
entrypoint rejects distinct declarations that share a public name. Missing
14301497
optional skills are warned and skipped; missing required skills fail fast.
1498+
Pass a module (or loaded module name) to discover all declarations, or an
1499+
iterable of decorated callables to register only a runtime-active subset.
14311500
"""
14321501
if isinstance(module, str):
14331502
module = sys.modules[module]
1434-
log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit"))
1503+
if inspect.ismodule(module):
1504+
declarations = tuple(obj for _, obj in inspect.getmembers(module))
1505+
declaration_module_name = getattr(module, "__name__", None)
1506+
else:
1507+
try:
1508+
declarations = tuple(module)
1509+
except TypeError as exc:
1510+
raise TypeError(
1511+
"module must be a module, module name, or iterable of decorated callables"
1512+
) from exc
1513+
for declaration in declarations:
1514+
if not callable(declaration) or not any(
1515+
getattr(declaration, attr, None)
1516+
for attr in (
1517+
_COMMAND_SPEC_ATTR,
1518+
_SPEC_ATTR,
1519+
_MIDDLEWARE_SPEC_ATTR,
1520+
_HOOK_SPEC_ATTR,
1521+
)
1522+
):
1523+
raise TypeError(
1524+
"declaration iterables must contain only decorated callables"
1525+
)
1526+
declaration_module_name = next(
1527+
(
1528+
getattr(declaration, "__module__", None)
1529+
for declaration in declarations
1530+
if getattr(declaration, "__module__", None)
1531+
),
1532+
None,
1533+
)
1534+
if logger is not None and not isinstance(logger, logging.Logger):
1535+
raise TypeError("logger must be a logging.Logger")
1536+
log = logger or logging.getLogger(
1537+
declaration_module_name or "hermes_plugin_kit"
1538+
)
1539+
resolved_plugin_name = (
1540+
plugin_name
1541+
if plugin_name is not None
1542+
else (
1543+
getattr(getattr(ctx, "manifest", None), "name", None)
1544+
or declaration_module_name
1545+
or "hermes_plugin_kit"
1546+
)
1547+
)
1548+
if not isinstance(resolved_plugin_name, str) or not resolved_plugin_name.strip():
1549+
raise ValueError("plugin_name must be a non-empty string")
1550+
resolved_plugin_name = resolved_plugin_name.strip()
14351551

14361552
commands: dict[str, Callable] = {}
14371553
tools: dict[str, Callable] = {}
14381554
middlewares: dict[str, Callable] = {}
14391555
hooks: dict[str, Callable] = {}
1440-
for _, obj in inspect.getmembers(module):
1556+
for obj in declarations:
14411557
command_spec = getattr(obj, _COMMAND_SPEC_ATTR, None)
14421558
if command_spec:
14431559
existing = commands.get(command_spec["name"])
@@ -1538,10 +1654,5 @@ def register_plugin(
15381654
skills=tuple(registered_skills),
15391655
skipped_optional_skills=tuple(skipped_skills),
15401656
)
1541-
plugin_name = (
1542-
getattr(getattr(ctx, "manifest", None), "name", None)
1543-
or getattr(module, "__name__", None)
1544-
or "hermes_plugin_kit"
1545-
)
1546-
log_registration_summary(log, plugin_name, summary)
1657+
log_registration_summary(log, resolved_plugin_name, summary)
15471658
return summary

0 commit comments

Comments
 (0)