fix: address self-review findings — lint + comment + missing test

- Drop unused `import time` from inbound.py and `import call` from
  test_inbound.py (caught by ruff in CI; would have caught locally if I'd
  run it before pushing).
- Rewrite the misleading comment in PollDelivery.run_once: the cursor DOES
  advance past handler exceptions (poison-pill resilience). The previous
  comment claimed otherwise, which would have confused future readers.
- Drop `_parse_activity_row` from inbound.py's `__all__`. The leading
  underscore signals "private helper"; exposing it via `__all__`
  contradicted the convention. Tests still import it directly via the
  module path.
- Add `test_fetch_inbound_429_retries_via_get_with_retry` — the PR
  description claimed branch-coverage of the 429 path but no test
  exercised it. Closes the gap.
This commit is contained in:
Hongming Wang 2026-04-30 13:09:06 -07:00
parent 70d66cd814
commit 87a4cfcc55
31 changed files with 232 additions and 7 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -29,7 +29,6 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import logging import logging
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
@ -253,10 +252,13 @@ class PollDelivery:
def run_once(self, handler: MessageHandler) -> int: def run_once(self, handler: MessageHandler) -> int:
"""Fetch one batch and dispatch each message to ``handler``. """Fetch one batch and dispatch each message to ``handler``.
Returns the number of messages dispatched. A handler exception is Returns the number of messages dispatched. The cursor advances past
logged but does not abort the batch at-least-once semantics, the every dispatched row, including ones whose handler raised a
same row may be re-delivered on the next iteration if its cursor poison-pill input shouldn't block the queue forever. The handler
wasn't advanced. is responsible for surfacing its own errors via logging or its own
observability. This matches Slack Events delivery and SQS DLQ
semantics; the platform makes no exactly-once guarantees on
activity poll, so handlers must be idempotent regardless.
""" """
if self._stopped: if self._stopped:
return 0 return 0
@ -366,5 +368,4 @@ __all__ = [
"MessageHandler", "MessageHandler",
"PollDelivery", "PollDelivery",
"PushDelivery", "PushDelivery",
"_parse_activity_row",
] ]

View File

