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

236 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```bash
# 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`):
```bash
# 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:
```bash
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`