feat: add --import-modules CLI flag for preloading custom modules#2771
feat: add --import-modules CLI flag for preloading custom modules#277109473ZH wants to merge 4 commits intoOpenHands:mainfrom
Conversation
Allow users to specify modules to import at agent-server startup, enabling custom tool registration before any conversation is created. This avoids a race condition where dynamically registered tools (via tool_module_qualnames) may not be available when resolve_tool() runs in a different thread during agent initialization.
|
[Automatic Post]: I have assigned @raymyers as a reviewer based on git blame information. Thanks in advance for the help! |
|
[Automatic Post]: This PR seems to be currently waiting for review. @raymyers, could you please take a look when you have a chance? |
VascoSch92
left a comment
There was a problem hiding this comment.
There are zero tests for this new flag.
| for module_name in args.import_modules.split(","): | ||
| module_name = module_name.strip() | ||
| if module_name: | ||
| importlib.import_module(module_name) |
There was a problem hiding this comment.
This can raise an ImportError, SyntaxError, or anything the module raises at import time, and crash the server with a raw traceback.
The sibling code in conversation_service.py:445-449 catches ImportError and logs. This new path should at minimum match that pattern, and probably do better (log and sys.exit(1)
A missing startup module is a deployment misconfiguration the operator wants to know about loudly.
| action="store_true", | ||
| help="Check if browser functionality works and exit", | ||
| ) | ||
| parser.add_argument( |
There was a problem hiding this comment.
The import runs before the --check-browser short-circuit.
If someone runs --check-browser --import-modules myapp.tools in CI to validate the image, a broken module will fail the browser check for an unrelated reason.
Extract the inline preload logic in `main()` into a top-level `preload_modules()` helper so it can be unit-tested directly — mirrors the pattern used for `check_browser()` in the same module. Covers: None / empty no-op, single module, comma-separated with whitespace, empty-segment skipping, missing-module ImportError propagation (no swallowing), and a contract pair verifying module body side effects land with the flag and do not land without it. Also wraps the new argparse help string to satisfy E501.
|
Thanks for the catch @VascoSch92 — added tests in To make it unit-testable I pulled the inline preload loop in Coverage:
Not covered (deliberately):
Let me know if you'd like the integration test too. |
|
[Automatic Post]: This PR seems to be currently waiting for review. @raymyers, @VascoSch92, could you please take a look when you have a chance? |
|
[Automatic Post]: It has been a while since there was any activity on this PR. @09473ZH, are you still working on it? If so, please go ahead, if not then please request review, close it, or request that someone else follow up. |
all-hands-bot
left a comment
There was a problem hiding this comment.
The implementation is clean and well-tested, but the two unresolved review threads identify legitimate blocking issues that should be addressed before merging.
|
|
||
| args = parser.parse_args() | ||
|
|
||
| preload_modules(args.import_modules) |
There was a problem hiding this comment.
🟠 Important: Module import runs before --check-browser short-circuit.
If a user runs --check-browser --import-modules broken.module in CI, the import error will mask the browser check result, making it impossible to validate the image independently.
Fix: Move preload_modules() call after the --check-browser block:
| preload_modules(args.import_modules) | |
| args = parser.parse_args() | |
| # Handle browser check (should run without importing user modules) | |
| if args.check_browser: | |
| if check_browser(): | |
| sys.exit(0) | |
| else: | |
| sys.exit(1) | |
| # Import user modules after early-exit checks | |
| preload_modules(args.import_modules) |
| module_name = module_name.strip() | ||
| if not module_name: | ||
| continue | ||
| importlib.import_module(module_name) |
There was a problem hiding this comment.
🟠 Important: No error handling for import failures.
The sibling code in conversation_service.py:445-449 catches ImportError and logs it. This startup path should at minimum log import errors with context (module name, error details) before propagating the exception.
Recommendation: Add error handling with logging:
| importlib.import_module(module_name) | |
| for module_name in modules_arg.split(","): | |
| module_name = module_name.strip() | |
| if not module_name: | |
| continue | |
| try: | |
| importlib.import_module(module_name) | |
| logger.info("Imported module: %s", module_name) | |
| except ImportError as e: | |
| logger.error( | |
| "Failed to import module '%s' specified in --import-modules: %s", | |
| module_name, | |
| e, | |
| ) | |
| raise |
This preserves fail-fast behavior at startup (appropriate for server launch) while giving operators clear diagnostics about which module failed and why.
Problem
In source mode, the agent-server shares the same Python environment as the application code. However, custom tools registered via
register_tool()at module import time are not available because the agent-server does not import application modules at startup.The SDK has a dynamic registration mechanism (
tool_module_qualnames) where the client sends module paths to the server during conversation creation. But in practice, there is a race condition — the dynamic import inconversation_service.pyand theresolve_tool()call inagent.init_state()(which runs in aThreadPoolExecutor) can race, causingKeyError: "ToolDefinition 'MyCustomTool' is not registered"and a 500 response.Solution
Add a
--import-modulesCLI argument to the agent-server entry point. It accepts a comma-separated list of module paths to import before the server starts, ensuring all custom tools are registered in the global registry before any conversation is created.python -m openhands.agent_server \ --import-modules "myapp.tools,myapp.plugins" \ --port 8000Usage
In a Kubernetes deployment:
Where
myapp/tools.pyimports all custom tool modules to trigger theirregister_tool()calls:Backward Compatibility
No behavior change when
--import-modulesis not provided.