release: SDK 0.2.1 attachment support #30

Merged
hongming merged 1 commits from release/sdk-0.2.1 into main 2026-05-24 03:27:37 +00:00
13 changed files with 109 additions and 35 deletions
+8 -6
View File
@@ -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
+24 -4
View File
@@ -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
+1 -1
View File
@@ -75,4 +75,4 @@ __all__ = [
"strip_a2a_boundary",
"__version__",
]
__version__ = "0.1.0"
__version__ = "0.2.1"
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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__}")
+9 -2
View File
@@ -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",
)
)
+2 -2
View File
@@ -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
+3 -7
View File
@@ -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
View File
@@ -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()``.
+1 -1
View File
@@ -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:
+53 -4
View File
@@ -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