66import inspect
77import logging
88import pkgutil
9+ import threading
910from functools import lru_cache
1011from types import ModuleType
1112
2526 "utils" ,
2627}
2728
29+ # Extension point: callers outside ``app.tools.*`` (e.g. test suites,
30+ # external benchmark harnesses, downstream integrators) can register
31+ # additional tool packages by calling
32+ # :func:`register_external_tool_package`. Registered packages are walked
33+ # the same way as :mod:`app.tools` — each top-level submodule is imported
34+ # and any ``@tool``-decorated callables are picked up.
35+ #
36+ # Production stays clean: with no external registrations, the registry
37+ # discovers only ``app.tools.*``. The list is *not* persisted across
38+ # processes — every fresh import of opensre starts with zero externals.
39+ _external_tool_packages : list [ModuleType ] = []
40+ _external_registration_lock = threading .Lock ()
41+
42+
43+ def register_external_tool_package (package : ModuleType ) -> None :
44+ """Register an additional tool package for registry discovery.
45+
46+ Call before any ``get_registered_tools()`` consumer in the same
47+ process. The registry cache is cleared so the new package's tools
48+ appear on the next lookup.
49+
50+ Idempotent and thread-safe: concurrent callers registering the same
51+ package (e.g. multiple workers in a ``ThreadPoolExecutor`` each
52+ importing a bench package) won't add duplicate entries that would
53+ otherwise produce noisy ``Duplicate tool name`` warnings on every
54+ subsequent registry walk.
55+
56+ Production code does NOT call this — it's a hook for test suites
57+ and external integrators that ship their own tools but want them
58+ routed through opensre's agent loop.
59+ """
60+ with _external_registration_lock :
61+ if package in _external_tool_packages :
62+ return
63+ _external_tool_packages .append (package )
64+ clear_tool_registry_cache ()
65+
66+
2867# Preserve the current chat surface while the repo migrates toward explicit
2968# per-tool surface metadata.
3069_LEGACY_CHAT_TOOL_NAMES = {
4685}
4786
4887
49- def _iter_tool_module_names () -> list [str ]:
88+ def _iter_tool_module_names (package : ModuleType ) -> list [str ]:
5089 module_names : list [str ] = []
51- for module_info in pkgutil .iter_modules (tools_package .__path__ ):
90+ for module_info in pkgutil .iter_modules (package .__path__ ):
5291 if module_info .name in _SKIP_MODULE_NAMES :
5392 continue
5493 if module_info .name .startswith ("_" ) or module_info .name .endswith ("_test" ):
@@ -57,8 +96,8 @@ def _iter_tool_module_names() -> list[str]:
5796 return sorted (module_names )
5897
5998
60- def _import_tool_module (module_name : str ) -> ModuleType :
61- return importlib .import_module (f"{ tools_package .__name__ } .{ module_name } " )
99+ def _import_tool_module (package : ModuleType , module_name : str ) -> ModuleType :
100+ return importlib .import_module (f"{ package .__name__ } .{ module_name } " )
62101
63102
64103def _candidate_belongs_to_module (candidate : object , module_name : str ) -> bool :
@@ -122,29 +161,35 @@ def _collect_registered_tools_from_module(module: ModuleType) -> list[Registered
122161def _load_registry_snapshot () -> tuple [RegisteredTool , ...]:
123162 tools_by_name : dict [str , RegisteredTool ] = {}
124163
125- for module_name in _iter_tool_module_names ():
126- try :
127- module = _import_tool_module (module_name )
128- except ModuleNotFoundError as exc :
129- logger .warning ("[tools] Skipping %s: %s" , module_name , exc )
130- continue
131- except Exception as exc :
132- logger .warning (
133- "[tools] Skipping %s due to import failure: %s" ,
134- module_name ,
135- exc ,
136- exc_info = True ,
137- )
138- continue
139-
140- for tool in _collect_registered_tools_from_module (module ):
141- if tool .name in tools_by_name :
164+ # Walk the canonical tools package, then any externally-registered
165+ # packages in the order they were registered. First definition of a
166+ # given tool name wins; duplicates are logged and skipped.
167+ packages : list [ModuleType ] = [tools_package , * _external_tool_packages ]
168+ for package in packages :
169+ for module_name in _iter_tool_module_names (package ):
170+ try :
171+ module = _import_tool_module (package , module_name )
172+ except ModuleNotFoundError as exc :
173+ logger .warning ("[tools] Skipping %s.%s: %s" , package .__name__ , module_name , exc )
174+ continue
175+ except Exception as exc :
142176 logger .warning (
143- "[tools] Duplicate tool name '%s' across modules; keeping first definition" ,
144- tool .name ,
177+ "[tools] Skipping %s.%s due to import failure: %s" ,
178+ package .__name__ ,
179+ module_name ,
180+ exc ,
181+ exc_info = True ,
145182 )
146183 continue
147- tools_by_name [tool .name ] = tool
184+
185+ for tool in _collect_registered_tools_from_module (module ):
186+ if tool .name in tools_by_name :
187+ logger .warning (
188+ "[tools] Duplicate tool name '%s' across modules; keeping first definition" ,
189+ tool .name ,
190+ )
191+ continue
192+ tools_by_name [tool .name ] = tool
148193
149194 return tuple (sorted (tools_by_name .values (), key = lambda tool : tool .name ))
150195
0 commit comments