Skip to content

feat: add --import-modules CLI flag for preloading custom modules#2771

Open
09473ZH wants to merge 4 commits intoOpenHands:mainfrom
09473ZH:feat/import-modules-cli
Open

feat: add --import-modules CLI flag for preloading custom modules#2771
09473ZH wants to merge 4 commits intoOpenHands:mainfrom
09473ZH:feat/import-modules-cli

Conversation

@09473ZH
Copy link
Copy Markdown

@09473ZH 09473ZH commented Apr 9, 2026

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 in conversation_service.py and the resolve_tool() call in agent.init_state() (which runs in a ThreadPoolExecutor) can race, causing KeyError: "ToolDefinition 'MyCustomTool' is not registered" and a 500 response.

Solution

Add a --import-modules CLI 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 8000

Usage

In a Kubernetes deployment:

command: ["python", "-m", "openhands.agent_server"]
args: ["--port", "8000", "--import-modules", "myapp.tools"]

Where myapp/tools.py imports all custom tool modules to trigger their register_tool() calls:

import myapp.report_tool  # noqa: F401 — triggers register_tool()
import myapp.review_tool   # noqa: F401

Backward Compatibility

No behavior change when --import-modules is not provided.

09473ZH added 2 commits April 9, 2026 15:41
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.
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[Automatic Post]: I have assigned @raymyers as a reviewer based on git blame information. Thanks in advance for the help!

@all-hands-bot all-hands-bot requested a review from raymyers April 12, 2026 12:25
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[Automatic Post]: This PR seems to be currently waiting for review. @raymyers, could you please take a look when you have a chance?

Copy link
Copy Markdown
Contributor

@VascoSch92 VascoSch92 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@09473ZH
Copy link
Copy Markdown
Author

09473ZH commented Apr 20, 2026

Thanks for the catch @VascoSch92 — added tests in tests/agent_server/test_preload_modules.py (commit d026f11).

To make it unit-testable I pulled the inline preload loop in main() out into a top-level preload_modules() helper, following the same pattern as check_browser() in the same module (tests/agent_server/test_check_browser.py).

Coverage:

  • None / empty string → no-op (matches the "no behavior change when not provided" claim in the PR body)
  • single module
  • comma-separated with surrounding whitespace
  • empty segments skipped ("a,,b", trailing comma)
  • missing module → ModuleNotFoundError propagates (per the repo convention of not swallowing exceptions)
  • a contract pair using a tmp_path-backed fake module whose body has an observable side effect: calling preload_modules(qualname) runs the body, while calling preload_modules(None) leaves it out of sys.modules entirely — this is the minimal reproduction of the broken state the flag exists to fix.

Not covered (deliberately):

  • The full race between conversation_service's dynamic tool_module_qualnames import and agent.init_state()'s resolve_tool() call — reproducing that needs a running agent-server plus a registered custom tool, which felt out of scope for a CLI flag test. Happy to add an integration test under a separate file if you'd prefer.
  • The argparse wiring itself (main() passing args.import_modules into the helper) — test_check_browser.py sets the precedent of testing the helper directly rather than mocking uvicorn in main(), so I followed that.

Let me know if you'd like the integration test too.

@09473ZH 09473ZH requested a review from VascoSch92 April 22, 2026 04:56
@all-hands-bot
Copy link
Copy Markdown
Collaborator

[Automatic Post]: This PR seems to be currently waiting for review. @raymyers, @VascoSch92, could you please take a look when you have a chance?

@all-hands-bot
Copy link
Copy Markdown
Collaborator

[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.

@xingyaoww xingyaoww requested a review from all-hands-bot May 3, 2026 03:11
Copy link
Copy Markdown
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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:

Suggested change
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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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:

Suggested change
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants