feat(agent): Refactor agent provisioning and cleanup logic for improved functionality
This commit is contained in:
@@ -11,7 +11,6 @@ from sqlmodel import Session, col, select
|
|||||||
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
|
||||||
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
|
from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token
|
||||||
from app.core.auth import AuthContext
|
from app.core.auth import AuthContext
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||||
@@ -31,8 +30,8 @@ from app.schemas.agents import (
|
|||||||
from app.services.activity_log import record_activity
|
from app.services.activity_log import record_activity
|
||||||
from app.services.agent_provisioning import (
|
from app.services.agent_provisioning import (
|
||||||
DEFAULT_HEARTBEAT_CONFIG,
|
DEFAULT_HEARTBEAT_CONFIG,
|
||||||
send_provisioning_message,
|
cleanup_agent_direct,
|
||||||
send_update_message,
|
provision_agent,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||||
@@ -194,8 +193,6 @@ async def create_agent(
|
|||||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||||
if agent.heartbeat_config is None:
|
if agent.heartbeat_config is None:
|
||||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||||
provision_token = generate_agent_token()
|
|
||||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "provision"
|
agent.provision_action = "provision"
|
||||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||||
@@ -219,15 +216,27 @@ async def create_agent(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
try:
|
try:
|
||||||
await send_provisioning_message(
|
await provision_agent(agent, board, gateway, raw_token, auth.user, action="provision")
|
||||||
agent, board, gateway, raw_token, provision_token, auth.user
|
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||||
|
agent.provision_confirm_token_hash = None
|
||||||
|
agent.provision_requested_at = None
|
||||||
|
agent.provision_action = None
|
||||||
|
agent.updated_at = datetime.utcnow()
|
||||||
|
session.add(agent)
|
||||||
|
session.commit()
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="agent.provision",
|
||||||
|
message=f"Provisioned directly for {agent.name}.",
|
||||||
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
event_type="agent.provision.requested",
|
event_type="agent.wakeup.sent",
|
||||||
message=f"Provisioning requested for {agent.name}.",
|
message=f"Wakeup message sent to {agent.name}.",
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -295,9 +304,7 @@ async def update_agent(
|
|||||||
_record_instruction_failure(session, agent, str(exc), "update")
|
_record_instruction_failure(session, agent, str(exc), "update")
|
||||||
session.commit()
|
session.commit()
|
||||||
raw_token = generate_agent_token()
|
raw_token = generate_agent_token()
|
||||||
provision_token = generate_agent_token()
|
|
||||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "update"
|
agent.provision_action = "update"
|
||||||
agent.status = "updating"
|
agent.status = "updating"
|
||||||
@@ -305,11 +312,25 @@ async def update_agent(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(agent)
|
session.refresh(agent)
|
||||||
try:
|
try:
|
||||||
await send_update_message(agent, board, gateway, raw_token, provision_token, auth.user)
|
await provision_agent(agent, board, gateway, raw_token, auth.user, action="update")
|
||||||
|
await _send_wakeup_message(agent, client_config, verb="updated")
|
||||||
|
agent.provision_confirm_token_hash = None
|
||||||
|
agent.provision_requested_at = None
|
||||||
|
agent.provision_action = None
|
||||||
|
agent.status = "online"
|
||||||
|
agent.updated_at = datetime.utcnow()
|
||||||
|
session.add(agent)
|
||||||
|
session.commit()
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
event_type="agent.update.requested",
|
event_type="agent.update.direct",
|
||||||
message=f"Update requested for {agent.name}.",
|
message=f"Updated directly for {agent.name}.",
|
||||||
|
agent_id=agent.id,
|
||||||
|
)
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="agent.wakeup.sent",
|
||||||
|
message=f"Wakeup message sent to {agent.name}.",
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -367,8 +388,6 @@ async def heartbeat_or_create_agent(
|
|||||||
)
|
)
|
||||||
raw_token = generate_agent_token()
|
raw_token = generate_agent_token()
|
||||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||||
provision_token = generate_agent_token()
|
|
||||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "provision"
|
agent.provision_action = "provision"
|
||||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||||
@@ -392,15 +411,27 @@ async def heartbeat_or_create_agent(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
try:
|
try:
|
||||||
await send_provisioning_message(
|
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
|
||||||
agent, board, gateway, raw_token, provision_token, actor.user
|
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||||
|
agent.provision_confirm_token_hash = None
|
||||||
|
agent.provision_requested_at = None
|
||||||
|
agent.provision_action = None
|
||||||
|
agent.updated_at = datetime.utcnow()
|
||||||
|
session.add(agent)
|
||||||
|
session.commit()
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="agent.provision",
|
||||||
|
message=f"Provisioned directly for {agent.name}.",
|
||||||
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
event_type="agent.provision.requested",
|
event_type="agent.wakeup.sent",
|
||||||
message=f"Provisioning requested for {agent.name}.",
|
message=f"Wakeup message sent to {agent.name}.",
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -414,8 +445,6 @@ async def heartbeat_or_create_agent(
|
|||||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||||
if agent.heartbeat_config is None:
|
if agent.heartbeat_config is None:
|
||||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||||
provision_token = generate_agent_token()
|
|
||||||
agent.provision_confirm_token_hash = hash_agent_token(provision_token)
|
|
||||||
agent.provision_requested_at = datetime.utcnow()
|
agent.provision_requested_at = datetime.utcnow()
|
||||||
agent.provision_action = "provision"
|
agent.provision_action = "provision"
|
||||||
session.add(agent)
|
session.add(agent)
|
||||||
@@ -424,15 +453,27 @@ async def heartbeat_or_create_agent(
|
|||||||
try:
|
try:
|
||||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||||
gateway, client_config = _require_gateway(session, board)
|
gateway, client_config = _require_gateway(session, board)
|
||||||
await send_provisioning_message(
|
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
|
||||||
agent, board, gateway, raw_token, provision_token, actor.user
|
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||||
|
agent.provision_confirm_token_hash = None
|
||||||
|
agent.provision_requested_at = None
|
||||||
|
agent.provision_action = None
|
||||||
|
agent.updated_at = datetime.utcnow()
|
||||||
|
session.add(agent)
|
||||||
|
session.commit()
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="agent.provision",
|
||||||
|
message=f"Provisioned directly for {agent.name}.",
|
||||||
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
record_activity(
|
record_activity(
|
||||||
session,
|
session,
|
||||||
event_type="agent.provision.requested",
|
event_type="agent.wakeup.sent",
|
||||||
message=f"Provisioning requested for {agent.name}.",
|
message=f"Wakeup message sent to {agent.name}.",
|
||||||
agent_id=agent.id,
|
agent_id=agent.id,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
_record_instruction_failure(session, agent, str(exc), "provision")
|
_record_instruction_failure(session, agent, str(exc), "provision")
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -485,61 +526,39 @@ def delete_agent(
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||||
gateway, client_config = _require_gateway(session, board)
|
gateway, _ = _require_gateway(session, board)
|
||||||
raw_token = generate_agent_token()
|
|
||||||
agent.delete_confirm_token_hash = hash_agent_token(raw_token)
|
|
||||||
agent.delete_requested_at = datetime.utcnow()
|
|
||||||
agent.status = "deleting"
|
|
||||||
agent.updated_at = datetime.utcnow()
|
|
||||||
session.add(agent)
|
|
||||||
record_activity(
|
|
||||||
session,
|
|
||||||
event_type="agent.delete.requested",
|
|
||||||
message=f"Delete requested for {agent.name}.",
|
|
||||||
agent_id=agent.id,
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
async def _gateway_cleanup_request() -> None:
|
|
||||||
main_session = gateway.main_session_key
|
|
||||||
if not main_session:
|
|
||||||
raise OpenClawGatewayError("Gateway main_session_key is required")
|
|
||||||
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
|
|
||||||
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
|
|
||||||
cleanup_message = (
|
|
||||||
"Cleanup request for deleted agent.\n\n"
|
|
||||||
f"Agent name: {agent.name}\n"
|
|
||||||
f"Agent id: {agent.id}\n"
|
|
||||||
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
|
|
||||||
f"Workspace path: {workspace_path}\n\n"
|
|
||||||
"Actions:\n"
|
|
||||||
"1) Remove the workspace directory.\n"
|
|
||||||
"2) Delete the agent session from the gateway.\n"
|
|
||||||
"3) Confirm deletion by calling:\n"
|
|
||||||
f" POST {base_url}/api/v1/agents/{agent.id}/delete/confirm\n"
|
|
||||||
' Body: {"token": "' + raw_token + '"}\n'
|
|
||||||
"Reply NO_REPLY."
|
|
||||||
)
|
|
||||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
|
||||||
await send_message(
|
|
||||||
cleanup_message,
|
|
||||||
session_key=main_session,
|
|
||||||
config=client_config,
|
|
||||||
deliver=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
asyncio.run(_gateway_cleanup_request())
|
asyncio.run(cleanup_agent_direct(agent, gateway, delete_workspace=True))
|
||||||
except OpenClawGatewayError as exc:
|
except OpenClawGatewayError as exc:
|
||||||
_record_instruction_failure(session, agent, str(exc), "delete")
|
_record_instruction_failure(session, agent, str(exc), "delete")
|
||||||
session.commit()
|
session.commit()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Gateway cleanup request failed: {exc}",
|
detail=f"Gateway cleanup failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Exception as exc: # pragma: no cover - unexpected cleanup errors
|
||||||
|
_record_instruction_failure(session, agent, str(exc), "delete")
|
||||||
|
session.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Workspace cleanup failed: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="agent.delete.direct",
|
||||||
|
message=f"Deleted agent {agent.name}.",
|
||||||
|
agent_id=None,
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
update(ActivityEvent)
|
||||||
|
.where(col(ActivityEvent.agent_id) == agent.id)
|
||||||
|
.values(agent_id=None)
|
||||||
|
)
|
||||||
|
session.delete(agent)
|
||||||
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ class Settings(BaseSettings):
|
|||||||
log_use_utc: bool = False
|
log_use_utc: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -10,25 +11,27 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||||
from app.integrations.openclaw_gateway import ensure_session, send_message
|
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, openclaw_call
|
||||||
from app.models.agents import Agent
|
from app.models.agents import Agent
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
TEMPLATE_FILES = [
|
|
||||||
"AGENTS.md",
|
|
||||||
"BOOT.md",
|
|
||||||
"BOOTSTRAP.md",
|
|
||||||
"HEARTBEAT.md",
|
|
||||||
"IDENTITY.md",
|
|
||||||
"SOUL.md",
|
|
||||||
"TOOLS.md",
|
|
||||||
"USER.md",
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
|
DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"}
|
||||||
|
|
||||||
|
DEFAULT_GATEWAY_FILES = frozenset(
|
||||||
|
{
|
||||||
|
"AGENTS.md",
|
||||||
|
"SOUL.md",
|
||||||
|
"TOOLS.md",
|
||||||
|
"IDENTITY.md",
|
||||||
|
"USER.md",
|
||||||
|
"HEARTBEAT.md",
|
||||||
|
"BOOTSTRAP.md",
|
||||||
|
"MEMORY.md",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> Path:
|
def _repo_root() -> Path:
|
||||||
return Path(__file__).resolve().parents[3]
|
return Path(__file__).resolve().parents[3]
|
||||||
@@ -67,31 +70,6 @@ def _template_env() -> Environment:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_templates(
|
|
||||||
context: dict[str, str], overrides: dict[str, str] | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
env = _template_env()
|
|
||||||
templates: dict[str, str] = {}
|
|
||||||
override_map = overrides or {}
|
|
||||||
for name in TEMPLATE_FILES:
|
|
||||||
path = _templates_root() / name
|
|
||||||
override = override_map.get(name)
|
|
||||||
if override:
|
|
||||||
templates[name] = env.from_string(override).render(**context).strip()
|
|
||||||
continue
|
|
||||||
if not path.exists():
|
|
||||||
templates[name] = ""
|
|
||||||
continue
|
|
||||||
template = env.get_template(name)
|
|
||||||
templates[name] = template.render(**context).strip()
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
||||||
if not workspace_root:
|
if not workspace_root:
|
||||||
raise ValueError("gateway_workspace_root is required")
|
raise ValueError("gateway_workspace_root is required")
|
||||||
@@ -100,6 +78,16 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
|||||||
return f"{root}/workspace-{_slugify(agent_name)}"
|
return f"{root}/workspace-{_slugify(agent_name)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_workspace_dir(workspace_root: str, agent_name: str) -> Path:
|
||||||
|
if not workspace_root:
|
||||||
|
raise ValueError("gateway_workspace_root is required")
|
||||||
|
root = Path(workspace_root).expanduser().resolve()
|
||||||
|
workspace = Path(_workspace_path(agent_name, workspace_root)).expanduser().resolve()
|
||||||
|
if workspace == root or root not in workspace.parents:
|
||||||
|
raise ValueError("workspace path is not under workspace root")
|
||||||
|
return workspace
|
||||||
|
|
||||||
|
|
||||||
def _build_context(
|
def _build_context(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
board: Board,
|
board: Board,
|
||||||
@@ -136,152 +124,220 @@ def _build_context(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_file_blocks(context: dict[str, str], agent: Agent) -> str:
|
def _session_key(agent: Agent) -> str:
|
||||||
|
if agent.openclaw_session_id:
|
||||||
|
return agent.openclaw_session_id
|
||||||
|
return f"agent:{_agent_key(agent)}:main"
|
||||||
|
|
||||||
|
|
||||||
|
async def _supported_gateway_files(config: GatewayClientConfig) -> set[str]:
|
||||||
|
try:
|
||||||
|
agents_payload = await openclaw_call("agents.list", config=config)
|
||||||
|
agents = []
|
||||||
|
default_id = None
|
||||||
|
if isinstance(agents_payload, dict):
|
||||||
|
agents = list(agents_payload.get("agents") or [])
|
||||||
|
default_id = agents_payload.get("defaultId") or agents_payload.get("default_id")
|
||||||
|
agent_id = default_id or (agents[0].get("id") if agents else None)
|
||||||
|
if not agent_id:
|
||||||
|
return set(DEFAULT_GATEWAY_FILES)
|
||||||
|
files_payload = await openclaw_call(
|
||||||
|
"agents.files.list", {"agentId": agent_id}, config=config
|
||||||
|
)
|
||||||
|
if isinstance(files_payload, dict):
|
||||||
|
files = files_payload.get("files") or []
|
||||||
|
supported = {item.get("name") for item in files if isinstance(item, dict)}
|
||||||
|
return supported or set(DEFAULT_GATEWAY_FILES)
|
||||||
|
except OpenClawGatewayError:
|
||||||
|
pass
|
||||||
|
return set(DEFAULT_GATEWAY_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
async def _gateway_agent_files_index(
|
||||||
|
agent_id: str, config: GatewayClientConfig
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
payload = await openclaw_call("agents.files.list", {"agentId": agent_id}, config=config)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
files = payload.get("files") or []
|
||||||
|
return {
|
||||||
|
item.get("name"): item
|
||||||
|
for item in files
|
||||||
|
if isinstance(item, dict) and item.get("name")
|
||||||
|
}
|
||||||
|
except OpenClawGatewayError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_agent_files(
|
||||||
|
context: dict[str, str],
|
||||||
|
agent: Agent,
|
||||||
|
file_names: set[str],
|
||||||
|
*,
|
||||||
|
include_bootstrap: bool,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
env = _template_env()
|
||||||
overrides: dict[str, str] = {}
|
overrides: dict[str, str] = {}
|
||||||
if agent.identity_template:
|
if agent.identity_template:
|
||||||
overrides["IDENTITY.md"] = agent.identity_template
|
overrides["IDENTITY.md"] = agent.identity_template
|
||||||
if agent.soul_template:
|
if agent.soul_template:
|
||||||
overrides["SOUL.md"] = agent.soul_template
|
overrides["SOUL.md"] = agent.soul_template
|
||||||
templates = _read_templates(context, overrides=overrides)
|
|
||||||
return "".join(_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES)
|
rendered: dict[str, str] = {}
|
||||||
|
for name in sorted(file_names):
|
||||||
|
if name == "BOOTSTRAP.md" and not include_bootstrap:
|
||||||
|
continue
|
||||||
|
if name == "MEMORY.md":
|
||||||
|
rendered[name] = "# MEMORY\n\nBootstrap pending.\n"
|
||||||
|
continue
|
||||||
|
override = overrides.get(name)
|
||||||
|
if override:
|
||||||
|
rendered[name] = env.from_string(override).render(**context).strip()
|
||||||
|
continue
|
||||||
|
path = _templates_root() / name
|
||||||
|
if path.exists():
|
||||||
|
rendered[name] = env.get_template(name).render(**context).strip()
|
||||||
|
continue
|
||||||
|
rendered[name] = ""
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
def build_provisioning_message(
|
async def _patch_gateway_agent_list(
|
||||||
|
agent_id: str,
|
||||||
|
workspace_path: str,
|
||||||
|
heartbeat: dict[str, Any],
|
||||||
|
config: GatewayClientConfig,
|
||||||
|
) -> None:
|
||||||
|
cfg = await openclaw_call("config.get", config=config)
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
raise OpenClawGatewayError("config.get returned invalid payload")
|
||||||
|
base_hash = cfg.get("hash")
|
||||||
|
data = cfg.get("config") or cfg.get("parsed") or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise OpenClawGatewayError("config.get returned invalid config")
|
||||||
|
agents = data.get("agents") or {}
|
||||||
|
lst = agents.get("list") or []
|
||||||
|
if not isinstance(lst, list):
|
||||||
|
raise OpenClawGatewayError("config agents.list is not a list")
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
new_list: list[dict[str, Any]] = []
|
||||||
|
for entry in lst:
|
||||||
|
if isinstance(entry, dict) and entry.get("id") == agent_id:
|
||||||
|
new_entry = dict(entry)
|
||||||
|
new_entry["workspace"] = workspace_path
|
||||||
|
new_entry["heartbeat"] = heartbeat
|
||||||
|
new_list.append(new_entry)
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
new_list.append(entry)
|
||||||
|
if not updated:
|
||||||
|
new_list.append({"id": agent_id, "workspace": workspace_path, "heartbeat": heartbeat})
|
||||||
|
|
||||||
|
patch = {"agents": {"list": new_list}}
|
||||||
|
params = {"raw": json.dumps(patch)}
|
||||||
|
if base_hash:
|
||||||
|
params["baseHash"] = base_hash
|
||||||
|
await openclaw_call("config.patch", params, config=config)
|
||||||
|
|
||||||
|
|
||||||
|
async def _remove_gateway_agent_list(
|
||||||
|
agent_id: str,
|
||||||
|
config: GatewayClientConfig,
|
||||||
|
) -> None:
|
||||||
|
cfg = await openclaw_call("config.get", config=config)
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
raise OpenClawGatewayError("config.get returned invalid payload")
|
||||||
|
base_hash = cfg.get("hash")
|
||||||
|
data = cfg.get("config") or cfg.get("parsed") or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise OpenClawGatewayError("config.get returned invalid config")
|
||||||
|
agents = data.get("agents") or {}
|
||||||
|
lst = agents.get("list") or []
|
||||||
|
if not isinstance(lst, list):
|
||||||
|
raise OpenClawGatewayError("config agents.list is not a list")
|
||||||
|
|
||||||
|
new_list = [entry for entry in lst if not (isinstance(entry, dict) and entry.get("id") == agent_id)]
|
||||||
|
if len(new_list) == len(lst):
|
||||||
|
return
|
||||||
|
patch = {"agents": {"list": new_list}}
|
||||||
|
params = {"raw": json.dumps(patch)}
|
||||||
|
if base_hash:
|
||||||
|
params["baseHash"] = base_hash
|
||||||
|
await openclaw_call("config.patch", params, config=config)
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_agent(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
board: Board,
|
board: Board,
|
||||||
gateway: Gateway,
|
gateway: Gateway,
|
||||||
auth_token: str,
|
auth_token: str,
|
||||||
confirm_token: str,
|
|
||||||
user: User | None,
|
|
||||||
) -> str:
|
|
||||||
context = _build_context(agent, board, gateway, auth_token, user)
|
|
||||||
file_blocks = _build_file_blocks(context, agent)
|
|
||||||
heartbeat_snippet = json.dumps(
|
|
||||||
{
|
|
||||||
"id": _agent_key(agent),
|
|
||||||
"workspace": context["workspace_path"],
|
|
||||||
"heartbeat": _heartbeat_config(agent),
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
"Provision a new 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) Create the workspace directory.\n"
|
|
||||||
"2) Write the files below with the exact contents.\n"
|
|
||||||
"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"
|
|
||||||
"5) Register agent id in OpenClaw so it uses this workspace path "
|
|
||||||
"(never overwrite the main agent session).\n"
|
|
||||||
" IMPORTANT: Use the configured gateway workspace root. "
|
|
||||||
"Workspace path must be <root>/workspace-<slug>.\n"
|
|
||||||
"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 "
|
|
||||||
"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"
|
|
||||||
f' Body: {{"token": "{confirm_token}", "action": "provision"}}\n\n'
|
|
||||||
"Files:" + file_blocks
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_update_message(
|
|
||||||
agent: Agent,
|
|
||||||
board: Board,
|
|
||||||
gateway: Gateway,
|
|
||||||
auth_token: str,
|
|
||||||
confirm_token: str,
|
|
||||||
user: User | None,
|
|
||||||
) -> str:
|
|
||||||
context = _build_context(agent, board, gateway, auth_token, user)
|
|
||||||
file_blocks = _build_file_blocks(context, agent)
|
|
||||||
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"
|
|
||||||
" IMPORTANT: Use the configured gateway workspace root. "
|
|
||||||
"Workspace path must be <root>/workspace-<slug>.\n"
|
|
||||||
"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 "
|
|
||||||
"run heartbeats.\n"
|
|
||||||
"7) After the update completes (and only after files are written), confirm by calling:\n"
|
|
||||||
f" POST {context['base_url']}/api/v1/agents/{context['agent_id']}/provision/confirm\n"
|
|
||||||
f' Body: {{"token": "{confirm_token}", "action": "update"}}\n'
|
|
||||||
" Mission Control will send the hello message only after this confirmation.\n\n"
|
|
||||||
"Files:" + file_blocks
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_provisioning_message(
|
|
||||||
agent: Agent,
|
|
||||||
board: Board,
|
|
||||||
gateway: Gateway,
|
|
||||||
auth_token: str,
|
|
||||||
confirm_token: str,
|
|
||||||
user: User | None,
|
user: User | None,
|
||||||
|
*,
|
||||||
|
action: str = "provision",
|
||||||
) -> None:
|
) -> None:
|
||||||
if not gateway.url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
if not gateway.main_session_key:
|
if not gateway.workspace_root:
|
||||||
raise ValueError("gateway_main_session_key is required")
|
raise ValueError("gateway_workspace_root is required")
|
||||||
main_session = gateway.main_session_key
|
|
||||||
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
session_key = _session_key(agent)
|
||||||
message = build_provisioning_message(agent, board, gateway, auth_token, confirm_token, user)
|
await ensure_session(session_key, config=client_config, label=agent.name)
|
||||||
await send_message(message, session_key=main_session, config=client_config, deliver=False)
|
|
||||||
|
agent_id = _agent_key(agent)
|
||||||
|
workspace_path = _workspace_path(agent.name, gateway.workspace_root)
|
||||||
|
heartbeat = _heartbeat_config(agent)
|
||||||
|
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
|
||||||
|
|
||||||
|
context = _build_context(agent, board, gateway, auth_token, user)
|
||||||
|
supported = await _supported_gateway_files(client_config)
|
||||||
|
existing_files = await _gateway_agent_files_index(agent_id, client_config)
|
||||||
|
include_bootstrap = True
|
||||||
|
if action == "update":
|
||||||
|
if not existing_files:
|
||||||
|
include_bootstrap = False
|
||||||
|
else:
|
||||||
|
entry = existing_files.get("BOOTSTRAP.md")
|
||||||
|
if entry and entry.get("missing") is True:
|
||||||
|
include_bootstrap = False
|
||||||
|
|
||||||
|
rendered = _render_agent_files(
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
supported,
|
||||||
|
include_bootstrap=include_bootstrap,
|
||||||
|
)
|
||||||
|
for name, content in rendered.items():
|
||||||
|
if content == "":
|
||||||
|
continue
|
||||||
|
await openclaw_call(
|
||||||
|
"agents.files.set",
|
||||||
|
{"agentId": agent_id, "name": name, "content": content},
|
||||||
|
config=client_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def send_update_message(
|
async def cleanup_agent_direct(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
board: Board,
|
|
||||||
gateway: Gateway,
|
gateway: Gateway,
|
||||||
auth_token: str,
|
*,
|
||||||
confirm_token: str,
|
delete_workspace: bool = True,
|
||||||
user: User | None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if not gateway.url:
|
if not gateway.url:
|
||||||
return
|
return
|
||||||
if not gateway.main_session_key:
|
if not gateway.workspace_root:
|
||||||
raise ValueError("gateway_main_session_key is required")
|
raise ValueError("gateway_workspace_root is required")
|
||||||
main_session = gateway.main_session_key
|
|
||||||
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
|
||||||
await ensure_session(main_session, config=client_config, label="Main Agent")
|
|
||||||
message = build_update_message(agent, board, gateway, auth_token, confirm_token, user)
|
agent_id = _agent_key(agent)
|
||||||
await send_message(message, session_key=main_session, config=client_config, deliver=False)
|
await _remove_gateway_agent_list(agent_id, client_config)
|
||||||
|
|
||||||
|
session_key = _session_key(agent)
|
||||||
|
await openclaw_call("sessions.delete", {"key": session_key}, config=client_config)
|
||||||
|
|
||||||
|
if delete_workspace:
|
||||||
|
workspace_dir = _resolve_workspace_dir(gateway.workspace_root, agent.name)
|
||||||
|
if workspace_dir.exists():
|
||||||
|
shutil.rmtree(workspace_dir)
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ There is no memory yet. Create what is missing and proceed without blocking.
|
|||||||
1) Create `memory/` and `memory.md` if missing.
|
1) Create `memory/` and `memory.md` if missing.
|
||||||
2) Read `IDENTITY.md`, `USER.md`, and `SOUL.md`.
|
2) Read `IDENTITY.md`, `USER.md`, and `SOUL.md`.
|
||||||
3) If any fields are blank, leave them blank. Do not invent values.
|
3) If any fields are blank, leave them blank. Do not invent values.
|
||||||
4) Write a short note to `memory.md` that bootstrap completed and list any
|
4) If `BASE_URL`, `AUTH_TOKEN`, and `BOARD_ID` are set in `TOOLS.md`, check in
|
||||||
|
to Mission Control to mark the agent online:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
|
||||||
|
-H "X-Agent-Token: $AUTH_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "'$AGENT_NAME'", "board_id": "'$BOARD_ID'", "status": "online"}'
|
||||||
|
```
|
||||||
|
5) Write a short note to `memory.md` that bootstrap completed and list any
|
||||||
missing fields (e.g., user name, timezone).
|
missing fields (e.g., user name, timezone).
|
||||||
5) Delete this file.
|
6) Delete this file.
|
||||||
|
|
||||||
## Optional: if a human is already present
|
## Optional: if a human is already present
|
||||||
You may ask a short, single message to fill missing fields. If no reply arrives
|
You may ask a short, single message to fill missing fields. If no reply arrives
|
||||||
|
|||||||
Reference in New Issue
Block a user