diff --git a/README.md b/README.md index 913f8dd..1cde5e7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/molecule_agent/README.md b/molecule_agent/README.md index 535125b..32b0b59 100644 --- a/molecule_agent/README.md +++ b/molecule_agent/README.md @@ -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//.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 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//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 diff --git a/molecule_agent/__init__.py b/molecule_agent/__init__.py index a9f4f83..d20f4e8 100644 --- a/molecule_agent/__init__.py +++ b/molecule_agent/__init__.py @@ -75,4 +75,4 @@ __all__ = [ "strip_a2a_boundary", "__version__", ] -__version__ = "0.1.0" +__version__ = "0.2.1" diff --git a/molecule_plugin/__init__.py b/molecule_plugin/__init__.py index 3601abc..1687c78 100644 --- a/molecule_plugin/__init__.py +++ b/molecule_plugin/__init__.py @@ -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", diff --git a/molecule_plugin/builtins.py b/molecule_plugin/builtins.py index 63e6110..5e3aaad 100644 --- a/molecule_plugin/builtins.py +++ b/molecule_plugin/builtins.py @@ -3,7 +3,7 @@ One class per agent shape. Currently ships :class:`AgentskillsAdaptor` (the `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: diff --git a/molecule_plugin/manifest.py b/molecule_plugin/manifest.py index 50a53cc..a10779e 100644 --- a/molecule_plugin/manifest.py +++ b/molecule_plugin/manifest.py @@ -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__}") diff --git a/molecule_plugin/org.py b/molecule_plugin/org.py index d5bde74..3f7faeb 100644 --- a/molecule_plugin/org.py +++ b/molecule_plugin/org.py @@ -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", ) ) diff --git a/molecule_plugin/protocol.py b/molecule_plugin/protocol.py index 601029b..8e79869 100644 --- a/molecule_plugin/protocol.py +++ b/molecule_plugin/protocol.py @@ -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 diff --git a/molecule_plugin/workspace.py b/molecule_plugin/workspace.py index 92a5595..95b5aaa 100644 --- a/molecule_plugin/workspace.py +++ b/molecule_plugin/workspace.py @@ -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"] - diff --git a/pyproject.toml b/pyproject.toml index e744582..73f185c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/template/adapters/deepagents.py b/template/adapters/codex.py similarity index 90% rename from template/adapters/deepagents.py rename to template/adapters/codex.py index d6e10af..6c50bab 100644 --- a/template/adapters/deepagents.py +++ b/template/adapters/codex.py @@ -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()``. diff --git a/template/plugin.yaml b/template/plugin.yaml index d12149a..19d8c70 100644 --- a/template/plugin.yaml +++ b/template/plugin.yaml @@ -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: diff --git a/tests/test_validators.py b/tests/test_validators.py index c85dfd2..dc2e8ba 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -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