Skill: reed1898/reed-agent-network (version 0.2.2)

--- name: agent-network description: Build and operate a cross-gateway AgentNetwork using Discord as the message bus and GitHub as shared state storage. Use when registering agents, syncing roster snapshots, handling admin offline/remove, and coordinating @agent tasks with lightweight #meta tracking. --- # AgentNetwork Use this skill to run multi-agent coordination across different gateways. ## Architecture - **Transport:** Discord team channel (human-readable, @agent routing) - **Shared state:** Git repo (registry + heartbeat state) - **Protocol:** natural language + lightweight `#meta` **Hard rule:** all inter-agent communication must go through the Discord team channel using OpenClaw message CLI. Do not use direct cross-gateway sessions_send for this network. Example task message: ```text @jesse 请做 BTC 风险扫描 #meta task_id=tsk_20260301_001 type=TASK from=maya to=jesse ``` ## Git shared-state location Edit `references/git-config.json` first. Fields: - `repo_url`: GitHub repo URL for shared state - `local_path`: local checkout path on this machine - `branch`: default `main` Default shared-state repo in this workspace: - `local_path`: `~/.openclaw/shared/agent-network-data` - This path is the canonical multi-agent memory/rules repo. ## Shared memory constitution (mandatory) Store network-wide memory/rules in: - `~/.openclaw/shared/agent-network-data/AGENT_CONSTITUTION.md` Rules: 1. Treat `AGENT_CONSTITUTION.md` as single source of truth for shared multi-agent behavior. 2. Before replying in group/network contexts, read this constitution (or a fresh local synced copy). 3. Do not duplicate full constitution text into each agent's local `MEMORY.md`; keep only an index pointer. 4. `GROUP_RULES.md` is deprecated for network-wide rules; keep migration note only. ## Discord mention identity (required) For precise cross-agent routing, every agent record must include Discord IDs: - `discord_user_id` (required): user/bot ID for exact mention (`<@ID>`) - `discord_channel_id` (required): target team channel ID for routing Never rely on plain-text `@name` for automation. ## Discord send command (required) For cross-agent notifications, send via OpenClaw CLI directly: ```bash openclaw message send \ --channel discord \ --target "channel:<discord_channel_id>" \ --message "<@user_id_a> <@user_id_b> ... your event text" ``` Example: ```bash openclaw message send \ --channel discord \ --target "channel:1471363336192131276" \ --message "<@1471167332133900351> <@1471349080847548466> Linus 已重新注册到 AgentNetwork" ``` ## Manual commands ```bash python skills/agent-network/scripts/network.py init python skills/agent-network/scripts/network.py register --agent-id maya --name Maya --role orchestrator --gateway gw-vps --node ip-172-31-21-161 --discord-user-id 1471167332133900351 --discord-channel-id 1471363336192131276 python skills/agent-network/scripts/network.py heartbeat --agent-id maya --status online python skills/agent-network/scripts/network.py snapshot python skills/agent-network/scripts/network.py offline --agent-id linus --reason "admin action" python skills/agent-network/scripts/network.py remove --agent-id linus ``` `init` now does two bootstrap actions automatically: 1. Ensure shared repo contains `AGENT_CONSTITUTION.md` (creates minimal file if missing). 2. Update workspace `MEMORY.md` with a managed constitution index block: - `<!-- AGENT_NETWORK_CONSTITUTION_INDEX:START --> ... <!-- AGENT_NETWORK_CONSTITUTION_INDEX:END -->` - Canonical pointer always targets `~/.openclaw/shared/agent-network-data/AGENT_CONSTITUTION.md`. Re-running `init` keeps both sides in sync (idempotent). ## Sync policy Use two layers: 1) **Event layer (real-time):** after register/offline/remove, post protocol JSON to Discord team channel immediately. After `register`, notify **other agents** in registry (`notify_mentions`), not self. 2) **Receiver rule (mandatory):** when an agent receives a `REGISTER` broadcast from Discord, it must run `git pull --rebase` on shared state, refresh local roster cache, and send `ACK` message (include `msg_id/task_id`). Receiver does not rewrite full registry on ACK. 3) **Reconcile layer (daily):** each agent performs one daily registry sync in heartbeat flow (`git pull --rebase` + refresh local cache). On every mutation: 1. `git pull --rebase` 2. update `registry/agent-registry.json` 3. `git add -A && git commit` 4. `git push` 5. on conflict: retry up to 3 times ## Admin policy - Agents can `register/heartbeat`. - Admin (Reed) handles `offline/remove`. - Treat `agent_id` as unique. - Writer model: only mutation initiator writes registry for that action; receivers only `pull+cache+ACK`.

