Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4cf7bc75c | |||
| 56e963bb87 | |||
| 6a1b2bf1a1 |
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gitea-merge-queue — conservative serialized merge bot for Gitea.
|
||||
|
||||
Gitea 1.22.6 has auto-merge (`pull_auto_merge`) but no GitHub-style merge
|
||||
queue. This script provides the missing serialized policy in user space:
|
||||
|
||||
1. Pick the oldest open PR carrying QUEUE_LABEL.
|
||||
2. Refuse to act unless main is green.
|
||||
3. Refuse fork PRs; the queue may only mutate same-repo branches.
|
||||
4. If the PR branch does not contain current main, call Gitea's
|
||||
/pulls/{n}/update endpoint and stop. CI must rerun on the updated head.
|
||||
5. If the updated PR head has all required contexts green, merge with the
|
||||
non-bypass merge actor token.
|
||||
|
||||
The script is intentionally one-PR-per-run. Workflow/cron concurrency should
|
||||
serialize invocations so two green PRs cannot merge against the same main.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _env(key: str, *, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
GITEA_TOKEN = _env("GITEA_TOKEN")
|
||||
GITEA_HOST = _env("GITEA_HOST")
|
||||
REPO = _env("REPO")
|
||||
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
|
||||
QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue")
|
||||
HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold")
|
||||
UPDATE_STYLE = _env("UPDATE_STYLE", default="merge")
|
||||
REQUIRED_CONTEXTS_RAW = _env(
|
||||
"REQUIRED_CONTEXTS",
|
||||
default=(
|
||||
"CI / all-required (pull_request),"
|
||||
"sop-checklist / all-items-acked (pull_request)"
|
||||
),
|
||||
)
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MergeDecision:
|
||||
ready: bool
|
||||
action: str
|
||||
reason: str
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "QUEUE_LABEL"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
if UPDATE_STYLE not in {"merge", "rebase"}:
|
||||
sys.stderr.write("::error::UPDATE_STYLE must be merge or rebase\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = exc.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
if expect_json:
|
||||
raise ApiError(f"{method} {path} -> HTTP {status} non-JSON: {exc}") from exc
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
def required_contexts(raw: str) -> list[str]:
|
||||
return [part.strip() for part in raw.split(",") if part.strip()]
|
||||
|
||||
|
||||
def status_state(status: dict) -> str:
|
||||
return str(status.get("status") or status.get("state") or "").lower()
|
||||
|
||||
|
||||
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||
latest: dict[str, dict] = {}
|
||||
for status in statuses:
|
||||
context = status.get("context")
|
||||
if isinstance(context, str) and context not in latest:
|
||||
latest[context] = status
|
||||
return latest
|
||||
|
||||
|
||||
def required_contexts_green(
|
||||
latest_statuses: dict[str, dict],
|
||||
contexts: list[str],
|
||||
) -> tuple[bool, list[str]]:
|
||||
missing_or_bad: list[str] = []
|
||||
for context in contexts:
|
||||
status = latest_statuses.get(context)
|
||||
state = status_state(status or {})
|
||||
if state != "success":
|
||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||
return not missing_or_bad, missing_or_bad
|
||||
|
||||
|
||||
def label_names(issue: dict) -> set[str]:
|
||||
return {
|
||||
label["name"]
|
||||
for label in issue.get("labels", [])
|
||||
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||
}
|
||||
|
||||
|
||||
def choose_next_queued_issue(
|
||||
issues: list[dict],
|
||||
*,
|
||||
queue_label: str,
|
||||
hold_label: str = "",
|
||||
) -> dict | None:
|
||||
candidates = []
|
||||
for issue in issues:
|
||||
labels = label_names(issue)
|
||||
if queue_label not in labels:
|
||||
continue
|
||||
if hold_label and hold_label in labels:
|
||||
continue
|
||||
if "pull_request" not in issue:
|
||||
continue
|
||||
candidates.append(issue)
|
||||
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool:
|
||||
for commit in commits:
|
||||
sha = commit.get("sha") or commit.get("id")
|
||||
if sha == base_sha:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pr_has_current_base(pr: dict, commits: list[dict], main_sha: str) -> bool:
|
||||
if pr.get("merge_base") == main_sha:
|
||||
return True
|
||||
return pr_contains_base_sha(commits, main_sha)
|
||||
|
||||
|
||||
def evaluate_merge_readiness(
|
||||
*,
|
||||
main_status: dict,
|
||||
pr_status: dict,
|
||||
required_contexts: list[str],
|
||||
pr_has_current_base: bool,
|
||||
) -> MergeDecision:
|
||||
main_state = str(main_status.get("state") or "").lower()
|
||||
if main_state != "success":
|
||||
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
|
||||
if not pr_has_current_base:
|
||||
return MergeDecision(False, "update", "PR head does not contain current main")
|
||||
|
||||
pr_state = str(pr_status.get("state") or "").lower()
|
||||
if pr_state != "success":
|
||||
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
|
||||
|
||||
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
|
||||
if not ok:
|
||||
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
||||
return MergeDecision(True, "merge", "ready")
|
||||
|
||||
|
||||
def get_branch_head(branch: str) -> str:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
|
||||
commit = body.get("commit") if isinstance(body, dict) else None
|
||||
sha = commit.get("id") if isinstance(commit, dict) else None
|
||||
if not isinstance(sha, str) or len(sha) < 7:
|
||||
raise ApiError(f"branch {branch} response missing commit id")
|
||||
return sha
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
return body
|
||||
|
||||
|
||||
def list_queued_issues() -> list[dict]:
|
||||
_, body = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
"limit": "50",
|
||||
},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError("queued issues response not list")
|
||||
return body
|
||||
|
||||
|
||||
def get_pull(pr_number: int) -> dict:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"PR #{pr_number} response not object")
|
||||
return body
|
||||
|
||||
|
||||
def get_pull_commits(pr_number: int) -> list[dict]:
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/commits")
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(f"PR #{pr_number} commits response not list")
|
||||
return body
|
||||
|
||||
|
||||
def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
|
||||
print(f"::notice::comment PR #{pr_number}: {body.splitlines()[0][:160]}")
|
||||
if dry_run:
|
||||
return
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
|
||||
|
||||
|
||||
def update_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
|
||||
if dry_run:
|
||||
return
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update",
|
||||
query={"style": UPDATE_STYLE},
|
||||
expect_json=False,
|
||||
)
|
||||
|
||||
|
||||
def merge_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
payload = {
|
||||
"Do": "merge",
|
||||
"MergeTitleField": f"Merge PR #{pr_number} via Gitea merge queue",
|
||||
"MergeMessageField": (
|
||||
"Serialized merge by gitea-merge-queue after current-main, "
|
||||
"SOP, and required CI checks were green."
|
||||
),
|
||||
}
|
||||
print(f"::notice::merging PR #{pr_number}")
|
||||
if dry_run:
|
||||
return
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||
|
||||
|
||||
def process_once(*, dry_run: bool = False) -> int:
|
||||
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
|
||||
main_sha = get_branch_head(WATCH_BRANCH)
|
||||
main_status = get_combined_status(main_sha)
|
||||
if str(main_status.get("state") or "").lower() != "success":
|
||||
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
|
||||
return 0
|
||||
|
||||
issue = choose_next_queued_issue(
|
||||
list_queued_issues(),
|
||||
queue_label=QUEUE_LABEL,
|
||||
hold_label=HOLD_LABEL,
|
||||
)
|
||||
if not issue:
|
||||
print("::notice::merge queue empty")
|
||||
return 0
|
||||
|
||||
pr_number = int(issue["number"])
|
||||
pr = get_pull(pr_number)
|
||||
if pr.get("state") != "open":
|
||||
print(f"::notice::PR #{pr_number} is not open; skipping")
|
||||
return 0
|
||||
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
|
||||
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
|
||||
return 0
|
||||
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
|
||||
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
|
||||
return 0
|
||||
|
||||
head_sha = pr.get("head", {}).get("sha")
|
||||
if not isinstance(head_sha, str) or len(head_sha) < 7:
|
||||
raise ApiError(f"PR #{pr_number} missing head sha")
|
||||
commits = get_pull_commits(pr_number)
|
||||
current_base = pr_has_current_base(pr, commits, main_sha)
|
||||
pr_status = get_combined_status(head_sha)
|
||||
decision = evaluate_merge_readiness(
|
||||
main_status=main_status,
|
||||
pr_status=pr_status,
|
||||
required_contexts=contexts,
|
||||
pr_has_current_base=current_base,
|
||||
)
|
||||
|
||||
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
|
||||
if decision.action == "update":
|
||||
update_pull(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
f"merge-queue: updated this branch with `{WATCH_BRANCH}` at "
|
||||
f"`{main_sha[:12]}`. Waiting for CI on the refreshed head."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.ready:
|
||||
latest_main_sha = get_branch_head(WATCH_BRANCH)
|
||||
if latest_main_sha != main_sha:
|
||||
print(
|
||||
f"::notice::main moved {main_sha[:8]} -> {latest_main_sha[:8]}; "
|
||||
"deferring to next tick"
|
||||
)
|
||||
return 0
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
_require_runtime_env()
|
||||
return process_once(dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: gitea-merge-queue
|
||||
|
||||
# External serialized merge queue for Gitea 1.22.6.
|
||||
# Uses AUTO_SYNC_TOKEN (devops-engineer persona PAT) as merge actor.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: gitea-merge-queue-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
queue:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out queue script from main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Process one queued PR
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
QUEUE_LABEL: merge-queue
|
||||
HOLD_LABEL: merge-queue-hold
|
||||
UPDATE_STYLE: merge
|
||||
# Context names: CI workflow (.gitea/workflows/ci.yml) is "Test", job is "test",
|
||||
# matrix: python 3.11/3.12/3.13 → "Test / test (3.x)".
|
||||
# The CI workflow triggers on pull_request events, so Gitea posts "(pull_request)" suffix.
|
||||
REQUIRED_CONTEXTS: >-
|
||||
Test / test (3.13) (pull_request), Test / test (3.12) (pull_request), Test / test (3.11) (pull_request)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
@@ -233,4 +233,4 @@ python -m molecule_agent verify-sha256 ./my-plugin-dir
|
||||
triage.
|
||||
- **CLAUDE.md/PLAN.md sync PRs:** treat these as always noteworthy.
|
||||
- **molecule-core docs:** Full platform `PLAN.md` and architecture docs at
|
||||
`https://git.moleculesai.app/molecule-ai/molecule-core`
|
||||
`https://github.com/hongmingw/molecule-monorepo`
|
||||
|
||||
@@ -1,136 +1,23 @@
|
||||
# molecule-ai-sdk — Python SDK for Molecule AI
|
||||
# molecule_plugin — Python SDK for building Molecule AI plugins
|
||||
|
||||
Two packages, one install:
|
||||
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.
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `molecule_agent` | **Remote-agent client.** Run an agent outside the platform's Docker network — it registers, pulls secrets, heartbeats, and participates in A2A delegation. |
|
||||
| `molecule_plugin` | **Plugin-authoring SDK.** Bundle rules, skills, and per-runtime adaptors for any Molecule AI workspace. |
|
||||
## Quick start
|
||||
|
||||
Published on PyPI as [`molecule-ai-sdk`](https://pypi.org/project/molecule-ai-sdk/).
|
||||
Copy `template/` to a new directory and edit:
|
||||
|
||||
```bash
|
||||
pip install molecule-ai-sdk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## molecule_agent — Remote-Agent Client
|
||||
|
||||
Write an agent that runs on a laptop, VM, or any machine outside the platform's Docker network. The SDK handles registration, authentication, heartbeat, and A2A communication via the Phase 30 HTTP contract.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```python
|
||||
from molecule_agent import RemoteAgentClient
|
||||
|
||||
client = RemoteAgentClient(
|
||||
workspace_id="550e8400-e29b-41d4-a716-446655440000",
|
||||
platform_url="https://your-platform.example.com",
|
||||
agent_card={"name": "my-remote-agent", "skills": []},
|
||||
)
|
||||
client.register() # mints and persists the auth token
|
||||
secrets = client.pull_secrets() # returns {"OPENAI_API_KEY": "sk-..."}
|
||||
client.run_heartbeat_loop() # background heartbeat + state-poll; detects pause/delete
|
||||
```
|
||||
|
||||
### One-liner bootstrap (poll mode)
|
||||
|
||||
```bash
|
||||
pip install molecule-ai-sdk
|
||||
WORKSPACE_ID=... PLATFORM_URL=... AGENT_TOKEN=... \
|
||||
python -m molecule_agent connect --handler my_module:handle_message
|
||||
```
|
||||
|
||||
Picks `PollDelivery` automatically when no public URL is available — works behind NAT with no inbound firewall holes. `SIGTERM`/`SIGINT` shut the loop down cleanly.
|
||||
|
||||
### A2A Delegation
|
||||
|
||||
```python
|
||||
from molecule_agent import RemoteAgentClient
|
||||
|
||||
client = RemoteAgentClient(workspace_id="...", platform_url="...")
|
||||
|
||||
# Sync: wait for the peer's response
|
||||
response = client.delegate(peer_workspace_id, "Summarise the Q1 report")
|
||||
|
||||
# Async: get a task_id, poll for result
|
||||
task_id = client.delegate(peer_workspace_id, "Audit the homepage", async_delegate=True)
|
||||
result = client.check_delegation_status(task_id)
|
||||
```
|
||||
|
||||
### Inbound Messages (Two delivery paths)
|
||||
|
||||
**Poll mode** (default, no public URL needed):
|
||||
|
||||
```python
|
||||
from molecule_agent import RemoteAgentClient, PollDelivery
|
||||
|
||||
client = RemoteAgentClient(workspace_id="...", platform_url="...")
|
||||
client.register()
|
||||
|
||||
def handle(msg):
|
||||
reply = f"Got: {msg.text}"
|
||||
client.reply(msg, reply) # routes to /notify (canvas user) or /a2a (peer)
|
||||
|
||||
client.run_agent_loop(handler=handle) # uses PollDelivery internally
|
||||
```
|
||||
|
||||
**Push mode** (requires a public URL, lower latency):
|
||||
|
||||
```python
|
||||
from molecule_agent import RemoteAgentClient, PushDelivery, A2AServer
|
||||
|
||||
server = A2AServer(agent_id="...", inbound_url="https://your-agent.example.com/a2a/inbound")
|
||||
server.start_in_background()
|
||||
|
||||
client = RemoteAgentClient(workspace_id="...", platform_url="...")
|
||||
client.reported_url = server.inbound_url # register with public URL
|
||||
client.register()
|
||||
client.run_agent_loop(handler=handle, delivery=PushDelivery(client, server))
|
||||
```
|
||||
|
||||
### Plugin Install
|
||||
|
||||
Agents can install plugins from the platform registry at runtime:
|
||||
|
||||
```python
|
||||
client.install_plugin(source="local://my-plugin")
|
||||
# or from a tarball
|
||||
client.install_plugin_from_tarball("/path/to/plugin.tar.gz", expected_sha256="...")
|
||||
```
|
||||
|
||||
### All public exports
|
||||
|
||||
```python
|
||||
from molecule_agent import (
|
||||
RemoteAgentClient, # Main entry point
|
||||
A2AServer, # Push-mode inbound HTTP server
|
||||
PollDelivery, # Default poll-mode delivery
|
||||
PushDelivery, # Push-mode delivery (needs public URL)
|
||||
InboundMessage, # Inbound message object
|
||||
MessageHandler, # Handler callable signature
|
||||
WorkspaceState, # Pause / running / deleted
|
||||
PeerInfo, # Peer workspace metadata
|
||||
compute_plugin_sha256,
|
||||
verify_plugin_sha256,
|
||||
)
|
||||
```
|
||||
|
||||
See `examples/remote-agent/run.py` for a full runnable demo.
|
||||
|
||||
---
|
||||
|
||||
## molecule_plugin — Plugin Authoring SDK
|
||||
|
||||
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 supports it.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the template
|
||||
cp -r template/ my-plugin/
|
||||
# Edit my-plugin/plugin.yaml, rules/, skills/, adapters/
|
||||
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:
|
||||
@@ -141,26 +28,49 @@ errors = validate_manifest("my-plugin/plugin.yaml")
|
||||
assert not errors, errors
|
||||
```
|
||||
|
||||
### CLI
|
||||
## 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 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 0 when valid, 1 when errors found — suitable for CI. Add `-q` / `--quiet` to suppress success lines.
|
||||
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.
|
||||
|
||||
### Writing a Custom Adaptor
|
||||
|
||||
The default `AgentskillsAdaptor` handles rules + skills. Write a custom adaptor when you need to:
|
||||
|
||||
- Register runtime tools dynamically — `ctx.register_tool(name, fn)`
|
||||
- Register DeepAgents sub-agents — `ctx.register_subagent(name, spec)`
|
||||
- Write to a non-standard memory file — `ctx.append_to_memory(filename, content)`
|
||||
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:
|
||||
@@ -175,15 +85,23 @@ class Adaptor:
|
||||
pass
|
||||
```
|
||||
|
||||
### Resolution order
|
||||
## Resolution order (understood by the platform)
|
||||
|
||||
For `(plugin_name, runtime)`:
|
||||
|
||||
1. **Platform registry** — curated, set by the Molecule AI team
|
||||
2. **Plugin-shipped** — `<plugin_root>/adapters/<runtime>.py` (what this SDK helps you build)
|
||||
3. **Raw-drop fallback** — copies files, no tools wired
|
||||
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.
|
||||
|
||||
### Testing locally
|
||||
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
|
||||
@@ -200,24 +118,18 @@ asyncio.run(AgentskillsAdaptor("my-plugin", "claude_code").install(ctx))
|
||||
# check /tmp/configs/CLAUDE.md, /tmp/configs/skills/
|
||||
```
|
||||
|
||||
### Supported runtimes
|
||||
## Publishing
|
||||
|
||||
`claude_code`, `deepagents`, `langgraph`, `crewai`, `autogen`, `openclaw`. See the live list:
|
||||
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"
|
||||
curl $PLATFORM_URL/plugins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Both packages
|
||||
|
||||
- **Python:** `>=3.11`, no external async dependencies in `molecule_agent`
|
||||
(uses blocking `requests` so it embeds in any event loop).
|
||||
- **Error handling:** Network errors in loops are logged and swallowed so a
|
||||
transient platform hiccup does not take a remote agent offline. API-level
|
||||
errors (4xx) propagate via `raise_for_status()`.
|
||||
- **Token security:** Auth token cached at `~/.molecule/<workspace_id>/.auth_token`
|
||||
with `0600` permissions.
|
||||
- **Full documentation:** See `CLAUDE.md` for architecture, platform API
|
||||
endpoints, SDK conventions, and known issues.
|
||||
|
||||
@@ -27,7 +27,7 @@ curl -s -X POST http://localhost:8080/workspaces/<UUID>/secrets \
|
||||
|
||||
# 3. Run the demo from any machine that can reach the platform:
|
||||
WORKSPACE_ID=<UUID> PLATFORM_URL=http://localhost:8080 \
|
||||
python3 sdk/python/examples/remote-agent/run.py
|
||||
python3 examples/remote-agent/run.py
|
||||
```
|
||||
|
||||
You should see log lines for each of the three phases, and then
|
||||
|
||||
@@ -60,7 +60,7 @@ print(f"loop exited: {terminal}")
|
||||
```
|
||||
|
||||
A runnable demo with full setup walkthrough lives at
|
||||
[`sdk/python/examples/remote-agent/`](../examples/remote-agent).
|
||||
[`examples/remote-agent/`](https://git.moleculesai.app/Molecule-AI/molecule-sdk-python/-/tree/main/examples/remote-agent) — the runnable demo with full setup walkthrough.
|
||||
|
||||
## What the SDK gives you
|
||||
|
||||
@@ -282,5 +282,5 @@ the security benefits of bearer auth until both sides upgrade.
|
||||
|
||||
- [`molecule_plugin`](../molecule_plugin) — the *other* SDK in this
|
||||
package, for plugin authors. Different audience.
|
||||
- [`sdk/python/examples/remote-agent/run.py`](../examples/remote-agent/run.py)
|
||||
- [`examples/remote-agent/run.py`](https://git.moleculesai.app/Molecule-AI/molecule-sdk-python/-/blob/main/examples/remote-agent/run.py)
|
||||
— the runnable demo that proves all of the above end-to-end.
|
||||
|
||||
@@ -20,7 +20,7 @@ Intended usage::
|
||||
env = client.pull_secrets() # decrypted secrets dict
|
||||
client.run_heartbeat_loop() # background heartbeat + state-poll
|
||||
|
||||
See ``sdk/python/examples/remote-agent/`` for a runnable demo.
|
||||
See ``examples/remote-agent/`` for a runnable demo.
|
||||
|
||||
Design notes:
|
||||
* **No async.** The SDK uses blocking ``requests`` so a remote agent author
|
||||
|
||||
@@ -11,10 +11,19 @@ a Phase 30 endpoint:
|
||||
* :py:meth:`run_heartbeat_loop` — drives heartbeat + state-poll on a timer,
|
||||
returns when the platform reports the workspace paused or deleted.
|
||||
|
||||
No inbound A2A server is bundled here yet — that requires hosting an HTTP
|
||||
endpoint the platform's proxy can reach, which is network-dependent.
|
||||
Use :class:`molecule_agent.a2a_server.A2AServer` to add inbound A2A support.
|
||||
See that module for usage and the Phase 30.8b contract.
|
||||
The client also exposes two inbound delivery paths for handling platform-initiated
|
||||
A2A messages:
|
||||
|
||||
* **Push mode** — :class:`molecule_agent.a2a_server.A2AServer` hosts an HTTP server
|
||||
in a background thread; platform pushes inbound A2A as HTTP requests. Use when the
|
||||
agent has a publicly reachable URL (cloud VM, ngrok tunnel, etc.).
|
||||
* **Poll mode** — :class:`molecule_agent.inbound.PollDelivery` polls
|
||||
``GET /workspaces/:id/activity`` on a configurable interval; no public URL required.
|
||||
The default when no explicit delivery is passed to :py:meth:`run_agent_loop`.
|
||||
|
||||
Both feed the same :py:class:`molecule_agent.inbound.MessageHandler` callback.
|
||||
Reply routing (``/notify`` for canvas users, ``/a2a`` for peer agents) is handled
|
||||
automatically by :py:meth:`reply`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Example: a minimal plugin that's installable on Claude Code and DeepAgents
|
||||
├── claude_code.py # `from molecule_plugin import AgentskillsAdaptor as Adaptor`
|
||||
└── deepagents.py # same one-liner
|
||||
|
||||
Full docs + cookiecutter template: see ``sdk/python/README.md``.
|
||||
Full docs + cookiecutter template: see the repo root ``README.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
Reference in New Issue
Block a user