diff --git a/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py b/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py new file mode 100644 index 00000000..dec703c4 --- /dev/null +++ b/backend/alembic/versions/2b4c2f7b3eda_add_agent_heartbeat_config_column.py @@ -0,0 +1,27 @@ +"""add agent heartbeat config column + +Revision ID: 2b4c2f7b3eda +Revises: 69858cb75533 +Create Date: 2026-02-04 16:36:55.587762 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '2b4c2f7b3eda' +down_revision = '69858cb75533' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config") diff --git a/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py b/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py new file mode 100644 index 00000000..516f179a --- /dev/null +++ b/backend/alembic/versions/69858cb75533_add_agent_heartbeat_config.py @@ -0,0 +1,29 @@ +"""add agent heartbeat config + +Revision ID: 69858cb75533 +Revises: f1a2b3c4d5e6 +Create Date: 2026-02-04 16:32:42.028772 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '69858cb75533' +down_revision = 'f1a2b3c4d5e6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config" + ) diff --git a/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py b/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py new file mode 100644 index 00000000..f077f7a7 --- /dev/null +++ b/backend/alembic/versions/cefef25d4634_ensure_heartbeat_config_column.py @@ -0,0 +1,29 @@ +"""ensure heartbeat config column + +Revision ID: cefef25d4634 +Revises: 2b4c2f7b3eda +Create Date: 2026-02-04 16:38:25.234627 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'cefef25d4634' +down_revision = '2b4c2f7b3eda' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS heartbeat_config JSON" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE agents DROP COLUMN IF EXISTS heartbeat_config" + ) diff --git a/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py b/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py new file mode 100644 index 00000000..4092d4fc --- /dev/null +++ b/backend/alembic/versions/e0f28e965fa5_add_agent_delete_confirmation.py @@ -0,0 +1,35 @@ +"""add agent delete confirmation + +Revision ID: e0f28e965fa5 +Revises: cefef25d4634 +Create Date: 2026-02-04 16:55:33.389505 + +""" +from __future__ import annotations + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'e0f28e965fa5' +down_revision = 'cefef25d4634' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_requested_at TIMESTAMP" + ) + op.execute( + "ALTER TABLE agents ADD COLUMN IF NOT EXISTS delete_confirm_token_hash VARCHAR" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE agents DROP COLUMN IF EXISTS delete_confirm_token_hash" + ) + op.execute( + "ALTER TABLE agents DROP COLUMN IF EXISTS delete_requested_at" + ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 7ff542e7..f6150c7e 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -9,13 +9,13 @@ from sqlmodel import Session, col, select from sqlalchemy import update 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 +from app.core.agent_tokens import generate_agent_token, hash_agent_token, verify_agent_token from app.core.auth import AuthContext +from app.core.config import settings from app.db.session import get_session from app.integrations.openclaw_gateway import ( GatewayConfig, OpenClawGatewayError, - delete_session, ensure_session, send_message, ) @@ -24,13 +24,18 @@ from app.models.activity_events import ActivityEvent from app.models.boards import Board from app.schemas.agents import ( AgentCreate, + AgentDeleteConfirm, AgentHeartbeat, AgentHeartbeatCreate, AgentRead, AgentUpdate, ) from app.services.activity_log import record_activity -from app.services.agent_provisioning import send_provisioning_message +from app.services.agent_provisioning import ( + DEFAULT_HEARTBEAT_CONFIG, + send_provisioning_message, + send_update_message, +) router = APIRouter(prefix="/agents", tags=["agents"]) @@ -82,6 +87,8 @@ async def _ensure_gateway_session( def _with_computed_status(agent: Agent) -> Agent: now = datetime.utcnow() + if agent.status == "deleting": + return agent if agent.last_seen_at is None: agent.status = "provisioning" elif now - agent.last_seen_at > OFFLINE_AFTER: @@ -98,11 +105,14 @@ def _record_heartbeat(session: Session, agent: Agent) -> None: ) -def _record_provisioning_failure(session: Session, agent: Agent, error: str) -> None: +def _record_instruction_failure( + session: Session, agent: Agent, error: str, action: str +) -> None: + action_label = action.replace("_", " ").capitalize() record_activity( session, - event_type="agent.provision.failed", - message=f"Provisioning message failed: {error}", + event_type=f"agent.{action}.failed", + message=f"{action_label} message failed: {error}", agent_id=agent.id, ) @@ -116,10 +126,12 @@ def _record_wakeup_failure(session: Session, agent: Agent, error: str) -> None: ) -async def _send_wakeup_message(agent: Agent, config: GatewayConfig) -> None: +async def _send_wakeup_message( + agent: Agent, config: GatewayConfig, verb: str = "provisioned" +) -> None: session_key = agent.openclaw_session_id or _build_session_key(agent.name) message = ( - f"Hello {agent.name}. Your workspace has been provisioned.\n\n" + f"Hello {agent.name}. Your workspace has been {verb}.\n\n" "Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once " "then delete it. Begin heartbeats after startup." ) @@ -147,6 +159,8 @@ async def create_agent( agent.status = "provisioning" raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() session_key, session_error = await _ensure_gateway_session(agent.name, config) agent.openclaw_session_id = session_key session.add(agent) @@ -177,11 +191,11 @@ async def create_agent( agent_id=agent.id, ) except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() except Exception as exc: # pragma: no cover - unexpected provisioning errors - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() return agent @@ -222,6 +236,8 @@ async def update_agent( for key, value in updates.items(): setattr(agent, key, value) agent.updated_at = datetime.utcnow() + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() session.add(agent) session.commit() session.refresh(agent) @@ -236,7 +252,7 @@ async def update_agent( session.commit() session.refresh(agent) except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "update") session.commit() raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) @@ -244,12 +260,12 @@ async def update_agent( session.commit() session.refresh(agent) try: - await send_provisioning_message(agent, board, raw_token) - await _send_wakeup_message(agent, config) + await send_update_message(agent, board, raw_token) + await _send_wakeup_message(agent, config, verb="updated") record_activity( session, - event_type="agent.reprovisioned", - message=f"Re-provisioned agent {agent.name}.", + event_type="agent.updated", + message=f"Updated agent {agent.name}.", agent_id=agent.id, ) record_activity( @@ -260,11 +276,11 @@ async def update_agent( ) session.commit() except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "update") _record_wakeup_failure(session, agent, str(exc)) session.commit() except Exception as exc: # pragma: no cover - unexpected provisioning errors - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "update") _record_wakeup_failure(session, agent, str(exc)) session.commit() return _with_computed_status(agent) @@ -307,7 +323,12 @@ async def heartbeat_or_create_agent( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) board = _require_board(session, payload.board_id) config = _require_gateway_config(board) - agent = Agent(name=payload.name, status="provisioning", board_id=board.id) + agent = Agent( + name=payload.name, + status="provisioning", + board_id=board.id, + heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(), + ) raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) session_key, session_error = await _ensure_gateway_session(agent.name, config) @@ -340,11 +361,11 @@ async def heartbeat_or_create_agent( agent_id=agent.id, ) except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() except Exception as exc: # pragma: no cover - unexpected provisioning errors - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id: @@ -352,6 +373,8 @@ async def heartbeat_or_create_agent( elif agent.agent_token_hash is None and actor.actor_type == "user": raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) + if agent.heartbeat_config is None: + agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy() session.add(agent) session.commit() session.refresh(agent) @@ -367,11 +390,11 @@ async def heartbeat_or_create_agent( agent_id=agent.id, ) except OpenClawGatewayError as exc: - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() except Exception as exc: # pragma: no cover - unexpected provisioning errors - _record_provisioning_failure(session, agent, str(exc)) + _record_instruction_failure(session, agent, str(exc), "provision") _record_wakeup_failure(session, agent, str(exc)) session.commit() elif not agent.openclaw_session_id: @@ -414,51 +437,104 @@ def delete_agent( auth: AuthContext = Depends(require_admin_auth), ) -> dict[str, bool]: agent = session.get(Agent, agent_id) - if agent: - board = _require_board(session, str(agent.board_id) if agent.board_id else None) - config = _require_gateway_config(board) - async def _gateway_cleanup() -> None: - if agent.openclaw_session_id: - await delete_session(agent.openclaw_session_id, config=config) - main_session = board.gateway_main_session_key or "agent:main:main" - if main_session: - workspace_root = ( - board.gateway_workspace_root or "~/.openclaw/workspaces" - ) - workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}" - 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 any lingering session artifacts.\n" - "Reply NO_REPLY." - ) - await ensure_session(main_session, config=config, label="Main Agent") - await send_message( - cleanup_message, - session_key=main_session, - config=config, - deliver=False, - ) + if agent is None: + return {"ok": True} + if agent.status == "deleting" and agent.delete_confirm_token_hash: + return {"ok": True} - try: - import asyncio + board = _require_board(session, str(agent.board_id) if agent.board_id else None) + config = _require_gateway_config(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() - asyncio.run(_gateway_cleanup()) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Gateway cleanup failed: {exc}", - ) from exc - session.execute( - update(ActivityEvent) - .where(col(ActivityEvent.agent_id) == agent.id) - .values(agent_id=None) + async def _gateway_cleanup_request() -> None: + main_session = board.gateway_main_session_key or "agent:main:main" + if not main_session: + return + workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces" + workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}" + 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." ) - session.delete(agent) + await ensure_session(main_session, config=config, label="Main Agent") + await send_message( + cleanup_message, + session_key=main_session, + config=config, + deliver=False, + ) + + try: + import asyncio + + asyncio.run(_gateway_cleanup_request()) + except OpenClawGatewayError as exc: + _record_instruction_failure(session, agent, str(exc), "delete") session.commit() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway cleanup request failed: {exc}", + ) from exc + + return {"ok": True} + + +@router.post("/{agent_id}/delete/confirm") +def confirm_delete_agent( + agent_id: str, + payload: AgentDeleteConfirm, + session: Session = Depends(get_session), +) -> dict[str, bool]: + agent = session.get(Agent, agent_id) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if agent.status != "deleting": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Agent is not pending deletion.", + ) + if not agent.delete_confirm_token_hash: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Delete confirmation not requested.", + ) + if not verify_agent_token(payload.token, agent.delete_confirm_token_hash): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token.") + + record_activity( + session, + event_type="agent.delete.confirmed", + 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} diff --git a/backend/app/api/gateway.py b/backend/app/api/gateway.py index e5f625ad..77f8db34 100644 --- a/backend/app/api/gateway.py +++ b/backend/app/api/gateway.py @@ -13,6 +13,11 @@ from app.integrations.openclaw_gateway import ( openclaw_call, send_message, ) +from app.integrations.openclaw_gateway_protocol import ( + GATEWAY_EVENTS, + GATEWAY_METHODS, + PROTOCOL_VERSION, +) from app.db.session import get_session from app.models.boards import Board @@ -196,3 +201,14 @@ async def send_session_message( except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc return {"ok": True} + + +@router.get("/commands") +async def gateway_commands( + auth: AuthContext = Depends(require_admin_auth), +) -> dict[str, object]: + return { + "protocol_version": PROTOCOL_VERSION, + "methods": GATEWAY_METHODS, + "events": GATEWAY_EVENTS, + } diff --git a/backend/app/integrations/openclaw_gateway.py b/backend/app/integrations/openclaw_gateway.py index 74b98847..aff18c5f 100644 --- a/backend/app/integrations/openclaw_gateway.py +++ b/backend/app/integrations/openclaw_gateway.py @@ -9,6 +9,7 @@ from uuid import uuid4 import websockets +from app.integrations.openclaw_gateway_protocol import PROTOCOL_VERSION class OpenClawGatewayError(RuntimeError): @@ -64,23 +65,10 @@ async def _send_request( return await _await_response(ws, request_id) -async def _handle_challenge( - ws: websockets.WebSocketClientProtocol, - first_message: str | bytes | None, - config: GatewayConfig, -) -> None: - if not first_message: - return - if isinstance(first_message, bytes): - first_message = first_message.decode("utf-8") - data = json.loads(first_message) - if data.get("type") != "event" or data.get("event") != "connect.challenge": - return - - connect_id = str(uuid4()) +def _build_connect_params(config: GatewayConfig) -> dict[str, Any]: params: dict[str, Any] = { - "minProtocol": 3, - "maxProtocol": 3, + "minProtocol": PROTOCOL_VERSION, + "maxProtocol": PROTOCOL_VERSION, "client": { "id": "gateway-client", "version": "1.0.0", @@ -90,11 +78,26 @@ async def _handle_challenge( } if config.token: params["auth"] = {"token": config.token} + return params + + +async def _ensure_connected( + ws: websockets.WebSocketClientProtocol, + first_message: str | bytes | None, + config: GatewayConfig, +) -> None: + if first_message: + if isinstance(first_message, bytes): + first_message = first_message.decode("utf-8") + data = json.loads(first_message) + if data.get("type") != "event" or data.get("event") != "connect.challenge": + pass + connect_id = str(uuid4()) response = { "type": "req", "id": connect_id, "method": "connect", - "params": params, + "params": _build_connect_params(config), } await ws.send(json.dumps(response)) await _await_response(ws, connect_id) @@ -114,7 +117,7 @@ async def openclaw_call( first_message = await asyncio.wait_for(ws.recv(), timeout=2) except asyncio.TimeoutError: first_message = None - await _handle_challenge(ws, first_message, config) + await _ensure_connected(ws, first_message, config) return await _send_request(ws, method, params) except OpenClawGatewayError: raise diff --git a/backend/app/integrations/openclaw_gateway_protocol.py b/backend/app/integrations/openclaw_gateway_protocol.py new file mode 100644 index 00000000..c9d2f841 --- /dev/null +++ b/backend/app/integrations/openclaw_gateway_protocol.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +PROTOCOL_VERSION = 3 + +# NOTE: These are the base gateway methods from the OpenClaw gateway repo. +# The gateway can expose additional methods at runtime via channel plugins. +GATEWAY_METHODS = [ + "health", + "logs.tail", + "channels.status", + "channels.logout", + "status", + "usage.status", + "usage.cost", + "tts.status", + "tts.providers", + "tts.enable", + "tts.disable", + "tts.convert", + "tts.setProvider", + "config.get", + "config.set", + "config.apply", + "config.patch", + "config.schema", + "exec.approvals.get", + "exec.approvals.set", + "exec.approvals.node.get", + "exec.approvals.node.set", + "exec.approval.request", + "exec.approval.resolve", + "wizard.start", + "wizard.next", + "wizard.cancel", + "wizard.status", + "talk.mode", + "models.list", + "agents.list", + "agents.files.list", + "agents.files.get", + "agents.files.set", + "skills.status", + "skills.bins", + "skills.install", + "skills.update", + "update.run", + "voicewake.get", + "voicewake.set", + "sessions.list", + "sessions.preview", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + "last-heartbeat", + "set-heartbeats", + "wake", + "node.pair.request", + "node.pair.list", + "node.pair.approve", + "node.pair.reject", + "node.pair.verify", + "device.pair.list", + "device.pair.approve", + "device.pair.reject", + "device.token.rotate", + "device.token.revoke", + "node.rename", + "node.list", + "node.describe", + "node.invoke", + "node.invoke.result", + "node.event", + "cron.list", + "cron.status", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "cron.runs", + "system-presence", + "system-event", + "send", + "agent", + "agent.identity.get", + "agent.wait", + "browser.request", + "chat.history", + "chat.abort", + "chat.send", +] + +GATEWAY_EVENTS = [ + "connect.challenge", + "agent", + "chat", + "presence", + "tick", + "talk.mode", + "shutdown", + "health", + "heartbeat", + "cron", + "node.pair.requested", + "node.pair.resolved", + "node.invoke.request", + "device.pair.requested", + "device.pair.resolved", + "voicewake.changed", + "exec.approval.requested", + "exec.approval.resolved", +] + +GATEWAY_METHODS_SET = frozenset(GATEWAY_METHODS) +GATEWAY_EVENTS_SET = frozenset(GATEWAY_EVENTS) + + +def is_known_gateway_method(method: str) -> bool: + return method in GATEWAY_METHODS_SET diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index 7fdbeff2..11cf7383 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -1,8 +1,10 @@ from __future__ import annotations from datetime import datetime +from typing import Any from uuid import UUID, uuid4 +from sqlalchemy import Column, JSON from sqlmodel import Field, SQLModel @@ -15,6 +17,11 @@ class Agent(SQLModel, table=True): status: str = Field(default="provisioning", index=True) openclaw_session_id: str | None = Field(default=None, index=True) agent_token_hash: str | None = Field(default=None, index=True) + heartbeat_config: dict[str, Any] | None = Field( + default=None, sa_column=Column(JSON) + ) + delete_requested_at: datetime | None = Field(default=None) + delete_confirm_token_hash: str | None = Field(default=None, index=True) last_seen_at: datetime | None = Field(default=None) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index ff4a30c2..b72192f2 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Any from uuid import UUID from sqlmodel import SQLModel @@ -10,6 +11,7 @@ class AgentBase(SQLModel): board_id: UUID | None = None name: str status: str = "provisioning" + heartbeat_config: dict[str, Any] | None = None class AgentCreate(AgentBase): @@ -20,6 +22,7 @@ class AgentUpdate(SQLModel): board_id: UUID | None = None name: str | None = None status: str | None = None + heartbeat_config: dict[str, Any] | None = None class AgentRead(AgentBase): @@ -37,3 +40,7 @@ class AgentHeartbeat(SQLModel): class AgentHeartbeatCreate(AgentHeartbeat): name: str board_id: UUID | None = None + + +class AgentDeleteConfirm(SQLModel): + token: str diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 510c7224..58852d75 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -1,7 +1,9 @@ from __future__ import annotations +import json import re from pathlib import Path +from typing import Any from uuid import uuid4 from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape @@ -22,6 +24,8 @@ TEMPLATE_FILES = [ "USER.md", ] +DEFAULT_HEARTBEAT_CONFIG = {"every": "10m", "target": "none"} + def _repo_root() -> Path: return Path(__file__).resolve().parents[3] @@ -36,6 +40,21 @@ def _slugify(value: str) -> str: return slug or uuid4().hex +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() + + def _template_env() -> Environment: return Environment( loader=FileSystemLoader(_templates_root()), @@ -69,15 +88,14 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str: return f"{root}/{_slugify(agent_name)}" -def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str: +def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str]: agent_id = str(agent.id) workspace_root = board.gateway_workspace_root or "~/.openclaw/workspaces" workspace_path = _workspace_path(agent.name, workspace_root) session_key = agent.openclaw_session_id or "" base_url = settings.base_url or "REPLACE_WITH_BASE_URL" main_session_key = board.gateway_main_session_key or "agent:main:main" - - context = { + return { "agent_name": agent.name, "agent_id": agent_id, "board_id": str(board.id), @@ -93,20 +111,34 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s "user_notes": "Fill in user context.", } - templates = _read_templates(context) - file_blocks = "".join( +def _build_file_blocks(context: dict[str, str]) -> str: + templates = _read_templates(context) + return "".join( _render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES ) + +def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> str: + context = _build_context(agent, board, auth_token) + file_blocks = _build_file_blocks(context) + 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: {agent.name}\n" - f"Agent id: {agent_id}\n" - f"Session key: {session_key}\n" - f"Workspace path: {workspace_path}\n\n" - f"Base URL: {base_url}\n" - f"Auth token: {auth_token}\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" @@ -115,7 +147,56 @@ def build_provisioning_message(agent: Agent, board: Board, auth_token: str) -> s "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\n" + "(never overwrite the main agent session).\n" + " IMPORTANT: Do NOT use ~/.openclaw/workspace-. The canonical path " + "is ~/.openclaw/workspaces/.\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\n" + "Files:" + file_blocks + ) + + +def build_update_message(agent: Agent, board: Board, auth_token: str) -> str: + context = _build_context(agent, board, auth_token) + file_blocks = _build_file_blocks(context) + 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: Do NOT use ~/.openclaw/workspace-. The canonical path " + "is ~/.openclaw/workspaces/.\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\n" "Files:" + file_blocks ) @@ -132,3 +213,17 @@ async def send_provisioning_message( await ensure_session(main_session, config=config, label="Main Agent") message = build_provisioning_message(agent, board, auth_token) await send_message(message, session_key=main_session, config=config, deliver=False) + + +async def send_update_message( + agent: Agent, + board: Board, + auth_token: str, +) -> None: + main_session = board.gateway_main_session_key or "agent:main:main" + if not board.gateway_url: + return + config = GatewayConfig(url=board.gateway_url, token=board.gateway_token) + await ensure_session(main_session, config=config, label="Main Agent") + message = build_update_message(agent, board, auth_token) + await send_message(message, session_key=main_session, config=config, deliver=False) diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index 173debc4..c47ae985 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -25,6 +25,10 @@ type Agent = { id: string; name: string; board_id?: string | null; + heartbeat_config?: { + every?: string; + target?: string; + } | null; }; type Board = { @@ -44,6 +48,8 @@ export default function EditAgentPage() { const [name, setName] = useState(""); const [boards, setBoards] = useState([]); const [boardId, setBoardId] = useState(""); + const [heartbeatEvery, setHeartbeatEvery] = useState("10m"); + const [heartbeatTarget, setHeartbeatTarget] = useState("none"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -59,9 +65,6 @@ export default function EditAgentPage() { } const data = (await response.json()) as Board[]; setBoards(data); - if (!boardId && data.length > 0) { - setBoardId(data[0].id); - } } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } @@ -85,6 +88,12 @@ export default function EditAgentPage() { if (data.board_id) { setBoardId(data.board_id); } + if (data.heartbeat_config?.every) { + setHeartbeatEvery(data.heartbeat_config.every); + } + if (data.heartbeat_config?.target) { + setHeartbeatTarget(data.heartbeat_config.target); + } } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } finally { @@ -98,6 +107,17 @@ export default function EditAgentPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSignedIn, agentId]); + useEffect(() => { + if (boardId) return; + if (agent?.board_id) { + setBoardId(agent.board_id); + return; + } + if (boards.length > 0) { + setBoardId(boards[0].id); + } + }, [agent, boards, boardId]); + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!isSignedIn || !agentId) return; @@ -120,7 +140,14 @@ export default function EditAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, board_id: boardId }), + body: JSON.stringify({ + name: trimmed, + board_id: boardId, + heartbeat_config: { + every: heartbeatEvery.trim() || "10m", + target: heartbeatTarget, + }, + }), }); if (!response.ok) { throw new Error("Unable to update agent."); @@ -195,6 +222,38 @@ export default function EditAgentPage() {

) : null} +
+ + setHeartbeatEvery(event.target.value)} + placeholder="e.g. 10m" + disabled={isLoading} + /> +

