molecule-sdk-python/molecule_agent/__main__.py
Hongming Wang 70d66cd814 feat: poll-mode inbound delivery + molecule connect CLI (Phase 30.8c)
External agents that can't expose a public HTTP endpoint (laptops behind
NAT, ephemeral CI runners, hermes self-hosted, codex et al) had to reverse-
engineer the activity-poll loop from molecule-mcp-claude-channel/server.ts
because the SDK only shipped the push-mode `A2AServer` (Phase 30.8b).

This adds the complementary path:

- `RemoteAgentClient.fetch_inbound(since_id=…)` — one-shot GET against
  `/workspaces/:id/activity?type=a2a_receive&since_id=…`. Cursor-loss (410)
  surfaces as `CursorLostError`; caller resets and re-polls.
- `RemoteAgentClient.reply(msg, text)` — smart-routes to `/notify` for
  canvas users, `/a2a` (JSON-RPC envelope + X-Source-Workspace-Id) for peer
  agents. Hides the reply-path bifurcation from connector authors.
- `PollDelivery` / `PushDelivery` / `InboundDelivery` protocol — same
  `MessageHandler` callback works for both transports.
- `RemoteAgentClient.run_agent_loop(handler, delivery=None)` — combined
  heartbeat + state-poll + inbound dispatch. Defaults to `PollDelivery`.
  Async handlers detected and `asyncio.run`'d (matches A2AServer pattern).
  Sleep cadence = min(heartbeat_interval, delivery.interval).
- `python -m molecule_agent connect` CLI — one-line bootstrap. Loads a
  user's `module:function` via importlib, registers, runs the loop until
  pause/delete or SIGTERM. All flags also read from environment variables.

Tests: 50 new (test_inbound.py, test_cli_connect.py) covering every prod
branch — source normalization, cursor advancement, 410 reset, async/sync
handler dispatch, handler exception → log+continue+advance, smart-reply
routing for canvas vs peer vs unknown sources, run_agent_loop terminal
states, sleep-interval selection, CLI handler resolution failures.

Resolves #17.
2026-04-30 13:03:44 -07:00

283 lines
9.7 KiB
Python

