2026-02-04 03:46:46 +05:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-02-04 17:05:58 +05:30
|
|
|
import json
|
2026-02-04 03:46:46 +05:30
|
|
|
import re
|
|
|
|
|
from pathlib import Path
|
2026-02-04 17:05:58 +05:30
|
|
|
from typing import Any
|
2026-02-04 03:46:46 +05:30
|
|
|
from uuid import uuid4
|
|
|
|
|
|
2026-02-04 15:16:28 +05:30
|
|
|
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
|
2026-02-04 13:03:18 +05:30
|
|
|
|
2026-02-04 03:46:46 +05:30
|
|
|
from app.core.config import settings
|
2026-02-05 00:21:33 +05:30
|
|
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
|
|
|
|
from app.integrations.openclaw_gateway import ensure_session, send_message
|
2026-02-04 03:46:46 +05:30
|
|
|
from app.models.agents import Agent
|
2026-02-04 16:04:52 +05:30
|
|
|
from app.models.boards import Board
|
2026-02-04 23:07:22 +05:30
|
|
|
from app.models.gateways import Gateway
|
2026-02-04 20:21:33 +05:30
|
|
|
from app.models.users import User
|
2026-02-04 03:46:46 +05:30
|
|
|
|
|
|
|
|
TEMPLATE_FILES = [
|
|
|
|
|
"AGENTS.md",
|
|
|
|
|
"BOOT.md",
|
|
|
|
|
"BOOTSTRAP.md",
|
|
|
|
|
"HEARTBEAT.md",
|
|
|
|
|
"IDENTITY.md",
|
|
|
|
|
"SOUL.md",
|
|
|
|
|
"TOOLS.md",
|
|
|
|
|
"USER.md",
|
|
|
|
|
]
|
|
|
|
|
|
2026-02-04 17:05:58 +05:30
|
|
|
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
|
|
|
|
|
|
2026-02-04 03:46:46 +05:30
|
|
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
|
|
|
return Path(__file__).resolve().parents[3]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _templates_root() -> Path:
|
|
|
|
|
return _repo_root() / "templates"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _slugify(value: str) -> str:
|
|
|
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
|
|
|
return slug or uuid4().hex
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 17:05:58 +05:30
|
|
|
def _agent_key(agent: Agent) -> str:
|
|
|
|
|
session_key = agent.openclaw_session_id or ""
|
|
|
|
|
if session_key.startswith("agent:"):
|
|
|
|
|
parts = session_key.split(":")
|
|
|
|
|
if len(parts) >= 2 and parts[1]:
|
|
|
|
|
return parts[1]
|
|
|
|
|
return _slugify(agent.name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heartbeat_config(agent: Agent) -> dict[str, Any]:
|
|
|
|
|
if agent.heartbeat_config:
|
|
|
|
|
return agent.heartbeat_config
|
|
|
|
|
return DEFAULT_HEARTBEAT_CONFIG.copy()
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 13:03:18 +05:30
|
|
|
def _template_env() -> Environment:
|
|
|
|
|
return Environment(
|
|
|
|
|
loader=FileSystemLoader(_templates_root()),
|
2026-02-04 15:16:28 +05:30
|
|
|
autoescape=select_autoescape(default=True),
|
2026-02-04 13:03:18 +05:30
|
|
|
undefined=StrictUndefined,
|
|
|
|
|
keep_trailing_newline=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 20:21:33 +05:30
|
|
|
def _read_templates(
|
|
|
|
|
context: dict[str, str], overrides: dict[str, str] | None = None
|
|
|
|
|
) -> dict[str, str]:
|
2026-02-04 13:03:18 +05:30
|
|
|
env = _template_env()
|
2026-02-04 03:46:46 +05:30
|
|
|
templates: dict[str, str] = {}
|
2026-02-04 20:21:33 +05:30
|
|
|
override_map = overrides or {}
|
2026-02-04 03:46:46 +05:30
|
|
|
for name in TEMPLATE_FILES:
|
2026-02-04 13:03:18 +05:30
|
|
|
path = _templates_root() / name
|
2026-02-04 20:21:33 +05:30
|
|
|
override = override_map.get(name)
|
|
|
|
|
if override:
|
|
|
|
|
templates[name] = env.from_string(override).render(**context).strip()
|
|
|
|
|
continue
|
2026-02-04 13:03:18 +05:30
|
|
|
if not path.exists():
|
2026-02-04 03:46:46 +05:30
|
|
|
templates[name] = ""
|
2026-02-04 13:03:18 +05:30
|
|
|
continue
|
|
|
|
|
template = env.get_template(name)
|
|
|
|
|
templates[name] = template.render(**context).strip()
|
2026-02-04 03:46:46 +05:30
|
|
|
return templates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_file_block(name: str, content: str) -> str:
|
|
|
|
|
body = content if content else f"# {name}\n\nTODO: add content\n"
|
|
|
|
|
return f"\n{name}\n```md\n{body}\n```\n"
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 16:04:52 +05:30
|
|
|
def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
2026-02-04 17:14:47 +05:30
|
|
|
if not workspace_root:
|
|
|
|
|
raise ValueError("gateway_workspace_root is required")
|
|
|
|
|
root = workspace_root
|
2026-02-04 03:46:46 +05:30
|
|
|
root = root.rstrip("/")
|
2026-02-04 17:14:47 +05:30
|
|
|
return f"{root}/workspace-{_slugify(agent_name)}"
|
2026-02-04 03:46:46 +05:30
|
|
|
|
|
|
|
|
|
2026-02-04 20:21:33 +05:30
|
|
|
def _build_context(
|
2026-02-04 23:07:22 +05:30
|
|
|
agent: Agent,
|
|
|
|
|
board: Board,
|
|
|
|
|
gateway: Gateway,
|
|
|
|
|
auth_token: str,
|
|
|
|
|
user: User | None,
|
2026-02-04 20:21:33 +05:30
|
|
|
) -> dict[str, str]:
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.workspace_root:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise ValueError("gateway_workspace_root is required")
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.main_session_key:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise ValueError("gateway_main_session_key is required")
|
2026-02-04 13:03:18 +05:30
|
|
|
agent_id = str(agent.id)
|
2026-02-04 23:07:22 +05:30
|
|
|
workspace_root = gateway.workspace_root
|
2026-02-04 16:04:52 +05:30
|
|
|
workspace_path = _workspace_path(agent.name, workspace_root)
|
2026-02-04 03:46:46 +05:30
|
|
|
session_key = agent.openclaw_session_id or ""
|
2026-02-04 13:03:18 +05:30
|
|
|
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
2026-02-04 23:07:22 +05:30
|
|
|
main_session_key = gateway.main_session_key
|
2026-02-04 17:05:58 +05:30
|
|
|
return {
|
2026-02-04 13:03:18 +05:30
|
|
|
"agent_name": agent.name,
|
|
|
|
|
"agent_id": agent_id,
|
2026-02-04 16:04:52 +05:30
|
|
|
"board_id": str(board.id),
|
2026-02-04 13:03:18 +05:30
|
|
|
"session_key": session_key,
|
|
|
|
|
"workspace_path": workspace_path,
|
|
|
|
|
"base_url": base_url,
|
|
|
|
|
"auth_token": auth_token,
|
2026-02-04 16:04:52 +05:30
|
|
|
"main_session_key": main_session_key,
|
|
|
|
|
"workspace_root": workspace_root,
|
2026-02-05 00:21:33 +05:30
|
|
|
"user_name": (user.name or "") if user else "",
|
|
|
|
|
"user_preferred_name": (user.preferred_name or "") if user else "",
|
|
|
|
|
"user_pronouns": (user.pronouns or "") if user else "",
|
|
|
|
|
"user_timezone": (user.timezone or "") if user else "",
|
|
|
|
|
"user_notes": (user.notes or "") if user else "",
|
|
|
|
|
"user_context": (user.context or "") if user else "",
|
2026-02-04 13:03:18 +05:30
|
|
|
}
|
|
|
|
|
|
2026-02-04 03:46:46 +05:30
|
|
|
|
2026-02-04 23:07:22 +05:30
|
|
|
def _build_file_blocks(context: dict[str, str], agent: Agent) -> str:
|
2026-02-04 20:21:33 +05:30
|
|
|
overrides: dict[str, str] = {}
|
2026-02-04 23:07:22 +05:30
|
|
|
if agent.identity_template:
|
|
|
|
|
overrides["IDENTITY.md"] = agent.identity_template
|
|
|
|
|
if agent.soul_template:
|
|
|
|
|
overrides["SOUL.md"] = agent.soul_template
|
2026-02-04 20:21:33 +05:30
|
|
|
templates = _read_templates(context, overrides=overrides)
|
2026-02-05 00:21:33 +05:30
|
|
|
return "".join(_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES)
|
2026-02-04 03:46:46 +05:30
|
|
|
|
2026-02-04 17:05:58 +05:30
|
|
|
|
2026-02-04 17:24:52 +05:30
|
|
|
def build_provisioning_message(
|
2026-02-04 23:07:22 +05:30
|
|
|
agent: Agent,
|
|
|
|
|
board: Board,
|
|
|
|
|
gateway: Gateway,
|
|
|
|
|
auth_token: str,
|
|
|
|
|
confirm_token: str,
|
|
|
|
|
user: User | None,
|
2026-02-04 17:24:52 +05:30
|
|
|
) -> str:
|
2026-02-04 23:07:22 +05:30
|
|
|
context = _build_context(agent, board, gateway, auth_token, user)
|
|
|
|
|
file_blocks = _build_file_blocks(context, agent)
|
2026-02-04 17:05:58 +05:30
|
|
|
heartbeat_snippet = json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"id": _agent_key(agent),
|
|
|
|
|
"workspace": context["workspace_path"],
|
|
|
|
|
"heartbeat": _heartbeat_config(agent),
|
|
|
|
|
},
|
|
|
|
|
indent=2,
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
)
|
2026-02-04 03:46:46 +05:30
|
|
|
return (
|
|
|
|
|
"Provision a new OpenClaw agent workspace.\n\n"
|
2026-02-04 17:05:58 +05:30
|
|
|
f"Agent name: {context['agent_name']}\n"
|
|
|
|
|
f"Agent id: {context['agent_id']}\n"
|
|
|
|
|
f"Session key: {context['session_key']}\n"
|
|
|
|
|
f"Workspace path: {context['workspace_path']}\n\n"
|
|
|
|
|
f"Base URL: {context['base_url']}\n"
|
|
|
|
|
f"Auth token: {context['auth_token']}\n\n"
|
2026-02-04 03:46:46 +05:30
|
|
|
"Steps:\n"
|
2026-02-04 16:08:28 +05:30
|
|
|
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
|
|
|
|
|
f"{context['main_session_key']} unchanged and its workspace intact.\n"
|
2026-02-04 03:46:46 +05:30
|
|
|
"1) Create the workspace directory.\n"
|
|
|
|
|
"2) Write the files below with the exact contents.\n"
|
2026-02-04 13:03:18 +05:30
|
|
|
"3) Update TOOLS.md if BASE_URL/AUTH_TOKEN must change.\n"
|
|
|
|
|
"4) Leave BOOTSTRAP.md in place; the agent should run it on first start and delete it.\n"
|
2026-02-04 16:08:28 +05:30
|
|
|
"5) Register agent id in OpenClaw so it uses this workspace path "
|
2026-02-04 17:05:58 +05:30
|
|
|
"(never overwrite the main agent session).\n"
|
2026-02-04 17:14:47 +05:30
|
|
|
" IMPORTANT: Use the configured gateway workspace root. "
|
|
|
|
|
"Workspace path must be <root>/workspace-<slug>.\n"
|
2026-02-04 17:05:58 +05:30
|
|
|
"6) Add/update the per-agent heartbeat config in the gateway config "
|
|
|
|
|
"for this agent (merge into agents.list entry):\n"
|
|
|
|
|
"```json\n"
|
|
|
|
|
f"{heartbeat_snippet}\n"
|
|
|
|
|
"```\n"
|
|
|
|
|
"Note: if any agents.list entry defines heartbeat, only those agents "
|
2026-02-04 17:24:52 +05:30
|
|
|
"run heartbeats.\n"
|
|
|
|
|
"7) After provisioning completes, confirm by calling:\n"
|
|
|
|
|
f" POST {context['base_url']}/api/v1/agents/{context['agent_id']}/provision/confirm\n"
|
2026-02-05 00:21:33 +05:30
|
|
|
f' Body: {{"token": "{confirm_token}", "action": "provision"}}\n\n'
|
2026-02-04 17:05:58 +05:30
|
|
|
"Files:" + file_blocks
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 17:24:52 +05:30
|
|
|
def build_update_message(
|
2026-02-04 23:07:22 +05:30
|
|
|
agent: Agent,
|
|
|
|
|
board: Board,
|
|
|
|
|
gateway: Gateway,
|
|
|
|
|
auth_token: str,
|
|
|
|
|
confirm_token: str,
|
|
|
|
|
user: User | None,
|
2026-02-04 17:24:52 +05:30
|
|
|
) -> str:
|
2026-02-04 23:07:22 +05:30
|
|
|
context = _build_context(agent, board, gateway, auth_token, user)
|
|
|
|
|
file_blocks = _build_file_blocks(context, agent)
|
2026-02-04 17:05:58 +05:30
|
|
|
heartbeat_snippet = json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"id": _agent_key(agent),
|
|
|
|
|
"workspace": context["workspace_path"],
|
|
|
|
|
"heartbeat": _heartbeat_config(agent),
|
|
|
|
|
},
|
|
|
|
|
indent=2,
|
|
|
|
|
sort_keys=True,
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
"Update an existing OpenClaw agent workspace.\n\n"
|
|
|
|
|
f"Agent name: {context['agent_name']}\n"
|
|
|
|
|
f"Agent id: {context['agent_id']}\n"
|
|
|
|
|
f"Session key: {context['session_key']}\n"
|
|
|
|
|
f"Workspace path: {context['workspace_path']}\n\n"
|
|
|
|
|
f"Base URL: {context['base_url']}\n"
|
|
|
|
|
f"Auth token: {context['auth_token']}\n\n"
|
|
|
|
|
"Steps:\n"
|
|
|
|
|
"0) IMPORTANT: Do NOT replace or repurpose the main agent. Keep "
|
|
|
|
|
f"{context['main_session_key']} unchanged and its workspace intact.\n"
|
|
|
|
|
"1) Locate the existing workspace directory (do NOT create a new one or change its path).\n"
|
|
|
|
|
"2) Overwrite the files below with the exact contents.\n"
|
|
|
|
|
"3) Update TOOLS.md with the new BASE_URL/AUTH_TOKEN/SESSION_KEY values.\n"
|
|
|
|
|
"4) Do NOT create a new agent or session; update the existing one in place.\n"
|
|
|
|
|
"5) Keep BOOTSTRAP.md only if it already exists; do not recreate it if missing.\n\n"
|
2026-02-04 17:14:47 +05:30
|
|
|
" IMPORTANT: Use the configured gateway workspace root. "
|
|
|
|
|
"Workspace path must be <root>/workspace-<slug>.\n"
|
2026-02-04 17:05:58 +05:30
|
|
|
"6) Update the per-agent heartbeat config in the gateway config for this agent:\n"
|
|
|
|
|
"```json\n"
|
|
|
|
|
f"{heartbeat_snippet}\n"
|
|
|
|
|
"```\n"
|
|
|
|
|
"Note: if any agents.list entry defines heartbeat, only those agents "
|
2026-02-04 17:24:52 +05:30
|
|
|
"run heartbeats.\n"
|
2026-02-04 18:25:13 +05:30
|
|
|
"7) After the update completes (and only after files are written), confirm by calling:\n"
|
2026-02-04 17:24:52 +05:30
|
|
|
f" POST {context['base_url']}/api/v1/agents/{context['agent_id']}/provision/confirm\n"
|
2026-02-05 00:21:33 +05:30
|
|
|
f' Body: {{"token": "{confirm_token}", "action": "update"}}\n'
|
2026-02-04 18:25:13 +05:30
|
|
|
" Mission Control will send the hello message only after this confirmation.\n\n"
|
2026-02-04 03:46:46 +05:30
|
|
|
"Files:" + file_blocks
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 16:04:52 +05:30
|
|
|
async def send_provisioning_message(
|
|
|
|
|
agent: Agent,
|
|
|
|
|
board: Board,
|
2026-02-04 23:07:22 +05:30
|
|
|
gateway: Gateway,
|
2026-02-04 16:04:52 +05:30
|
|
|
auth_token: str,
|
2026-02-04 17:24:52 +05:30
|
|
|
confirm_token: str,
|
2026-02-04 20:21:33 +05:30
|
|
|
user: User | None,
|
2026-02-04 16:04:52 +05:30
|
|
|
) -> None:
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.url:
|
2026-02-04 03:46:46 +05:30
|
|
|
return
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.main_session_key:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise ValueError("gateway_main_session_key is required")
|
2026-02-04 23:07:22 +05:30
|
|
|
main_session = gateway.main_session_key
|
2026-02-05 00:21:33 +05:30
|
|
|
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
2026-02-04 23:07:22 +05:30
|
|
|
await ensure_session(main_session, config=client_config, label="Main Agent")
|
2026-02-05 00:21:33 +05:30
|
|
|
message = build_provisioning_message(agent, board, gateway, auth_token, confirm_token, user)
|
|
|
|
|
await send_message(message, session_key=main_session, config=client_config, deliver=False)
|
2026-02-04 17:05:58 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_update_message(
|
|
|
|
|
agent: Agent,
|
|
|
|
|
board: Board,
|
2026-02-04 23:07:22 +05:30
|
|
|
gateway: Gateway,
|
2026-02-04 17:05:58 +05:30
|
|
|
auth_token: str,
|
2026-02-04 17:24:52 +05:30
|
|
|
confirm_token: str,
|
2026-02-04 20:21:33 +05:30
|
|
|
user: User | None,
|
2026-02-04 17:05:58 +05:30
|
|
|
) -> None:
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.url:
|
2026-02-04 17:05:58 +05:30
|
|
|
return
|
2026-02-04 23:07:22 +05:30
|
|
|
if not gateway.main_session_key:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise ValueError("gateway_main_session_key is required")
|
2026-02-04 23:07:22 +05:30
|
|
|
main_session = gateway.main_session_key
|
2026-02-05 00:21:33 +05:30
|
|
|
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
2026-02-04 23:07:22 +05:30
|
|
|
await ensure_session(main_session, config=client_config, label="Main Agent")
|
2026-02-05 00:21:33 +05:30
|
|
|
message = build_update_message(agent, board, gateway, auth_token, confirm_token, user)
|
|
|
|
|
await send_message(message, session_key=main_session, config=client_config, deliver=False)
|