molecule-sdk-python/CLAUDE.md
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

9.9 KiB
Raw Permalink Blame History

CLAUDE.md — molecule-sdk-python

Project overview

Python SDK for the Molecule AI agent platform. Exposes two user-facing packages:

  • molecule_agent — Phase 30.8 remote-agent client. Write an agent that runs outside the platform's Docker network; it registers with the platform, pulls secrets, sends heartbeats, and detects pause/delete. Wraps the Phase 30.130.7 HTTP contract (register, secrets, heartbeat, state-poll, A2A peer discovery, delegation, plugin install).

  • molecule_plugin — Plugin-authoring SDK. Build installable plugin directories that ship rules, skills (agentskills.io format), and per-runtime adaptors to any Molecule AI workspace. Ships validators for plugin.yaml, SKILL.md (agentskills.io spec), workspace/org/channel templates, and a python -m molecule_plugin CLI.

Both packages are published together as molecule-ai-sdk on PyPI (setuptools, pyproject.toml, requires-python = ">=3.11").


Build and test

# Install in dev mode
pip install -e .

# Run the full suite
pytest

# Run only molecule_agent tests (remote-agent client)
pytest tests/test_remote_agent.py

# Run only molecule_plugin tests (SDK + validators)
pytest tests/test_sdk.py tests/test_validators.py

# CLI smoke
python -m molecule_plugin validate --help
python -m molecule_plugin validate plugin /path/to/my-plugin/
python -m molecule_plugin validate workspace /path/to/workspace-template/

Tests use standard pytest fixtures with in-memory mocks — no live platform required. The molecule_agent tests mock requests.Session directly via unittest.mock.MagicMock.


Package conventions

molecule_agent/          # Remote-agent client (blocking requests, Phase 30)
  client.py              # RemoteAgentClient, WorkspaceState, PeerInfo,
                        # make_idempotency_key, _safe_extract_tar

molecule_plugin/         # Plugin-authoring SDK
  protocol.py            # PluginAdaptor (runtime_checkable Protocol),
                        # InstallContext, InstallResult
  builtins.py            # AgentskillsAdaptor (default),
                        # SKIP_ROOT_MD, _install_claude_layer
  manifest.py            # PLUGIN_YAML_SCHEMA, validate_manifest,
                        # parse_skill_md, validate_skill, validate_plugin
  workspace.py           # validate_workspace_template, SUPPORTED_RUNTIMES
  org.py                 # validate_org_template
  channel.py             # validate_channel_config, validate_channel_file,
                        # SUPPORTED_CHANNEL_TYPES
  __main__.py            # CLI: python -m molecule_plugin validate [plugin|workspace|org|channel]

template/               # Reference plugin layout (NOT pip-installable)
  adapters/
    claude_code.py       # AgentskillsAdaptor — one-liner per runtime
    deepagents.py        # AgentskillsAdaptor — one-liner per runtime

examples/remote-agent/   # Runnable Phase 30.130.5 demo
  run.py

Adding a new tool or endpoint to molecule_agent

  1. Pick the Phase 30 sub-phase that matches the contract (e.g. 30.6 = peer discovery).
  2. Add the method to RemoteAgentClient in client.py. Follow the existing pattern: _auth_headers() for bearer token, raise_for_status() on the response, logger.warning() instead of re-raising for transient errors in loops.
  3. Add a corresponding test fixture + test cases in tests/test_remote_agent.py. Mock client._session.get/.post with FakeResponse or a side_effect.
  4. Export from __init__.py and add to __all__.

Adding a new validator to molecule_plugin

  1. Add the validation function to the appropriate module (manifest.py for SKILL.md, workspace.py for workspace templates, etc.).
  2. Return a list of error strings (manifest layer) or a list of ValidationError objects (workspace/org/channel layer — see existing patterns in workspace.py).
  3. Re-export from molecule_plugin/__init__.py.
  4. Add python -m molecule_plugin validate <kind> /path CLI cases or hook into the existing dispatch in __main__.py if the kind is new.
  5. Add tests in tests/test_sdk.py or tests/test_validators.py.

Release process

PyPI publication is automated via GitHub Actions and triggered by git tags with a v prefix matching the version in pyproject.toml (e.g. tag v0.2.1 publishes molecule-ai-sdk==0.2.1):

# 1. Update version in pyproject.toml
# 2. Tag and push
git tag v0.2.1
git push origin v0.2.1

The GitHub Actions workflow handles sdist + wheel build and upload to PyPI. No manual steps required. Ensure you have PyPI token permissions in the repo secrets before the first release.


Platform integration notes

molecule_agent wraps these Phase 30 HTTP endpoints (all require bearer token unless noted):

