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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -29,7 +29,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
@@ -253,10 +252,13 @@ class PollDelivery:
|
||||
def run_once(self, handler: MessageHandler) -> int:
|
||||
"""Fetch one batch and dispatch each message to ``handler``.
|
||||
|
||||
Returns the number of messages dispatched. A handler exception is
|
||||
logged but does not abort the batch — at-least-once semantics, the
|
||||
same row may be re-delivered on the next iteration if its cursor
|
||||
wasn't advanced.
|
||||
Returns the number of messages dispatched. The cursor advances past
|
||||
every dispatched row, including ones whose handler raised — a
|
||||
poison-pill input shouldn't block the queue forever. The handler
|
||||
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:
|
||||
return 0
|
||||
@@ -366,5 +368,4 @@ __all__ = [
|
||||
"MessageHandler",
|
||||
"PollDelivery",
|
||||
"PushDelivery",
|
||||
"_parse_activity_row",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pyyaml>=6.0
|
||||
requests>=2.31
|
||||
|
||||
[test]
|
||||
pytest-asyncio>=0.24
|
||||
@@ -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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28
-1
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -223,6 +223,33 @@ def test_fetch_inbound_empty_returns_empty(client: RemoteAgentClient):
|
||||
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()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user