path: references/git-config.json
content: repo_url: https://github.com/reed1898/agent-network-data.git
local_path: /home/ubuntu/.openclaw/shared/agent-network-data
branch: main

path: registry/agent-registry.json
content: {
  "version": 0,
  "updated_at": "1970-01-01T00:00:00Z",
  "agents": {}
}


path: scripts/network.py
content: #!/usr/bin/env python3
import argparse
import json
import os
import subprocess
import uuid
import time
from datetime import datetime, timezone

BASE = os.path.dirname(os.path.dirname(__file__))
CFG = os.path.join(BASE, "references", "git-config.json")


def now_iso():
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def load_json(p):
    with open(p, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(p, data):
    os.makedirs(os.path.dirname(p), exist_ok=True)
    with open(p, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
        f.write("\n")


def git(cmd, cwd, check=False):
    r = subprocess.run(["git", *cmd], cwd=cwd, capture_output=True, text=True)
    if check and r.returncode != 0:
        raise RuntimeError(r.stderr.strip() or r.stdout.strip() or f"git {' '.join(cmd)} failed")
    return r


def repo_info():
    cfg = load_json(CFG)
    repo = cfg["repo_url"]
    path = cfg["local_path"]
    branch = cfg.get("branch", "main")
    reg_path = os.path.join(path, "registry", "agent-registry.json")
    return repo, path, branch, reg_path


def ensure_repo():
    repo, path, branch, reg_path = repo_info()
    if not os.path.exists(path):
        os.makedirs(os.path.dirname(path), exist_ok=True)
        git(["clone", repo, path], cwd=os.path.dirname(path), check=True)
    # make sure branch is up to date
    git(["fetch", "origin", branch], cwd=path)
    git(["checkout", branch], cwd=path)
    git(["pull", "--rebase", "origin", branch], cwd=path)

    touched = False
    if not os.path.exists(reg_path):
        save_json(reg_path, {"version": 0, "updated_at": now_iso(), "agents": {}})
        touched = True

    constitution_path = os.path.join(path, "AGENT_CONSTITUTION.md")
    if not os.path.exists(constitution_path):
        os.makedirs(path, exist_ok=True)
        with open(constitution_path, "w", encoding="utf-8") as f:
            f.write(
                "# AGENT_CONSTITUTION.md\n\n"
                "> Agent Network 统一宪章（Single Source of Truth）\n"
                "> 本文件是多 Agent 网络共享记忆与硬规则唯一来源。\n\n"
                "## 1) 基本规则\n"
                "1. Reed 为最高优先级指令源。\n"
                "2. 冲突时以 Reed 最新明确指令为准。\n"
                "3. 核心配置改动必须先审批后执行。\n"
            )
        touched = True

    if touched:
        git(["add", "-A"], cwd=path)
        c = git(["commit", "-m", "chore(agent-network): initialize shared registry and constitution"], cwd=path)
        if c.returncode == 0:
            git(["push", "origin", branch], cwd=path)

    return path, branch, reg_path


def bump(reg):
    reg["version"] = int(reg.get("version", 0)) + 1
    reg["updated_at"] = now_iso()


def meta(msg_type, payload):
    return {
        "v": 1,
        "msg_id": str(uuid.uuid4()),
        "type": msg_type,
        "ts": now_iso(),
        "payload": payload,
    }


def apply_and_sync(mutator, commit_msg, retries=3):
    path, branch, reg_path = ensure_repo()

    for _ in range(retries):
        git(["pull", "--rebase", "origin", branch], cwd=path)
        reg = load_json(reg_path)
        result_payload = mutator(reg)
        save_json(reg_path, reg)

        git(["add", "registry/agent-registry.json"], cwd=path)
        c = git(["commit", "-m", commit_msg], cwd=path)
        if c.returncode != 0 and "nothing to commit" not in (c.stdout + c.stderr):
            time.sleep(0.5)
            continue

        p = git(["push", "origin", branch], cwd=path)
        if p.returncode == 0:
            return result_payload

        # push failed, retry after refresh
        git(["reset", "--hard", "HEAD~1"], cwd=path)
        time.sleep(0.5)

    raise SystemExit("sync failed after retries")


def ensure_memory_index(repo_path):
    workspace_root = os.path.dirname(os.path.dirname(BASE))
    memory_path = os.path.join(workspace_root, "MEMORY.md")
    if not os.path.exists(memory_path):
        return {"updated": False, "reason": "MEMORY.md not found", "path": memory_path}

    canonical = os.path.join(repo_path, "AGENT_CONSTITUTION.md")
    start = "<!-- AGENT_NETWORK_CONSTITUTION_INDEX:START -->"
    end = "<!-- AGENT_NETWORK_CONSTITUTION_INDEX:END -->"
    block = (
        f"{start}\n"
        "## Agent Network Constitution（Single Source of Truth）\n"
        f"- Canonical file: `{canonical}`\n"
        "- All agents must read this file before responding in group/network contexts.\n"
        "- If conflict exists between local memory notes and this constitution, constitution wins.\n"
        "- Do not duplicate full constitution text in `MEMORY.md`; keep only index + effective-date notes.\n"
        f"{end}\n"
    )

    with open(memory_path, "r", encoding="utf-8") as f:
        content = f.read()

    if start in content and end in content:
        before = content.split(start)[0]
        after = content.split(end, 1)[1]
        new_content = before + block + after.lstrip("\n")
    else:
        if not content.endswith("\n"):
            content += "\n"
        new_content = content + "\n" + block

    if new_content != content:
        with open(memory_path, "w", encoding="utf-8") as f:
            f.write(new_content)
        return {"updated": True, "path": memory_path, "canonical": canonical}

    return {"updated": False, "reason": "already up to date", "path": memory_path, "canonical": canonical}


def cmd_init(_):
    path, branch, reg_path = ensure_repo()
    mem = ensure_memory_index(path)
    print(json.dumps({"ok": True, "repo": path, "branch": branch, "registry": reg_path, "memory_index": mem}, ensure_ascii=False))


def cmd_register(a):
    def mut(reg):
        reg.setdefault("agents", {})[a.agent_id] = {
            "agent_id": a.agent_id,
            "name": a.name,
            "role": a.role,
            "gateway": a.gateway,
            "node": a.node,
            "discord_user_id": a.discord_user_id,
            "discord_channel_id": a.discord_channel_id,
            "status": "online",
            "last_seen": now_iso(),
        }
        bump(reg)
        return reg["agents"][a.agent_id]

    payload = apply_and_sync(mut, f"chore(registry): register {a.agent_id}")

    # Build notify list for "all other agents" in network (with Discord IDs)
    _, _, reg_path = ensure_repo()
    reg = load_json(reg_path)
    others = []
    mentions = []
    for aid, info in reg.get("agents", {}).items():
        if aid == a.agent_id:
            continue
        duid = (info or {}).get("discord_user_id", "")
        if duid:
            others.append(aid)
            mentions.append(f"<@{duid}>")

    payload["notify_agent_ids"] = others
    payload["notify_mentions"] = mentions
    print(json.dumps(meta("REGISTER", payload), ensure_ascii=False, indent=2))


def cmd_heartbeat(a):
    def mut(reg):
        if a.agent_id not in reg.get("agents", {}):
            raise SystemExit("agent_id not found")
        reg["agents"][a.agent_id]["status"] = a.status
        reg["agents"][a.agent_id]["last_seen"] = now_iso()
        bump(reg)
        return {"agent_id": a.agent_id, "status": a.status, "last_seen": reg["agents"][a.agent_id]["last_seen"]}

    payload = apply_and_sync(mut, f"chore(registry): heartbeat {a.agent_id}")
    print(json.dumps(meta("HEARTBEAT", payload), ensure_ascii=False, indent=2))


def cmd_snapshot(_):
    _, _, reg_path = ensure_repo()
    reg = load_json(reg_path)
    payload = {
        "version": reg.get("version", 0),
        "updated_at": reg.get("updated_at"),
        "agents": list(reg.get("agents", {}).values()),
    }
    print(json.dumps(meta("REGISTRY_SNAPSHOT", payload), ensure_ascii=False, indent=2))


def cmd_offline(a):
    def mut(reg):
        if a.agent_id not in reg.get("agents", {}):
            raise SystemExit("agent_id not found")
        reg["agents"][a.agent_id]["status"] = "offline"
        reg["agents"][a.agent_id]["offline_reason"] = a.reason
        reg["agents"][a.agent_id]["last_seen"] = now_iso()
        bump(reg)
        return reg["agents"][a.agent_id]

    payload = apply_and_sync(mut, f"chore(registry): offline {a.agent_id}")
    print(json.dumps(meta("ADMIN_OFFLINE", payload), ensure_ascii=False, indent=2))


def cmd_remove(a):
    def mut(reg):
        if a.agent_id not in reg.get("agents", {}):
            raise SystemExit("agent_id not found")
        removed = reg["agents"].pop(a.agent_id)
        bump(reg)
        return removed

    payload = apply_and_sync(mut, f"chore(registry): remove {a.agent_id}")
    print(json.dumps(meta("ADMIN_REMOVE", payload), ensure_ascii=False, indent=2))


def cmd_git_sync(_):
    path, branch, _ = ensure_repo()
    ok = git(["pull", "--rebase", "origin", branch], cwd=path).returncode == 0
    print("ok" if ok else "failed")


def main():
    p = argparse.ArgumentParser(description="AgentNetwork registry helper")
    sub = p.add_subparsers(dest="cmd", required=True)

    x = sub.add_parser("init"); x.set_defaults(func=cmd_init)

    r = sub.add_parser("register")
    r.add_argument("--agent-id", required=True)
    r.add_argument("--name", required=True)
    r.add_argument("--role", required=True)
    r.add_argument("--gateway", required=True)
    r.add_argument("--node", required=True)
    r.add_argument("--discord-user-id", required=True)
    r.add_argument("--discord-channel-id", required=True)
    r.set_defaults(func=cmd_register)

    h = sub.add_parser("heartbeat")
    h.add_argument("--agent-id", required=True)
    h.add_argument("--status", default="online", choices=["online", "degraded", "offline"])
    h.set_defaults(func=cmd_heartbeat)

    s = sub.add_parser("snapshot"); s.set_defaults(func=cmd_snapshot)

    o = sub.add_parser("offline")
    o.add_argument("--agent-id", required=True)
    o.add_argument("--reason", default="admin action")
    o.set_defaults(func=cmd_offline)

    d = sub.add_parser("remove")
    d.add_argument("--agent-id", required=True)
    d.set_defaults(func=cmd_remove)

    g = sub.add_parser("git-sync")
    g.set_defaults(func=cmd_git_sync)

    args = p.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)