+ Set how often this agent runs HEARTBEAT.md. +

+
+
+ + +
{error ? (
{error} diff --git a/frontend/src/app/agents/new/page.tsx b/frontend/src/app/agents/new/page.tsx index 644975e9..fb0c7952 100644 --- a/frontend/src/app/agents/new/page.tsx +++ b/frontend/src/app/agents/new/page.tsx @@ -39,6 +39,8 @@ export default function NewAgentPage() { const [name, setName] = useState(""); const [boards, setBoards] = useState([]); const [boardId, setBoardId] = useState(""); + const [heartbeatEvery, setHeartbeatEvery] = useState("10m"); + const [heartbeatTarget, setHeartbeatTarget] = useState("none"); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -89,7 +91,14 @@ export default function NewAgentPage() { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "", }, - body: JSON.stringify({ name: trimmed, board_id: boardId }), + body: JSON.stringify({ + name: trimmed, + board_id: boardId, + heartbeat_config: { + every: heartbeatEvery.trim() || "10m", + target: heartbeatTarget, + }, + }), }); if (!response.ok) { throw new Error("Unable to create agent."); @@ -165,6 +174,38 @@ export default function NewAgentPage() {

) : null}
+
+ + setHeartbeatEvery(event.target.value)} + placeholder="e.g. 10m" + disabled={isLoading} + /> +