Method Endpoint Phase Auth
POST /registry/register 30.1 none (issues token)
GET /workspaces/:id/secrets/values 30.2 bearer
POST /registry/heartbeat 30.1 bearer
GET /workspaces/:id/state 30.4 bearer
GET /registry/:id/peers 30.6 bearer + X-Workspace-ID
GET /registry/discover/:id 30.6 bearer + X-Workspace-ID
POST peer direct URL (A2A) 30.6 bearer + X-Workspace-ID
POST /workspaces/:id/a2a (proxy) 30.6 bearer + X-Workspace-ID
POST /workspaces/:id/delegate 30.6 bearer + X-Workspace-ID, 300s timeout
GET /workspaces/:id/plugins/:name/download 30.3 bearer
POST /workspaces/:id/plugins 30.3 bearer
GET /workspaces/:id/activity?type=a2a_receive&since_id=… 30.8c bearer (poll-mode inbound)
POST /workspaces/:id/notify 30.8c bearer (canvas-user reply)

Token is cached at ~/.molecule/<workspace_id>/.auth_token with 0600 permissions. On restart the client reuses the cached token — the platform refuses to issue a second token when one is on file.

Idempotency (KI-002): delegate() auto-generates an idempotency key as SHA256(task + current_minute) (rounded to the minute). Two container restarts within the same minute that send the same task string share the key, preventing duplicate processing.

Plugin install: Tars are extracted with _safe_extract_tar() — rejects .. path components and absolute paths; silently skips symlinks/hardlinks. Atomic rename via staging dir + rename prevents partial installs.

Content integrity (KI-006): install_plugin() verifies the unpacked tarball against the sha256 field in plugin.yaml before running setup.sh. If the hash doesn't match, the staging dir is removed and execution aborts. The hash is a content-addressed manifest of all files except plugin.yaml (excluded to avoid circularity). Generate the hash for a local plugin:

python -m molecule_agent verify-sha256 ./my-plugin-dir
# Outputs: Computed SHA256: <64-char hash>
# Paste the hash into plugin.yaml under the sha256 field.

SDK-specific conventions

  • Python: >=3.11, no external async dependencies in molecule_agent (uses blocking requests so it embeds in any event loop). molecule_plugin adaptor methods are async (install/uninstall satisfy PluginAdaptor).

  • Async: molecule_plugin uses async def/await for PluginAdaptor. Call asyncio.run(adaptor.install(ctx)) to run inline in a sync context.

  • Error handling: Network errors in loops are logged and swallowed so a transient platform hiccup does not take a remote agent offline. API-level errors (4xx) propagate via raise_for_status().

  • Token security: Token file created with 0o600 — other local users must not be able to read it. _safe_extract_tar guards against tar-slip attacks in plugin install.

  • Validation: validate_manifest/validate_skill/validate_plugin are pure and have no external dependencies (no jsonschema). They return lists of error strings. The workspace/org/channel validators return list[ValidationError] objects with .file and .message fields.

  • First-party plugins: test_first_party_plugins_are_spec_compliant() in tests/test_sdk.py validates every plugin in the repo's top-level plugins/ directory against full agentskills.io spec. Keep that test passing.


Known issues

  • Before patching a silent failure or quirky behavior, file a GitHub issue first. Do not patch silently — the SDK is consumed across multiple runtime environments and silent patches can cause subtle breakage elsewhere.

  • molecule_agent ships two inbound delivery paths: push (Phase 30.8b, A2AServer — for agents with a publicly reachable URL) and poll (Phase 30.8c, PollDelivery — for agents behind NAT or without a public endpoint, the typical case for hermes-self-hosted, codex, and similar OSS runtimes). Both feed the same MessageHandler callback through RemoteAgentClient.run_agent_loop(handler). The reply transport (/notify for canvas users vs /a2a for peer agents) is hidden behind client.reply(msg, text).

  • One-line bootstrap for poll-mode agents: python -m molecule_agent connect --platform-url … --workspace-id … --token … --handler my_module:fn. Picks PollDelivery automatically when --reported-url is empty; SIGTERM/SIGINT shut the loop down cleanly. Cursor optionally persisted to --cursor-file so restarts resume from the last-seen activity row.


Relevant platform docs

  • Platform conventions: docs/development/constraints-and-rules.md — no auth for MVP, Postgres as source of truth, no secrets in bundles, generic workspace-template.
  • Secrets runbook: docs/runbooks/saas-secrets.md — read before rotating any secrets.
  • Cron learnings: cron-learnings.md (platform root) — read before reviewing PRs; write a 1-line reflection to .claude/per-tick-reflections.md after triage.
  • CLAUDE.md/PLAN.md sync PRs: treat these as always noteworthy.
  • molecule-core docs: Full platform PLAN.md and architecture docs at https://github.com/hongmingw/molecule-monorepo