@@ -63,7 +63,7 @@ def register(ctx):
6363from dataclasses import dataclass
6464from enum import Enum
6565from 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
14211485def 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