@ -0,0 +1,159 @@
Metadata-Version: 2.4
Name: molecule-ai-sdk
Version: 0.2.0
Summary: Molecule AI SDK — build plugins (molecule_plugin) AND remote agents that join a Molecule AI org from another machine (molecule_agent).
Author: Molecule AI
License: MIT
Project-URL: Homepage, https://github.com/Molecule-AI/molecule-sdk-python
Project-URL: Repository, https://github.com/Molecule-AI/molecule-sdk-python
Project-URL: Documentation, https://github.com/Molecule-AI/molecule-sdk-python#readme
Keywords: agents,ai,multi-agent,a2a,plugins,saas,remote-agent
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: pyyaml>=6.0
Requires-Dist: requests>=2.31
Provides-Extra: test
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
# molecule_plugin — Python SDK for building Molecule AI plugins
A Molecule AI plugin is a directory that bundles rules, skills, and per-runtime
install adaptors. Any plugin that conforms to this contract is installable
on any Molecule AI workspace whose runtime the plugin supports.
## Quick start
Copy `template/` to a new directory and edit:
```
my-plugin/
├── plugin.yaml # name, version, runtimes, description
├── rules/my-rule.md # optional — appended to CLAUDE.md at install
├── skills/my-skill/
│ ├── SKILL.md # instructions injected into the system prompt
│ └── tools/do_thing.py # optional LangChain @tool functions
└── adapters/
├── claude_code.py # one-liner: `from molecule_plugin import AgentskillsAdaptor as Adaptor`
└── deepagents.py # same
```
Validate:
```python
from molecule_plugin import validate_manifest
errors = validate_manifest("my-plugin/plugin.yaml")
assert not errors, errors
```
## CLI
The SDK ships a CLI for validating Molecule AI artifacts before publishing:
```bash
python -m molecule_plugin validate plugin my-plugin/
python -m molecule_plugin validate workspace workspace-configs-templates/claude-code-default/
python -m molecule_plugin validate org org-templates/molecule-dev/
python -m molecule_plugin validate channel channels.yaml
python -m molecule_plugin validate my-plugin/ # kind defaults to 'plugin'
```
Exit code is 0 when valid, 1 when any errors are found — suitable for CI.
Add `-q` / `--quiet` to suppress success lines and emit only errors.
Programmatic equivalents:
```python
from molecule_plugin import (
validate_plugin,
validate_workspace_template,
validate_org_template,
validate_channel_file,
validate_channel_config,
)
```
## Per-runtime adaptors — when to write a custom one
The default `AgentskillsAdaptor` handles the common shape: rules go into
the runtime's memory file (CLAUDE.md), skill dirs go into `/configs/skills/`.
That covers most plugins.
Write a custom adaptor when you need to:
- **Register runtime tools dynamically** — call `ctx.register_tool(name, fn)`.
- **Register DeepAgents sub-agents** — call `ctx.register_subagent(name, spec)`.
- **Write to a non-standard memory file** — call `ctx.append_to_memory(filename, content)`.
Minimum custom adaptor:
```python
# adapters/deepagents.py
from molecule_plugin import InstallContext, InstallResult
class Adaptor:
def __init__(self, plugin_name: str, runtime: str):
self.plugin_name, self.runtime = plugin_name, runtime
async def install(self, ctx: InstallContext) -> InstallResult:
ctx.register_subagent("my-agent", {"prompt": "...", "tools": [...]})
return InstallResult(plugin_name=self.plugin_name, runtime=self.runtime, source="plugin")
async def uninstall(self, ctx: InstallContext) -> None:
pass
```
## Resolution order (understood by the platform)
For `(plugin_name, runtime)`:
1. **Platform registry** — `workspace-template/plugins_registry/<plugin>/<runtime>.py`
(curated; set by the Molecule AI team for quality-assured plugins).
2. **Plugin-shipped** — `<plugin_root>/adapters/<runtime>.py` (what this SDK helps you build).
3. **Raw-drop fallback** — copies plugin files into `/configs/plugins/<name>/`
and surfaces a warning; no tools are wired.
You generally ship for path #2. If your plugin becomes popular enough to be
promoted to "default," the Molecule AI team PRs a copy of your adaptor into
the platform registry (path #1) so it survives upstream breakage.
## Testing locally
The SDK ships `AgentskillsAdaptor` as a standalone, unit-testable class:
```python
import asyncio
from pathlib import Path
from molecule_plugin import AgentskillsAdaptor, InstallContext
ctx = InstallContext(
configs_dir=Path("/tmp/configs"),
workspace_id="local",
runtime="claude_code",
plugin_root=Path("./my-plugin"),
)
asyncio.run(AgentskillsAdaptor("my-plugin", "claude_code").install(ctx))
# check /tmp/configs/CLAUDE.md, /tmp/configs/skills/
```
## Publishing
A plugin is just a directory. Push it to any Git host. Installation via
`POST /plugins/install {git_url}` is on the roadmap — see the platform's
`PLAN.md` under "Install-from-GitHub-URL flow." Until then, plugins are
bundled into the platform by dropping them into `plugins/` at deploy time.
## Supported runtimes
As of 2026-Q2: `claude_code`, `deepagents`, `langgraph`, `crewai`, `autogen`,
`openclaw`. See the live list with:
```bash
curl $PLATFORM_URL/plugins
```

View File

@ -0,0 +1,30 @@
README.md
pyproject.toml
molecule_agent/__init__.py
molecule_agent/__main__.py
molecule_agent/a2a_server.py
molecule_agent/client.py
molecule_agent/inbound.py
molecule_ai_sdk.egg-info/PKG-INFO
molecule_ai_sdk.egg-info/SOURCES.txt
molecule_ai_sdk.egg-info/dependency_links.txt
molecule_ai_sdk.egg-info/requires.txt
molecule_ai_sdk.egg-info/top_level.txt
molecule_plugin/__init__.py
molecule_plugin/__main__.py
molecule_plugin/builtins.py
molecule_plugin/channel.py
molecule_plugin/manifest.py
molecule_plugin/org.py
molecule_plugin/protocol.py
molecule_plugin/workspace.py
tests/test_a2a_server.py
tests/test_call_peer_errors.py
tests/test_cli_connect.py
tests/test_inbound.py
tests/test_remote_agent.py
tests/test_retry_backoff.py
tests/test_safe_extract.py
tests/test_sdk.py
tests/test_sha256_verification.py
tests/test_validators.py

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,5 @@
pyyaml>=6.0
requests>=2.31
[test]
pytest-asyncio>=0.24

View File

@ -0,0 +1,2 @@
molecule_agent
molecule_plugin

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import MagicMock, call from unittest.mock import MagicMock
import pytest import pytest
import requests import requests
@ -223,6 +223,33 @@ def test_fetch_inbound_empty_returns_empty(client: RemoteAgentClient):
assert client.fetch_inbound() == [] assert client.fetch_inbound() == []
def test_fetch_inbound_429_retries_via_get_with_retry(
client: RemoteAgentClient, monkeypatch
):
"""A 429 on the first GET should route through _get_with_retry, which
honours Retry-After / jittered backoff and eventually returns a 2xx.
"""
# Don't actually sleep during the retry — keeps the test fast.
monkeypatch.setattr("time.sleep", lambda _s: None)
rows = [{"id": "act-after-retry", "data": {"source": "canvas_user", "text": "ok"}}]
# First call: 429. Second call (the retry): 200 + rows. _get_with_retry
# will see 429 and call session.get again with the rebuilt URL — both
# responses come from the same mocked session.get, so we use side_effect.
first_429 = FakeResponse(429)
first_429.headers = {"Retry-After": "0"}
second_200 = FakeResponse(200, rows)
client._session.get.side_effect = [first_429, second_200]
out = client.fetch_inbound(since_id="act-prev")
assert len(out) == 1
assert out[0].activity_id == "act-after-retry"
# Two GETs total: one 429, one 200.
assert client._session.get.call_count == 2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# reply() # reply()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------