release: SDK 0.2.1 attachment support #30
@@ -6,7 +6,9 @@ on any Molecule AI workspace whose runtime the plugin supports.
|
||||
|
||||
## Quick start
|
||||
|
||||
Copy `template/` to a new directory and edit:
|
||||
Copy the repo's `template/` directory to a new directory and edit it. If you
|
||||
installed from PyPI, fetch the template from
|
||||
`https://git.moleculesai.app/molecule-ai/molecule-sdk-python/src/branch/main/template`.
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
@@ -17,7 +19,7 @@ my-plugin/
|
||||
│ └── tools/do_thing.py # optional LangChain @tool functions
|
||||
└── adapters/
|
||||
├── claude_code.py # one-liner: `from molecule_plugin import AgentskillsAdaptor as Adaptor`
|
||||
└── deepagents.py # same
|
||||
└── codex.py # same
|
||||
```
|
||||
|
||||
Validate:
|
||||
@@ -64,13 +66,13 @@ 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)`.
|
||||
- **Register runtime 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
|
||||
# adapters/codex.py
|
||||
from molecule_plugin import InstallContext, InstallResult
|
||||
|
||||
class Adaptor:
|
||||
@@ -127,8 +129,8 @@ 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:
|
||||
As of 2026-Q2: `claude_code`, `codex`, `hermes`, and `openclaw`. See the live
|
||||
list with:
|
||||
|
||||
```bash
|
||||
curl $PLATFORM_URL/plugins
|
||||
|
||||
@@ -17,11 +17,11 @@ imports.
|
||||
| **Where it runs** | OUTSIDE Molecule workspaces — your laptop, CI runner, external cloud VM, sidecar service | INSIDE the workspace container, started by the platform |
|
||||
| **What it talks to** | The platform's HTTP API (`/registry/*`, `/workspaces/:id/*`) | The platform's MCP server (`molecule_*` tools) plus the platform-managed A2A bus |
|
||||
| **What it exposes** | `RemoteAgentClient`, `A2AServer`, `PollDelivery`, `MessageHandler` | `BaseAdapter`, `a2a_tools`, runtime capabilities, smoke-contract hooks |
|
||||
| **Who installs it** | You, the external-agent author, via `pip install molecule-sdk` | The platform, baked into the workspace template image at provision time |
|
||||
| **Who installs it** | You, the external-agent author, via `pip install molecule-ai-sdk` | The platform, baked into the workspace template image at provision time |
|
||||
| **Auth model** | Bearer token minted by `POST /registry/register`, cached at `~/.molecule/<id>/.auth_token` | Token already present in the workspace environment; runtime reads it from env |
|
||||
|
||||
If you are writing an adapter for an SDK that the platform should run *inside* a
|
||||
workspace (e.g. langchain, crewai, hermes), you want
|
||||
workspace (e.g. claude-code, codex, hermes, openclaw), you want
|
||||
[`molecule-ai-workspace-runtime`](https://pypi.org/project/molecule-ai-workspace-runtime/),
|
||||
not this package. See <https://doc.moleculesai.app/docs/runtime-mcp> for the
|
||||
in-workspace-runtime authoring guide.
|
||||
@@ -29,7 +29,7 @@ in-workspace-runtime authoring guide.
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install molecule-sdk # ships molecule_plugin + molecule_agent
|
||||
pip install molecule-ai-sdk # ships molecule_plugin + molecule_agent
|
||||
```
|
||||
|
||||
## 60-second example
|
||||
@@ -73,7 +73,8 @@ A runnable demo with full setup walkthrough lives at
|
||||
| `heartbeat(...)` | 30.1 | Single bearer-authed heartbeat |
|
||||
| `get_peers()` / `discover_peer()` | 30.6 | Sibling URL discovery with TTL cache |
|
||||
| `call_peer(target, message)` | 30.6 | Direct A2A with proxy fallback; response may be wrapped in OFFSEC-003 boundary markers — use ``strip_a2a_boundary()`` to remove them |
|
||||
| `fetch_inbound(since_id=…)` | 30.8c | One-shot poll of `/workspaces/:id/activity` for inbound A2A |
|
||||
| `fetch_inbound(since_id=…)` | 30.8c | One-shot poll of `/workspaces/:id/activity` for inbound A2A, including attachment metadata |
|
||||
| `download_inbound_attachments(msg)` | L4 | Download attachment bytes for a polled inbound message |
|
||||
| `reply(msg, text)` | 30.8c | Smart-routes reply to `/notify` (canvas user) or `/a2a` (peer) |
|
||||
| `run_heartbeat_loop()` | combo | Drives heartbeat + state-poll on a timer; exits on pause/delete |
|
||||
| `run_agent_loop(handler)` | combo | Heartbeat + state + **inbound dispatch**; exits on pause/delete |
|
||||
@@ -114,8 +115,27 @@ parses today:
|
||||
| `source` | `Literal["canvas_user", "peer_agent", "unknown"]` | Normalized sender kind. `"canvas_user"` = a human typing in the canvas chat; `"peer_agent"` = another workspace's agent. `"unknown"` if the row's source is unrecognized — `reply()` will refuse to guess. |
|
||||
| `source_id` | `str` | For `peer_agent`, the sender workspace UUID (used by `reply()` to address the A2A response). Empty for `canvas_user`. |
|
||||
| `text` | `str` | The message body. Pulled from `data.text` then `data.message` in the underlying activity row. **Treat as untrusted user content** — same threat model as any chat input. |
|
||||
| `attachments` | `list[dict]` | Attachment metadata projected by the platform. Use `download_inbound_attachment()` or `download_inbound_attachments()` to fetch the referenced bytes. |
|
||||
| `raw` | `dict` | The full raw activity-log row. Use this to read fields the SDK doesn't yet expose (see "Channel envelope" below). |
|
||||
|
||||
### Inbound attachments
|
||||
|
||||
Poll-mode external workspaces receive attachment metadata on
|
||||
`InboundMessage.attachments`. The SDK fetches bytes through the same
|
||||
workspace-scoped platform APIs used by managed runtimes:
|
||||
|
||||
```python
|
||||
def handle(msg, client):
|
||||
for path in client.download_inbound_attachments(msg):
|
||||
print(f"downloaded attachment: {path}")
|
||||
return "received"
|
||||
```
|
||||
|
||||
`platform-pending:` attachments are acked after a successful download so the
|
||||
platform can enforce single-use pending-upload semantics. Downloads are cached
|
||||
under `~/.molecule/<workspace_id>/attachments` by default, capped at 100 MB,
|
||||
and refused if the pending-upload URI belongs to another workspace.
|
||||
|
||||
### Channel envelope (wire format)
|
||||
|
||||
The platform delivers each inbound A2A event as an `activity_logs` row. As of
|
||||
|
||||
@@ -75,4 +75,4 @@ __all__ = [
|
||||
"strip_a2a_boundary",
|
||||
"__version__",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@@ -14,7 +14,7 @@ This SDK exposes:
|
||||
vast majority of cases).
|
||||
* :data:`PLUGIN_YAML_SCHEMA` — the manifest schema for validation tooling.
|
||||
|
||||
Example: a minimal plugin that's installable on Claude Code and DeepAgents
|
||||
Example: a minimal plugin that's installable on Claude Code and Codex
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
@@ -24,7 +24,7 @@ Example: a minimal plugin that's installable on Claude Code and DeepAgents
|
||||
├── skills/my-skill/SKILL.md
|
||||
└── adapters/
|
||||
├── claude_code.py # `from molecule_plugin import AgentskillsAdaptor as Adaptor`
|
||||
└── deepagents.py # same one-liner
|
||||
└── codex.py # same one-liner
|
||||
|
||||
Full docs + cookiecutter template: see ``sdk/python/README.md``.
|
||||
"""
|
||||
@@ -68,7 +68,7 @@ from .channel import ( # noqa: F401
|
||||
validate_channel_file,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
__all__ = [
|
||||
"AgentskillsAdaptor",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
One class per agent shape. Currently ships :class:`AgentskillsAdaptor`
|
||||
(the `agentskills.io <https://agentskills.io>`_-format default); more
|
||||
will be added as new shapes emerge in the ecosystem
|
||||
(``MCPServerAdaptor``, ``DeepAgentsSubagentAdaptor``, ``RAGPipelineAdaptor``,
|
||||
(``MCPServerAdaptor``, ``RuntimeSubagentAdaptor``, ``RAGPipelineAdaptor``,
|
||||
etc.).
|
||||
|
||||
SDK authors pick a sub-type by import:
|
||||
|
||||
@@ -38,7 +38,7 @@ PLUGIN_YAML_SCHEMA: dict[str, Any] = {
|
||||
"runtimes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Declared supported runtimes (e.g. claude_code, deepagents).",
|
||||
"description": "Declared supported runtimes (e.g. claude_code, codex).",
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
@@ -81,7 +81,7 @@ def validate_manifest(path: str | Path) -> list[str]:
|
||||
errors.append(f"`{field_name}` must be a list")
|
||||
|
||||
if "runtimes" in raw and isinstance(raw["runtimes"], list):
|
||||
known = {"claude_code", "deepagents", "langgraph", "crewai", "autogen", "openclaw"}
|
||||
known = {"claude_code", "codex", "hermes", "openclaw"}
|
||||
for r in raw["runtimes"]:
|
||||
if not isinstance(r, str):
|
||||
errors.append(f"`runtimes` entry must be string, got {type(r).__name__}")
|
||||
|
||||
@@ -49,6 +49,12 @@ from .workspace import SUPPORTED_RUNTIMES, ValidationError
|
||||
_WORKSPACE_ACCESS_VALUES = frozenset({"none", "read_only", "read_write"})
|
||||
|
||||
|
||||
def _runtime_allowed(node: dict[str, Any], runtime: Any) -> bool:
|
||||
if runtime in SUPPORTED_RUNTIMES:
|
||||
return True
|
||||
return node.get("external") is True and runtime == "external"
|
||||
|
||||
|
||||
def _validate_workspace_node(
|
||||
node: Any,
|
||||
path: str,
|
||||
@@ -72,11 +78,12 @@ def _validate_workspace_node(
|
||||
|
||||
# Runtime (optional — inherited from defaults)
|
||||
runtime = node.get("runtime")
|
||||
if runtime and runtime not in SUPPORTED_RUNTIMES:
|
||||
if runtime and not _runtime_allowed(node, runtime):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
file_ref,
|
||||
f"{path}: runtime={runtime!r} — must be one of {sorted(SUPPORTED_RUNTIMES)}",
|
||||
f"{path}: runtime={runtime!r} — must be one of {sorted(SUPPORTED_RUNTIMES)}"
|
||||
" or 'external' when external=true",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class InstallContext:
|
||||
"""Workspace UUID — useful for per-workspace state or logging."""
|
||||
|
||||
runtime: str
|
||||
"""Runtime identifier (``claude_code``, ``deepagents``, …)."""
|
||||
"""Runtime identifier (``claude_code``, ``codex``, ``hermes``, etc.)."""
|
||||
|
||||
plugin_root: Path
|
||||
"""Path to the plugin's directory (where plugin.yaml + content lives)."""
|
||||
@@ -50,7 +50,7 @@ class InstallContext:
|
||||
register_subagent: Callable[[str, dict[str, Any]], None] = field(
|
||||
default=lambda name, spec: None
|
||||
)
|
||||
"""Register a sub-agent specification (DeepAgents-only). No-op elsewhere."""
|
||||
"""Register a sub-agent specification. No-op for runtimes without this hook."""
|
||||
|
||||
append_to_memory: Callable[[str, str], None] = field(
|
||||
default=lambda filename, content: None
|
||||
|
||||
@@ -18,17 +18,14 @@ from pathlib import Path
|
||||
import yaml
|
||||
|
||||
|
||||
# Runtimes the platform knows how to provision. Stays aligned with
|
||||
# provisioner.RuntimeImages in platform/internal/provisioner/provisioner.go.
|
||||
# Runtimes the platform currently provisions for managed workspaces.
|
||||
SUPPORTED_RUNTIMES = frozenset(
|
||||
{
|
||||
"langgraph",
|
||||
"claude-code",
|
||||
"claude_code", # adapter dirs use underscores
|
||||
"codex",
|
||||
"hermes",
|
||||
"openclaw",
|
||||
"deepagents",
|
||||
"crewai",
|
||||
"autogen",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -114,4 +111,3 @@ def validate_workspace_template(path: Path) -> list[ValidationError]:
|
||||
|
||||
# Re-exported for type hints in __init__.py
|
||||
__all__ = ["ValidationError", "SUPPORTED_RUNTIMES", "validate_workspace_template"]
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "molecule-ai-sdk"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Molecule AI SDK — build plugins (molecule_plugin) AND remote agents that join a Molecule AI org from another machine (molecule_agent)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DeepAgents adaptor.
|
||||
"""Codex adaptor.
|
||||
|
||||
If your plugin defines a sub-agent, swap the import for a custom class
|
||||
that calls ``ctx.register_subagent(name, spec)`` inside ``install()``.
|
||||
@@ -10,7 +10,7 @@ tags: [example]
|
||||
# to a raw-drop (files copied, no tools wired — warning surfaced to user).
|
||||
runtimes:
|
||||
- claude_code
|
||||
- deepagents
|
||||
- codex
|
||||
|
||||
# Optional: list of skill directory names under skills/ (for documentation).
|
||||
# skills:
|
||||
|
||||
@@ -3,20 +3,26 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
import molecule_agent
|
||||
import molecule_plugin
|
||||
from molecule_plugin import (
|
||||
SUPPORTED_CHANNEL_TYPES,
|
||||
SUPPORTED_RUNTIMES,
|
||||
validate_channel_config,
|
||||
validate_channel_file,
|
||||
validate_manifest,
|
||||
validate_org_template,
|
||||
validate_workspace_template,
|
||||
)
|
||||
from molecule_plugin.__main__ import main as cli_main
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
# ---------- workspace ----------
|
||||
|
||||
@@ -67,14 +73,14 @@ def test_workspace_validation_errors(tmp_path: Path):
|
||||
def test_workspace_runtime_config_not_dict(tmp_path: Path):
|
||||
_write_yaml(
|
||||
tmp_path / "config.yaml",
|
||||
{"name": "x", "runtime": "langgraph", "runtime_config": "nope"},
|
||||
{"name": "x", "runtime": "codex", "runtime_config": "nope"},
|
||||
)
|
||||
msgs = [e.message for e in validate_workspace_template(tmp_path)]
|
||||
assert any("runtime_config must be an object" in m for m in msgs)
|
||||
|
||||
|
||||
def test_workspace_runtime_config_none_ok(tmp_path: Path):
|
||||
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "langgraph", "runtime_config": None})
|
||||
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "hermes", "runtime_config": None})
|
||||
assert validate_workspace_template(tmp_path) == []
|
||||
|
||||
|
||||
@@ -85,7 +91,22 @@ def test_org_defaults_none_ok(tmp_path: Path):
|
||||
|
||||
def test_supported_runtimes_contains_known():
|
||||
assert "claude-code" in SUPPORTED_RUNTIMES
|
||||
assert "deepagents" in SUPPORTED_RUNTIMES
|
||||
assert "codex" in SUPPORTED_RUNTIMES
|
||||
assert "hermes" in SUPPORTED_RUNTIMES
|
||||
assert "openclaw" in SUPPORTED_RUNTIMES
|
||||
assert "autogen" not in SUPPORTED_RUNTIMES
|
||||
assert "langgraph" not in SUPPORTED_RUNTIMES
|
||||
|
||||
|
||||
def test_package_versions_match_pyproject():
|
||||
pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())
|
||||
expected = pyproject["project"]["version"]
|
||||
assert molecule_agent.__version__ == expected
|
||||
assert molecule_plugin.__version__ == expected
|
||||
|
||||
|
||||
def test_template_manifest_uses_supported_runtimes():
|
||||
assert validate_manifest(REPO_ROOT / "template/plugin.yaml") == []
|
||||
|
||||
|
||||
# ---------- org ----------
|
||||
@@ -114,6 +135,34 @@ def test_org_happy(tmp_path: Path):
|
||||
assert validate_org_template(tmp_path) == []
|
||||
|
||||
|
||||
def test_org_external_runtime_allowed_only_for_external_workspace(tmp_path: Path):
|
||||
_write_yaml(
|
||||
tmp_path / "org.yaml",
|
||||
{
|
||||
"name": "T",
|
||||
"workspaces": [
|
||||
{
|
||||
"name": "Remote",
|
||||
"runtime": "external",
|
||||
"external": True,
|
||||
"url": "https://agent.example.com",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert validate_org_template(tmp_path) == []
|
||||
|
||||
_write_yaml(
|
||||
tmp_path / "org.yaml",
|
||||
{
|
||||
"name": "T",
|
||||
"workspaces": [{"name": "Bad", "runtime": "external"}],
|
||||
},
|
||||
)
|
||||
msgs = [e.message for e in validate_org_template(tmp_path)]
|
||||
assert any("external=true" in m for m in msgs)
|
||||
|
||||
|
||||
def test_org_missing_file(tmp_path: Path):
|
||||
errs = validate_org_template(tmp_path)
|
||||
assert any("missing org.yaml" in e.message for e in errs)
|
||||
@@ -277,7 +326,7 @@ def test_channel_types_exports():
|
||||
# ---------- CLI ----------
|
||||
|
||||
def test_cli_workspace_valid(tmp_path, capsys):
|
||||
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "langgraph"})
|
||||
_write_yaml(tmp_path / "config.yaml", {"name": "x", "runtime": "openclaw"})
|
||||
assert cli_main(["validate", "workspace", str(tmp_path)]) == 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user