diff --git a/molecule_agent/__pycache__/__init__.cpython-313.pyc b/molecule_agent/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3ddc6c2 Binary files /dev/null and b/molecule_agent/__pycache__/__init__.cpython-313.pyc differ diff --git a/molecule_agent/__pycache__/__main__.cpython-313.pyc b/molecule_agent/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000..2f95813 Binary files /dev/null and b/molecule_agent/__pycache__/__main__.cpython-313.pyc differ diff --git a/molecule_agent/__pycache__/a2a_server.cpython-313.pyc b/molecule_agent/__pycache__/a2a_server.cpython-313.pyc new file mode 100644 index 0000000..7505483 Binary files /dev/null and b/molecule_agent/__pycache__/a2a_server.cpython-313.pyc differ diff --git a/molecule_agent/__pycache__/client.cpython-313.pyc b/molecule_agent/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..2622dad Binary files /dev/null and b/molecule_agent/__pycache__/client.cpython-313.pyc differ diff --git a/molecule_agent/__pycache__/inbound.cpython-313.pyc b/molecule_agent/__pycache__/inbound.cpython-313.pyc new file mode 100644 index 0000000..a236c10 Binary files /dev/null and b/molecule_agent/__pycache__/inbound.cpython-313.pyc differ diff --git a/molecule_agent/inbound.py b/molecule_agent/inbound.py index 9e00aac..b56a4e0 100644 --- a/molecule_agent/inbound.py +++ b/molecule_agent/inbound.py @@ -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", ] diff --git a/molecule_ai_sdk.egg-info/PKG-INFO b/molecule_ai_sdk.egg-info/PKG-INFO new file mode 100644 index 0000000..55501cb --- /dev/null +++ b/molecule_ai_sdk.egg-info/PKG-INFO @@ -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//.py` + (curated; set by the Molecule AI team for quality-assured plugins). +2. **Plugin-shipped** — `/adapters/.py` (what this SDK helps you build). +3. **Raw-drop fallback** — copies plugin files into `/configs/plugins//` + 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 +``` diff --git a/molecule_ai_sdk.egg-info/SOURCES.txt b/molecule_ai_sdk.egg-info/SOURCES.txt new file mode 100644 index 0000000..1960a58 --- /dev/null +++ b/molecule_ai_sdk.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/molecule_ai_sdk.egg-info/dependency_links.txt b/molecule_ai_sdk.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/molecule_ai_sdk.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/molecule_ai_sdk.egg-info/requires.txt b/molecule_ai_sdk.egg-info/requires.txt new file mode 100644 index 0000000..2bace8a --- /dev/null +++ b/molecule_ai_sdk.egg-info/requires.txt @@ -0,0 +1,5 @@ +pyyaml>=6.0 +requests>=2.31 + +[test] +pytest-asyncio>=0.24 diff --git a/molecule_ai_sdk.egg-info/top_level.txt b/molecule_ai_sdk.egg-info/top_level.txt new file mode 100644 index 0000000..a1a75a3 --- /dev/null +++ b/molecule_ai_sdk.egg-info/top_level.txt @@ -0,0 +1,2 @@ +molecule_agent +molecule_plugin diff --git a/molecule_plugin/__pycache__/__init__.cpython-313.pyc b/molecule_plugin/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..68f12ca Binary files /dev/null and b/molecule_plugin/__pycache__/__init__.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/__main__.cpython-313.pyc b/molecule_plugin/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000..b077327 Binary files /dev/null and b/molecule_plugin/__pycache__/__main__.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/builtins.cpython-313.pyc b/molecule_plugin/__pycache__/builtins.cpython-313.pyc new file mode 100644 index 0000000..4a2be62 Binary files /dev/null and b/molecule_plugin/__pycache__/builtins.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/channel.cpython-313.pyc b/molecule_plugin/__pycache__/channel.cpython-313.pyc new file mode 100644 index 0000000..d5905e4 Binary files /dev/null and b/molecule_plugin/__pycache__/channel.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/manifest.cpython-313.pyc b/molecule_plugin/__pycache__/manifest.cpython-313.pyc new file mode 100644 index 0000000..79f2dc9 Binary files /dev/null and b/molecule_plugin/__pycache__/manifest.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/org.cpython-313.pyc b/molecule_plugin/__pycache__/org.cpython-313.pyc new file mode 100644 index 0000000..0772217 Binary files /dev/null and b/molecule_plugin/__pycache__/org.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/protocol.cpython-313.pyc b/molecule_plugin/__pycache__/protocol.cpython-313.pyc new file mode 100644 index 0000000..b1ce560 Binary files /dev/null and b/molecule_plugin/__pycache__/protocol.cpython-313.pyc differ diff --git a/molecule_plugin/__pycache__/workspace.cpython-313.pyc b/molecule_plugin/__pycache__/workspace.cpython-313.pyc new file mode 100644 index 0000000..8ff4ce1 Binary files /dev/null and b/molecule_plugin/__pycache__/workspace.cpython-313.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..b82b238 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_a2a_server.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_a2a_server.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0d31601 Binary files /dev/null and b/tests/__pycache__/test_a2a_server.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_call_peer_errors.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_call_peer_errors.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..50d9b84 Binary files /dev/null and b/tests/__pycache__/test_call_peer_errors.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_cli_connect.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_cli_connect.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..23f9da1 Binary files /dev/null and b/tests/__pycache__/test_cli_connect.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_inbound.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_inbound.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0d7d77a Binary files /dev/null and b/tests/__pycache__/test_inbound.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_remote_agent.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_remote_agent.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..451d7bb Binary files /dev/null and b/tests/__pycache__/test_remote_agent.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_retry_backoff.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_retry_backoff.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..adbfd03 Binary files /dev/null and b/tests/__pycache__/test_retry_backoff.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_safe_extract.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_safe_extract.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..afac67f Binary files /dev/null and b/tests/__pycache__/test_safe_extract.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_sdk.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_sdk.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..ee540f7 Binary files /dev/null and b/tests/__pycache__/test_sdk.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_sha256_verification.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_sha256_verification.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..43ffc1a Binary files /dev/null and b/tests/__pycache__/test_sha256_verification.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_validators.cpython-313-pytest-9.0.2.pyc b/tests/__pycache__/test_validators.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..881bed0 Binary files /dev/null and b/tests/__pycache__/test_validators.cpython-313-pytest-9.0.2.pyc differ diff --git a/tests/test_inbound.py b/tests/test_inbound.py index f09034d..3fa1ebc 100644 --- a/tests/test_inbound.py +++ b/tests/test_inbound.py @@ -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() # ---------------------------------------------------------------------------