"""CLI for molecule_agent — python -m molecule_agent [command]
Commands:
verify-sha256 <plugin-dir> Compute the content-integrity SHA256 for a
plugin directory. The hash excludes
plugin.yaml (self-referential). Output the
hash so you can paste it into plugin.yaml
under the sha256 field.
connect Register and run a remote agent against a
Molecule platform — heartbeat + state-poll
+ inbound message poll, all in one process.
Loads a user-supplied handler module:func
and dispatches every inbound A2A message.
Designed for hermes / codex / any third-party
runtime that can't expose a reachable URL.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import logging
import os
import signal
import sys
from pathlib import Path
def _walk_files(root: Path) -> list[str]:
"""Yield relative file paths under ``root`` (directories excluded)."""
rel: list[str] = []
for p in root.rglob("*"):
if p.is_file():
rel.append(p.relative_to(root).as_posix())
return rel
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def compute_plugin_sha256(plugin_dir: Path) -> str:
"""Compute the content-integrity SHA256 for a plugin directory.
The manifest is the SHA256 of the canonical JSON of
``sorted((relative_path, SHA256(file_content)) for every file
EXCEPT plugin.yaml``.
``plugin.yaml`` is excluded from its own hash because it contains the
hash — otherwise the bootstrap is circular and convergence is impossible.
"""
file_hashes: list[tuple[str, str]] = []
for relpath in sorted(_walk_files(plugin_dir)):
if relpath == "plugin.yaml":
continue
file_hashes.append((relpath, _sha256_file(plugin_dir / relpath)))
manifest_bytes = json.dumps(file_hashes, sort_keys=True).encode()
return hashlib.sha256(manifest_bytes).hexdigest()
def _resolve_handler(spec: str):
"""Resolve a ``module.path:function`` spec into the callable.
Mirrors the convention used by gunicorn / uvicorn / celery for app
references — a single string the user can put in a config file or env
var. Raises ``SystemExit`` with a readable message on any failure
(import, attribute lookup, non-callable result) so the CLI's exit
surface is clean.
"""
if ":" not in spec:
raise SystemExit(
f"error: handler spec {spec!r} must be of the form 'module.path:function'"
)
mod_path, func_name = spec.split(":", 1)
if not mod_path or not func_name:
raise SystemExit(f"error: handler spec {spec!r} is malformed")
try:
# Importing the user's module pulls in their code — we run it from
# the current working directory by default so 'my_handler:fn' works
# without setting PYTHONPATH first.
if "" not in sys.path:
sys.path.insert(0, "")
module = importlib.import_module(mod_path)
except Exception as exc:
raise SystemExit(f"error: could not import {mod_path}: {exc}")
try:
func = getattr(module, func_name)
except AttributeError:
raise SystemExit(f"error: {mod_path} has no attribute {func_name!r}")
if not callable(func):
raise SystemExit(f"error: {spec} is not callable")
return func
def _connect_command(args: argparse.Namespace) -> int:
"""Run the register + heartbeat + inbound-poll loop.
Returns the process exit code. 0 on graceful exit (paused/removed/SIGTERM),
non-zero on registration / handler-import failures.
"""
# Lazy import — the connect path pulls in requests + the full client,
# while verify-sha256 should stay light.
from .client import RemoteAgentClient
from .inbound import PollDelivery
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="[molecule] %(message)s",
)
handler = _resolve_handler(args.handler)
client = RemoteAgentClient(
workspace_id=args.workspace_id,
platform_url=args.platform_url,
agent_card={"name": args.agent_name or f"remote-{args.workspace_id[:8]}"},
reported_url=args.reported_url or "",
)
if args.token:
# User passed a token explicitly — persist it so register() can be
# skipped on a known-tokened workspace. The platform's register
# endpoint refuses to issue a second token when one is on file.
client.save_token(args.token)
# If we don't have a token yet (and one wasn't provided), call register
# so the platform mints one. On a known-tokened workspace this still
# succeeds and just returns the cached token.
if client.load_token() is None:
try:
client.register()
except Exception as exc:
print(f"[molecule] register failed: {exc}", file=sys.stderr)
return 2
print(
f"[molecule] connected as {args.workspace_id} "
f"(platform={args.platform_url}, delivery=poll, interval={args.poll_interval}s)"
)
cursor_file = None
if args.cursor_file:
cursor_file = Path(args.cursor_file).expanduser()
delivery = PollDelivery(
client,
interval=args.poll_interval,
cursor_file=cursor_file,
)
# Graceful shutdown on SIGINT / SIGTERM. The loop's built-in stop
# condition is platform-driven (paused / deleted), so we install a
# signal handler that sets max_iterations to the loop counter +1
# by raising KeyboardInterrupt — caught below.
def _on_signal(_sig, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGINT, _on_signal)
signal.signal(signal.SIGTERM, _on_signal)
try:
terminal = client.run_agent_loop(handler, delivery=delivery)
print(f"[molecule] platform reports workspace {terminal} — exiting")
return 0
except KeyboardInterrupt:
print("[molecule] received signal — shutting down cleanly")
try:
delivery.stop()
except Exception:
pass
return 0
def main() -> None:
parser = argparse.ArgumentParser(
prog="molecule_agent",
description="Molecule AI remote-agent CLI utilities.",
)
sub = parser.add_subparsers(dest="command", required=True)
vs = sub.add_parser(
"verify-sha256",
help="Compute the content-integrity SHA256 for a plugin directory.",
)
vs.add_argument(
"plugin_dir",
type=Path,
help="Path to the plugin directory (must contain plugin.yaml)",
)
cn = sub.add_parser(
"connect",
help=(
"Register and run a remote agent against a Molecule platform — "
"heartbeat + state-poll + inbound A2A message dispatch."
),
)
cn.add_argument(
"--platform-url",
required=True,
default=os.environ.get("MOLECULE_PLATFORM_URL"),
help="Base URL of the Molecule platform (env: MOLECULE_PLATFORM_URL)",
)
cn.add_argument(
"--workspace-id",
required=True,
default=os.environ.get("MOLECULE_WORKSPACE_ID"),
help="UUID of the workspace this agent claims (env: MOLECULE_WORKSPACE_ID)",
)
cn.add_argument(
"--token",
default=os.environ.get("MOLECULE_WORKSPACE_TOKEN"),
help=(
"Pre-issued workspace bearer token (env: MOLECULE_WORKSPACE_TOKEN). "
"If omitted, the CLI calls /registry/register and caches the issued token."
),
)
cn.add_argument(
"--handler",
required=True,
help=(
"Handler spec in 'module.path:function' form. The function receives "
"(InboundMessage, RemoteAgentClient) and returns a reply string or None."
),
)
cn.add_argument(
"--agent-name",
default=os.environ.get("MOLECULE_AGENT_NAME"),
help="Name in the agent_card (env: MOLECULE_AGENT_NAME). Defaults to remote-<id8>.",
)
cn.add_argument(
"--reported-url",
default=os.environ.get("MOLECULE_REPORTED_URL", ""),
help=(
"Externally-reachable URL siblings can call. Empty = poll-only mode "
"(env: MOLECULE_REPORTED_URL)."
),
)
cn.add_argument(
"--poll-interval",
type=float,
default=float(os.environ.get("MOLECULE_POLL_INTERVAL", "5.0")),
help="Seconds between activity polls (env: MOLECULE_POLL_INTERVAL).",
)
cn.add_argument(
"--cursor-file",
default=os.environ.get("MOLECULE_CURSOR_FILE"),
help=(
"Path to persist the activity cursor across restarts (env: "
"MOLECULE_CURSOR_FILE). Default: in-process only."
),
)
cn.add_argument(
"--verbose",
action="store_true",
help="Enable INFO-level logging.",
)
args = parser.parse_args()
if args.command == "verify-sha256":
plugin_dir = args.plugin_dir.resolve()
if not plugin_dir.is_dir():
sys.exit(f"error: {plugin_dir} is not a directory")
try:
h = compute_plugin_sha256(plugin_dir)
print(f"Computed SHA256: {h}")
except Exception as exc:
sys.exit(f"error: {exc}")
elif args.command == "connect":
sys.exit(_connect_command(args))
else:
parser.print_help()
if __name__ == "__main__":
main()