+ Set how often this agent runs HEARTBEAT.md (e.g. 10m, 30m, 2h). +

+
+
+ + +
{error ? (
{error} diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index 4717ad3c..75137cfd 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -204,7 +204,7 @@ export default function AgentsPage() { if (!response.ok) { throw new Error("Unable to delete agent."); } - setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id)); + await loadAgents(); setDeleteTarget(null); } catch (err) { setDeleteError(err instanceof Error ? err.message : "Something went wrong."); diff --git a/frontend/src/components/atoms/StatusPill.tsx b/frontend/src/components/atoms/StatusPill.tsx index dbd66da8..8071b673 100644 --- a/frontend/src/components/atoms/StatusPill.tsx +++ b/frontend/src/components/atoms/StatusPill.tsx @@ -14,6 +14,7 @@ const STATUS_STYLES: Record< busy: "warning", provisioning: "warning", offline: "outline", + deleting: "danger", }; export function StatusPill({ status }: { status: string }) { diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index 80f7328d..2dbdd0bb 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -9,7 +9,7 @@ If this file is empty, skip heartbeat work. - BOARD_ID ## Schedule -- Run this heartbeat every 10 minutes. +- Schedule is controlled by gateway heartbeat config (default: every 10 minutes). - On first boot, send one immediate check-in before the schedule starts. ## On every heartbeat